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:
commit
15c41fcc64
6 changed files with 447 additions and 19 deletions
|
|
@ -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%})"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
115
core/matching/version_mismatch.py
Normal file
115
core/matching/version_mismatch.py
Normal 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']
|
||||
231
tests/matching/test_version_mismatch.py
Normal file
231
tests/matching/test_version_mismatch.py
Normal 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
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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">▾</span>
|
||||
</div>
|
||||
<div class="library-history-entry-details">
|
||||
${reasonDetail}
|
||||
${sourceDetail}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
|
|
|||
Loading…
Reference in a new issue