Merge pull request #610 from Nezreka/fix/acoustid-quarantine-issues

AcoustID + quarantine modal: three bug fixes (closes #607, closes #608)
This commit is contained in:
BoulderBadgeDad 2026-05-15 09:07:08 -07:00 committed by GitHub
commit 15c41fcc64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 447 additions and 19 deletions

View file

@ -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%})"
)

View file

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

View file

@ -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']

View file

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

View file

@ -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.",

View file

@ -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 = `<span class="library-history-badge" style="border-color:${triggerColor};color:${triggerColor}">${escapeHtml(triggerLabel)}</span>`;
const reasonDetail = `<div class="library-history-entry-source"><span class="lh-prov-label">Reason:</span> ${escapeHtml(entry.reason || 'Unknown')}</div>`;
// Issue #608 follow-up: show source uploader + original soulseek
// filename when the sidecar carried that context. Helps the user
// understand which uploader the bad file came from at a glance.
const sourceParts = [];
if (entry.source_username) {
sourceParts.push(`<div class="library-history-entry-source"><span class="lh-prov-label">Source:</span> ${escapeHtml(entry.source_username)}</div>`);
}
if (entry.source_filename) {
sourceParts.push(`<div class="library-history-entry-source"><span class="lh-prov-label">Original filename:</span> ${escapeHtml(entry.source_filename)}</div>`);
}
const sourceDetail = sourceParts.join('');
return `<div class="library-history-entry lh-expandable" onclick="this.classList.toggle('lh-expanded')">
<div class="library-history-thumb-placeholder">🛡</div>
<div class="library-history-entry-content">
@ -3161,11 +3180,12 @@ function renderQuarantineEntry(entry) {
<div class="library-history-entry-badges">${triggerBadge}</div>
<div class="library-history-entry-time">${formatHistoryTime(entry.timestamp)}</div>
<button class="lh-audit-btn" title="${approveTitle}" onclick="event.stopPropagation();${approveCall}">${approveLabel}</button>
<button class="lh-audit-btn" title="Delete permanently" style="border-color:rgba(248,113,113,0.4);color:#f87171" onclick="event.stopPropagation();deleteQuarantineEntry('${id}')">Delete</button>
<button class="lh-audit-btn" title="Delete permanently" style="border-color:rgba(248,113,113,0.4);color:#f87171" onclick="event.stopPropagation();deleteQuarantineEntry(${idJs})">Delete</button>
<span class="lh-expand-btn">&#x25BE;</span>
</div>
<div class="library-history-entry-details">
${reasonDetail}
${sourceDetail}
</div>
</div>
</div>`;