feat: deduplicate history — suppress failed records superseded by successful import, flag failed+hasFile as availableForUpgrade
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
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:
@@ -114,6 +114,75 @@ function gatherEpisodes(titleLower, records) {
|
|||||||
return episodes;
|
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) {
|
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}`;
|
||||||
@@ -223,7 +292,8 @@ router.get('/recent', requireAuth, async (req, res) => {
|
|||||||
arrLink: getSonarrLink(series),
|
arrLink: getSonarrLink(series),
|
||||||
allTags,
|
allTags,
|
||||||
matchedUserTag: matchedUserTag || null,
|
matchedUserTag: matchedUserTag || null,
|
||||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
|
||||||
|
_contentId: record.episodeId != null ? record.episodeId : null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
@@ -270,7 +340,8 @@ router.get('/recent', requireAuth, async (req, res) => {
|
|||||||
arrLink: getRadarrLink(movie),
|
arrLink: getRadarrLink(movie),
|
||||||
allTags,
|
allTags,
|
||||||
matchedUserTag: matchedUserTag || null,
|
matchedUserTag: matchedUserTag || null,
|
||||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
|
||||||
|
_contentId: record.movieId != null ? record.movieId : null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
@@ -286,16 +357,24 @@ router.get('/recent', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort newest first
|
// Deduplicate: for each content item keep only the most-recent record,
|
||||||
historyItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
|
// 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({
|
res.json({
|
||||||
user: user.name,
|
user: user.name,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
days,
|
days,
|
||||||
history: historyItems
|
history: dedupedItems
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[History] Error:', err.message);
|
console.error('[History] Error:', err.message);
|
||||||
|
|||||||
@@ -97,6 +97,60 @@ const RADARR_RECORD_IMPORTED = {
|
|||||||
movieId: 20
|
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 ---
|
// --- Helpers ---
|
||||||
function interceptLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
function interceptLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
||||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
|
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', () => {
|
describe('response shape', () => {
|
||||||
it('returns correct top-level fields', async () => {
|
it('returns correct top-level fields', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user