const express = require('express'); const router = express.Router(); const axios = require('axios'); 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; } async function getEmbyUsers() { const cached = cache.get('emby:users'); if (cached) return cached; try { const embyUrl = process.env.EMBY_URL; const embyKey = process.env.EMBY_API_KEY; if (!embyUrl || !embyKey) return new Map(); const res = await axios.get(`${embyUrl}/Users`, { params: { api_key: embyKey } }); const users = res.data || []; const map = new Map(); for (const u of users) { if (!u.Name) continue; const lower = u.Name.toLowerCase(); map.set(lower, u.Name); map.set(sanitizeTagLabel(lower), u.Name); } cache.set('emby:users', map, 60000); return map; } catch (err) { console.error('[History] Failed to fetch Emby users:', err.message); return new Map(); } } function buildTagBadges(allTags, embyUserMap) { return allTags.map(label => { const lower = label.toLowerCase(); const sanitized = sanitizeTagLabel(label); const matchedUser = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null; return { label, matchedUser }; }); } // Extract episode info from a Sonarr history record. function extractEpisode(record) { const s = record.seasonNumber; const e = record.episodeNumber; if (s == null || e == null) return null; const title = record.episode && record.episode.title ? record.episode.title : null; return { season: s, episode: e, title }; } // Find all episodes associated with a download by matching all history records // that share the same source title. Returns sorted, deduplicated array. function gatherEpisodes(titleLower, records) { const episodes = []; const seen = new Set(); for (const r of records) { const rTitle = (r.sourceTitle || r.title || '').toLowerCase(); if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) { const ep = extractEpisode(r); if (ep) { const key = `${ep.season}x${ep.episode}`; if (!seen.has(key)) { seen.add(key); episodes.push(ep); } } } } episodes.sort((a, b) => a.season - b.season || a.episode - b.episode); return episodes; } 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, embyUserMap] = await Promise.all([ fetchSonarrHistory(since), fetchRadarrHistory(since), showAll ? getEmbyUsers() : Promise.resolve(new Map()) ]); // 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 sourceTitle = record.sourceTitle || record.title || series.title; const item = { type: 'series', outcome, title: sourceTitle, seriesName: series.title, episodes: gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory), coverArt: getCoverArt(series), completedAt: record.date, quality, instanceName: record._instanceName || null, arrLink: getSonarrLink(series), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; 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, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; 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;