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:
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.
|
||||
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;
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user