Files
sofarr/server/utils/historyFetcher.js
T
gronod 93a09e10a8
Build and Push Docker Image / build (push) Successful in 36s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m18s
CI / Security audit (push) Successful in 1m13s
CI / Tests & coverage (push) Successful in 1m15s
Fix background fetch to not overwrite cache with empty data
- Only trigger background refresh if cache is incomplete (less than max records)
- Only update cache if background fetch returns records (don't overwrite on failure)
- Prevents test failures when background fetch fails to connect to Sonarr/Radarr
- Fixes integration test 'includes imported and failed records, excludes grabbed'
2026-05-21 01:53:19 +01:00

358 lines
12 KiB
JavaScript

// Copyright (c) 2026 Gordon Bolton. MIT License.
const cache = require('./cache');
const { getSonarrInstances, getRadarrInstances } = require('./config');
const arrRetrieverRegistry = require('./arrRetrievers');
// Cache TTL for recent-history data: 5 minutes.
// History changes slowly compared to active downloads.
const HISTORY_CACHE_TTL = 5 * 60 * 1000;
// Staged loading configuration
const INITIAL_PAGE_SIZE = 100;
const MAX_TOTAL_RECORDS = 1000;
const MAX_PAGES = 10; // 10 pages * 100 records = 1000 total
// Background fetch state to prevent concurrent fetches
const backgroundFetchState = {
sonarr: { inProgress: false, lastFetchTime: 0 },
radarr: { inProgress: false, lastFetchTime: 0 }
};
// Event subscribers for history updates
const historyUpdateSubscribers = new Set();
// Sonarr event types that represent a successful import
const SONARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
// Sonarr event types that represent a failed import
const SONARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
// Radarr equivalents
const RADARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
const RADARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
/**
* Fetch recent history records from all Sonarr instances for the given date window.
* Results are cached under 'history:sonarr' for HISTORY_CACHE_TTL.
* Uses staged loading: fetches 100 records immediately, then background-fetches up to 1000.
* @param {Date} since - Only include records on or after this date
* @returns {Promise<Array>} Flat array of Sonarr history records (with _instanceUrl and _instanceName)
*/
async function fetchSonarrHistory(since) {
const cacheKey = 'history:sonarr';
const cached = cache.get(cacheKey);
if (cached) {
// Only trigger background refresh if cache is incomplete (less than max records)
if (!backgroundFetchState.sonarr.inProgress && cached.length < MAX_TOTAL_RECORDS) {
triggerBackgroundSonarrFetch(since);
}
return cached;
}
// Ensure retrievers are initialized
await arrRetrieverRegistry.initialize();
const instances = getSonarrInstances();
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr');
// Stage 1: Fetch initial batch (100 records)
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 retriever.getHistory({
pageSize: INITIAL_PAGE_SIZE,
maxPages: 1,
sortKey: 'date',
sortDir: 'descending',
includeSeries: true,
includeEpisode: true,
startDate: since.toISOString()
});
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;
r._instanceUrl = inst.url;
r._instanceName = inst.name || inst.id;
return r;
});
} catch (err) {
console.error(`[HistoryFetcher] Sonarr ${inst.id} error:`, err.message);
return [];
}
}));
const flat = results.flat();
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
// Stage 2: Trigger background fetch for remaining records
triggerBackgroundSonarrFetch(since);
return flat;
}
/**
* Trigger background fetch for remaining Sonarr history records.
* Uses the retriever's built-in pagination to fetch up to 1000 records.
*/
async function triggerBackgroundSonarrFetch(since) {
if (backgroundFetchState.sonarr.inProgress) return;
// Debounce: don't fetch if we fetched within the last minute
const now = Date.now();
if (now - backgroundFetchState.sonarr.lastFetchTime < 60000) return;
backgroundFetchState.sonarr.inProgress = true;
backgroundFetchState.sonarr.lastFetchTime = now;
try {
await arrRetrieverRegistry.initialize();
const instances = getSonarrInstances();
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr');
// Fetch all records up to MAX_PAGES using built-in pagination
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 retriever.getHistory({
pageSize: INITIAL_PAGE_SIZE,
maxPages: MAX_PAGES,
sortKey: 'date',
sortDir: 'descending',
includeSeries: true,
includeEpisode: true,
startDate: since.toISOString()
});
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;
r._instanceUrl = inst.url;
r._instanceName = inst.name || inst.id;
return r;
});
} catch (err) {
console.error(`[HistoryFetcher] Sonarr background fetch ${inst.id} error:`, err.message);
return [];
}
}));
const allRecords = results.flat();
// Only update cache if we got records (don't overwrite with empty data on failure)
if (allRecords.length > 0) {
cache.set('history:sonarr', allRecords, HISTORY_CACHE_TTL);
// Emit SSE event for history update
emitHistoryUpdate('sonarr');
console.log(`[HistoryFetcher] Background fetch complete: ${allRecords.length} Sonarr records`);
}
} catch (err) {
console.error('[HistoryFetcher] Background Sonarr fetch error:', err.message);
} finally {
backgroundFetchState.sonarr.inProgress = false;
}
}
/**
* Fetch recent history records from all Radarr instances for the given date window.
* Results are cached under 'history:radarr' for HISTORY_CACHE_TTL.
* Uses staged loading: fetches 100 records immediately, then background-fetches up to 1000.
* @param {Date} since - Only include records on or after this date
* @returns {Promise<Array>} Flat array of Radarr history records (with _instanceUrl and _instanceName)
*/
async function fetchRadarrHistory(since) {
const cacheKey = 'history:radarr';
const cached = cache.get(cacheKey);
if (cached) {
// Only trigger background refresh if cache is incomplete (less than max records)
if (!backgroundFetchState.radarr.inProgress && cached.length < MAX_TOTAL_RECORDS) {
triggerBackgroundRadarrFetch(since);
}
return cached;
}
// Ensure retrievers are initialized
await arrRetrieverRegistry.initialize();
const instances = getRadarrInstances();
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr');
// Stage 1: Fetch initial batch (100 records)
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 retriever.getHistory({
pageSize: INITIAL_PAGE_SIZE,
maxPages: 1,
sortKey: 'date',
sortDir: 'descending',
includeMovie: true,
startDate: since.toISOString()
});
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;
r._instanceUrl = inst.url;
r._instanceName = inst.name || inst.id;
return r;
});
} catch (err) {
console.error(`[HistoryFetcher] Radarr ${inst.id} error:`, err.message);
return [];
}
}));
const flat = results.flat();
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
// Stage 2: Trigger background fetch for remaining records
triggerBackgroundRadarrFetch(since);
return flat;
}
/**
* Trigger background fetch for remaining Radarr history records.
* Uses the retriever's built-in pagination to fetch up to 1000 records.
*/
async function triggerBackgroundRadarrFetch(since) {
if (backgroundFetchState.radarr.inProgress) return;
// Debounce: don't fetch if we fetched within the last minute
const now = Date.now();
if (now - backgroundFetchState.radarr.lastFetchTime < 60000) return;
backgroundFetchState.radarr.inProgress = true;
backgroundFetchState.radarr.lastFetchTime = now;
try {
await arrRetrieverRegistry.initialize();
const instances = getRadarrInstances();
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr');
// Fetch all records up to MAX_PAGES using built-in pagination
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 retriever.getHistory({
pageSize: INITIAL_PAGE_SIZE,
maxPages: MAX_PAGES,
sortKey: 'date',
sortDir: 'descending',
includeMovie: true,
startDate: since.toISOString()
});
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;
r._instanceUrl = inst.url;
r._instanceName = inst.name || inst.id;
return r;
});
} catch (err) {
console.error(`[HistoryFetcher] Radarr background fetch ${inst.id} error:`, err.message);
return [];
}
}));
const allRecords = results.flat();
// Only update cache if we got records (don't overwrite with empty data on failure)
if (allRecords.length > 0) {
cache.set('history:radarr', allRecords, HISTORY_CACHE_TTL);
// Emit SSE event for history update
emitHistoryUpdate('radarr');
console.log(`[HistoryFetcher] Background fetch complete: ${allRecords.length} Radarr records`);
}
} catch (err) {
console.error('[HistoryFetcher] Background Radarr fetch error:', err.message);
} finally {
backgroundFetchState.radarr.inProgress = false;
}
}
/**
* Subscribe to history update events.
* @param {Function} callback - Function to call when history is updated
*/
function onHistoryUpdate(callback) {
historyUpdateSubscribers.add(callback);
}
/**
* Unsubscribe from history update events.
* @param {Function} callback - Function to remove from subscribers
*/
function offHistoryUpdate(callback) {
historyUpdateSubscribers.delete(callback);
}
/**
* Emit SSE event for history update.
* Notifies all subscribers when history cache is updated.
*/
function emitHistoryUpdate(type) {
console.log(`[HistoryFetcher] History updated for ${type}, notifying ${historyUpdateSubscribers.size} subscribers`);
historyUpdateSubscribers.forEach(callback => {
try {
callback(type);
} catch (err) {
console.error('[HistoryFetcher] Error in history update subscriber:', err.message);
}
});
}
/**
* Classify a Sonarr history record's event type.
* @param {string} eventType
* @returns {'imported'|'failed'|'other'}
*/
function classifySonarrEvent(eventType) {
if (SONARR_IMPORTED_EVENTS.has(eventType)) return 'imported';
if (SONARR_FAILED_EVENTS.has(eventType)) return 'failed';
return 'other';
}
/**
* Classify a Radarr history record's event type.
* @param {string} eventType
* @returns {'imported'|'failed'|'other'}
*/
function classifyRadarrEvent(eventType) {
if (RADARR_IMPORTED_EVENTS.has(eventType)) return 'imported';
if (RADARR_FAILED_EVENTS.has(eventType)) return 'failed';
return 'other';
}
/**
* Invalidate cached history so the next request fetches fresh data.
* Called externally if needed (e.g. after a forced refresh).
*/
function invalidateHistoryCache() {
cache.invalidate('history:sonarr');
cache.invalidate('history:radarr');
}
module.exports = {
fetchSonarrHistory,
fetchRadarrHistory,
classifySonarrEvent,
classifyRadarrEvent,
invalidateHistoryCache,
onHistoryUpdate,
offHistoryUpdate,
HISTORY_CACHE_TTL
};