From d1496a76e2b6a7dbcde6f61f50ca7db9d4e833e5 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 17:03:23 +0100 Subject: [PATCH] feat: show episode info on download and history cards - Add includeEpisode:true to Sonarr queue and history API requests in both the poller and historyFetcher - Add extractEpisode() / gatherEpisodes() helpers in dashboard.js and history.js to build a sorted, deduplicated episodes array covering all records matching a download title (handles multi- episode packs and series packs) - Replace episodeInfo: sonarrMatch with episodes: gatherEpisodes() across all 8 assignment sites in dashboard.js - Add episodes field to /api/history/recent response items - Frontend: formatEpisodeInfo() renders S01E05 for single episodes or 'Multiple episodes' with hover tooltip listing all for packs - CSS: .episode-info and .multi-episode tooltip styles - ARCHITECTURE.md: update polling table and download/history schemas --- docs/ARCHITECTURE.md | 10 ++++--- public/app.js | 28 ++++++++++++++++++++ public/style.css | 30 +++++++++++++++++++++ server/routes/dashboard.js | 48 ++++++++++++++++++++++++++++------ server/routes/history.js | 35 ++++++++++++++++++++++++- server/utils/historyFetcher.js | 1 + server/utils/poller.js | 4 +-- 7 files changed, 142 insertions(+), 14 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 50d013f..fdd1c6f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -250,8 +250,8 @@ Every `POLL_INTERVAL` ms (default 5000), the poller fetches from all services in | SABnzbd Queue | `GET /api?mode=queue` | `output=json` | | SABnzbd History | `GET /api?mode=history` | `limit=10` | | Sonarr Tags | `GET /api/v3/tag` | — | -| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true` | -| Sonarr History | `GET /api/v3/history` | `pageSize=10` | +| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true` | +| Sonarr History | `GET /api/v3/history` | `pageSize=10`, `includeEpisode=true` | | Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` | | Radarr History | `GET /api/v3/history` | `pageSize=10` | | Radarr Tags | `GET /api/v3/tag` | — | @@ -408,7 +408,7 @@ Each matched download produces an object with: | `speed` | string | Current download speed | | `eta` | string | Estimated time remaining | | `seriesName` / `movieName` | string | Friendly media title | -| `episodeInfo` / `movieInfo` | object | Full *arr queue/history record | +| `episodes` | `{season, episode, title}[]` | (Series only) Episodes covered by this download, sorted by season/episode. Single-episode downloads have one entry; series packs have multiple. Empty array if Sonarr has no episode data. | | `allTags` | string[] | All resolved tag labels on the series/movie | | `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` | | `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list | @@ -616,6 +616,9 @@ Returns recently completed (imported or failed) downloads from Sonarr/Radarr his "outcome": "imported", "title": "Show.S01E01.720p", "seriesName": "My Show", + "episodes": [ + { "season": 1, "episode": 1, "title": "Pilot" } + ], "coverArt": "https://…/poster.jpg", "completedAt": "2026-05-15T18:00:00.000Z", "quality": "720p", @@ -631,6 +634,7 @@ Returns recently completed (imported or failed) downloads from Sonarr/Radarr his ``` - `outcome` is `"imported"` or `"failed"`. Records with other event types (e.g. `grabbed`) are filtered out. +- `episodes` is a sorted array of `{ season, episode, title }` objects. Single-episode downloads have one entry; series packs have multiple. `title` is `null` if not returned by Sonarr. Empty array if Sonarr has no episode data. - `failureMessage` is only included when the authenticated user is an admin and `outcome` is `"failed"`. - `arrRecordId` is only included for admin users. - Results are sorted newest first. diff --git a/public/app.js b/public/app.js index 18f3740..8d1a7cb 100644 --- a/public/app.js +++ b/public/app.js @@ -290,6 +290,30 @@ function hideLoginError() { errorDiv.style.display = 'none'; } +// Build an episode-info element for series downloads/history. +// Single episode: "S01E05 — Episode Title" +// Multiple episodes: "Multiple episodes" with tooltip listing them all. +// Returns null if no episode data. +function formatEpisodeInfo(episodes) { + if (!episodes || episodes.length === 0) return null; + const el = document.createElement('p'); + el.className = 'episode-info'; + if (episodes.length === 1) { + const ep = episodes[0]; + const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0'); + el.textContent = ep.title ? code + ' \u2014 ' + ep.title : code; + } else { + el.textContent = 'Multiple episodes'; + el.classList.add('multi-episode'); + const lines = episodes.map(ep => { + const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0'); + return ep.title ? code + ' \u2014 ' + ep.title : code; + }); + el.setAttribute('data-tooltip', lines.join('\n')); + } + return el; +} + // fetchUserDownloads is kept for the showAll toggle re-connection case // but the primary data path is now via SSE (startSSE / EventSource). @@ -478,6 +502,8 @@ function createDownloadCard(download) { series.textContent = `Series: ${download.seriesName}`; } infoDiv.appendChild(series); + const epEl = formatEpisodeInfo(download.episodes); + if (epEl) infoDiv.appendChild(epEl); } if (download.movieName) { @@ -982,6 +1008,8 @@ function createHistoryCard(item) { p.textContent = 'Series: ' + item.seriesName; } info.appendChild(p); + const epEl = formatEpisodeInfo(item.episodes); + if (epEl) info.appendChild(epEl); } if (item.movieName) { const p = document.createElement('p'); diff --git a/public/style.css b/public/style.css index 9aff7be..f901593 100644 --- a/public/style.css +++ b/public/style.css @@ -485,6 +485,36 @@ body { font-size: 0.8rem; } +.episode-info { + color: var(--text-secondary); + font-size: 0.78rem; + margin: -2px 0 6px; +} + +.episode-info.multi-episode { + cursor: help; + text-decoration: underline dotted; + position: relative; +} + +.episode-info.multi-episode:hover::after { + content: attr(data-tooltip); + position: absolute; + left: 0; + top: 100%; + z-index: 20; + background: var(--card-bg); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + font-size: 0.75rem; + white-space: pre-line; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + max-width: 320px; + pointer-events: none; +} + /* ===== Detail Row (Inline) ===== */ .download-details { display: flex; diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 6885dd3..a78d575 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -94,6 +94,38 @@ function getRadarrLink(movie) { return `${movie._instanceUrl}/movie/${movie.titleSlug}`; } +// Extract episode info from a Sonarr queue/history record. +// Returns { season, episode, title } or null if data is missing. +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 queue/history records +// that share the same title string. Returns sorted array of { season, episode, title }. +function gatherEpisodes(titleLower, sonarrRecords) { + const episodes = []; + const seen = new Set(); + for (const r of sonarrRecords) { + const rTitle = (r.title || r.sourceTitle || '').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; +} + // Fetch all Emby users and return a Map displayName> (and sanitized variants). // Result is cached for 60s to avoid hammering Emby on every dashboard poll. async function getEmbyUsers() { @@ -277,7 +309,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => { speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, - episodeInfo: sonarrMatch, + episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined @@ -374,7 +406,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => { size: slot.size, completedAt: slot.completed_time, seriesName: series.title, - episodeInfo: sonarrMatch, + episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined @@ -477,7 +509,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => { download.type = 'series'; download.coverArt = getCoverArt(series); download.seriesName = series.title; - download.episodeInfo = sonarrMatch; + download.episodes = gatherEpisodes(torrentNameLower, sonarrQueue.data.records); download.allTags = allTags; download.matchedUserTag = matchedUserTag || null; download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined; @@ -547,7 +579,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => { download.type = 'series'; download.coverArt = getCoverArt(series); download.seriesName = series.title; - download.episodeInfo = sonarrHistoryMatch; + download.episodes = gatherEpisodes(torrentNameLower, sonarrHistory.data.records); download.allTags = allTags; download.matchedUserTag = matchedUserTag || null; download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined; @@ -857,7 +889,7 @@ router.get('/stream', requireAuth, async (req, res) => { const allTags = extractAllTags(series.tags, sonarrTagMap); const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username); if (showAll ? allTags.length > 0 : !!matchedUserTag) { - const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodeInfo: sonarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; + const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; const issues = getImportIssues(sonarrMatch); if (issues) dlObj.importIssues = issues; if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); } @@ -904,7 +936,7 @@ router.get('/stream', requireAuth, async (req, res) => { const allTags = extractAllTags(series.tags, sonarrTagMap); const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username); if (showAll ? allTags.length > 0 : !!matchedUserTag) { - const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, size: slot.size, completedAt: slot.completed_time, seriesName: series.title, episodeInfo: sonarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; + const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, size: slot.size, completedAt: slot.completed_time, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); } userDownloads.push(dlObj); } @@ -944,7 +976,7 @@ router.get('/stream', requireAuth, async (req, res) => { const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username); if (showAll ? allTags.length > 0 : !!matchedUserTag) { const download = mapTorrentToDownload(torrent); - Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodeInfo: sonarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }); + Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }); const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues; if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); } userDownloads.push(download); continue; @@ -976,7 +1008,7 @@ router.get('/stream', requireAuth, async (req, res) => { const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username); if (showAll ? allTags.length > 0 : !!matchedUserTag) { const download = mapTorrentToDownload(torrent); - Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodeInfo: sonarrHistoryMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }); + Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }); if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); } userDownloads.push(download); continue; } diff --git a/server/routes/history.js b/server/routes/history.js index 89fb331..aeb1dfb 100644 --- a/server/routes/history.js +++ b/server/routes/history.js @@ -82,6 +82,37 @@ function buildTagBadges(allTags, embyUserMap) { }); } +// 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}`; @@ -177,11 +208,13 @@ router.get('/recent', requireAuth, async (req, res) => { ? record.quality.quality.name : null; + const sourceTitle = record.sourceTitle || record.title || series.title; const item = { type: 'series', outcome, - title: record.sourceTitle || record.title || series.title, + title: sourceTitle, seriesName: series.title, + episodes: gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory), coverArt: getCoverArt(series), completedAt: record.date, quality, diff --git a/server/utils/historyFetcher.js b/server/utils/historyFetcher.js index cb3b2b5..3e0707f 100644 --- a/server/utils/historyFetcher.js +++ b/server/utils/historyFetcher.js @@ -35,6 +35,7 @@ async function fetchSonarrHistory(since) { sortKey: 'date', sortDir: 'descending', includeSeries: true, + includeEpisode: true, startDate: since.toISOString() } }); diff --git a/server/utils/poller.js b/server/utils/poller.js index 66259b3..d2ec021 100644 --- a/server/utils/poller.js +++ b/server/utils/poller.js @@ -71,7 +71,7 @@ async function pollAllServices() { timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst => axios.get(`${inst.url}/api/v3/queue`, { headers: { 'X-Api-Key': inst.apiKey }, - params: { includeSeries: true } + params: { includeSeries: true, includeEpisode: true } }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message); return { instance: inst.id, data: { records: [] } }; @@ -80,7 +80,7 @@ async function pollAllServices() { timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst => axios.get(`${inst.url}/api/v3/history`, { headers: { 'X-Api-Key': inst.apiKey }, - params: { pageSize: 10 } + params: { pageSize: 10, includeEpisode: true } }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message); return { instance: inst.id, data: { records: [] } };