e8037afbb8
Build and Push Docker Image / build (push) Successful in 1m7s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m20s
CI / Security audit (push) Successful in 2m45s
CI / Tests & coverage (push) Successful in 3m18s
CI / Swagger Validation & Coverage (push) Successful in 3m29s
Add arrType field to Sonarr and Radarr history items for admin users to enable proper icon display in the recently downloaded section. This mirrors the existing behavior in active downloads where arrType is set by DownloadMatcher. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
487 lines
18 KiB
JavaScript
487 lines
18 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
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 ep = record.episode || {};
|
|
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
|
|
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
|
|
if (s == null || e == null) return null;
|
|
const title = ep.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;
|
|
}
|
|
|
|
/**
|
|
* Deduplicate history items so that for each unique content item (episode or
|
|
* movie) only the most-recent record is shown, with the following rules:
|
|
*
|
|
* - If the most recent event is 'imported' → show it; suppress older failures.
|
|
* - If the most recent event is 'failed' and the item currently has a file
|
|
* (hasFile = true) → show the failure but flag it as availableForUpgrade:true
|
|
* so the UI can indicate the item is available but an upgrade is in progress.
|
|
* - If the most recent event is 'failed' and hasFile is false → show normally.
|
|
*
|
|
* Items are keyed by: type + instanceName + contentId (episodeId or movieId).
|
|
* Records without a contentId fall through unchanged (no deduplication possible).
|
|
*
|
|
* @param {Array} items - Already-built history items (unsorted)
|
|
* @param {Array} sonarrRaw - Raw Sonarr records (for hasFile lookup)
|
|
* @param {Array} radarrRaw - Raw Radarr records (for hasFile lookup)
|
|
* @returns {Array}
|
|
*/
|
|
function deduplicateHistoryItems(items, sonarrRaw, radarrRaw) {
|
|
// Build hasFile lookup: contentId → boolean
|
|
const sonarrHasFile = new Map();
|
|
for (const r of sonarrRaw) {
|
|
const id = r.episodeId;
|
|
if (id != null) {
|
|
const hf = r.episode && r.episode.hasFile != null ? r.episode.hasFile : undefined;
|
|
if (hf !== undefined && !sonarrHasFile.has(id)) sonarrHasFile.set(id, hf);
|
|
}
|
|
}
|
|
const radarrHasFile = new Map();
|
|
for (const r of radarrRaw) {
|
|
const id = r.movieId;
|
|
if (id != null) {
|
|
const hf = r.movie && r.movie.hasFile != null ? r.movie.hasFile : undefined;
|
|
if (hf !== undefined && !radarrHasFile.has(id)) radarrHasFile.set(id, hf);
|
|
}
|
|
}
|
|
|
|
// Group items by dedup key; preserve insertion order (newest first from caller)
|
|
const groups = new Map();
|
|
const noKey = [];
|
|
for (const item of items) {
|
|
const cid = item._contentId;
|
|
if (cid == null) { noKey.push(item); continue; }
|
|
const key = `${item.type}|${item.instanceName}|${cid}`;
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key).push(item);
|
|
}
|
|
|
|
const result = [...noKey];
|
|
for (const [, group] of groups) {
|
|
// group[0] is the most recent (items are pushed in date-descending order)
|
|
const best = group[0];
|
|
if (best.outcome === 'imported') {
|
|
result.push(best);
|
|
continue;
|
|
}
|
|
if (best.outcome === 'failed') {
|
|
const hasFile = best.type === 'series'
|
|
? sonarrHasFile.get(best._contentId)
|
|
: radarrHasFile.get(best._contentId);
|
|
if (hasFile) best.availableForUpgrade = true;
|
|
result.push(best);
|
|
continue;
|
|
}
|
|
result.push(best);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/history/recent:
|
|
* get:
|
|
* tags: [History]
|
|
* summary: Get recent history
|
|
* description: |
|
|
* Returns Sonarr/Radarr history records (imported + failed) for the authenticated user,
|
|
* filtered to the last N days (default 7, max 90).
|
|
*
|
|
* **Authentication:** Requires valid `emby_user` cookie.
|
|
*
|
|
* **Filtering:**
|
|
* - Non-admin users: Only see history items tagged with their username
|
|
* - Admin users: Can see all history by setting query parameter `showAll=true`
|
|
* - Date range: Configurable via `?days=` query parameter (default: 7, max: 90)
|
|
*
|
|
* **Deduplication Rules:**
|
|
* For each unique content item (episode or movie), only the most recent record is shown:
|
|
* - If the most recent event is "imported" → show it; suppress older failures
|
|
* - If the most recent event is "failed" and the item has a file on disk → show with `availableForUpgrade=true`
|
|
* - If the most recent event is "failed" and no file exists → show normally
|
|
*
|
|
* **Event Classification:**
|
|
* - Sonarr: DownloadFolderImported, ImportFailed → included
|
|
* - Radarr: DownloadFolderImported, ImportFailed → included
|
|
* - Other event types (Rename, Health, etc.) → excluded
|
|
*
|
|
* **Response Structure:**
|
|
* - `type`: "series" or "movie"
|
|
* - `outcome`: "imported" or "failed"
|
|
* - `title`: Source title from *arr record
|
|
* - `seriesName`/`movieName`: Friendly media title
|
|
* - `coverArt`: Poster URL
|
|
* - `completedAt`: ISO 8601 timestamp
|
|
* - `quality`: Quality string (e.g., "HDTV-1080p")
|
|
* - `instanceName`: *arr instance name
|
|
* - `arrLink`: Link to item in *arr UI
|
|
* - `allTags`: All tags on the series/movie
|
|
* - `matchedUserTag`: Tag matching the requesting user
|
|
* - `availableForUpgrade`: True if failed but content is on disk (admin-only)
|
|
* - `failureMessage`: Failure details (admin-only)
|
|
*
|
|
* **x-integration-notes:** Used by the history tab to show recently completed downloads.
|
|
* Episodes are gathered from all history records sharing the same source title.
|
|
* security:
|
|
* - CookieAuth: []
|
|
* parameters:
|
|
* - name: days
|
|
* in: query
|
|
* schema:
|
|
* type: integer
|
|
* minimum: 1
|
|
* maximum: 90
|
|
* default: 7
|
|
* description: Number of days to look back (max 90)
|
|
* example: 7
|
|
* - name: showAll
|
|
* in: query
|
|
* schema:
|
|
* type: boolean
|
|
* default: false
|
|
* description: 'Admin-only: show all users'' history'
|
|
* responses:
|
|
* '200':
|
|
* description: History items
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* user:
|
|
* type: string
|
|
* example: "john"
|
|
* isAdmin:
|
|
* type: boolean
|
|
* example: false
|
|
* days:
|
|
* type: integer
|
|
* example: 7
|
|
* history:
|
|
* type: array
|
|
* items:
|
|
* $ref: '#/components/schemas/HistoryItem'
|
|
* example:
|
|
* user: "john"
|
|
* isAdmin: false
|
|
* days: 7
|
|
* history:
|
|
* - type: "series"
|
|
* outcome: "imported"
|
|
* title: "Show.Name.S01E01.1080p.WEB-DL"
|
|
* seriesName: "Show Name"
|
|
* episodes:
|
|
* - season: 1
|
|
* episode: 1
|
|
* title: "Pilot"
|
|
* coverArt: "http://sonarr:8989/media/poster.jpg"
|
|
* completedAt: "2026-05-21T10:00:00.000Z"
|
|
* quality: "HDTV-1080p"
|
|
* instanceName: "Main Sonarr"
|
|
* arrLink: "http://sonarr:8989/series/show-slug"
|
|
* allTags: ["user-john"]
|
|
* matchedUserTag: "user-john"
|
|
* '401':
|
|
* description: Not authenticated
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ErrorResponse'
|
|
* '500':
|
|
* description: Server error
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ErrorResponse'
|
|
* x-code-samples:
|
|
* - lang: curl
|
|
* label: cURL
|
|
* source: |
|
|
* curl -X GET "http://localhost:3001/api/history/recent?days=7" \
|
|
* -b cookies.txt
|
|
* - lang: JavaScript
|
|
* label: JavaScript (fetch)
|
|
* source: |
|
|
* const response = await fetch('http://localhost:3001/api/history/recent?days=7', {
|
|
* method: 'GET',
|
|
* credentials: 'include'
|
|
* });
|
|
* const data = await response.json();
|
|
* console.log('History items:', data.history.length);
|
|
*/
|
|
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,
|
|
_contentId: record.episodeId != null ? record.episodeId : null
|
|
};
|
|
|
|
if (isAdmin) {
|
|
item.arrRecordId = record.id;
|
|
item.arrType = 'sonarr';
|
|
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,
|
|
_contentId: record.movieId != null ? record.movieId : null
|
|
};
|
|
|
|
if (isAdmin) {
|
|
item.arrRecordId = record.id;
|
|
item.arrType = 'radarr';
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Deduplicate: for each content item keep only the most-recent record,
|
|
// suppressing failures that were superseded by a successful import.
|
|
// Must run before sort so insertion order (newest-first from arr API) is preserved.
|
|
const dedupedItems = deduplicateHistoryItems(historyItems, sonarrHistory, radarrHistory);
|
|
|
|
// Strip internal dedup key before sending to client
|
|
for (const item of dedupedItems) delete item._contentId;
|
|
|
|
// Sort newest first
|
|
dedupedItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
|
|
|
|
console.log(`[History] Returning ${dedupedItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`);
|
|
|
|
res.json({
|
|
user: user.name,
|
|
isAdmin,
|
|
days,
|
|
history: dedupedItems
|
|
});
|
|
} catch (err) {
|
|
console.error('[History] Error:', err.message);
|
|
res.status(500).json({ error: 'Failed to fetch history', details: sanitizeError(err) });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|