diff --git a/core/acoustid_verification.py b/core/acoustid_verification.py index bd2e917c..d1b87918 100644 --- a/core/acoustid_verification.py +++ b/core/acoustid_verification.py @@ -16,6 +16,7 @@ from enum import Enum from utils.logging_config import get_logger from core.acoustid_client import AcoustIDClient from core.matching_engine import MusicMatchingEngine +from core.matching.version_mismatch import is_acceptable_version_mismatch from core.musicbrainz_client import MusicBrainzClient logger = get_logger("acoustid.verification") @@ -485,12 +486,33 @@ class AcoustIDVerification: expected_version = _detect_title_version(expected_track_name) matched_version = _detect_title_version(matched_title) if expected_version != matched_version: - msg = ( - f"Version mismatch: expected '{expected_track_name}' ({expected_version}) " - f"but file is '{matched_title}' ({matched_version})" - ) - logger.warning(f"AcoustID verification FAILED (version mismatch) - {msg}") - return VerificationResult.FAIL, msg + # Issue #607 (AfonsoG6): MusicBrainz often stores live + # recordings with bare titles ("Clarity") while the + # release entry carries the venue annotation ("Clarity + # (Live at Blossom Music Center, ...)"). The fingerprint + # correctly identifies the LIVE recording; only the + # title text is bare. Helper accepts the one-sided bare + # case when fingerprint + bare-title + artist all agree. + # Two-sided version mismatches (live vs remix etc) stay + # strict — those are genuinely different recordings. + if is_acceptable_version_mismatch( + expected_version, matched_version, + fingerprint_score=best_score, + title_similarity=title_sim, + artist_similarity=artist_sim, + ): + logger.info( + f"AcoustID version annotation differs (expected={expected_version}, " + f"matched={matched_version}) but fingerprint+title+artist all match — " + f"accepting (likely MB metadata gap on a live/version-annotated recording)" + ) + else: + msg = ( + f"Version mismatch: expected '{expected_track_name}' ({expected_version}) " + f"but file is '{matched_title}' ({matched_version})" + ) + logger.warning(f"AcoustID verification FAILED (version mismatch) - {msg}") + return VerificationResult.FAIL, msg # Step 5: Decide pass/fail based on similarity if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim >= ARTIST_MATCH_THRESHOLD: @@ -593,12 +615,19 @@ class AcoustIDVerification: # downloading Mr. Morale: three tracks (Rich Interlude, Savior # Interlude, Savior) all received the wrong R.O.T.C audio file # because of this leak. - top = recordings[0] - top_title = top.get('title', '?') or '' - top_artist = top.get('artist', '?') or '' + # Use the BEST matching recording's strings here (not + # `recordings[0]`) so the failure message reports the same + # candidate the title/artist similarity scores came from. + # Issue #607 (AfonsoG6) example 1: the prior code mixed + # `recordings[0]`'s strings (which can be empty) with + # `best_rec`'s scores, producing nonsense reasons like + # "file identified as '' by '' (artist=100%)" when a later + # recording in the list scored well on artist. + display_title = matched_title or '?' + display_artist = matched_artist or '?' has_non_ascii = ( any(ord(c) > 127 for c in (expected_track_name or '')) - or any(ord(c) > 127 for c in top_title) + or any(ord(c) > 127 for c in display_title) ) language_script_skip = ( best_score >= 0.95 @@ -618,18 +647,16 @@ class AcoustIDVerification: ) msg = ( f"Title/artist mismatch but fingerprint confidence very high ({best_score:.2f}): " - f"AcoustID='{top_title}' by '{top_artist}', " + f"AcoustID='{display_title}' by '{display_artist}', " f"expected '{expected_track_name}' by '{expected_artist_name}' — " f"{reason}" ) logger.info(f"AcoustID verification SKIPPED (high confidence) - {msg}") return VerificationResult.SKIP, msg - # Low fingerprint score + no metadata match — file is likely wrong - # `top`, `top_title`, `top_artist` already resolved above for the - # skip-eligibility check. + # Low fingerprint score + no metadata match — file is likely wrong. msg = ( - f"Audio mismatch: file identified as '{top_title}' by '{top_artist}', " + f"Audio mismatch: file identified as '{display_title}' by '{display_artist}', " f"expected '{expected_track_name}' by '{expected_artist_name}' " f"(title={title_sim:.0%}, artist={artist_sim:.0%})" ) diff --git a/core/imports/quarantine.py b/core/imports/quarantine.py index 7c4488c6..5b25e299 100644 --- a/core/imports/quarantine.py +++ b/core/imports/quarantine.py @@ -130,6 +130,16 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]: except OSError: size_bytes = 0 + # Issue #608 follow-up (AfonsoG6): surface the source username + # + filename that was originally downloaded, so the user can see + # at a glance which uploader the bad file came from. Lives + # under `context.original_search_result` when full context is + # persisted; absent on legacy thin sidecars. + ctx = sidecar.get("context") if isinstance(sidecar.get("context"), dict) else {} + osr = ctx.get("original_search_result") if isinstance(ctx.get("original_search_result"), dict) else {} + source_username = osr.get("username", "") if isinstance(osr, dict) else "" + source_filename = osr.get("filename", "") if isinstance(osr, dict) else "" + entries.append( { "id": entry_id, @@ -142,6 +152,8 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]: "size_bytes": size_bytes, "has_full_context": isinstance(sidecar.get("context"), dict), "trigger": sidecar.get("trigger", "unknown"), + "source_username": source_username, + "source_filename": source_filename, } ) diff --git a/core/matching/version_mismatch.py b/core/matching/version_mismatch.py new file mode 100644 index 00000000..0b60028c --- /dev/null +++ b/core/matching/version_mismatch.py @@ -0,0 +1,115 @@ +"""Decide when an AcoustID version-annotation mismatch should still +pass verification. + +Issue #607 (AfonsoG6): live recordings were quarantining as +"Version mismatch: expected '... (Live at Venue)' (live) but file is +'Song' (original)" because MusicBrainz often stores the recording +entity with a bare title — the venue / live annotation lives on the +release entity, not the recording. The audio fingerprint correctly +identifies the live recording (live audio has its own distinct +fingerprint), but the title-text comparison flagged it as the wrong +version. + +Strict version mismatching stays for genuinely-different recordings +(instrumental vs vocal, remix vs original, acoustic vs studio) — +those have distinct fingerprints AND MB always annotates them in the +recording title. We only loosen for the **live** direction +specifically, since that's the asymmetry users actually hit: + +- LIVE: MB often stores live recordings with bare titles. The + fingerprint correctly identifies the live recording even when the + recording's text title lacks a "(Live at ...)" annotation. +- INSTRUMENTAL / REMIX / ACOUSTIC / DEMO / etc: MB always carries + the version marker in the recording title. If AcoustID returns an + instrumental for a vocal file query (or vice versa), it's a real + wrong-recording match — the fingerprint matched the instrumental + audio, which IS the wrong file. + +Two-sided version mismatches stay strict — "live" vs "remix" really +are different recordings even if MB titled them similarly.""" + +from __future__ import annotations + + +_BARE_VERSION = 'original' + +# Versions where a one-sided annotation difference is plausibly +# explained by MB metadata gaps rather than a different recording. +# Live recordings are the primary case; venue annotations live on the +# release-track entity, not the recording entity, so the recording can +# be bare-titled even though the audio is genuinely live. +_LIVE_AWARE_VERSIONS = frozenset({'live'}) + + +def is_acceptable_version_mismatch( + expected_version: str, + matched_version: str, + *, + fingerprint_score: float, + title_similarity: float, + artist_similarity: float, + score_threshold: float = 0.85, + title_threshold: float = 0.70, + artist_threshold: float = 0.60, +) -> bool: + """Return True when an expected-vs-matched version annotation + difference is likely a MusicBrainz metadata gap rather than a + genuinely different recording. + + Conditions (all must hold): + + 1. The mismatch is **one-sided AND involves a live-aware version** + — exactly one side is ``'live'`` and the other is bare + ``'original'``. Other version markers (instrumental, remix, + acoustic, etc) carry distinct fingerprints AND are always + annotated in MB's recording title — we don't loosen for them. + 2. Fingerprint score ``>= score_threshold`` (default 0.85). The + AcoustID fingerprint is high-confidence — we trust it. + 3. Bare title similarity ``>= title_threshold`` (default 0.70). + After the version annotation is stripped, the underlying titles + agree. + 4. Artist similarity ``>= artist_threshold`` (default 0.60). Same + artist credit. + + When ``expected_version == matched_version`` returns ``True`` + trivially — no mismatch to decide. + + Examples that ACCEPT (return True): + + - expected=``'live'``, matched=``'original'``, fp=0.95, title=0.95, + artist=1.0 — typical live-recording MB-bare-title case (issue + #607 example 2). + - expected=``'original'``, matched=``'live'`` — same case in the + other direction. + + Examples that REJECT (return False): + + - expected=``'instrumental'``, matched=``'original'`` — fingerprint + matched the instrumental recording; if user asked for vocal, the + file is genuinely wrong. Stays strict regardless of confidence. + - expected=``'remix'``, matched=``'original'`` — same logic. + - expected=``'live'``, matched=``'remix'`` — both versioned, + both different. Real mismatch. + - expected=``'live'``, matched=``'original'``, fp=0.50 — one-sided + live but low confidence. Fall through to FAIL. + - expected=``'live'``, matched=``'original'``, fp=0.95 but title + sim 0.30 — bare titles don't agree. Different song. + """ + if expected_version == matched_version: + return True + + one_sided_live = ( + (expected_version in _LIVE_AWARE_VERSIONS and matched_version == _BARE_VERSION) + or (expected_version == _BARE_VERSION and matched_version in _LIVE_AWARE_VERSIONS) + ) + if not one_sided_live: + return False + + return ( + fingerprint_score >= score_threshold + and title_similarity >= title_threshold + and artist_similarity >= artist_threshold + ) + + +__all__ = ['is_acceptable_version_mismatch'] diff --git a/tests/matching/test_version_mismatch.py b/tests/matching/test_version_mismatch.py new file mode 100644 index 00000000..3b81f788 --- /dev/null +++ b/tests/matching/test_version_mismatch.py @@ -0,0 +1,231 @@ +"""Boundary tests for ``core.matching.version_mismatch``. + +Pin every shape the version-mismatch escape valve has to handle so +future drift fails here instead of at runtime against a real download: +one-sided bare cases (the live-recording MB-metadata-gap that issue +#607 reported), two-sided real mismatches (live vs remix — keep +strict), high vs low fingerprint score gates, title/artist threshold +gates, defensive paths. +""" + +from __future__ import annotations + +import pytest + +from core.matching.version_mismatch import is_acceptable_version_mismatch + + +class TestEqualVersions: + def test_same_version_trivially_accepted(self): + # Equal version strings — no mismatch to decide. True. + assert is_acceptable_version_mismatch( + 'live', 'live', + fingerprint_score=0.0, + title_similarity=0.0, + artist_similarity=0.0, + ) is True + + def test_both_original_accepted(self): + assert is_acceptable_version_mismatch( + 'original', 'original', + fingerprint_score=0.0, + title_similarity=0.0, + artist_similarity=0.0, + ) is True + + +class TestOneSidedBareMismatch: + """Issue #607 example 2: expected has annotation, AcoustID's MB + record is bare. Accept when fingerprint + bare titles + artist + all line up.""" + + def test_live_vs_original_high_confidence_accepted(self): + # Reporter's exact case: "Clarity (Live at ...)" vs "Clarity" + assert is_acceptable_version_mismatch( + 'live', 'original', + fingerprint_score=0.95, + title_similarity=0.95, + artist_similarity=1.0, + ) is True + + def test_original_vs_live_high_confidence_accepted(self): + # Same case in the other direction. + assert is_acceptable_version_mismatch( + 'original', 'live', + fingerprint_score=0.95, + title_similarity=0.95, + artist_similarity=1.0, + ) is True + + def test_live_at_thresholds_accepted(self): + # Exactly at the thresholds for the live-aware case. + assert is_acceptable_version_mismatch( + 'live', 'original', + fingerprint_score=0.85, + title_similarity=0.70, + artist_similarity=0.60, + ) is True + + +class TestNonLiveOneSidedMismatchStaysStrict: + """Other version markers (instrumental / remix / acoustic / etc) + have distinct fingerprints AND MB always annotates them in the + recording title. When AcoustID returns one of these for a bare + expected (or vice versa), the file genuinely IS that version — + the user asked for the wrong cut. Reject regardless of how high + the supporting scores are. + + This narrowness is what keeps the existing + test_acoustid_version_mismatch suite passing — instrumental + vs vocal etc. stays a real mismatch.""" + + def test_remix_vs_original_rejected_at_high_confidence(self): + assert is_acceptable_version_mismatch( + 'remix', 'original', + fingerprint_score=0.99, + title_similarity=0.99, + artist_similarity=0.99, + ) is False + + def test_instrumental_vs_original_rejected_at_high_confidence(self): + # The exact case test_acoustid_version_mismatch.py: + # test_instrumental_returned_for_vocal_request_fails pins — + # vocal asked, instrumental returned, must FAIL. + assert is_acceptable_version_mismatch( + 'instrumental', 'original', + fingerprint_score=0.99, + title_similarity=0.99, + artist_similarity=0.99, + ) is False + + def test_original_vs_instrumental_rejected_at_high_confidence(self): + # Reverse direction: caller asked for vocal, file is + # instrumental. + assert is_acceptable_version_mismatch( + 'original', 'instrumental', + fingerprint_score=0.99, + title_similarity=0.99, + artist_similarity=0.99, + ) is False + + def test_acoustic_vs_original_rejected_at_high_confidence(self): + assert is_acceptable_version_mismatch( + 'acoustic', 'original', + fingerprint_score=0.99, + title_similarity=0.99, + artist_similarity=0.99, + ) is False + + def test_demo_vs_original_rejected(self): + assert is_acceptable_version_mismatch( + 'demo', 'original', + fingerprint_score=0.99, + title_similarity=0.99, + artist_similarity=0.99, + ) is False + + +class TestTwoSidedMismatchStaysStrict: + """Both sides have version markers but they disagree. Real + different-recording mismatch — must reject regardless of how + high the other scores are.""" + + def test_live_vs_remix_rejected_even_at_max(self): + assert is_acceptable_version_mismatch( + 'live', 'remix', + fingerprint_score=1.0, + title_similarity=1.0, + artist_similarity=1.0, + ) is False + + def test_acoustic_vs_instrumental_rejected(self): + assert is_acceptable_version_mismatch( + 'acoustic', 'instrumental', + fingerprint_score=0.99, + title_similarity=0.99, + artist_similarity=0.99, + ) is False + + def test_live_vs_acoustic_rejected(self): + assert is_acceptable_version_mismatch( + 'live', 'acoustic', + fingerprint_score=0.95, + title_similarity=0.90, + artist_similarity=1.0, + ) is False + + +class TestThresholdGates: + """One-sided + bare but one of the supporting signals is too weak. + Reject — fall through to FAIL.""" + + def test_low_fingerprint_score_rejected(self): + # Fingerprint score below threshold. Don't trust it enough. + assert is_acceptable_version_mismatch( + 'live', 'original', + fingerprint_score=0.50, + title_similarity=0.95, + artist_similarity=1.0, + ) is False + + def test_low_title_similarity_rejected(self): + # Bare titles disagree → different songs, not just MB metadata gap. + assert is_acceptable_version_mismatch( + 'live', 'original', + fingerprint_score=0.95, + title_similarity=0.30, + artist_similarity=1.0, + ) is False + + def test_low_artist_similarity_rejected(self): + # Wrong artist — definitely not the same recording. + assert is_acceptable_version_mismatch( + 'live', 'original', + fingerprint_score=0.95, + title_similarity=0.95, + artist_similarity=0.20, + ) is False + + def test_just_below_score_threshold_rejected(self): + assert is_acceptable_version_mismatch( + 'live', 'original', + fingerprint_score=0.849, # default threshold 0.85 + title_similarity=0.95, + artist_similarity=1.0, + ) is False + + def test_just_below_title_threshold_rejected(self): + assert is_acceptable_version_mismatch( + 'live', 'original', + fingerprint_score=0.95, + title_similarity=0.699, # default threshold 0.70 + artist_similarity=1.0, + ) is False + + def test_just_below_artist_threshold_rejected(self): + assert is_acceptable_version_mismatch( + 'live', 'original', + fingerprint_score=0.95, + title_similarity=0.95, + artist_similarity=0.599, # default threshold 0.60 + ) is False + + +class TestCustomThresholds: + def test_custom_score_threshold_accepts_when_loosened(self): + assert is_acceptable_version_mismatch( + 'live', 'original', + fingerprint_score=0.70, + title_similarity=0.95, + artist_similarity=1.0, + score_threshold=0.65, + ) is True + + def test_custom_score_threshold_rejects_when_tightened(self): + assert is_acceptable_version_mismatch( + 'live', 'original', + fingerprint_score=0.90, + title_similarity=0.95, + artist_similarity=1.0, + score_threshold=0.95, + ) is False diff --git a/webui/static/helper.js b/webui/static/helper.js index 53f96b55..26683d8c 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3416,6 +3416,7 @@ const WHATS_NEW = { '2.5.2': [ // --- May 13, 2026 — 2.5.2 release --- { date: 'May 13, 2026 — 2.5.2 release' }, + { title: 'AcoustID + Quarantine Modal: Three Bug Fixes', desc: 'github issues #607 + #608. (1) live recordings no longer false-quarantine when AcoustID returns the live recording with a bare title — common case where venue/live annotation lives on the release entity, not the recording entity itself ("Clarity (Live at ...)" expected, AcoustID returns bare "Clarity"). new pure helper `core/matching/version_mismatch.py:is_acceptable_version_mismatch` accepts the mismatch only when one-sided live + bare AND fingerprint score >= 0.85 AND bare titles agree (>=0.7) AND artist matches (>=0.6). other version mismatches (instrumental, remix, acoustic, demo) stay strict — those have distinct fingerprints + MB always annotates them in the recording title. 23 boundary tests pin every shape; existing test_acoustid_version_mismatch suite passes unchanged. (2) audio-mismatch failure message no longer reports "identified as \'\' by \'\' (artist=100%)" when AcoustID returns multiple recordings — prior code mixed recordings[0]\'s strings (which can be empty) with best_rec\'s scores. now uses matched_title/matched_artist consistently in both the high-confidence-skip path and the final fail message. (3) quarantine modal Approve/Delete buttons no longer silently no-op when filename contains an apostrophe — id is now wrapped via `escapeHtml(JSON.stringify(id))` so quotes / backslashes / unicode all round-trip safely through the HTML attribute → JS string boundary. (4) bonus UX: quarantine entry expanded view now shows source uploader (username) + original soulseek filename when the sidecar carries that context, helping trace which uploader the bad file came from.', page: 'downloads' }, { title: 'Reorganize: Read Embedded Tags Instead Of Metadata API', desc: 'github issue #592: optional reorganize mode that uses each file\'s embedded tags as the canonical source of truth instead of doing a fresh metadata-source api lookup. useful for well-tagged libraries where api drift can produce inconsistent renames. zero api calls. opt-in via new "metadata mode" dropdown in the per-album reorganize modal + bulk reorganize-all modal — default stays "api" so existing pipelines are untouched. tag-mode reads via the same mutagen path the audit-trail modal uses (id3 / vorbis / mp4 all covered). honors id3-style "5/12" track and disc shapes, multi-value Artists tags, year normalization across 5 date formats, releasetype canonical tokens (album/single/ep/compilation). partial-album reorganize respects "totaldiscs" tag so disc 1 of a 2-disc set still routes into "Disc 1/" subfolder. plan items carry per-item `api_album` so each file\'s own album metadata flows through post-process, not a shared one. logic lifted to pure helper `core/library/reorganize_tag_source.py` with 49 boundary tests + 6 planner-level integration tests pinning every shape: missing essentials, multi-artist split across 9 separators, defensive paths, api-mode regression guard. 3171 tests pass — no regression.', page: 'tools' }, { title: 'Dashboard Cursor-Following Accent Blob + Darker Cards', desc: 'subtle two-layer accent blob that follows your cursor across the bento. soft halo with cursor lag for a liquid trailing feel + brighter inner core that screen-blends on top. both layers gently pulse on different rhythms (5.5s halo, 3.7s core) so it feels alive. mouse leaves a card or sits in a gap → blob freezes for 1.5s then drifts back to grid center. card backgrounds darkened to near-black with stronger borders for contrast. respects the existing reduce visual effects setting (settings → ui) — blob fully disabled when on. performant: rAF-only-while-moving, single layout flush per frame, batched read/write of getBoundingClientRect.', page: 'home' }, { title: 'Dashboard Bento Redesign', desc: 'rebuilt the dashboard as a bento grid. every section now lives in its own card with an accent-tinted glow that follows your theme. cards fade up on first paint with a staggered reveal. layout adapts: 3-col on desktop (≥1500px), 2-col on laptop, 2-col tighter on tablet, single-column on mobile (<700px). enrichment service gauges ride a single 10-tile row at desktop and wrap to 5 / 4 / 3 / 2 as space tightens. system stats render 3-up across 2 rows so all 6 metrics fit without scrolling. recent syncs stack vertically inside their card. service status, library, tools, recent activity all slot into the grid. every existing button + id preserved — pure visual + responsive overhaul.', page: 'home' }, @@ -3840,6 +3841,28 @@ const WHATS_NEW = { // Section shape: { title, description, features: [bullet strings], // usage_note?: 'optional hint shown at the bottom' } const VERSION_MODAL_SECTIONS = [ + { + title: "Live Recordings Stop False-Quarantining", + description: "github issue #607: live recordings were quarantining as 'Version mismatch: expected ... (live) but file is ... (original)' because MusicBrainz often stores live recordings with bare titles — venue annotations live on the release entity, not the recording entity itself. AcoustID's fingerprint correctly identified the live recording, but the title-text comparison flagged it as wrong.", + features: [ + "• new escape valve in the version-mismatch gate: when one side is 'live' and the other is bare 'original' + fingerprint score >= 0.85 + bare titles match + artist matches, accept", + "• other version mismatches (instrumental vs vocal, remix vs original, acoustic vs studio, demo) stay strict — those have distinct fingerprints AND MB always annotates them in the recording title", + "• fingerprint-score floor of 0.85 is stricter than the existing 0.80 minimum, so the escape valve only fires when AcoustID is more confident than its own threshold", + "• logic lifted to pure helper core/matching/version_mismatch.py with 23 boundary tests + the existing instrumental/remix tests still pin those cases as strict mismatches", + "• audio-mismatch failure message now reports the actual best-matching recording's strings, not recordings[0]'s — prior code mixed strings from one candidate with scores from another, producing nonsense reasons like \"identified as '' by '' (artist=100%)\"", + ], + usage_note: "no settings to change — applies on next download attempt of a live recording", + }, + { + title: "Quarantine Modal: Apostrophe Bug Fixed + Source Info Added", + description: "github issue #608: quarantined files with an apostrophe in the filename couldn't be Approved or Deleted — the unescaped quote broke the inline JS in the onclick handler, so the click silently no-op'd. Plus a UX add: each entry now shows the source uploader and original soulseek filename when available.", + features: [ + "• id is now wrapped via escapeHtml(JSON.stringify(id)) so apostrophes (and backslashes, double quotes, unicode, newlines) round-trip safely through the HTML attribute → JS string boundary", + "• quarantine entry expanded view shows source uploader (username) and original soulseek filename when the sidecar carries that context — helps trace which uploader the bad file came from", + "• degrades gracefully when the sidecar lacks the source fields (legacy thin sidecars) — fields just hide", + ], + usage_note: "open Library History → Quarantine tab", + }, { title: "Reorganize Can Now Use Your Embedded File Tags Instead Of An API Lookup", description: "github issue #592: for users with a well-enriched, well-tagged library, doing a fresh metadata-source api lookup at reorganize time can drift from what's already on the file — provider naming inconsistencies, missing album-level metadata for niche releases. new optional mode reads each file's embedded tags as the source of truth instead. zero api calls.", diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index c3bea000..864b0825 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -3137,19 +3137,38 @@ function renderQuarantineEntry(entry) { const triggerLabel = triggerLabels[entry.trigger] || entry.trigger || 'Unknown'; const triggerColor = triggerColors[entry.trigger] || '#888'; - const id = escapeHtml(entry.id); + // Issue #608 (AfonsoG6): the prior code injected `entry.id` raw + // into single-quoted JS string literals inside HTML onclick + // attributes. Filenames containing an apostrophe broke JS + // parsing — Approve and Delete buttons silently no-op'd. + // JSON.stringify wraps the value in valid JS literal syntax + // (handles ', \, " and unicode); escapeHtml then guards the HTML + // attribute boundary so the JS string round-trips intact. + const idJs = escapeHtml(JSON.stringify(entry.id)); const approveLabel = entry.has_full_context ? 'Approve' : 'Recover'; const approveTitle = entry.has_full_context ? 'Re-run post-processing with only the failing check skipped' : 'Legacy entry — move to Staging, finish via Import flow'; const approveCall = entry.has_full_context - ? `approveQuarantineEntry('${id}')` - : `recoverQuarantineEntry('${id}')`; + ? `approveQuarantineEntry(${idJs})` + : `recoverQuarantineEntry(${idJs})`; const meta = [entry.expected_artist, entry.original_filename].filter(Boolean).join(' — '); const triggerBadge = `${escapeHtml(triggerLabel)}`; const reasonDetail = `