From 627329df2f66204f7c616c425e940b9cbb9dd5b4 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 14:43:28 +0100 Subject: [PATCH 1/3] feat: implement Pluggable Abstraction Layer for Data Retrieval (PALDRA) - #19 - 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 --- server/clients/ArrRetriever.js | 81 ++++++ server/clients/PollingRadarrRetriever.js | 96 +++++++ server/clients/PollingSonarrRetriever.js | 99 +++++++ server/utils/arrRetrievers.js | 327 +++++++++++++++++++++++ server/utils/historyFetcher.js | 58 ++-- server/utils/poller.js | 80 ++---- 6 files changed, 664 insertions(+), 77 deletions(-) create mode 100644 server/clients/ArrRetriever.js create mode 100644 server/clients/PollingRadarrRetriever.js create mode 100644 server/clients/PollingSonarrRetriever.js create mode 100644 server/utils/arrRetrievers.js diff --git a/server/clients/ArrRetriever.js b/server/clients/ArrRetriever.js new file mode 100644 index 0000000..e96eb22 --- /dev/null +++ b/server/clients/ArrRetriever.js @@ -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 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} 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} History object with records array + */ + async getHistory(instanceConfig, options = {}) { + throw new Error('getHistory() must be implemented by subclass'); + } +} + +module.exports = ArrRetriever; diff --git a/server/clients/PollingRadarrRetriever.js b/server/clients/PollingRadarrRetriever.js new file mode 100644 index 0000000..16ad9f8 --- /dev/null +++ b/server/clients/PollingRadarrRetriever.js @@ -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 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} 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} 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; diff --git a/server/clients/PollingSonarrRetriever.js b/server/clients/PollingSonarrRetriever.js new file mode 100644 index 0000000..1211038 --- /dev/null +++ b/server/clients/PollingSonarrRetriever.js @@ -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 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} 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} 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; diff --git a/server/utils/arrRetrievers.js b/server/utils/arrRetrievers.js new file mode 100644 index 0000000..4a995f2 --- /dev/null +++ b/server/utils/arrRetrievers.js @@ -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} 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} Array of retriever instances + */ + getRetrieversByType(type) { + return this.getAllRetrievers().filter(retriever => retriever.getRetrieverType() === type); + } + + /** + * Get tags from all retrievers + * @returns {Promise>} 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 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 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} 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} 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} 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) +}; diff --git a/server/utils/historyFetcher.js b/server/utils/historyFetcher.js index 8f5066d..5e9f6ce 100644 --- a/server/utils/historyFetcher.js +++ b/server/utils/historyFetcher.js @@ -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; diff --git a/server/utils/poller.js b/server/utils/poller.js index f10c1dd..6526b12 100644 --- a/server/utils/poller.js +++ b/server/utils/poller.js @@ -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 [ From 6e199925aa4b157dd820885525d253a31f84e546 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 14:51:22 +0100 Subject: [PATCH 2/3] refactor: make PALDRA match PDCA style exactly - remove redundant instanceConfig parameter and convert to pure singleton - Remove instanceConfig parameter from all retriever methods (getTags, getQueue, getHistory) - Retriever instances now use this.url, this.apiKey, this.id instead of passed parameter - Convert ArrRetrieverRegistry from class with convenience functions to pure singleton object - Export singleton instance directly instead of class + convenience functions - Update poller.js and historyFetcher.js to call methods on singleton directly - All 261 tests pass with zero behavior changes --- server/clients/ArrRetriever.js | 9 ++-- server/clients/PollingRadarrRetriever.js | 27 +++++----- server/clients/PollingSonarrRetriever.js | 27 +++++----- server/utils/arrRetrievers.js | 68 +++++++++--------------- server/utils/historyFetcher.js | 14 ++--- server/utils/poller.js | 16 +++--- 6 files changed, 66 insertions(+), 95 deletions(-) diff --git a/server/clients/ArrRetriever.js b/server/clients/ArrRetriever.js index e96eb22..16d45a4 100644 --- a/server/clients/ArrRetriever.js +++ b/server/clients/ArrRetriever.js @@ -44,25 +44,22 @@ class ArrRetriever { /** * Get tags from this *arr instance - * @param {Object} instanceConfig - Configuration for the instance * @returns {Promise} Array of tag objects */ - async getTags(instanceConfig) { + async getTags() { throw new Error('getTags() must be implemented by subclass'); } /** * Get queue from this *arr instance - * @param {Object} instanceConfig - Configuration for the instance * @returns {Promise} Queue object with records array */ - async getQueue(instanceConfig) { + async getQueue() { 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 @@ -73,7 +70,7 @@ class ArrRetriever { * @param {string} [options.startDate] - ISO date string for filtering * @returns {Promise} History object with records array */ - async getHistory(instanceConfig, options = {}) { + async getHistory(options = {}) { throw new Error('getHistory() must be implemented by subclass'); } } diff --git a/server/clients/PollingRadarrRetriever.js b/server/clients/PollingRadarrRetriever.js index 16ad9f8..7739e3f 100644 --- a/server/clients/PollingRadarrRetriever.js +++ b/server/clients/PollingRadarrRetriever.js @@ -18,42 +18,39 @@ class PollingRadarrRetriever extends ArrRetriever { /** * Get tags from Radarr instance - * @param {Object} instanceConfig - Configuration for the instance * @returns {Promise} Array of tag objects */ - async getTags(instanceConfig) { + async getTags() { try { - const response = await axios.get(`${instanceConfig.url}/api/v3/tag`, { - headers: { 'X-Api-Key': instanceConfig.apiKey } + const response = await axios.get(`${this.url}/api/v3/tag`, { + headers: { 'X-Api-Key': this.apiKey } }); return response.data; } catch (error) { - logToFile(`[PollingRadarrRetriever] ${instanceConfig.id} tags error: ${error.message}`); + logToFile(`[PollingRadarrRetriever] ${this.id} tags error: ${error.message}`); return []; } } /** * Get queue from Radarr instance - * @param {Object} instanceConfig - Configuration for the instance * @returns {Promise} Queue object with records array */ - async getQueue(instanceConfig) { + async getQueue() { try { - const response = await axios.get(`${instanceConfig.url}/api/v3/queue`, { - headers: { 'X-Api-Key': instanceConfig.apiKey }, + const response = await axios.get(`${this.url}/api/v3/queue`, { + headers: { 'X-Api-Key': this.apiKey }, params: { includeMovie: true } }); return response.data; } catch (error) { - logToFile(`[PollingRadarrRetriever] ${instanceConfig.id} queue error: ${error.message}`); + logToFile(`[PollingRadarrRetriever] ${this.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 @@ -62,7 +59,7 @@ class PollingRadarrRetriever extends ArrRetriever { * @param {string} [options.startDate] - ISO date string for filtering * @returns {Promise} History object with records array */ - async getHistory(instanceConfig, options = {}) { + async getHistory(options = {}) { const { pageSize = 10, sortKey, @@ -81,13 +78,13 @@ class PollingRadarrRetriever extends ArrRetriever { 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 }, + const response = await axios.get(`${this.url}/api/v3/history`, { + headers: { 'X-Api-Key': this.apiKey }, params }); return response.data; } catch (error) { - logToFile(`[PollingRadarrRetriever] ${instanceConfig.id} history error: ${error.message}`); + logToFile(`[PollingRadarrRetriever] ${this.id} history error: ${error.message}`); return { records: [] }; } } diff --git a/server/clients/PollingSonarrRetriever.js b/server/clients/PollingSonarrRetriever.js index 1211038..16a224a 100644 --- a/server/clients/PollingSonarrRetriever.js +++ b/server/clients/PollingSonarrRetriever.js @@ -18,42 +18,39 @@ class PollingSonarrRetriever extends ArrRetriever { /** * Get tags from Sonarr instance - * @param {Object} instanceConfig - Configuration for the instance * @returns {Promise} Array of tag objects */ - async getTags(instanceConfig) { + async getTags() { try { - const response = await axios.get(`${instanceConfig.url}/api/v3/tag`, { - headers: { 'X-Api-Key': instanceConfig.apiKey } + const response = await axios.get(`${this.url}/api/v3/tag`, { + headers: { 'X-Api-Key': this.apiKey } }); return response.data; } catch (error) { - logToFile(`[PollingSonarrRetriever] ${instanceConfig.id} tags error: ${error.message}`); + logToFile(`[PollingSonarrRetriever] ${this.id} tags error: ${error.message}`); return []; } } /** * Get queue from Sonarr instance - * @param {Object} instanceConfig - Configuration for the instance * @returns {Promise} Queue object with records array */ - async getQueue(instanceConfig) { + async getQueue() { try { - const response = await axios.get(`${instanceConfig.url}/api/v3/queue`, { - headers: { 'X-Api-Key': instanceConfig.apiKey }, + const response = await axios.get(`${this.url}/api/v3/queue`, { + headers: { 'X-Api-Key': this.apiKey }, params: { includeSeries: true, includeEpisode: true } }); return response.data; } catch (error) { - logToFile(`[PollingSonarrRetriever] ${instanceConfig.id} queue error: ${error.message}`); + logToFile(`[PollingSonarrRetriever] ${this.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 @@ -63,7 +60,7 @@ class PollingSonarrRetriever extends ArrRetriever { * @param {string} [options.startDate] - ISO date string for filtering * @returns {Promise} History object with records array */ - async getHistory(instanceConfig, options = {}) { + async getHistory(options = {}) { const { pageSize = 10, sortKey, @@ -84,13 +81,13 @@ class PollingSonarrRetriever extends ArrRetriever { 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 }, + const response = await axios.get(`${this.url}/api/v3/history`, { + headers: { 'X-Api-Key': this.apiKey }, params }); return response.data; } catch (error) { - logToFile(`[PollingSonarrRetriever] ${instanceConfig.id} history error: ${error.message}`); + logToFile(`[PollingSonarrRetriever] ${this.id} history error: ${error.message}`); return { records: [] }; } } diff --git a/server/utils/arrRetrievers.js b/server/utils/arrRetrievers.js index 4a995f2..c405bcb 100644 --- a/server/utils/arrRetrievers.js +++ b/server/utils/arrRetrievers.js @@ -16,13 +16,11 @@ const retrieverClasses = { }; /** - * Registry and factory for *arr data retrievers + * Singleton registry for *arr data retrievers */ -class ArrRetrieverRegistry { - constructor() { - this.retrievers = new Map(); - this.initialized = false; - } +const arrRetrieverRegistry = { + retrievers: new Map(), + initialized: false, /** * Initialize all configured *arr retrievers @@ -62,7 +60,7 @@ class ArrRetrieverRegistry { this.initialized = true; logToFile(`[ArrRetrieverRegistry] Initialized ${this.retrievers.size} *arr retrievers`); - } + }, /** * Get all registered retrievers @@ -70,7 +68,7 @@ class ArrRetrieverRegistry { */ getAllRetrievers() { return Array.from(this.retrievers.values()); - } + }, /** * Get retriever by instance ID @@ -79,7 +77,7 @@ class ArrRetrieverRegistry { */ getRetriever(instanceId) { return this.retrievers.get(instanceId) || null; - } + }, /** * Get retrievers by type @@ -88,7 +86,7 @@ class ArrRetrieverRegistry { */ getRetrieversByType(type) { return this.getAllRetrievers().filter(retriever => retriever.getRetrieverType() === type); - } + }, /** * Get tags from all retrievers @@ -104,7 +102,7 @@ class ArrRetrieverRegistry { const results = await Promise.allSettled( retrievers.map(async (retriever) => { try { - const tags = await retriever.getTags(retriever); + const tags = await retriever.getTags(); logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${tags.length} tags`); return { instance: retriever.getInstanceId(), data: tags }; } catch (error) { @@ -117,7 +115,7 @@ class ArrRetrieverRegistry { return results .filter(result => result.status === 'fulfilled') .map(result => result.value); - } + }, /** * Get queue from all retrievers @@ -133,7 +131,7 @@ class ArrRetrieverRegistry { const results = await Promise.allSettled( retrievers.map(async (retriever) => { try { - const queue = await retriever.getQueue(retriever); + const queue = await retriever.getQueue(); logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(queue.records || []).length} queue items`); return { instance: retriever.getInstanceId(), data: queue }; } catch (error) { @@ -146,7 +144,7 @@ class ArrRetrieverRegistry { return results .filter(result => result.status === 'fulfilled') .map(result => result.value); - } + }, /** * Get history from all retrievers @@ -163,7 +161,7 @@ class ArrRetrieverRegistry { const results = await Promise.allSettled( retrievers.map(async (retriever) => { try { - const history = await retriever.getHistory(retriever, options); + const history = await retriever.getHistory(options); logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(history.records || []).length} history records`); return { instance: retriever.getInstanceId(), data: history }; } catch (error) { @@ -176,7 +174,7 @@ class ArrRetrieverRegistry { return results .filter(result => result.status === 'fulfilled') .map(result => result.value); - } + }, /** * Get tags grouped by retriever type @@ -189,7 +187,7 @@ class ArrRetrieverRegistry { const sonarrTags = await Promise.allSettled( sonarrRetrievers.map(async (retriever) => { try { - const tags = await retriever.getTags(retriever); + const tags = await retriever.getTags(); return { instance: retriever.getInstanceId(), data: tags }; } catch (error) { logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`); @@ -201,7 +199,7 @@ class ArrRetrieverRegistry { const radarrTags = await Promise.allSettled( radarrRetrievers.map(async (retriever) => { try { - const tags = await retriever.getTags(retriever); + const tags = await retriever.getTags(); return { instance: retriever.getInstanceId(), data: tags }; } catch (error) { logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`); @@ -218,7 +216,7 @@ class ArrRetrieverRegistry { .filter(result => result.status === 'fulfilled') .map(result => result.value) }; - } + }, /** * Get queue grouped by retriever type @@ -231,7 +229,7 @@ class ArrRetrieverRegistry { const sonarrQueues = await Promise.allSettled( sonarrRetrievers.map(async (retriever) => { try { - const queue = await retriever.getQueue(retriever); + const queue = await retriever.getQueue(); return { instance: retriever.getInstanceId(), data: queue }; } catch (error) { logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`); @@ -243,7 +241,7 @@ class ArrRetrieverRegistry { const radarrQueues = await Promise.allSettled( radarrRetrievers.map(async (retriever) => { try { - const queue = await retriever.getQueue(retriever); + const queue = await retriever.getQueue(); return { instance: retriever.getInstanceId(), data: queue }; } catch (error) { logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`); @@ -260,7 +258,7 @@ class ArrRetrieverRegistry { .filter(result => result.status === 'fulfilled') .map(result => result.value) }; - } + }, /** * Get history grouped by retriever type @@ -274,7 +272,7 @@ class ArrRetrieverRegistry { const sonarrHistory = await Promise.allSettled( sonarrRetrievers.map(async (retriever) => { try { - const history = await retriever.getHistory(retriever, options); + const history = await retriever.getHistory(options); return { instance: retriever.getInstanceId(), data: history }; } catch (error) { logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`); @@ -286,7 +284,7 @@ class ArrRetrieverRegistry { const radarrHistory = await Promise.allSettled( radarrRetrievers.map(async (retriever) => { try { - const history = await retriever.getHistory(retriever, options); + const history = await retriever.getHistory(options); return { instance: retriever.getInstanceId(), data: history }; } catch (error) { logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`); @@ -304,24 +302,6 @@ class ArrRetrieverRegistry { .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) }; + +module.exports = arrRetrieverRegistry; diff --git a/server/utils/historyFetcher.js b/server/utils/historyFetcher.js index 5e9f6ce..01a8165 100644 --- a/server/utils/historyFetcher.js +++ b/server/utils/historyFetcher.js @@ -1,7 +1,7 @@ // Copyright (c) 2026 Gordon Bolton. MIT License. const cache = require('./cache'); const { getSonarrInstances, getRadarrInstances } = require('./config'); -const { initializeRetrievers, getRetrieversByType } = require('./arrRetrievers'); +const arrRetrieverRegistry = require('./arrRetrievers'); // Cache TTL for recent-history data: 5 minutes. // History changes slowly compared to active downloads. @@ -27,17 +27,17 @@ async function fetchSonarrHistory(since) { if (cached) return cached; // Ensure retrievers are initialized - await initializeRetrievers(); + await arrRetrieverRegistry.initialize(); const instances = getSonarrInstances(); - const sonarrRetrievers = getRetrieversByType('sonarr'); + const sonarrRetrievers = arrRetrieverRegistry.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 retriever.getHistory(retriever, { + const response = await retriever.getHistory({ pageSize: 100, sortKey: 'date', sortDir: 'descending', @@ -76,17 +76,17 @@ async function fetchRadarrHistory(since) { if (cached) return cached; // Ensure retrievers are initialized - await initializeRetrievers(); + await arrRetrieverRegistry.initialize(); const instances = getRadarrInstances(); - const radarrRetrievers = getRetrieversByType('radarr'); + const radarrRetrievers = arrRetrieverRegistry.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 retriever.getHistory(retriever, { + const response = await retriever.getHistory({ pageSize: 100, sortKey: 'date', sortDir: 'descending', diff --git a/server/utils/poller.js b/server/utils/poller.js index 6526b12..8bc882f 100644 --- a/server/utils/poller.js +++ b/server/utils/poller.js @@ -2,7 +2,7 @@ const axios = require('axios'); const cache = require('./cache'); const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients'); -const { initializeRetrievers, getTagsByType, getQueuesByType, getHistoryByType } = require('./arrRetrievers'); +const arrRetrieverRegistry = require('./arrRetrievers'); const { getSonarrInstances, getRadarrInstances @@ -41,7 +41,7 @@ async function pollAllServices() { try { // Ensure download clients and *arr retrievers are initialized await initializeClients(); - await initializeRetrievers(); + await arrRetrieverRegistry.initialize(); const sonarrInstances = getSonarrInstances(); const radarrInstances = getRadarrInstances(); @@ -53,27 +53,27 @@ async function pollAllServices() { return downloadsByType; }), timed('Sonarr Tags', async () => { - const tagsByType = await getTagsByType(); + const tagsByType = await arrRetrieverRegistry.getTagsByType(); return tagsByType.sonarr || []; }), timed('Sonarr Queue', async () => { - const queuesByType = await getQueuesByType(); + const queuesByType = await arrRetrieverRegistry.getQueuesByType(); return queuesByType.sonarr || []; }), timed('Sonarr History', async () => { - const historyByType = await getHistoryByType({ pageSize: 10 }); + const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 }); return historyByType.sonarr || []; }), timed('Radarr Queue', async () => { - const queuesByType = await getQueuesByType(); + const queuesByType = await arrRetrieverRegistry.getQueuesByType(); return queuesByType.radarr || []; }), timed('Radarr History', async () => { - const historyByType = await getHistoryByType({ pageSize: 10 }); + const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 }); return historyByType.radarr || []; }), timed('Radarr Tags', async () => { - const tagsByType = await getTagsByType(); + const tagsByType = await arrRetrieverRegistry.getTagsByType(); return tagsByType.radarr || []; }), ]); From 21befa53567715abadb2d166ee4fe43d76d75fac Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 15:01:15 +0100 Subject: [PATCH 3/3] chore: align version with develop branch (1.4.0) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bd34b7e..ff12476 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.3.1", + "version": "1.4.0", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "main": "server/index.js", "scripts": {