// Copyright (c) 2026 Gordon Bolton. MIT License. const { logToFile } = require('./logger'); const cache = require('./cache'); 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 }; /** * Singleton registry for *arr data retrievers */ const arrRetrieverRegistry = { retrievers: new Map(), 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); const uniqueKey = `${config.type}:${config.id}`; this.retrievers.set(uniqueKey, retriever); logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${uniqueKey})`); } 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(); 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(); 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(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(); 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(); 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(); 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(); 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(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(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) }; } }; /** * Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim */ function sanitizeTagLabel(input) { if (!input) return ''; return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); } /** * Check if a tag matches the username: exact match first, then sanitized match */ function tagMatchesUser(tag, username) { if (!tag || !username) return false; const tagLower = tag.toLowerCase(); const usernameLower = username.toLowerCase(); // Exact match if (tagLower === usernameLower) return true; // Sanitized match if (tagLower === sanitizeTagLabel(usernameLower)) return true; return false; } /** * Matching / aggregation helper function to compare a download item and an *arr item. */ function matchDownload(download, arrItem, username, tagMap) { if (!download || !arrItem) return false; // 1. First attempt an exact ID match using the stable fields that exist in the fetched data if (download.arrInfo) { // Sonarr stable IDs if (download.arrInfo.episodeFileId !== undefined && arrItem.episodeFileId !== undefined) { if (download.arrInfo.episodeFileId === arrItem.episodeFileId) return true; } if (download.arrInfo.episodeId !== undefined && arrItem.episodeId !== undefined) { if (download.arrInfo.episodeId === arrItem.episodeId) return true; } if (download.arrInfo.seriesId !== undefined && arrItem.seriesId !== undefined) { if (download.arrInfo.seriesId === arrItem.seriesId) return true; } // Radarr stable IDs if (download.arrInfo.movieFileId !== undefined && arrItem.movieFileId !== undefined) { if (download.arrInfo.movieFileId === arrItem.movieFileId) return true; } if (download.arrInfo.movieId !== undefined && arrItem.movieId !== undefined) { if (download.arrInfo.movieId === arrItem.movieId) return true; } } // 2. Only fall back to the existing title + tag string matching if no ID match is possible const dlTitle = (download.title || '').toLowerCase(); const arrTitle = (arrItem.title || arrItem.sourceTitle || '').toLowerCase(); const titleMatches = dlTitle && arrTitle && (dlTitle.includes(arrTitle) || arrTitle.includes(dlTitle)); if (!titleMatches) return false; // Preserve the existing lowercase-username tag logic exactly if (!username) return true; const getLabels = (item) => { if (!item) return []; const tags = item.tags || (item.series && item.series.tags) || (item.movie && item.movie.tags) || []; return tags.map(t => { if (typeof t === 'object' && t !== null) { return t.label || t.name; } if (tagMap && tagMap.has && tagMap.has(t)) { return tagMap.get(t); } // Try resolving from cache as fallback const cachedSonarrTags = cache.get('poll:sonarr-tags') || []; const cachedRadarrTags = cache.get('poll:radarr-tags') || []; const allCachedTags = [...cachedSonarrTags, ...cachedRadarrTags]; const found = allCachedTags.find(tag => tag && tag.id === t); if (found) return found.label || found.name; return t; }).filter(Boolean); }; const dlTags = getLabels(download); const arrTags = getLabels(arrItem); const allTags = [...dlTags, ...arrTags]; return allTags.some(tag => tagMatchesUser(tag, username)); } // Attach matching helper functions to the registry object arrRetrieverRegistry.matchDownload = matchDownload; arrRetrieverRegistry.matchDownloadToArr = matchDownload; arrRetrieverRegistry.aggregateMatch = matchDownload; arrRetrieverRegistry.matchingHelper = matchDownload; arrRetrieverRegistry.compareDownloadAndArr = matchDownload; module.exports = arrRetrieverRegistry;