merge: develop into main for v1.1.0 release
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 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(--surface);
|
||||
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;
|
||||
|
||||
@@ -94,6 +94,39 @@ 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 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 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).
|
||||
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
|
||||
async function getEmbyUsers() {
|
||||
@@ -277,7 +310,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 +407,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 +510,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 +580,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 +890,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 +937,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 +977,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 +1009,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;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,38 @@ function buildTagBadges(allTags, embyUserMap) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
function getSonarrLink(series) {
|
||||
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||
@@ -177,11 +209,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,
|
||||
|
||||
@@ -35,6 +35,7 @@ async function fetchSonarrHistory(since) {
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeSeries: true,
|
||||
includeEpisode: true,
|
||||
startDate: since.toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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: [] } };
|
||||
|
||||
Reference in New Issue
Block a user