Files
sofarr/server/utils/arrRetrievers.js
T
gronod 6ac0a8421e
Build and Push Docker Image / build (push) Successful in 1m34s
Docs Check / Markdown lint (push) Successful in 2m14s
CI / Security audit (push) Successful in 2m30s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m40s
CI / Swagger Validation & Coverage (push) Successful in 3m22s
Docs Check / Mermaid diagram parse check (push) Successful in 3m43s
CI / Tests & coverage (push) Successful in 3m59s
fix: resolve rate-limiting and Ombi requests caching bugs (fixes #42, fixes #43)
2026-05-23 18:55:03 +01:00

461 lines
16 KiB
JavaScript

// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger');
const cache = require('./cache');
const {
getSonarrInstances,
getRadarrInstances,
getOmbiInstances
} = require('./config');
const TagMatcher = require('../services/TagMatcher');
// Import retriever classes
const PollingSonarrRetriever = require('../clients/PollingSonarrRetriever');
const PollingRadarrRetriever = require('../clients/PollingRadarrRetriever');
const OmbiRetriever = require('../clients/OmbiRetriever');
// Retriever type mapping
const retrieverClasses = {
sonarr: PollingSonarrRetriever,
radarr: PollingRadarrRetriever,
ombi: OmbiRetriever
};
/**
* 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();
const ombiInstances = getOmbiInstances();
// Create retriever instances
const instanceConfigs = [
...sonarrInstances.map(inst => ({ ...inst, type: 'sonarr' })),
...radarrInstances.map(inst => ({ ...inst, type: 'radarr' })),
...ombiInstances.map(inst => ({ ...inst, type: 'ombi' }))
];
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<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();
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();
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(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();
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<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();
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<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(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)
};
},
/**
* Get Ombi retrievers
* @returns {Array<OmbiRetriever>} Array of Ombi retriever instances
*/
getOmbiRetrievers() {
return this.getRetrieversByType('ombi');
},
/**
* Get all Ombi requests
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Object>} Object with movie and TV request arrays
*/
async getOmbiRequests(force = false) {
const ombiRetrievers = this.getOmbiRetrievers();
if (ombiRetrievers.length === 0) {
return { movie: [], tv: [] };
}
// Use the first Ombi retriever (single instance expected)
const retriever = ombiRetrievers[0];
try {
const movieRequests = await retriever.getMovieRequests(force);
const tvRequests = await retriever.getTvRequests(false);
return { movie: movieRequests, tv: tvRequests };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
return { movie: [], tv: [] };
}
},
/**
* Get Ombi requests grouped by type
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Object>} Requests grouped by type (movie, tv)
*/
async getOmbiRequestsByType(force = false) {
return await this.getOmbiRequests(force);
},
/**
* Find Ombi request by external IDs
* @param {string} type - 'movie' or 'tv'
* @param {Object} externalIds - External IDs to search with
* @param {string} externalIds.tmdbId - TheMovieDB ID
* @param {string} externalIds.tvdbId - TheTVDB ID (for TV)
* @param {string} externalIds.imdbId - IMDB ID (for movies)
* @returns {Promise<Object|null>} Ombi request object or null if not found
*/
async findOmbiRequest(type, externalIds) {
const ombiRetrievers = this.getOmbiRetrievers();
if (ombiRetrievers.length === 0) {
return null;
}
const retriever = ombiRetrievers[0];
try {
if (type === 'movie') {
return await retriever.findMovieRequest(externalIds.tmdbId, externalIds.imdbId);
} else if (type === 'tv') {
return await retriever.findTvRequest(externalIds.tvdbId, externalIds.tmdbId);
}
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error finding Ombi request: ${error.message}`);
}
return null;
}
};
/**
* 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 => TagMatcher.tagMatchesUser(tag, username.toLowerCase()));
}
// 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;