feat(history): add /api/history/recent endpoint with Sonarr/Radarr history fetching, tag filtering, and 5-min cache
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user