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