From 61390954447f1e1e68aac3a006e266e28acaff9b Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 21:52:55 +0100 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20deduplicate=20history=20=E2=80=94?= =?UTF-8?q?=20suppress=20failed=20records=20superseded=20by=20successful?= =?UTF-8?q?=20import,=20flag=20failed+hasFile=20as=20availableForUpgrade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routes/history.js | 91 ++++++++++++++++++++++-- tests/integration/history.test.js | 111 ++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 6 deletions(-) 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/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 }); From 55a5577f2a643027bc560ff8255e5d3f71ed40d9 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 21:53:58 +0100 Subject: [PATCH 02/10] feat: render availableForUpgrade badge on failed history items where episode/movie is already on disk --- public/app.js | 8 ++++++++ public/style.css | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/public/app.js b/public/app.js index 3fde051..6ee3f14 100644 --- a/public/app.js +++ b/public/app.js @@ -973,6 +973,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/style.css b/public/style.css index 4405222..290c89b 100644 --- a/public/style.css +++ b/public/style.css @@ -779,7 +779,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 +788,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; From 19b9c97e6452511288641d8e79d2e1401c388131 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 22:52:55 +0100 Subject: [PATCH 03/10] feat: add 'Hide upgrade failures' checkbox to history controls --- public/app.js | 22 +++++++++++++++++++--- public/index.html | 4 ++++ public/style.css | 16 ++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/public/app.js b/public/app.js index 6ee3f14..7981c56 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; @@ -869,6 +871,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 +885,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 +908,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 +932,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 +946,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) { diff --git a/public/index.html b/public/index.html index dec9207..7eeb1fb 100644 --- a/public/index.html +++ b/public/index.html @@ -98,6 +98,10 @@ days + diff --git a/public/style.css b/public/style.css index 290c89b..8aeae00 100644 --- a/public/style.css +++ b/public/style.css @@ -714,6 +714,22 @@ 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; +} + +.history-toggle-label input[type="checkbox"] { + cursor: pointer; + accent-color: var(--accent, #2980b9); +} + .history-loading, .history-error, .no-history { From 15152714fd244ec768571d6854774e663fe6bbfe Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 22:55:52 +0100 Subject: [PATCH 04/10] fix: use data-tooltip CSS popup for hide-upgrade-failures checkbox, matching episode tooltip style --- public/index.html | 2 +- public/style.css | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/public/index.html b/public/index.html index 7eeb1fb..f50f3f5 100644 --- a/public/index.html +++ b/public/index.html @@ -98,7 +98,7 @@ days -