feat: implement Pluggable Abstraction Layer for Data Retrieval (PALDRA) - #19
All checks were successful
All checks were successful
- Create ArrRetriever abstract base class defining pluggable interface - Implement PollingSonarrRetriever and PollingRadarrRetriever with HTTP polling - Add ArrRetrieverRegistry for managing retriever instances - Refactor poller.js to use retriever registry instead of direct Axios calls - Update historyFetcher.js to use retriever registry - Preserve all cache keys, TTLs, timing logs, SSE broadcasts, error handling - Enable future webhook listeners without touching poller logic
This commit is contained in:
81
server/clients/ArrRetriever.js
Normal file
81
server/clients/ArrRetriever.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
/**
|
||||
* Abstract base class for all *arr data retrievers.
|
||||
* Defines the common interface that all retrievers must implement.
|
||||
* This pluggable layer enables future retrieval strategies (e.g., webhook listeners)
|
||||
* to push normalized data directly into the existing cache and SSE system
|
||||
* without touching the poller logic.
|
||||
*/
|
||||
class ArrRetriever {
|
||||
/**
|
||||
* @param {Object} instanceConfig - Configuration for this retriever instance
|
||||
* @param {string} instanceConfig.id - Unique identifier for this instance
|
||||
* @param {string} instanceConfig.name - Display name for this instance
|
||||
* @param {string} instanceConfig.url - Base URL for the *arr API
|
||||
* @param {string} instanceConfig.apiKey - API key for authentication
|
||||
*/
|
||||
constructor(instanceConfig) {
|
||||
if (this.constructor === ArrRetriever) {
|
||||
throw new Error('ArrRetriever is an abstract class and cannot be instantiated directly');
|
||||
}
|
||||
|
||||
this.id = instanceConfig.id;
|
||||
this.name = instanceConfig.name;
|
||||
this.url = instanceConfig.url;
|
||||
this.apiKey = instanceConfig.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the retriever type identifier (e.g., 'sonarr', 'radarr')
|
||||
* @returns {string} The retriever type
|
||||
*/
|
||||
getRetrieverType() {
|
||||
throw new Error('getRetrieverType() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unique instance ID
|
||||
* @returns {string} The instance ID
|
||||
*/
|
||||
getInstanceId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags from this *arr instance
|
||||
* @param {Object} instanceConfig - Configuration for the instance
|
||||
* @returns {Promise<Array>} Array of tag objects
|
||||
*/
|
||||
async getTags(instanceConfig) {
|
||||
throw new Error('getTags() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue from this *arr instance
|
||||
* @param {Object} instanceConfig - Configuration for the instance
|
||||
* @returns {Promise<Object>} Queue object with records array
|
||||
*/
|
||||
async getQueue(instanceConfig) {
|
||||
throw new Error('getQueue() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history from this *arr instance
|
||||
* @param {Object} instanceConfig - Configuration for the instance
|
||||
* @param {Object} options - Optional parameters for history fetch
|
||||
* @param {number} [options.pageSize] - Number of records to fetch
|
||||
* @param {string} [options.sortKey] - Field to sort by
|
||||
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
|
||||
* @param {boolean} [options.includeSeries] - Include series data (Sonarr)
|
||||
* @param {boolean} [options.includeEpisode] - Include episode data (Sonarr)
|
||||
* @param {boolean} [options.includeMovie] - Include movie data (Radarr)
|
||||
* @param {string} [options.startDate] - ISO date string for filtering
|
||||
* @returns {Promise<Object>} History object with records array
|
||||
*/
|
||||
async getHistory(instanceConfig, options = {}) {
|
||||
throw new Error('getHistory() must be implemented by subclass');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ArrRetriever;
|
||||
96
server/clients/PollingRadarrRetriever.js
Normal file
96
server/clients/PollingRadarrRetriever.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const ArrRetriever = require('./ArrRetriever');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Polling-based Radarr data retriever.
|
||||
* Implements the ArrRetriever interface using direct HTTP polling.
|
||||
*/
|
||||
class PollingRadarrRetriever extends ArrRetriever {
|
||||
constructor(instanceConfig) {
|
||||
super(instanceConfig);
|
||||
}
|
||||
|
||||
getRetrieverType() {
|
||||
return 'radarr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags from Radarr instance
|
||||
* @param {Object} instanceConfig - Configuration for the instance
|
||||
* @returns {Promise<Array>} Array of tag objects
|
||||
*/
|
||||
async getTags(instanceConfig) {
|
||||
try {
|
||||
const response = await axios.get(`${instanceConfig.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': instanceConfig.apiKey }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logToFile(`[PollingRadarrRetriever] ${instanceConfig.id} tags error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue from Radarr instance
|
||||
* @param {Object} instanceConfig - Configuration for the instance
|
||||
* @returns {Promise<Object>} Queue object with records array
|
||||
*/
|
||||
async getQueue(instanceConfig) {
|
||||
try {
|
||||
const response = await axios.get(`${instanceConfig.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': instanceConfig.apiKey },
|
||||
params: { includeMovie: true }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logToFile(`[PollingRadarrRetriever] ${instanceConfig.id} queue error: ${error.message}`);
|
||||
return { records: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history from Radarr instance
|
||||
* @param {Object} instanceConfig - Configuration for the instance
|
||||
* @param {Object} options - Optional parameters for history fetch
|
||||
* @param {number} [options.pageSize=10] - Number of records to fetch
|
||||
* @param {string} [options.sortKey] - Field to sort by
|
||||
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
|
||||
* @param {boolean} [options.includeMovie=true] - Include movie data
|
||||
* @param {string} [options.startDate] - ISO date string for filtering
|
||||
* @returns {Promise<Object>} History object with records array
|
||||
*/
|
||||
async getHistory(instanceConfig, options = {}) {
|
||||
const {
|
||||
pageSize = 10,
|
||||
sortKey,
|
||||
sortDir,
|
||||
includeMovie = true,
|
||||
startDate
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const params = {
|
||||
pageSize,
|
||||
includeMovie
|
||||
};
|
||||
|
||||
if (sortKey) params.sortKey = sortKey;
|
||||
if (sortDir) params.sortDir = sortDir;
|
||||
if (startDate) params.startDate = startDate;
|
||||
|
||||
const response = await axios.get(`${instanceConfig.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': instanceConfig.apiKey },
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logToFile(`[PollingRadarrRetriever] ${instanceConfig.id} history error: ${error.message}`);
|
||||
return { records: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PollingRadarrRetriever;
|
||||
99
server/clients/PollingSonarrRetriever.js
Normal file
99
server/clients/PollingSonarrRetriever.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const ArrRetriever = require('./ArrRetriever');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Polling-based Sonarr data retriever.
|
||||
* Implements the ArrRetriever interface using direct HTTP polling.
|
||||
*/
|
||||
class PollingSonarrRetriever extends ArrRetriever {
|
||||
constructor(instanceConfig) {
|
||||
super(instanceConfig);
|
||||
}
|
||||
|
||||
getRetrieverType() {
|
||||
return 'sonarr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags from Sonarr instance
|
||||
* @param {Object} instanceConfig - Configuration for the instance
|
||||
* @returns {Promise<Array>} Array of tag objects
|
||||
*/
|
||||
async getTags(instanceConfig) {
|
||||
try {
|
||||
const response = await axios.get(`${instanceConfig.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': instanceConfig.apiKey }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logToFile(`[PollingSonarrRetriever] ${instanceConfig.id} tags error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue from Sonarr instance
|
||||
* @param {Object} instanceConfig - Configuration for the instance
|
||||
* @returns {Promise<Object>} Queue object with records array
|
||||
*/
|
||||
async getQueue(instanceConfig) {
|
||||
try {
|
||||
const response = await axios.get(`${instanceConfig.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': instanceConfig.apiKey },
|
||||
params: { includeSeries: true, includeEpisode: true }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logToFile(`[PollingSonarrRetriever] ${instanceConfig.id} queue error: ${error.message}`);
|
||||
return { records: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history from Sonarr instance
|
||||
* @param {Object} instanceConfig - Configuration for the instance
|
||||
* @param {Object} options - Optional parameters for history fetch
|
||||
* @param {number} [options.pageSize=10] - Number of records to fetch
|
||||
* @param {string} [options.sortKey] - Field to sort by
|
||||
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
|
||||
* @param {boolean} [options.includeSeries=true] - Include series data
|
||||
* @param {boolean} [options.includeEpisode=true] - Include episode data
|
||||
* @param {string} [options.startDate] - ISO date string for filtering
|
||||
* @returns {Promise<Object>} History object with records array
|
||||
*/
|
||||
async getHistory(instanceConfig, options = {}) {
|
||||
const {
|
||||
pageSize = 10,
|
||||
sortKey,
|
||||
sortDir,
|
||||
includeSeries = true,
|
||||
includeEpisode = true,
|
||||
startDate
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const params = {
|
||||
pageSize,
|
||||
includeSeries,
|
||||
includeEpisode
|
||||
};
|
||||
|
||||
if (sortKey) params.sortKey = sortKey;
|
||||
if (sortDir) params.sortDir = sortDir;
|
||||
if (startDate) params.startDate = startDate;
|
||||
|
||||
const response = await axios.get(`${instanceConfig.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': instanceConfig.apiKey },
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logToFile(`[PollingSonarrRetriever] ${instanceConfig.id} history error: ${error.message}`);
|
||||
return { records: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PollingSonarrRetriever;
|
||||
327
server/utils/arrRetrievers.js
Normal file
327
server/utils/arrRetrievers.js
Normal file
@@ -0,0 +1,327 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const { logToFile } = require('./logger');
|
||||
const {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
} = require('./config');
|
||||
|
||||
// Import retriever classes
|
||||
const PollingSonarrRetriever = require('../clients/PollingSonarrRetriever');
|
||||
const PollingRadarrRetriever = require('../clients/PollingRadarrRetriever');
|
||||
|
||||
// Retriever type mapping
|
||||
const retrieverClasses = {
|
||||
sonarr: PollingSonarrRetriever,
|
||||
radarr: PollingRadarrRetriever
|
||||
};
|
||||
|
||||
/**
|
||||
* Registry and factory for *arr data retrievers
|
||||
*/
|
||||
class ArrRetrieverRegistry {
|
||||
constructor() {
|
||||
this.retrievers = new Map();
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all configured *arr retrievers
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
logToFile('[ArrRetrieverRegistry] Initializing *arr retrievers...');
|
||||
|
||||
// Get all instance configurations
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
// Create retriever instances
|
||||
const instanceConfigs = [
|
||||
...sonarrInstances.map(inst => ({ ...inst, type: 'sonarr' })),
|
||||
...radarrInstances.map(inst => ({ ...inst, type: 'radarr' }))
|
||||
];
|
||||
|
||||
for (const config of instanceConfigs) {
|
||||
try {
|
||||
const RetrieverClass = retrieverClasses[config.type];
|
||||
if (!RetrieverClass) {
|
||||
logToFile(`[ArrRetrieverRegistry] Unknown retriever type: ${config.type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const retriever = new RetrieverClass(config);
|
||||
this.retrievers.set(config.id, retriever);
|
||||
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${config.id})`);
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
logToFile(`[ArrRetrieverRegistry] Initialized ${this.retrievers.size} *arr retrievers`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered retrievers
|
||||
* @returns {Array<ArrRetriever>} Array of retriever instances
|
||||
*/
|
||||
getAllRetrievers() {
|
||||
return Array.from(this.retrievers.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retriever by instance ID
|
||||
* @param {string} instanceId - The instance ID
|
||||
* @returns {ArrRetriever|null} Retriever instance or null if not found
|
||||
*/
|
||||
getRetriever(instanceId) {
|
||||
return this.retrievers.get(instanceId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retrievers by type
|
||||
* @param {string} type - Retriever type ('sonarr', 'radarr')
|
||||
* @returns {Array<ArrRetriever>} Array of retriever instances
|
||||
*/
|
||||
getRetrieversByType(type) {
|
||||
return this.getAllRetrievers().filter(retriever => retriever.getRetrieverType() === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags from all retrievers
|
||||
* @returns {Promise<Array<Object>>} Array of tag results with instance info
|
||||
*/
|
||||
async getAllTags() {
|
||||
const retrievers = this.getAllRetrievers();
|
||||
if (retrievers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch tags from all retrievers in parallel
|
||||
const results = await Promise.allSettled(
|
||||
retrievers.map(async (retriever) => {
|
||||
try {
|
||||
const tags = await retriever.getTags(retriever);
|
||||
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${tags.length} tags`);
|
||||
return { instance: retriever.getInstanceId(), data: tags };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue from all retrievers
|
||||
* @returns {Promise<Array<Object>>} Array of queue results with instance info
|
||||
*/
|
||||
async getAllQueues() {
|
||||
const retrievers = this.getAllRetrievers();
|
||||
if (retrievers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch queues from all retrievers in parallel
|
||||
const results = await Promise.allSettled(
|
||||
retrievers.map(async (retriever) => {
|
||||
try {
|
||||
const queue = await retriever.getQueue(retriever);
|
||||
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(queue.records || []).length} queue items`);
|
||||
return { instance: retriever.getInstanceId(), data: queue };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history from all retrievers
|
||||
* @param {Object} options - Optional parameters for history fetch
|
||||
* @returns {Promise<Array<Object>>} Array of history results with instance info
|
||||
*/
|
||||
async getAllHistory(options = {}) {
|
||||
const retrievers = this.getAllRetrievers();
|
||||
if (retrievers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch history from all retrievers in parallel
|
||||
const results = await Promise.allSettled(
|
||||
retrievers.map(async (retriever) => {
|
||||
try {
|
||||
const history = await retriever.getHistory(retriever, options);
|
||||
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(history.records || []).length} history records`);
|
||||
return { instance: retriever.getInstanceId(), data: history };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags grouped by retriever type
|
||||
* @returns {Promise<Object>} Tags grouped by retriever type (array of { instance, data } objects)
|
||||
*/
|
||||
async getTagsByType() {
|
||||
const sonarrRetrievers = this.getRetrieversByType('sonarr');
|
||||
const radarrRetrievers = this.getRetrieversByType('radarr');
|
||||
|
||||
const sonarrTags = await Promise.allSettled(
|
||||
sonarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const tags = await retriever.getTags(retriever);
|
||||
return { instance: retriever.getInstanceId(), data: tags };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const radarrTags = await Promise.allSettled(
|
||||
radarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const tags = await retriever.getTags(retriever);
|
||||
return { instance: retriever.getInstanceId(), data: tags };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
sonarr: sonarrTags
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value),
|
||||
radarr: radarrTags
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue grouped by retriever type
|
||||
* @returns {Promise<Object>} Queue grouped by retriever type
|
||||
*/
|
||||
async getQueuesByType() {
|
||||
const sonarrRetrievers = this.getRetrieversByType('sonarr');
|
||||
const radarrRetrievers = this.getRetrieversByType('radarr');
|
||||
|
||||
const sonarrQueues = await Promise.allSettled(
|
||||
sonarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const queue = await retriever.getQueue(retriever);
|
||||
return { instance: retriever.getInstanceId(), data: queue };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const radarrQueues = await Promise.allSettled(
|
||||
radarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const queue = await retriever.getQueue(retriever);
|
||||
return { instance: retriever.getInstanceId(), data: queue };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
sonarr: sonarrQueues
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value),
|
||||
radarr: radarrQueues
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history grouped by retriever type
|
||||
* @param {Object} options - Optional parameters for history fetch
|
||||
* @returns {Promise<Object>} History grouped by retriever type
|
||||
*/
|
||||
async getHistoryByType(options = {}) {
|
||||
const sonarrRetrievers = this.getRetrieversByType('sonarr');
|
||||
const radarrRetrievers = this.getRetrieversByType('radarr');
|
||||
|
||||
const sonarrHistory = await Promise.allSettled(
|
||||
sonarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const history = await retriever.getHistory(retriever, options);
|
||||
return { instance: retriever.getInstanceId(), data: history };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const radarrHistory = await Promise.allSettled(
|
||||
radarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const history = await retriever.getHistory(retriever, options);
|
||||
return { instance: retriever.getInstanceId(), data: history };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
sonarr: sonarrHistory
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value),
|
||||
radarr: radarrHistory
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const registry = new ArrRetrieverRegistry();
|
||||
|
||||
module.exports = {
|
||||
ArrRetrieverRegistry,
|
||||
registry,
|
||||
|
||||
// Convenience functions
|
||||
initializeRetrievers: () => registry.initialize(),
|
||||
getAllRetrievers: () => registry.getAllRetrievers(),
|
||||
getRetriever: (instanceId) => registry.getRetriever(instanceId),
|
||||
getRetrieversByType: (type) => registry.getRetrieversByType(type),
|
||||
getAllTags: () => registry.getAllTags(),
|
||||
getAllQueues: () => registry.getAllQueues(),
|
||||
getAllHistory: (options) => registry.getAllHistory(options),
|
||||
getTagsByType: () => registry.getTagsByType(),
|
||||
getQueuesByType: () => registry.getQueuesByType(),
|
||||
getHistoryByType: (options) => registry.getHistoryByType(options)
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('./config');
|
||||
const { initializeRetrievers, getRetrieversByType } = require('./arrRetrievers');
|
||||
|
||||
// Cache TTL for recent-history data: 5 minutes.
|
||||
// History changes slowly compared to active downloads.
|
||||
@@ -26,21 +26,26 @@ async function fetchSonarrHistory(since) {
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// Ensure retrievers are initialized
|
||||
await initializeRetrievers();
|
||||
|
||||
const instances = getSonarrInstances();
|
||||
const results = await Promise.all(instances.map(async inst => {
|
||||
const sonarrRetrievers = getRetrieversByType('sonarr');
|
||||
|
||||
const results = await Promise.all(sonarrRetrievers.map(async (retriever) => {
|
||||
const inst = instances.find(i => i.id === retriever.getInstanceId());
|
||||
if (!inst) return [];
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: {
|
||||
pageSize: 100,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeSeries: true,
|
||||
includeEpisode: true,
|
||||
startDate: since.toISOString()
|
||||
}
|
||||
const response = await retriever.getHistory(retriever, {
|
||||
pageSize: 100,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeSeries: true,
|
||||
includeEpisode: true,
|
||||
startDate: since.toISOString()
|
||||
});
|
||||
const records = (response.data && response.data.records) || [];
|
||||
const records = (response && response.records) || [];
|
||||
return records.map(r => {
|
||||
if (r.series) r.series._instanceUrl = inst.url;
|
||||
if (r.series) r.series._instanceName = inst.name || inst.id;
|
||||
@@ -70,20 +75,25 @@ async function fetchRadarrHistory(since) {
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// Ensure retrievers are initialized
|
||||
await initializeRetrievers();
|
||||
|
||||
const instances = getRadarrInstances();
|
||||
const results = await Promise.all(instances.map(async inst => {
|
||||
const radarrRetrievers = getRetrieversByType('radarr');
|
||||
|
||||
const results = await Promise.all(radarrRetrievers.map(async (retriever) => {
|
||||
const inst = instances.find(i => i.id === retriever.getInstanceId());
|
||||
if (!inst) return [];
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: {
|
||||
pageSize: 100,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeMovie: true,
|
||||
startDate: since.toISOString()
|
||||
}
|
||||
const response = await retriever.getHistory(retriever, {
|
||||
pageSize: 100,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeMovie: true,
|
||||
startDate: since.toISOString()
|
||||
});
|
||||
const records = (response.data && response.data.records) || [];
|
||||
const records = (response && response.records) || [];
|
||||
return records.map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = inst.url;
|
||||
if (r.movie) r.movie._instanceName = inst.name || inst.id;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
|
||||
const { initializeRetrievers, getTagsByType, getQueuesByType, getHistoryByType } = require('./arrRetrievers');
|
||||
const {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
@@ -38,8 +39,9 @@ async function pollAllServices() {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Ensure download clients are initialized
|
||||
// Ensure download clients and *arr retrievers are initialized
|
||||
await initializeClients();
|
||||
await initializeRetrievers();
|
||||
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
@@ -50,58 +52,30 @@ async function pollAllServices() {
|
||||
const downloadsByType = await getDownloadsByClientType();
|
||||
return downloadsByType;
|
||||
}),
|
||||
timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} tags error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
))),
|
||||
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeSeries: true, includeEpisode: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 10, includeEpisode: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Radarr Queue', () => Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeMovie: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Radarr History', () => Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 10 }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Radarr Tags', () => Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} tags error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
))),
|
||||
timed('Sonarr Tags', async () => {
|
||||
const tagsByType = await getTagsByType();
|
||||
return tagsByType.sonarr || [];
|
||||
}),
|
||||
timed('Sonarr Queue', async () => {
|
||||
const queuesByType = await getQueuesByType();
|
||||
return queuesByType.sonarr || [];
|
||||
}),
|
||||
timed('Sonarr History', async () => {
|
||||
const historyByType = await getHistoryByType({ pageSize: 10 });
|
||||
return historyByType.sonarr || [];
|
||||
}),
|
||||
timed('Radarr Queue', async () => {
|
||||
const queuesByType = await getQueuesByType();
|
||||
return queuesByType.radarr || [];
|
||||
}),
|
||||
timed('Radarr History', async () => {
|
||||
const historyByType = await getHistoryByType({ pageSize: 10 });
|
||||
return historyByType.radarr || [];
|
||||
}),
|
||||
timed('Radarr Tags', async () => {
|
||||
const tagsByType = await getTagsByType();
|
||||
return tagsByType.radarr || [];
|
||||
}),
|
||||
]);
|
||||
|
||||
const [
|
||||
|
||||
Reference in New Issue
Block a user