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.
|
# Defaults to ./data relative to the project root.
|
||||||
# DATA_DIR=/app/data
|
# 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)
|
# Background polling interval in milliseconds (default: 5000)
|
||||||
# sofarr polls all services in the background and caches results so
|
# sofarr polls all services in the background and caches results so
|
||||||
# dashboard requests are near-instant.
|
# dashboard requests are near-instant.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const sonarrRoutes = require('./routes/sonarr');
|
|||||||
const radarrRoutes = require('./routes/radarr');
|
const radarrRoutes = require('./routes/radarr');
|
||||||
const embyRoutes = require('./routes/emby');
|
const embyRoutes = require('./routes/emby');
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
|
const historyRoutes = require('./routes/history');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
app.use('/api/radarr', radarrRoutes);
|
app.use('/api/radarr', radarrRoutes);
|
||||||
app.use('/api/emby', embyRoutes);
|
app.use('/api/emby', embyRoutes);
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
|
app.use('/api/history', historyRoutes);
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const sonarrRoutes = require('./routes/sonarr');
|
|||||||
const radarrRoutes = require('./routes/radarr');
|
const radarrRoutes = require('./routes/radarr');
|
||||||
const embyRoutes = require('./routes/emby');
|
const embyRoutes = require('./routes/emby');
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
|
const historyRoutes = require('./routes/history');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
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/radarr', radarrRoutes);
|
||||||
app.use('/api/emby', embyRoutes);
|
app.use('/api/emby', embyRoutes);
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
|
app.use('/api/history', historyRoutes);
|
||||||
|
|
||||||
// SPA catch-all — serve index.html for any unmatched path
|
// SPA catch-all — serve index.html for any unmatched path
|
||||||
app.get('*', serveIndex);
|
app.get('*', serveIndex);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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