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.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const axios = require('axios');
|
|
||||||
const cache = require('./cache');
|
const cache = require('./cache');
|
||||||
const { getSonarrInstances, getRadarrInstances } = require('./config');
|
const { getSonarrInstances, getRadarrInstances } = require('./config');
|
||||||
|
const { initializeRetrievers, getRetrieversByType } = require('./arrRetrievers');
|
||||||
|
|
||||||
// Cache TTL for recent-history data: 5 minutes.
|
// Cache TTL for recent-history data: 5 minutes.
|
||||||
// History changes slowly compared to active downloads.
|
// History changes slowly compared to active downloads.
|
||||||
@@ -26,21 +26,26 @@ async function fetchSonarrHistory(since) {
|
|||||||
const cached = cache.get(cacheKey);
|
const cached = cache.get(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// Ensure retrievers are initialized
|
||||||
|
await initializeRetrievers();
|
||||||
|
|
||||||
const instances = getSonarrInstances();
|
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 {
|
try {
|
||||||
const response = await axios.get(`${inst.url}/api/v3/history`, {
|
const response = await retriever.getHistory(retriever, {
|
||||||
headers: { 'X-Api-Key': inst.apiKey },
|
pageSize: 100,
|
||||||
params: {
|
sortKey: 'date',
|
||||||
pageSize: 100,
|
sortDir: 'descending',
|
||||||
sortKey: 'date',
|
includeSeries: true,
|
||||||
sortDir: 'descending',
|
includeEpisode: true,
|
||||||
includeSeries: true,
|
startDate: since.toISOString()
|
||||||
includeEpisode: true,
|
|
||||||
startDate: since.toISOString()
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
const records = (response.data && response.data.records) || [];
|
const records = (response && response.records) || [];
|
||||||
return records.map(r => {
|
return records.map(r => {
|
||||||
if (r.series) r.series._instanceUrl = inst.url;
|
if (r.series) r.series._instanceUrl = inst.url;
|
||||||
if (r.series) r.series._instanceName = inst.name || inst.id;
|
if (r.series) r.series._instanceName = inst.name || inst.id;
|
||||||
@@ -70,20 +75,25 @@ async function fetchRadarrHistory(since) {
|
|||||||
const cached = cache.get(cacheKey);
|
const cached = cache.get(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// Ensure retrievers are initialized
|
||||||
|
await initializeRetrievers();
|
||||||
|
|
||||||
const instances = getRadarrInstances();
|
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 {
|
try {
|
||||||
const response = await axios.get(`${inst.url}/api/v3/history`, {
|
const response = await retriever.getHistory(retriever, {
|
||||||
headers: { 'X-Api-Key': inst.apiKey },
|
pageSize: 100,
|
||||||
params: {
|
sortKey: 'date',
|
||||||
pageSize: 100,
|
sortDir: 'descending',
|
||||||
sortKey: 'date',
|
includeMovie: true,
|
||||||
sortDir: 'descending',
|
startDate: since.toISOString()
|
||||||
includeMovie: true,
|
|
||||||
startDate: since.toISOString()
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
const records = (response.data && response.data.records) || [];
|
const records = (response && response.records) || [];
|
||||||
return records.map(r => {
|
return records.map(r => {
|
||||||
if (r.movie) r.movie._instanceUrl = inst.url;
|
if (r.movie) r.movie._instanceUrl = inst.url;
|
||||||
if (r.movie) r.movie._instanceName = inst.name || inst.id;
|
if (r.movie) r.movie._instanceName = inst.name || inst.id;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const cache = require('./cache');
|
const cache = require('./cache');
|
||||||
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
|
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
|
||||||
|
const { initializeRetrievers, getTagsByType, getQueuesByType, getHistoryByType } = require('./arrRetrievers');
|
||||||
const {
|
const {
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
getRadarrInstances
|
getRadarrInstances
|
||||||
@@ -38,8 +39,9 @@ async function pollAllServices() {
|
|||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure download clients are initialized
|
// Ensure download clients and *arr retrievers are initialized
|
||||||
await initializeClients();
|
await initializeClients();
|
||||||
|
await initializeRetrievers();
|
||||||
|
|
||||||
const sonarrInstances = getSonarrInstances();
|
const sonarrInstances = getSonarrInstances();
|
||||||
const radarrInstances = getRadarrInstances();
|
const radarrInstances = getRadarrInstances();
|
||||||
@@ -50,58 +52,30 @@ async function pollAllServices() {
|
|||||||
const downloadsByType = await getDownloadsByClientType();
|
const downloadsByType = await getDownloadsByClientType();
|
||||||
return downloadsByType;
|
return downloadsByType;
|
||||||
}),
|
}),
|
||||||
timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst =>
|
timed('Sonarr Tags', async () => {
|
||||||
axios.get(`${inst.url}/api/v3/tag`, {
|
const tagsByType = await getTagsByType();
|
||||||
headers: { 'X-Api-Key': inst.apiKey }
|
return tagsByType.sonarr || [];
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
}),
|
||||||
console.error(`[Poller] Sonarr ${inst.id} tags error:`, err.message);
|
timed('Sonarr Queue', async () => {
|
||||||
return { instance: inst.id, data: [] };
|
const queuesByType = await getQueuesByType();
|
||||||
})
|
return queuesByType.sonarr || [];
|
||||||
))),
|
}),
|
||||||
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
|
timed('Sonarr History', async () => {
|
||||||
axios.get(`${inst.url}/api/v3/queue`, {
|
const historyByType = await getHistoryByType({ pageSize: 10 });
|
||||||
headers: { 'X-Api-Key': inst.apiKey },
|
return historyByType.sonarr || [];
|
||||||
params: { includeSeries: true, includeEpisode: true }
|
}),
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
timed('Radarr Queue', async () => {
|
||||||
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
|
const queuesByType = await getQueuesByType();
|
||||||
return { instance: inst.id, data: { records: [] } };
|
return queuesByType.radarr || [];
|
||||||
})
|
}),
|
||||||
))),
|
timed('Radarr History', async () => {
|
||||||
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
|
const historyByType = await getHistoryByType({ pageSize: 10 });
|
||||||
axios.get(`${inst.url}/api/v3/history`, {
|
return historyByType.radarr || [];
|
||||||
headers: { 'X-Api-Key': inst.apiKey },
|
}),
|
||||||
params: { pageSize: 10, includeEpisode: true }
|
timed('Radarr Tags', async () => {
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
const tagsByType = await getTagsByType();
|
||||||
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
|
return tagsByType.radarr || [];
|
||||||
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: [] };
|
|
||||||
})
|
|
||||||
))),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
|
|||||||
Reference in New Issue
Block a user