diff --git a/.env.sample b/.env.sample index 5aba643..38abe82 100644 --- a/.env.sample +++ b/.env.sample @@ -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. diff --git a/server/app.js b/server/app.js index edeaebc..6f15371 100644 --- a/server/app.js +++ b/server/app.js @@ -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 diff --git a/server/index.js b/server/index.js index 21f1c88..02d9844 100644 --- a/server/index.js +++ b/server/index.js @@ -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); diff --git a/server/routes/history.js b/server/routes/history.js new file mode 100644 index 0000000..28f4d3e --- /dev/null +++ b/server/routes/history.js @@ -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; diff --git a/server/utils/historyFetcher.js b/server/utils/historyFetcher.js new file mode 100644 index 0000000..cb3b2b5 --- /dev/null +++ b/server/utils/historyFetcher.js @@ -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} 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} 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 +};