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
This commit is contained in:
@@ -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 Queue | `GET /api?mode=queue` | `output=json` |
|
||||||
| SABnzbd History | `GET /api?mode=history` | `limit=10` |
|
| SABnzbd History | `GET /api?mode=history` | `limit=10` |
|
||||||
| Sonarr Tags | `GET /api/v3/tag` | — |
|
| Sonarr Tags | `GET /api/v3/tag` | — |
|
||||||
| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true` |
|
| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true` |
|
||||||
| Sonarr History | `GET /api/v3/history` | `pageSize=10` |
|
| Sonarr History | `GET /api/v3/history` | `pageSize=10`, `includeEpisode=true` |
|
||||||
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
|
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
|
||||||
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
|
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
|
||||||
| Radarr Tags | `GET /api/v3/tag` | — |
|
| Radarr Tags | `GET /api/v3/tag` | — |
|
||||||
@@ -408,7 +408,7 @@ Each matched download produces an object with:
|
|||||||
| `speed` | string | Current download speed |
|
| `speed` | string | Current download speed |
|
||||||
| `eta` | string | Estimated time remaining |
|
| `eta` | string | Estimated time remaining |
|
||||||
| `seriesName` / `movieName` | string | Friendly media title |
|
| `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 |
|
| `allTags` | string[] | All resolved tag labels on the series/movie |
|
||||||
| `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` |
|
| `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 |
|
| `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",
|
"outcome": "imported",
|
||||||
"title": "Show.S01E01.720p",
|
"title": "Show.S01E01.720p",
|
||||||
"seriesName": "My Show",
|
"seriesName": "My Show",
|
||||||
|
"episodes": [
|
||||||
|
{ "season": 1, "episode": 1, "title": "Pilot" }
|
||||||
|
],
|
||||||
"coverArt": "https://…/poster.jpg",
|
"coverArt": "https://…/poster.jpg",
|
||||||
"completedAt": "2026-05-15T18:00:00.000Z",
|
"completedAt": "2026-05-15T18:00:00.000Z",
|
||||||
"quality": "720p",
|
"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.
|
- `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"`.
|
- `failureMessage` is only included when the authenticated user is an admin and `outcome` is `"failed"`.
|
||||||
- `arrRecordId` is only included for admin users.
|
- `arrRecordId` is only included for admin users.
|
||||||
- Results are sorted newest first.
|
- Results are sorted newest first.
|
||||||
|
|||||||
@@ -290,6 +290,30 @@ function hideLoginError() {
|
|||||||
errorDiv.style.display = 'none';
|
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
|
// fetchUserDownloads is kept for the showAll toggle re-connection case
|
||||||
// but the primary data path is now via SSE (startSSE / EventSource).
|
// but the primary data path is now via SSE (startSSE / EventSource).
|
||||||
|
|
||||||
@@ -478,6 +502,8 @@ function createDownloadCard(download) {
|
|||||||
series.textContent = `Series: ${download.seriesName}`;
|
series.textContent = `Series: ${download.seriesName}`;
|
||||||
}
|
}
|
||||||
infoDiv.appendChild(series);
|
infoDiv.appendChild(series);
|
||||||
|
const epEl = formatEpisodeInfo(download.episodes);
|
||||||
|
if (epEl) infoDiv.appendChild(epEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (download.movieName) {
|
if (download.movieName) {
|
||||||
@@ -982,6 +1008,8 @@ function createHistoryCard(item) {
|
|||||||
p.textContent = 'Series: ' + item.seriesName;
|
p.textContent = 'Series: ' + item.seriesName;
|
||||||
}
|
}
|
||||||
info.appendChild(p);
|
info.appendChild(p);
|
||||||
|
const epEl = formatEpisodeInfo(item.episodes);
|
||||||
|
if (epEl) info.appendChild(epEl);
|
||||||
}
|
}
|
||||||
if (item.movieName) {
|
if (item.movieName) {
|
||||||
const p = document.createElement('p');
|
const p = document.createElement('p');
|
||||||
|
|||||||
@@ -485,6 +485,36 @@ body {
|
|||||||
font-size: 0.8rem;
|
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) ===== */
|
/* ===== Detail Row (Inline) ===== */
|
||||||
.download-details {
|
.download-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -94,6 +94,38 @@ function getRadarrLink(movie) {
|
|||||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
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<lowerName -> displayName> (and sanitized variants).
|
// Fetch all Emby users and return a Map<lowerName -> displayName> (and sanitized variants).
|
||||||
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
|
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
|
||||||
async function getEmbyUsers() {
|
async function getEmbyUsers() {
|
||||||
@@ -277,7 +309,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
speed: slotState.speed,
|
speed: slotState.speed,
|
||||||
eta: slot.timeleft,
|
eta: slot.timeleft,
|
||||||
seriesName: series.title,
|
seriesName: series.title,
|
||||||
episodeInfo: sonarrMatch,
|
episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records),
|
||||||
allTags,
|
allTags,
|
||||||
matchedUserTag: matchedUserTag || null,
|
matchedUserTag: matchedUserTag || null,
|
||||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||||
@@ -374,7 +406,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
size: slot.size,
|
size: slot.size,
|
||||||
completedAt: slot.completed_time,
|
completedAt: slot.completed_time,
|
||||||
seriesName: series.title,
|
seriesName: series.title,
|
||||||
episodeInfo: sonarrMatch,
|
episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records),
|
||||||
allTags,
|
allTags,
|
||||||
matchedUserTag: matchedUserTag || null,
|
matchedUserTag: matchedUserTag || null,
|
||||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||||
@@ -477,7 +509,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
download.type = 'series';
|
download.type = 'series';
|
||||||
download.coverArt = getCoverArt(series);
|
download.coverArt = getCoverArt(series);
|
||||||
download.seriesName = series.title;
|
download.seriesName = series.title;
|
||||||
download.episodeInfo = sonarrMatch;
|
download.episodes = gatherEpisodes(torrentNameLower, sonarrQueue.data.records);
|
||||||
download.allTags = allTags;
|
download.allTags = allTags;
|
||||||
download.matchedUserTag = matchedUserTag || null;
|
download.matchedUserTag = matchedUserTag || null;
|
||||||
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||||
@@ -547,7 +579,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
download.type = 'series';
|
download.type = 'series';
|
||||||
download.coverArt = getCoverArt(series);
|
download.coverArt = getCoverArt(series);
|
||||||
download.seriesName = series.title;
|
download.seriesName = series.title;
|
||||||
download.episodeInfo = sonarrHistoryMatch;
|
download.episodes = gatherEpisodes(torrentNameLower, sonarrHistory.data.records);
|
||||||
download.allTags = allTags;
|
download.allTags = allTags;
|
||||||
download.matchedUserTag = matchedUserTag || null;
|
download.matchedUserTag = matchedUserTag || null;
|
||||||
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
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 allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
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);
|
const issues = getImportIssues(sonarrMatch);
|
||||||
if (issues) dlObj.importIssues = issues;
|
if (issues) dlObj.importIssues = issues;
|
||||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
|
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 allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
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); }
|
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
|
||||||
userDownloads.push(dlObj);
|
userDownloads.push(dlObj);
|
||||||
}
|
}
|
||||||
@@ -944,7 +976,7 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||||
const download = mapTorrentToDownload(torrent);
|
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;
|
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); }
|
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
||||||
userDownloads.push(download); continue;
|
userDownloads.push(download); continue;
|
||||||
@@ -976,7 +1008,7 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||||
const download = mapTorrentToDownload(torrent);
|
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); }
|
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
||||||
userDownloads.push(download); continue;
|
userDownloads.push(download); continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
function getSonarrLink(series) {
|
||||||
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||||
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||||
@@ -177,11 +208,13 @@ router.get('/recent', requireAuth, async (req, res) => {
|
|||||||
? record.quality.quality.name
|
? record.quality.quality.name
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const sourceTitle = record.sourceTitle || record.title || series.title;
|
||||||
const item = {
|
const item = {
|
||||||
type: 'series',
|
type: 'series',
|
||||||
outcome,
|
outcome,
|
||||||
title: record.sourceTitle || record.title || series.title,
|
title: sourceTitle,
|
||||||
seriesName: series.title,
|
seriesName: series.title,
|
||||||
|
episodes: gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
|
||||||
coverArt: getCoverArt(series),
|
coverArt: getCoverArt(series),
|
||||||
completedAt: record.date,
|
completedAt: record.date,
|
||||||
quality,
|
quality,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ async function fetchSonarrHistory(since) {
|
|||||||
sortKey: 'date',
|
sortKey: 'date',
|
||||||
sortDir: 'descending',
|
sortDir: 'descending',
|
||||||
includeSeries: true,
|
includeSeries: true,
|
||||||
|
includeEpisode: true,
|
||||||
startDate: since.toISOString()
|
startDate: since.toISOString()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ async function pollAllServices() {
|
|||||||
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
|
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
|
||||||
axios.get(`${inst.url}/api/v3/queue`, {
|
axios.get(`${inst.url}/api/v3/queue`, {
|
||||||
headers: { 'X-Api-Key': inst.apiKey },
|
headers: { 'X-Api-Key': inst.apiKey },
|
||||||
params: { includeSeries: true }
|
params: { includeSeries: true, includeEpisode: true }
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||||
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
|
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
|
||||||
return { instance: inst.id, data: { records: [] } };
|
return { instance: inst.id, data: { records: [] } };
|
||||||
@@ -80,7 +80,7 @@ async function pollAllServices() {
|
|||||||
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
|
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
|
||||||
axios.get(`${inst.url}/api/v3/history`, {
|
axios.get(`${inst.url}/api/v3/history`, {
|
||||||
headers: { 'X-Api-Key': inst.apiKey },
|
headers: { 'X-Api-Key': inst.apiKey },
|
||||||
params: { pageSize: 10 }
|
params: { pageSize: 10, includeEpisode: true }
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||||
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
|
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
|
||||||
return { instance: inst.id, data: { records: [] } };
|
return { instance: inst.id, data: { records: [] } };
|
||||||
|
|||||||
Reference in New Issue
Block a user