feat(history): add /api/history/recent endpoint with Sonarr/Radarr history fetching, tag filtering, and 5-min cache

This commit is contained in:
2026-05-17 12:05:30 +01:00
parent ffd9e84a00
commit ddcfbda0c2
5 changed files with 384 additions and 0 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View 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;

View 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
};