// Copyright (c) 2026 Gordon Bolton. MIT License. const axios = require('axios'); const cache = require('./cache'); const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients'); const { initializeRetrievers, getTagsByType, getQueuesByType, getHistoryByType } = 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 initializeRetrievers(); 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 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 [ { 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 };