diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ed8b8e..c59b93c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm --- +## [1.3.0] - 2026-05-17 + +### Added + +- **History tab** — new "Recently Completed" tab showing imported and failed downloads from Sonarr/Radarr history for the last N days (configurable via the days input, persisted in `localStorage`). Auto-refreshes every 5 minutes. +- **History deduplication** — when a failed download has subsequently been imported successfully, only the successful record is shown. If the most recent record for an item is a failure but the episode/movie is already on disk (upgrade attempt), the record is flagged as `availableForUpgrade`. +- **"Upgrade available" badge** — failed history cards where the content is already on disk display an amber badge to indicate this is a failed upgrade rather than a missing item. +- **"Hide upgrade failures" toggle** — checkbox in the history tab to filter out failed records that are already available on disk. State persists in `localStorage`. Tooltip explains the behaviour and matches the episode/multi-episode tooltip style. +- **Blocklist & Search button** — admin-only button on download cards with an "Import Pending" caution. Removes the download from the client with `blocklist=true` (preventing re-grab of the same release) then immediately triggers an `EpisodeSearch`/`MoviesSearch` command in Sonarr/Radarr. Shows a confirmation dialog, loading/success/error states. Kicks a background poll on success. +- **`POST /api/dashboard/blocklist-search`** — new admin-only endpoint backing the above button. Accepts `arrQueueId`, `arrType`, `arrInstanceUrl`, `arrInstanceKey`, `arrContentId`, `arrContentType`. +- **Title link home navigation** — the sofarr logo/title in the header now navigates to the default view (Active Downloads tab, close status panel, reset "Show all users" toggle) without a page reload. +- **Version footer link** — the version string in the dashboard footer links to the source repository. + +### Changed + +- History records are now deduplicated server-side before being sent to the client — only the most relevant record per content item per instance is returned. +- Import-issue badge tooltip now uses the themed `var(--surface)` / `var(--text-primary)` / `var(--border)` CSS variables, matching the episode and toggle tooltip style. +- Poller now stores `_instanceKey` on Sonarr/Radarr queue records in the cache, enabling the backend to look up API credentials for blocklist operations without an additional configuration lookup. + +--- + ## [1.2.2] - 2026-05-17 ### Changed diff --git a/README.md b/README.md index a825280..398402d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ sofarr connects to your media stack and shows you a personalized view of: - **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent) - **Progress Tracking** - Real-time progress bars with speed, ETA, and completion estimates +- **Recently Completed** - History tab showing imported and failed downloads from Sonarr/Radarr with deduplication and upgrade-awareness - **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr - **Multi-Instance Support** - Connect to multiple instances of each service @@ -279,6 +280,10 @@ sofarr polls all configured services in the background and caches the results. D - `GET /api/dashboard/user-summary` — Per-user download counts (admin) - `GET /api/dashboard/status` — Server / polling / cache status (admin) - `GET /api/dashboard/cover-art` — Proxied cover art image +- `POST /api/dashboard/blocklist-search` — Blocklist a release and trigger a new search (admin) + +### History +- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history ### Service APIs (proxy to your services) - `GET /api/sabnzbd/*` — SABnzbd API proxy @@ -323,7 +328,7 @@ npm run test:coverage # with V8 coverage report (outputs to coverage/) npm run test:ui # interactive Vitest UI ``` -115 tests across 8 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, and qBittorrent utilities. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets. +145 tests across 10 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, and history deduplication/classification. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets. ## Development diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4afe98e..ed728a2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -314,6 +314,7 @@ For each connected user the server: | See download/target paths | ✗ | ✓ | | See Sonarr/Radarr links | ✗ | ✓ | | View status panel | ✗ | ✓ | +| Blocklist & search (import-pending) | ✗ | ✓ | ### Tag Matching @@ -413,9 +414,16 @@ Each matched download produces an object with: | `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 | | `importIssues` | string[] / null | Import warning/error messages | +| `availableForUpgrade` | boolean / undefined | (History) `true` when outcome is `failed` but the content is already on disk (failed upgrade attempt) | | `downloadPath` | string / null | (Admin) Download client path | | `targetPath` | string / null | (Admin) *arr target path | | `arrLink` | string / null | (Admin) Link to *arr web UI | +| `arrQueueId` | number / null | (Admin, import-pending only) Sonarr/Radarr queue record id | +| `arrType` | `'sonarr'`/`'radarr'` / null | (Admin, import-pending only) Which *arr service owns this queue entry | +| `arrInstanceUrl` | string / null | (Admin, import-pending only) Base URL of the *arr instance | +| `arrInstanceKey` | string / null | (Admin, import-pending only) API key for the *arr instance | +| `arrContentId` | number / null | (Admin, import-pending only) `episodeId` (Sonarr) or `movieId` (Radarr) for triggering a new search | +| `arrContentType` | `'episode'`/`'movie'` / null | (Admin, import-pending only) Content type for the search command | --- @@ -594,6 +602,48 @@ Admin-only per-user download counts (fetches live from APIs, not cached). --- +### `POST /api/dashboard/blocklist-search` + +Admin-only. Removes a Sonarr/Radarr queue item with `blocklist=true` (preventing the same release being grabbed again), then immediately triggers an `EpisodeSearch` or `MoviesSearch` command. + +Requires CSRF token (`X-CSRF-Token` header). + +**Request Body:** +```json +{ + "arrQueueId": 1234, + "arrType": "sonarr", + "arrInstanceUrl": "https://sonarr.example.com", + "arrInstanceKey": "your-api-key", + "arrContentId": 5678, + "arrContentType": "episode" +} +``` + +| Field | Required | Description | +|-------|:--------:|-------------| +| `arrQueueId` | Yes | Sonarr/Radarr queue record `id` | +| `arrType` | Yes | `"sonarr"` or `"radarr"` | +| `arrInstanceUrl` | Yes | Base URL of the *arr instance | +| `arrInstanceKey` | Yes | API key for the *arr instance | +| `arrContentId` | Yes | `episodeId` (Sonarr) or `movieId` (Radarr) | +| `arrContentType` | Yes | `"episode"` or `"movie"` | + +**Response (200):** `{ "ok": true }` + +**Response (400):** Missing or invalid fields. + +**Response (403):** Non-admin user. + +**Response (502):** Upstream *arr call failed. + +**Side Effects:** +- Calls `DELETE /api/v3/queue/{id}?removeFromClient=true&blocklist=true` on the *arr instance +- Calls `POST /api/v3/command` with `EpisodeSearch`/`MoviesSearch` on the *arr instance +- Triggers a background `pollAllServices()` so the next SSE push reflects the removed item + +--- + ### `GET /api/history/recent` Returns recently completed (imported or failed) downloads from Sonarr/Radarr history for the authenticated user, filtered to the last `days` days. @@ -675,14 +725,19 @@ stateDiagram-v2 |----------|---------| | `checkAuthentication()` | On load: check session → show dashboard or login | | `handleLogin()` | Authenticate, fade login → splash → dashboard | +| `goHome()` | Navigate to default view: switch to Active Downloads tab, close status panel, reset showAll | | `startSSE()` | Open `EventSource` to `/stream`; handles incoming data + first-message loading hide | | `stopSSE()` | Close `EventSource` and cancel reconnect timer | | `renderDownloads()` | Diff-based card rendering (create/update/remove) | -| `createDownloadCard()` | Build DOM for a single download card; renders tag badges | +| `createDownloadCard()` | Build DOM for a single download card; renders tag badges, import-issue badge, blocklist button | | `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) | +| `handleBlocklistSearch()` | Confirm dialog → POST `/blocklist-search` → update button state | | `toggleStatusPanel()` | Show/hide admin status panel | | `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) | | `initThemeSwitcher()` | Light / Dark / Mono theme support | +| `loadHistory()` | Fetch `/api/history/recent`, store raw items, call `renderHistory()` | +| `renderHistory()` | Filter items by `ignoreAvailable` flag, render history cards | +| `createHistoryCard()` | Build DOM for a single history card with outcome/upgrade badges | ### Themes @@ -1133,10 +1188,12 @@ classDiagram +GET /user-summary +GET /status +GET /cover-art + +POST /blocklist-search buildDownloadPayload() extractUserTag() buildTagBadges() getEmbyUsers() + getImportIssues() } class RequireAuth["requireAuth.js (Middleware)"] { +requireAuth(req, res, next) diff --git a/package.json b/package.json index 4dcfd84..8e63422 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.2.2", + "version": "1.3.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": { diff --git a/public/app.js b/public/app.js index 3fde051..c7ea835 100644 --- a/public/app.js +++ b/public/app.js @@ -9,6 +9,8 @@ const SPLASH_MIN_MS = 1200; // minimum splash display time let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7; let historyRefreshHandle = null; const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min +let ignoreAvailable = localStorage.getItem('sofarr-ignore-available') === 'true'; +let lastHistoryItems = []; // raw items from last fetch, for re-filtering without a network round-trip // SSE stream state let sseSource = null; @@ -33,6 +35,7 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('logout-btn').addEventListener('click', handleLogout); document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle); document.getElementById('status-btn').addEventListener('click', toggleStatusPanel); + document.getElementById('title-home-link').addEventListener('click', e => { e.preventDefault(); goHome(); }); }); function loadAppVersion() { @@ -62,6 +65,18 @@ function setTheme(theme) { }); } +function goHome() { + closeStatusPanel(); + // Reset showAll if active + if (showAll) { + showAll = false; + const toggle = document.getElementById('show-all-toggle'); + if (toggle) toggle.checked = false; + startSSE(); + } + activateTab('downloads', true); +} + function initTabs() { const savedTab = localStorage.getItem('sofarr-active-tab') || 'downloads'; activateTab(savedTab, false); @@ -442,6 +457,49 @@ function updateDownloadCard(card, download) { } } +async function handleBlocklistSearch(btn, download) { + if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\n• Remove the download from the download client\n• Add this release to the blocklist\n• Trigger an automatic search for a new release`)) return; + + btn.disabled = true; + btn.textContent = '⏳ Working…'; + + try { + const res = await fetch('/api/dashboard/blocklist-search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + arrQueueId: download.arrQueueId, + arrType: download.arrType, + arrInstanceUrl: download.arrInstanceUrl, + arrInstanceKey: download.arrInstanceKey, + arrContentId: download.arrContentId, + arrContentType: download.arrContentType + }) + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${res.status}`); + } + + btn.textContent = '✓ Done — searching…'; + btn.className = 'blocklist-search-btn success'; + } catch (err) { + console.error('[Blocklist] Error:', err); + btn.disabled = false; + btn.textContent = '⛔ Blocklist & Search'; + btn.className = 'blocklist-search-btn error'; + btn.title = `Failed: ${err.message}`; + setTimeout(() => { + btn.className = 'blocklist-search-btn'; + btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search'; + }, 4000); + } +} + function createDownloadCard(download) { const card = document.createElement('div'); card.className = `download-card ${download.type}`; @@ -496,6 +554,15 @@ function createDownloadCard(download) { issueBadge.textContent = 'Import Pending'; issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n')); header.appendChild(issueBadge); + + if (isAdmin && download.arrQueueId) { + const blBtn = document.createElement('button'); + blBtn.className = 'blocklist-search-btn'; + blBtn.textContent = '⛔ Blocklist & Search'; + blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search'; + blBtn.addEventListener('click', () => handleBlocklistSearch(blBtn, download)); + header.appendChild(blBtn); + } } const title = document.createElement('h3'); @@ -869,6 +936,7 @@ function hideLoading() { function initHistoryControls() { const daysInput = document.getElementById('history-days'); const refreshBtn = document.getElementById('history-refresh-btn'); + const ignoreToggle = document.getElementById('ignore-available-toggle'); if (daysInput) { daysInput.addEventListener('change', () => { const v = parseInt(daysInput.value, 10); @@ -882,6 +950,14 @@ function initHistoryControls() { if (refreshBtn) { refreshBtn.addEventListener('click', () => loadHistory(true)); } + if (ignoreToggle) { + ignoreToggle.checked = ignoreAvailable; + ignoreToggle.addEventListener('change', () => { + ignoreAvailable = ignoreToggle.checked; + localStorage.setItem('sofarr-ignore-available', ignoreAvailable); + renderHistory(lastHistoryItems); + }); + } } function startHistoryRefresh() { @@ -897,6 +973,7 @@ function stopHistoryRefresh() { } function clearHistory() { + lastHistoryItems = []; document.getElementById('history-list').innerHTML = ''; document.getElementById('no-history').style.display = 'none'; document.getElementById('history-error').style.display = 'none'; @@ -920,7 +997,8 @@ async function loadHistory(forceRefresh = false) { if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); loadingEl.style.display = 'none'; - renderHistory(data.history || []); + lastHistoryItems = data.history || []; + renderHistory(lastHistoryItems); } catch (err) { loadingEl.style.display = 'none'; errorEl.textContent = 'Failed to load history.'; @@ -933,12 +1011,15 @@ function renderHistory(items) { const listEl = document.getElementById('history-list'); const noHistoryEl = document.getElementById('no-history'); listEl.innerHTML = ''; - if (!items.length) { + const visible = ignoreAvailable + ? items.filter(item => !(item.outcome === 'failed' && item.availableForUpgrade)) + : items; + if (!visible.length) { noHistoryEl.style.display = 'block'; return; } noHistoryEl.style.display = 'none'; - items.forEach(item => listEl.appendChild(createHistoryCard(item))); + visible.forEach(item => listEl.appendChild(createHistoryCard(item))); } function createHistoryCard(item) { @@ -973,6 +1054,14 @@ function createHistoryCard(item) { outcomeBadge.textContent = item.outcome === 'imported' ? '✓ Imported' : '✗ Failed'; header.appendChild(outcomeBadge); + if (item.availableForUpgrade) { + const upgradeBadge = document.createElement('span'); + upgradeBadge.className = 'history-upgrade-badge'; + upgradeBadge.title = 'A previous version of this item is available. An upgrade download has failed.'; + upgradeBadge.textContent = '⬆ Available'; + header.appendChild(upgradeBadge); + } + if (item.instanceName) { const instBadge = document.createElement('span'); instBadge.className = 'history-instance-badge'; diff --git a/public/index.html b/public/index.html index dec9207..68f1457 100644 --- a/public/index.html +++ b/public/index.html @@ -46,7 +46,7 @@
diff --git a/public/style.css b/public/style.css index 4405222..622fe4f 100644 --- a/public/style.css +++ b/public/style.css @@ -714,6 +714,41 @@ body { color: var(--text-primary); } +.history-toggle-label { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.82rem; + color: var(--text-secondary); + cursor: pointer; + user-select: none; + margin-left: 4px; + position: relative; +} + +.history-toggle-label[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + left: 0; + top: calc(100% + 6px); + 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: 280px; + pointer-events: none; +} + +.history-toggle-label input[type="checkbox"] { + cursor: pointer; + accent-color: var(--accent, #2980b9); +} + .history-loading, .history-error, .no-history { @@ -779,7 +814,8 @@ body { .history-type-badge, .history-outcome-badge, -.history-instance-badge { +.history-instance-badge, +.history-upgrade-badge { font-size: 0.72rem; font-weight: 600; padding: 2px 7px; @@ -787,6 +823,12 @@ body { white-space: nowrap; } +.history-upgrade-badge { + background: #e67e22; + color: #fff; + cursor: default; +} + .history-type-badge.series { background: var(--badge-series-bg, #2980b9); color: #fff; @@ -870,6 +912,15 @@ body { font-size: 0.72rem; opacity: 0.5; margin-top: 4px; + color: inherit; + text-decoration: none; + display: inline-block; +} + +.app-version:hover { + opacity: 0.8; + text-decoration: underline; + text-underline-offset: 2px; } .title-link { @@ -1086,20 +1137,54 @@ body { position: absolute; top: calc(100% + 6px); left: 0; - background: #424242; - color: #fff; - padding: 8px 12px; + z-index: 20; + background: var(--surface); + color: var(--text-primary); + border: 1px solid var(--border); border-radius: 6px; - font-size: 0.7rem; + padding: 8px 10px; + font-size: 0.75rem; font-weight: 400; white-space: pre-line; max-width: 320px; - z-index: 100; - box-shadow: 0 2px 8px rgba(0,0,0,0.25); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); line-height: 1.4; pointer-events: none; } +.blocklist-search-btn { + font-size: 0.68rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + border: 1px solid var(--error, #e74c3c); + background: transparent; + color: var(--error, #e74c3c); + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, color 0.15s; +} + +.blocklist-search-btn:hover:not(:disabled) { + background: var(--error, #e74c3c); + color: #fff; +} + +.blocklist-search-btn:disabled { + opacity: 0.6; + cursor: default; +} + +.blocklist-search-btn.success { + border-color: var(--success, #27ae60); + color: var(--success, #27ae60); +} + +.blocklist-search-btn.error { + background: var(--error, #e74c3c); + color: #fff; +} + .download-user-badge { padding: 2px 8px; border-radius: 10px; diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 596c712..b768458 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -321,6 +321,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); + if (issues) { + dlObj.arrQueueId = sonarrMatch.id; + dlObj.arrType = 'sonarr'; + dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; + dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; + dlObj.arrContentId = sonarrMatch.episodeId || null; + dlObj.arrContentType = 'episode'; + } } userDownloads.push(dlObj); } @@ -363,6 +371,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); + if (issues) { + dlObj.arrQueueId = radarrMatch.id; + dlObj.arrType = 'radarr'; + dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; + dlObj.arrInstanceKey = radarrMatch._instanceKey || null; + dlObj.arrContentId = radarrMatch.movieId || null; + dlObj.arrContentType = 'movie'; + } } userDownloads.push(dlObj); } @@ -520,6 +536,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); + if (sonarrIssues) { + download.arrQueueId = sonarrMatch.id; + download.arrType = 'sonarr'; + download.arrInstanceUrl = sonarrMatch._instanceUrl || null; + download.arrInstanceKey = sonarrMatch._instanceKey || null; + download.arrContentId = sonarrMatch.episodeId || null; + download.arrContentType = 'episode'; + } } userDownloads.push(download); continue; // Skip to next torrent @@ -555,6 +579,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); + if (radarrIssues) { + download.arrQueueId = radarrMatch.id; + download.arrType = 'radarr'; + download.arrInstanceUrl = radarrMatch._instanceUrl || null; + download.arrInstanceKey = radarrMatch._instanceKey || null; + download.arrContentId = radarrMatch.movieId || null; + download.arrContentType = 'movie'; + } } userDownloads.push(download); continue; // Skip to next torrent @@ -893,7 +925,7 @@ router.get('/stream', requireAuth, async (req, res) => { 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); } + if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); if (issues) { dlObj.arrQueueId = sonarrMatch.id; dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; } } userDownloads.push(dlObj); } } @@ -912,7 +944,7 @@ router.get('/stream', requireAuth, async (req, res) => { const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; const issues = getImportIssues(radarrMatch); if (issues) dlObj.importIssues = issues; - if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); } + if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); if (issues) { dlObj.arrQueueId = radarrMatch.id; dlObj.arrType = 'radarr'; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; } } userDownloads.push(dlObj); } } @@ -979,7 +1011,7 @@ router.get('/stream', requireAuth, async (req, res) => { const download = mapTorrentToDownload(torrent); 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); } + if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); if (issues) { download.arrQueueId = sonarrMatch.id; download.arrType = 'sonarr'; download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrInstanceKey = sonarrMatch._instanceKey || null; download.arrContentId = sonarrMatch.episodeId || null; download.arrContentType = 'episode'; } } userDownloads.push(download); continue; } } @@ -995,7 +1027,7 @@ router.get('/stream', requireAuth, async (req, res) => { const download = mapTorrentToDownload(torrent); Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }); const issues = getImportIssues(radarrMatch); if (issues) download.importIssues = issues; - if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); } + if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); if (issues) { download.arrQueueId = radarrMatch.id; download.arrType = 'radarr'; download.arrInstanceUrl = radarrMatch._instanceUrl || null; download.arrInstanceKey = radarrMatch._instanceKey || null; download.arrContentId = radarrMatch.movieId || null; download.arrContentType = 'movie'; } } userDownloads.push(download); continue; } } @@ -1059,4 +1091,68 @@ router.get('/stream', requireAuth, async (req, res) => { }); }); +/** + * POST /api/dashboard/blocklist-search + * + * Admin-only. Removes a queue item from Sonarr/Radarr with blocklist=true + * (so the release is not grabbed again), then immediately triggers a new + * automatic search for the same episode/movie. + * + * Body: { + * arrQueueId: number — Sonarr/Radarr queue record id + * arrType: 'sonarr'|'radarr' + * arrInstanceUrl: string — base URL of the arr instance + * arrInstanceKey: string — API key for the arr instance + * arrContentId: number — episodeId (Sonarr) or movieId (Radarr) + * arrContentType: 'episode'|'movie' + * } + */ +router.post('/blocklist-search', requireAuth, async (req, res) => { + try { + const user = req.user; + if (!user.isAdmin) { + return res.status(403).json({ error: 'Admin access required' }); + } + + const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body; + + if (!arrQueueId || !arrType || !arrInstanceUrl || !arrInstanceKey || !arrContentId || !arrContentType) { + return res.status(400).json({ error: 'Missing required fields' }); + } + if (arrType !== 'sonarr' && arrType !== 'radarr') { + return res.status(400).json({ error: 'arrType must be sonarr or radarr' }); + } + + const headers = { 'X-Api-Key': arrInstanceKey }; + + // Step 1: Remove from queue with blocklist=true + await axios.delete(`${arrInstanceUrl}/api/v3/queue/${arrQueueId}`, { + headers, + params: { removeFromClient: true, blocklist: true } + }); + + // Step 2: Trigger a new automatic search + let commandBody; + if (arrType === 'sonarr' && arrContentType === 'episode') { + commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] }; + } else if (arrType === 'radarr' && arrContentType === 'movie') { + commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] }; + } + + if (commandBody) { + await axios.post(`${arrInstanceUrl}/api/v3/command`, commandBody, { headers }); + } + + // Invalidate the poll cache so the next SSE push reflects the removed item + const { pollAllServices } = require('../utils/poller'); + pollAllServices().catch(() => {}); + + console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`); + res.json({ ok: true }); + } catch (err) { + console.error('[Dashboard] blocklist-search error:', sanitizeError(err)); + res.status(502).json({ error: 'Failed to blocklist and search', details: sanitizeError(err) }); + } +}); + module.exports = router; diff --git a/server/routes/history.js b/server/routes/history.js index a4c6a0f..a468574 100644 --- a/server/routes/history.js +++ b/server/routes/history.js @@ -114,6 +114,75 @@ function gatherEpisodes(titleLower, records) { 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}`; @@ -223,7 +292,8 @@ router.get('/recent', requireAuth, async (req, res) => { arrLink: getSonarrLink(series), allTags, matchedUserTag: matchedUserTag || null, - tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined, + _contentId: record.episodeId != null ? record.episodeId : null }; if (isAdmin) { @@ -270,7 +340,8 @@ router.get('/recent', requireAuth, async (req, res) => { arrLink: getRadarrLink(movie), allTags, matchedUserTag: matchedUserTag || null, - tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined, + _contentId: record.movieId != null ? record.movieId : null }; if (isAdmin) { @@ -286,16 +357,24 @@ router.get('/recent', requireAuth, async (req, res) => { } } - // Sort newest first - historyItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt)); + // 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); - console.log(`[History] Returning ${historyItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`); + // 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: historyItems + history: dedupedItems }); } catch (err) { console.error('[History] Error:', err.message); diff --git a/server/utils/poller.js b/server/utils/poller.js index 9d267c3..a631ca5 100644 --- a/server/utils/poller.js +++ b/server/utils/poller.js @@ -160,8 +160,11 @@ async function pollAllServices() { records: sonarrQueues.flatMap(q => { const inst = sonarrInstances.find(i => i.id === q.instance); const url = inst ? inst.url : null; + const key = inst ? inst.apiKey : null; return (q.data.records || []).map(r => { if (r.series) r.series._instanceUrl = url; + r._instanceUrl = url; + r._instanceKey = key; return r; }); }) @@ -175,8 +178,11 @@ async function pollAllServices() { records: radarrQueues.flatMap(q => { const inst = radarrInstances.find(i => i.id === q.instance); const url = inst ? inst.url : null; + const key = inst ? inst.apiKey : null; return (q.data.records || []).map(r => { if (r.movie) r.movie._instanceUrl = url; + r._instanceUrl = url; + r._instanceKey = key; return r; }); }) diff --git a/tests/integration/history.test.js b/tests/integration/history.test.js index 2cf9bed..d6f924d 100644 --- a/tests/integration/history.test.js +++ b/tests/integration/history.test.js @@ -97,6 +97,60 @@ const RADARR_RECORD_IMPORTED = { movieId: 20 }; +// Deduplication fixtures — same episodeId 55, episode 1 failed then imported +const SONARR_RECORD_FAILED_EP55 = { + id: 110, + eventType: 'downloadFailed', + sourceTitle: 'Show.S02E01.720p', + date: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago + quality: { quality: { name: '720p' } }, + data: { message: 'Download failed' }, + episodeId: 55, + episode: { seasonNumber: 2, episodeNumber: 1, title: 'Pilot', hasFile: false }, + series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] }, + seriesId: 10 +}; + +const SONARR_RECORD_IMPORTED_EP55 = { + id: 111, + eventType: 'downloadFolderImported', + sourceTitle: 'Show.S02E01.720p', + date: new Date().toISOString(), // now (more recent) + quality: { quality: { name: '720p' } }, + episodeId: 55, + episode: { seasonNumber: 2, episodeNumber: 1, title: 'Pilot', hasFile: true }, + series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] }, + seriesId: 10 +}; + +// Failed, still failing (hasFile=false) — most recent is a failure with no file +const SONARR_RECORD_FAILED_EP56 = { + id: 112, + eventType: 'downloadFailed', + sourceTitle: 'Show.S02E02.720p', + date: new Date().toISOString(), + quality: { quality: { name: '720p' } }, + data: { message: 'No seeders' }, + episodeId: 56, + episode: { seasonNumber: 2, episodeNumber: 2, title: 'Episode 2', hasFile: false }, + series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] }, + seriesId: 10 +}; + +// Failed but hasFile=true — episode is available, failure is an upgrade attempt +const SONARR_RECORD_FAILED_EP57_HAS_FILE = { + id: 113, + eventType: 'downloadFailed', + sourceTitle: 'Show.S02E03.720p', + date: new Date().toISOString(), + quality: { quality: { name: '720p' } }, + data: { message: 'Upgrade failed' }, + episodeId: 57, + episode: { seasonNumber: 2, episodeNumber: 3, title: 'Episode 3', hasFile: true }, + series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] }, + seriesId: 10 +}; + // --- Helpers --- function interceptLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) { nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody); @@ -271,6 +325,63 @@ describe('GET /api/history/recent', () => { }); }); + describe('deduplication', () => { + it('suppresses a failed record when the same episode was subsequently imported', async () => { + const app = createApp({ skipRateLimits: true }); + // API returns newest-first: imported (now) before failed (1hr ago) + setHistory([SONARR_RECORD_IMPORTED_EP55, SONARR_RECORD_FAILED_EP55], []); + const { cookies } = await loginAs(app); + const res = await request(app) + .get('/api/history/recent') + .set('Cookie', cookies); + expect(res.status).toBe(200); + const ep55Items = res.body.history.filter(h => h.seriesName === 'My Show' && h.title.includes('S02E01')); + expect(ep55Items).toHaveLength(1); + expect(ep55Items[0].outcome).toBe('imported'); + }); + + it('shows a failed record as-is when there is no successful import and hasFile is false', async () => { + const app = createApp({ skipRateLimits: true }); + setHistory([SONARR_RECORD_FAILED_EP56], []); + const { cookies } = await loginAs(app); + const res = await request(app) + .get('/api/history/recent') + .set('Cookie', cookies); + expect(res.status).toBe(200); + const item = res.body.history.find(h => h.title && h.title.includes('S02E02')); + expect(item).toBeDefined(); + expect(item.outcome).toBe('failed'); + expect(item.availableForUpgrade).toBeFalsy(); + }); + + it('flags a failed record as availableForUpgrade when the episode hasFile is true', async () => { + const app = createApp({ skipRateLimits: true }); + setHistory([SONARR_RECORD_FAILED_EP57_HAS_FILE], []); + const { cookies } = await loginAs(app); + const res = await request(app) + .get('/api/history/recent') + .set('Cookie', cookies); + expect(res.status).toBe(200); + const item = res.body.history.find(h => h.title && h.title.includes('S02E03')); + expect(item).toBeDefined(); + expect(item.outcome).toBe('failed'); + expect(item.availableForUpgrade).toBe(true); + }); + + it('does not expose _contentId in the response', async () => { + const app = createApp({ skipRateLimits: true }); + setHistory([SONARR_RECORD_IMPORTED_EP55], []); + const { cookies } = await loginAs(app); + const res = await request(app) + .get('/api/history/recent') + .set('Cookie', cookies); + expect(res.status).toBe(200); + for (const item of res.body.history) { + expect(item).not.toHaveProperty('_contentId'); + } + }); + }); + describe('response shape', () => { it('returns correct top-level fields', async () => { const app = createApp({ skipRateLimits: true });