feat(history): add /api/history/recent endpoint with Sonarr/Radarr history fetching, tag filtering, and 5-min cache
This commit is contained in:
@@ -52,6 +52,10 @@ COOKIE_SECRET=your-cookie-secret-here
|
||||
# Defaults to ./data relative to the project root.
|
||||
# DATA_DIR=/app/data
|
||||
|
||||
# Number of days of completed download history to show in the Recently Completed section.
|
||||
# Override per-request with ?days=N (capped at 90).
|
||||
# RECENT_COMPLETED_DAYS=7
|
||||
|
||||
# Background polling interval in milliseconds (default: 5000)
|
||||
# sofarr polls all services in the background and caches results so
|
||||
# dashboard requests are near-instant.
|
||||
|
||||
@@ -16,6 +16,7 @@ const sonarrRoutes = require('./routes/sonarr');
|
||||
const radarrRoutes = require('./routes/radarr');
|
||||
const embyRoutes = require('./routes/emby');
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
|
||||
@@ -100,6 +101,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
|
||||
// Global error handler
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
|
||||
@@ -79,6 +79,7 @@ const sonarrRoutes = require('./routes/sonarr');
|
||||
const radarrRoutes = require('./routes/radarr');
|
||||
const embyRoutes = require('./routes/emby');
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||
@@ -250,6 +251,7 @@ app.use('/api/sonarr', sonarrRoutes);
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
|
||||
// SPA catch-all — serve index.html for any unmatched path
|
||||
app.get('*', serveIndex);
|
||||
|
||||
235
server/routes/history.js
Normal file
235
server/routes/history.js
Normal file
@@ -0,0 +1,235 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const cache = require('../utils/cache');
|
||||
const { fetchSonarrHistory, fetchRadarrHistory, classifySonarrEvent, classifyRadarrEvent } = require('../utils/historyFetcher');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
// Re-use the same tag/cover-art helpers as dashboard.js by importing them
|
||||
// from a shared location. For now they are inlined here to keep dashboard.js
|
||||
// untouched (zero-conflict v1 merges). If these diverge they can be extracted
|
||||
// into server/utils/dashboardHelpers.js in a later refactor.
|
||||
|
||||
function getCoverArt(item) {
|
||||
if (!item || !item.images) return null;
|
||||
const poster = item.images.find(img => img.coverType === 'poster');
|
||||
if (poster) return poster.remoteUrl || poster.url || null;
|
||||
const fanart = item.images.find(img => img.coverType === 'fanart');
|
||||
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
||||
}
|
||||
|
||||
function sanitizeTagLabel(input) {
|
||||
if (!input) return '';
|
||||
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function tagMatchesUser(tag, username) {
|
||||
if (!tag || !username) return false;
|
||||
const tagLower = tag.toLowerCase();
|
||||
if (tagLower === username) return true;
|
||||
if (tagLower === sanitizeTagLabel(username)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractAllTags(tags, tagMap) {
|
||||
if (!tags || tags.length === 0) return [];
|
||||
if (tagMap) return tags.map(id => tagMap.get(id)).filter(Boolean);
|
||||
return tags.map(t => t && t.label).filter(Boolean);
|
||||
}
|
||||
|
||||
function extractUserTag(tags, tagMap, username) {
|
||||
const allLabels = extractAllTags(tags, tagMap);
|
||||
if (!allLabels.length) return null;
|
||||
if (username) {
|
||||
const match = allLabels.find(label => tagMatchesUser(label, username));
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSonarrLink(series) {
|
||||
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||
}
|
||||
|
||||
function getRadarrLink(movie) {
|
||||
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/history/recent
|
||||
*
|
||||
* Returns Sonarr/Radarr history records (imported + failed) for the
|
||||
* authenticated user, filtered to the last RECENT_COMPLETED_DAYS days
|
||||
* (default 7, overridable via env or ?days= query param).
|
||||
*
|
||||
* Response shape:
|
||||
* {
|
||||
* user: string,
|
||||
* isAdmin: boolean,
|
||||
* days: number,
|
||||
* history: HistoryItem[]
|
||||
* }
|
||||
*
|
||||
* HistoryItem shape:
|
||||
* {
|
||||
* type: 'series'|'movie',
|
||||
* outcome: 'imported'|'failed',
|
||||
* title: string, // sourceTitle from arr record
|
||||
* seriesName?: string, // series.title (Sonarr)
|
||||
* movieName?: string, // movie.title (Radarr)
|
||||
* coverArt: string|null,
|
||||
* completedAt: string, // ISO date string from arr record
|
||||
* quality: string|null,
|
||||
* instanceName: string, // arr instance name
|
||||
* arrLink: string|null, // link to item in Sonarr/Radarr UI
|
||||
* allTags: string[],
|
||||
* matchedUserTag: string|null,
|
||||
* // admin-only:
|
||||
* arrRecordId?: number,
|
||||
* failureMessage?: string,
|
||||
* }
|
||||
*/
|
||||
router.get('/recent', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
const username = user.name.toLowerCase();
|
||||
const isAdmin = !!user.isAdmin;
|
||||
const showAll = isAdmin && req.query.showAll === 'true';
|
||||
|
||||
const defaultDays = parseInt(process.env.RECENT_COMPLETED_DAYS, 10) || 7;
|
||||
const requestedDays = parseInt(req.query.days, 10);
|
||||
const days = (requestedDays > 0 && requestedDays <= 90) ? requestedDays : defaultDays;
|
||||
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch tag maps and history in parallel
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
const [sonarrHistory, radarrHistory] = await Promise.all([
|
||||
fetchSonarrHistory(since),
|
||||
fetchRadarrHistory(since)
|
||||
]);
|
||||
|
||||
// Build tag maps from the cached poll data where available,
|
||||
// falling back to what's embedded in history records
|
||||
const sonarrTagsData = cache.get('poll:sonarr-tags') || [];
|
||||
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
||||
const sonarrTagMap = new Map(sonarrTagsData.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
||||
const radarrTagMap = new Map(radarrTagsData.map(t => [t.id, t.label]));
|
||||
|
||||
const historyItems = [];
|
||||
|
||||
// --- Sonarr history ---
|
||||
for (const record of sonarrHistory) {
|
||||
try {
|
||||
const outcome = classifySonarrEvent(record.eventType);
|
||||
if (outcome === 'other') continue;
|
||||
|
||||
const series = record.series;
|
||||
if (!series) continue;
|
||||
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
|
||||
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
|
||||
|
||||
const quality = record.quality && record.quality.quality && record.quality.quality.name
|
||||
? record.quality.quality.name
|
||||
: null;
|
||||
|
||||
const item = {
|
||||
type: 'series',
|
||||
outcome,
|
||||
title: record.sourceTitle || record.title || series.title,
|
||||
seriesName: series.title,
|
||||
coverArt: getCoverArt(series),
|
||||
completedAt: record.date,
|
||||
quality,
|
||||
instanceName: record._instanceName || null,
|
||||
arrLink: getSonarrLink(series),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null
|
||||
};
|
||||
|
||||
if (isAdmin) {
|
||||
item.arrRecordId = record.id;
|
||||
if (outcome === 'failed' && record.data && record.data.message) {
|
||||
item.failureMessage = record.data.message;
|
||||
}
|
||||
}
|
||||
|
||||
historyItems.push(item);
|
||||
} catch (err) {
|
||||
console.error('[History] Error processing Sonarr record:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Radarr history ---
|
||||
for (const record of radarrHistory) {
|
||||
try {
|
||||
const outcome = classifyRadarrEvent(record.eventType);
|
||||
if (outcome === 'other') continue;
|
||||
|
||||
const movie = record.movie;
|
||||
if (!movie) continue;
|
||||
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
|
||||
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
|
||||
|
||||
const quality = record.quality && record.quality.quality && record.quality.quality.name
|
||||
? record.quality.quality.name
|
||||
: null;
|
||||
|
||||
const item = {
|
||||
type: 'movie',
|
||||
outcome,
|
||||
title: record.sourceTitle || record.title || movie.title,
|
||||
movieName: movie.title,
|
||||
coverArt: getCoverArt(movie),
|
||||
completedAt: record.date,
|
||||
quality,
|
||||
instanceName: record._instanceName || null,
|
||||
arrLink: getRadarrLink(movie),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null
|
||||
};
|
||||
|
||||
if (isAdmin) {
|
||||
item.arrRecordId = record.id;
|
||||
if (outcome === 'failed' && record.data && record.data.message) {
|
||||
item.failureMessage = record.data.message;
|
||||
}
|
||||
}
|
||||
|
||||
historyItems.push(item);
|
||||
} catch (err) {
|
||||
console.error('[History] Error processing Radarr record:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
historyItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
|
||||
|
||||
console.log(`[History] Returning ${historyItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`);
|
||||
|
||||
res.json({
|
||||
user: user.name,
|
||||
isAdmin,
|
||||
days,
|
||||
history: historyItems
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[History] Error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to fetch history', details: sanitizeError(err) });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
141
server/utils/historyFetcher.js
Normal file
141
server/utils/historyFetcher.js
Normal file
@@ -0,0 +1,141 @@
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('./config');
|
||||
|
||||
// Cache TTL for recent-history data: 5 minutes.
|
||||
// History changes slowly compared to active downloads.
|
||||
const HISTORY_CACHE_TTL = 5 * 60 * 1000;
|
||||
|
||||
// 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.
|
||||
* @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) return cached;
|
||||
|
||||
const instances = getSonarrInstances();
|
||||
const results = await Promise.all(instances.map(async inst => {
|
||||
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,
|
||||
startDate: since.toISOString()
|
||||
}
|
||||
});
|
||||
const records = (response.data && response.data.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);
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recent history records from all Radarr instances for the given date window.
|
||||
* Results are cached under 'history:radarr' for HISTORY_CACHE_TTL.
|
||||
* @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) return cached;
|
||||
|
||||
const instances = getRadarrInstances();
|
||||
const results = await Promise.all(instances.map(async inst => {
|
||||
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 records = (response.data && response.data.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);
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
HISTORY_CACHE_TTL
|
||||
};
|
||||
Reference in New Issue
Block a user