bb7b66e06d
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m11s
CI / Tests & coverage (push) Failing after 1m8s
CI / Security audit (push) Successful in 1m11s
Licence Check / Licence compatibility and copyright header verification (pull_request) Successful in 52s
CI / Security audit (pull_request) Successful in 1m18s
CI / Tests & coverage (pull_request) Failing after 1m26s
406 lines
14 KiB
JavaScript
406 lines
14 KiB
JavaScript
// 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<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)
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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;
|