All checks were successful
- 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
246 lines
8.4 KiB
JavaScript
246 lines
8.4 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
const axios = require('axios');
|
|
const cache = require('./cache');
|
|
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
|
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
|
const {
|
|
getSonarrInstances,
|
|
getRadarrInstances
|
|
} = require('./config');
|
|
|
|
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
|
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
|
|
? 0
|
|
: (parseInt(process.env.POLL_INTERVAL, 10) || 5000);
|
|
const POLLING_ENABLED = POLL_INTERVAL > 0;
|
|
|
|
let polling = false;
|
|
let lastPollTimings = null;
|
|
|
|
// SSE subscribers: Set of () => void callbacks, each registered by an open /stream connection
|
|
const pollSubscribers = new Set();
|
|
|
|
function onPollComplete(cb) { pollSubscribers.add(cb); }
|
|
function offPollComplete(cb) { pollSubscribers.delete(cb); }
|
|
|
|
// Timed fetch helper: runs a fetch and records how long it took
|
|
async function timed(label, fn) {
|
|
const t0 = Date.now();
|
|
const result = await fn();
|
|
return { label, result, ms: Date.now() - t0 };
|
|
}
|
|
|
|
async function pollAllServices() {
|
|
if (polling) {
|
|
console.log('[Poller] Previous poll still running, skipping');
|
|
return;
|
|
}
|
|
polling = true;
|
|
const start = Date.now();
|
|
|
|
try {
|
|
// Ensure download clients and *arr retrievers are initialized
|
|
await initializeClients();
|
|
await arrRetrieverRegistry.initialize();
|
|
|
|
const sonarrInstances = getSonarrInstances();
|
|
const radarrInstances = getRadarrInstances();
|
|
|
|
// All fetches in parallel, each individually timed
|
|
const results = await Promise.all([
|
|
timed('Download Clients', async () => {
|
|
const downloadsByType = await getDownloadsByClientType();
|
|
return downloadsByType;
|
|
}),
|
|
timed('Sonarr Tags', async () => {
|
|
const tagsByType = await arrRetrieverRegistry.getTagsByType();
|
|
return tagsByType.sonarr || [];
|
|
}),
|
|
timed('Sonarr Queue', async () => {
|
|
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
|
return queuesByType.sonarr || [];
|
|
}),
|
|
timed('Sonarr History', async () => {
|
|
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
|
|
return historyByType.sonarr || [];
|
|
}),
|
|
timed('Radarr Queue', async () => {
|
|
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
|
return queuesByType.radarr || [];
|
|
}),
|
|
timed('Radarr History', async () => {
|
|
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
|
|
return historyByType.radarr || [];
|
|
}),
|
|
timed('Radarr Tags', async () => {
|
|
const tagsByType = await arrRetrieverRegistry.getTagsByType();
|
|
return tagsByType.radarr || [];
|
|
}),
|
|
]);
|
|
|
|
const [
|
|
{ result: downloadsByType },
|
|
{ result: sonarrTagsResults }, { result: sonarrQueues },
|
|
{ result: sonarrHistories },
|
|
{ result: radarrQueues }, { result: radarrHistories },
|
|
{ result: radarrTagsResults }
|
|
] = results;
|
|
|
|
// Store per-task timings
|
|
const totalMs = Date.now() - start;
|
|
lastPollTimings = {
|
|
totalMs,
|
|
timestamp: new Date().toISOString(),
|
|
tasks: results.map(r => ({ label: r.label, ms: r.ms }))
|
|
};
|
|
|
|
// When polling is active, TTL is 3x interval to avoid gaps between polls
|
|
// When polling is disabled (on-demand), use 30s so data refreshes on next request after expiry
|
|
const cacheTTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
|
|
|
|
// Download Clients (SABnzbd, qBittorrent, Transmission)
|
|
// Preserve backward compatibility with existing cache keys
|
|
const sabnzbdDownloads = downloadsByType.sabnzbd || [];
|
|
const qbittorrentDownloads = downloadsByType.qbittorrent || [];
|
|
|
|
// SABnzbd - separate queue and history based on source
|
|
const sabQueue = sabnzbdDownloads.filter(d => d.raw && d.raw.source === 'queue');
|
|
const sabHistory = sabnzbdDownloads.filter(d => d.raw && d.raw.source === 'history');
|
|
|
|
// Transform SABnzbd downloads to legacy format for cache
|
|
const sabQueueLegacy = {
|
|
slots: sabQueue.map(d => ({
|
|
nzo_id: d.id,
|
|
filename: d.title,
|
|
status: d.status,
|
|
progress: d.progress / 100,
|
|
mb: d.size / (1024 * 1024),
|
|
mbleft: (d.size - d.downloaded) / (1024 * 1024),
|
|
kbpersec: d.speed / 1024,
|
|
timeleft: d.eta ? `${Math.floor(d.eta / 60)}:${String(Math.floor(d.eta % 60)).padStart(2, '0')}` : 'unknown',
|
|
cat: d.category,
|
|
labels: d.tags.join(','),
|
|
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
|
|
raw: d.raw
|
|
}))
|
|
};
|
|
|
|
const sabHistoryLegacy = {
|
|
slots: sabHistory.map(d => ({
|
|
nzo_id: d.id,
|
|
filename: d.title,
|
|
status: d.status,
|
|
mb: d.size / (1024 * 1024),
|
|
cat: d.category,
|
|
labels: d.tags.join(','),
|
|
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
|
|
raw: d.raw
|
|
}))
|
|
};
|
|
|
|
// Extract status from first SABnzbd download if available
|
|
const firstSabDownload = sabQueue[0];
|
|
const sabStatus = firstSabDownload ? {
|
|
status: 'Active',
|
|
speed: firstSabDownload.speed,
|
|
kbpersec: firstSabDownload.speed / 1024
|
|
} : { status: 'Idle', speed: 0, kbpersec: 0 };
|
|
|
|
cache.set('poll:sab-queue', {
|
|
...sabQueueLegacy,
|
|
...sabStatus
|
|
}, cacheTTL);
|
|
|
|
cache.set('poll:sab-history', sabHistoryLegacy, cacheTTL);
|
|
|
|
// qBittorrent - transform to legacy format
|
|
const qbittorrentLegacy = qbittorrentDownloads.map(d => ({
|
|
...d.raw,
|
|
instanceId: d.instanceId,
|
|
instanceName: d.instanceName
|
|
}));
|
|
|
|
cache.set('poll:qbittorrent', qbittorrentLegacy, cacheTTL);
|
|
|
|
// Sonarr
|
|
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
|
|
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
|
|
cache.set('poll:sonarr-queue', {
|
|
records: sonarrQueues.flatMap(q => {
|
|
const inst = sonarrInstances.find(i => i.id === q.instance);
|
|
const url = inst ? inst.url : null;
|
|
const key = inst ? inst.apiKey : null;
|
|
return (q.data.records || []).map(r => {
|
|
if (r.series) r.series._instanceUrl = url;
|
|
r._instanceUrl = url;
|
|
r._instanceKey = key;
|
|
return r;
|
|
});
|
|
})
|
|
}, cacheTTL);
|
|
cache.set('poll:sonarr-history', {
|
|
records: sonarrHistories.flatMap(h => h.data.records || [])
|
|
}, cacheTTL);
|
|
|
|
// Radarr
|
|
cache.set('poll:radarr-queue', {
|
|
records: radarrQueues.flatMap(q => {
|
|
const inst = radarrInstances.find(i => i.id === q.instance);
|
|
const url = inst ? inst.url : null;
|
|
const key = inst ? inst.apiKey : null;
|
|
return (q.data.records || []).map(r => {
|
|
if (r.movie) r.movie._instanceUrl = url;
|
|
r._instanceUrl = url;
|
|
r._instanceKey = key;
|
|
return r;
|
|
});
|
|
})
|
|
}, cacheTTL);
|
|
cache.set('poll:radarr-history', {
|
|
records: radarrHistories.flatMap(h => h.data.records || [])
|
|
}, cacheTTL);
|
|
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
|
|
|
|
// qBittorrent (already set above in download clients section)
|
|
|
|
const elapsed = Date.now() - start;
|
|
console.log(`[Poller] Poll complete in ${elapsed}ms`);
|
|
|
|
// Notify all SSE stream connections so they push fresh data immediately
|
|
for (const cb of pollSubscribers) {
|
|
try { cb(); } catch { /* subscriber already disconnected */ }
|
|
}
|
|
} catch (err) {
|
|
console.error(`[Poller] Poll error:`, err.message);
|
|
} finally {
|
|
polling = false;
|
|
}
|
|
}
|
|
|
|
let intervalHandle = null;
|
|
|
|
function startPoller() {
|
|
if (!POLLING_ENABLED) {
|
|
console.log(`[Poller] Background polling disabled (POLL_INTERVAL=${process.env.POLL_INTERVAL || 'not set'}). Data will be fetched on-demand.`);
|
|
return;
|
|
}
|
|
console.log(`[Poller] Starting background poller (interval: ${POLL_INTERVAL}ms)`);
|
|
// Run immediately, then on interval
|
|
pollAllServices();
|
|
intervalHandle = setInterval(pollAllServices, POLL_INTERVAL);
|
|
}
|
|
|
|
function stopPoller() {
|
|
if (intervalHandle) {
|
|
clearInterval(intervalHandle);
|
|
intervalHandle = null;
|
|
console.log('[Poller] Stopped');
|
|
}
|
|
}
|
|
|
|
function getLastPollTimings() {
|
|
return lastPollTimings;
|
|
}
|
|
|
|
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLL_INTERVAL, POLLING_ENABLED };
|