From 16cb29c9eec54b2e426061503e834032af34c2a5 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 23 Jun 2026 15:32:26 -0700 Subject: [PATCH] #916: enhanced view stops flagging multi-disc tracks as missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-disc albums store disc_number=1 for EVERY track in the library (verified across the live DB โ€” even a 175-track OST shows disc[1..1]; the scanner doesn't split discs). The enhanced view matched owned<->canonical tracks strictly by disc:track_number slot, so every canonical disc-2+ track (slot 2:N) found no owned counterpart and was flagged missing ('62/72 ยท 36 missing'). _deriveEnhancedMissingTracks now matches each canonical track by slot first, then falls back to title against any UNUSED owned track (consuming each owned track once, so genuine missings and duplicate titles still count right). Display-only โ€” no scanner/data change. Verified by simulation (62 owned all disc-1, 72 canonical across 2 discs): old logic flags 36 missing, new flags 10 (the genuinely-absent tracks). Frontend-only; repo has no JS test runner. --- webui/static/library.js | 53 ++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/webui/static/library.js b/webui/static/library.js index 080b8fc4..502ea93e 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -3964,23 +3964,54 @@ async function ensureEnhancedAlbumCanonicalTracks(album) { } } +// Loose title key for owned<->canonical matching. Lowercase, drop bracketed +// annotations ((feat. X), [Explicit]) and punctuation so editions line up. +function _normTitleForMatch(value) { + return String(value || '') + .toLowerCase() + .replace(/\([^)]*\)|\[[^\]]*\]/g, ' ') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + function _deriveEnhancedMissingTracks(album, canonicalTracks) { - const occupiedSlots = new Set(); - (album.tracks || []).forEach(track => { + // #916: multi-disc albums store disc_number = 1 for EVERY track in the library + // (the scanner doesn't split discs), so a strict disc:track slot match flags every + // canonical disc-2+ track as missing. Match each canonical track to an owned track + // by slot FIRST, then fall back to title โ€” consuming each owned track once so genuine + // missings and duplicate titles still count correctly. + const owned = (album.tracks || []).map(t => ({ + slot: _trackSlotKey(t), + title: _normTitleForMatch(t.title || t.name), + used: false, + })); + const slotIndex = new Map(); + owned.forEach((o, i) => { if (o.slot !== '1:0' && !slotIndex.has(o.slot)) slotIndex.set(o.slot, i); }); + + const missing = []; + (canonicalTracks || []).forEach(track => { const key = _trackSlotKey(track); - if (key !== '1:0') occupiedSlots.add(key); - }); - return (canonicalTracks || []) - .map(track => ({ + const normalized = _normalizeExpectedMissingTrack(track, album); + if (key === '1:0' || !normalized._hasActionableContext) return; + + // 1) exact disc:track slot + const si = slotIndex.get(key); + if (si != null && !owned[si].used) { owned[si].used = true; return; } + + // 2) fallback: title vs any UNUSED owned track (handles the disc_number=1 collapse) + const nt = _normTitleForMatch(track.name || track.title); + if (nt) { + const m = owned.find(o => !o.used && o.title === nt); + if (m) { m.used = true; return; } + } + + missing.push({ ...track, name: track.name || track.title, duration_ms: track.duration_ms || track.duration || 0, - })) - .filter(track => { - const key = _trackSlotKey(track); - const normalized = _normalizeExpectedMissingTrack(track, album); - return key !== '1:0' && normalized._hasActionableContext && !occupiedSlots.has(key); }); + }); + return missing; } function _getEnhancedAlbumTrackRows(album) {