perf: background poller for near-instant dashboard responses
- New poller.js polls all services on a configurable interval - POLL_INTERVAL env var (default 5000ms / 5 seconds) - All data stored in cache with TTL of 3x poll interval - Dashboard endpoint now reads from cache only (no network calls) - API responses are near-instant regardless of service count - First poll runs immediately on server start
This commit is contained in:
199
server/utils/poller.js
Normal file
199
server/utils/poller.js
Normal file
@@ -0,0 +1,199 @@
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { getTorrents } = require('./qbittorrent');
|
||||
const {
|
||||
getSABnzbdInstances,
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
} = require('./config');
|
||||
|
||||
const POLL_INTERVAL = parseInt(process.env.POLL_INTERVAL, 10) || 5000;
|
||||
|
||||
let polling = false;
|
||||
|
||||
async function pollAllServices() {
|
||||
if (polling) {
|
||||
console.log('[Poller] Previous poll still running, skipping');
|
||||
return;
|
||||
}
|
||||
polling = true;
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const sabInstances = getSABnzbdInstances();
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
// All fetches in parallel
|
||||
const [
|
||||
sabQueues, sabHistories,
|
||||
sonarrTagsResults, sonarrQueues, sonarrHistories, sonarrSeriesResults,
|
||||
radarrQueues, radarrHistories, radarrMoviesResults, radarrTagsResults,
|
||||
qbittorrentTorrents
|
||||
] = await Promise.all([
|
||||
// SABnzbd
|
||||
Promise.all(sabInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api`, {
|
||||
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] SABnzbd ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { queue: { slots: [] } } };
|
||||
})
|
||||
)),
|
||||
Promise.all(sabInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api`, {
|
||||
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 20 }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] SABnzbd ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { history: { slots: [] } } };
|
||||
})
|
||||
)),
|
||||
// Sonarr
|
||||
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: [] };
|
||||
})
|
||||
)),
|
||||
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: [] } };
|
||||
})
|
||||
)),
|
||||
Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 20, includeSeries: true, 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: [] } };
|
||||
})
|
||||
)),
|
||||
Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} series error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
)),
|
||||
// Radarr
|
||||
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: [] } };
|
||||
})
|
||||
)),
|
||||
Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 20, includeMovie: true }
|
||||
}).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: [] } };
|
||||
})
|
||||
)),
|
||||
Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} movies error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
)),
|
||||
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: [] };
|
||||
})
|
||||
)),
|
||||
// qBittorrent
|
||||
getTorrents().catch(err => {
|
||||
console.error(`[Poller] qBittorrent error:`, err.message);
|
||||
return [];
|
||||
})
|
||||
]);
|
||||
|
||||
// Aggregate and store in cache (TTL slightly longer than poll interval to avoid gaps)
|
||||
const cacheTTL = POLL_INTERVAL * 3;
|
||||
|
||||
// SABnzbd
|
||||
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue;
|
||||
cache.set('poll:sab-queue', {
|
||||
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
|
||||
status: firstSabQueue && firstSabQueue.status,
|
||||
speed: firstSabQueue && firstSabQueue.speed,
|
||||
kbpersec: firstSabQueue && firstSabQueue.kbpersec
|
||||
}, cacheTTL);
|
||||
|
||||
cache.set('poll:sab-history', {
|
||||
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
|
||||
}, cacheTTL);
|
||||
|
||||
// Sonarr
|
||||
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
|
||||
cache.set('poll:sonarr-queue', {
|
||||
records: sonarrQueues.flatMap(q => q.data.records || [])
|
||||
}, cacheTTL);
|
||||
cache.set('poll:sonarr-history', {
|
||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||
}, cacheTTL);
|
||||
cache.set('poll:sonarr-series', sonarrSeriesResults.flatMap(s => {
|
||||
const inst = sonarrInstances.find(i => i.id === s.instance);
|
||||
return (s.data || []).map(item => ({ ...item, _instanceUrl: inst ? inst.url : null }));
|
||||
}), cacheTTL);
|
||||
|
||||
// Radarr
|
||||
cache.set('poll:radarr-queue', {
|
||||
records: radarrQueues.flatMap(q => q.data.records || [])
|
||||
}, cacheTTL);
|
||||
cache.set('poll:radarr-history', {
|
||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||
}, cacheTTL);
|
||||
cache.set('poll:radarr-movies', radarrMoviesResults.flatMap(m => {
|
||||
const inst = radarrInstances.find(i => i.id === m.instance);
|
||||
return (m.data || []).map(item => ({ ...item, _instanceUrl: inst ? inst.url : null }));
|
||||
}), cacheTTL);
|
||||
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
|
||||
|
||||
// qBittorrent
|
||||
cache.set('poll:qbittorrent', qbittorrentTorrents, cacheTTL);
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
console.log(`[Poller] Poll complete in ${elapsed}ms`);
|
||||
} catch (err) {
|
||||
console.error(`[Poller] Poll error:`, err.message);
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
let intervalHandle = null;
|
||||
|
||||
function startPoller() {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { startPoller, stopPoller, POLL_INTERVAL };
|
||||
Reference in New Issue
Block a user