feat: deduplicate history — suppress failed records superseded by successful import, flag failed+hasFile as availableForUpgrade
Some checks failed
Build and Push Docker Image / build (push) Successful in 58s
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Docs Check / Markdown lint (push) Successful in 1m14s
Licence Check / Dependency licence compatibility (push) Successful in 1m36s
Docs Check / Mermaid diagram parse check (push) Successful in 2m16s

This commit is contained in:
2026-05-17 21:52:55 +01:00
parent 4c9985e01a
commit 6139095444
2 changed files with 196 additions and 6 deletions

View File

@@ -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);

View File

@@ -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 });