236 lines
7.9 KiB
JavaScript
236 lines
7.9 KiB
JavaScript
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;
|