Fix silent wrong-artist track downloads (Maduk/Tom Walker bug)

User reported searching "Maduk - Leave A Light On" on Tidal silently
downloaded Tom Walker's completely different song of the same name, then
embedded Maduk's metadata into Tom Walker's audio. Three layers of
defense all failed permissively. Two of them are fixed here; the third
(score formula weights) was left alone since these two together cover it.

Layer 1 fix — candidate artist gate (web_server.py:27782)
  Old: `if _best_artist < 0.4 and confidence < 0.85: continue`
  New: `if _best_artist < 0.5 and confidence < 0.85: continue`

  SequenceMatcher returns exactly 0.400 for "maduk" vs "tom walker"
  (5-char vs 10-char strings with coincidental char matches), which
  slipped past the strict `< 0.4` check. The word-boundary containment
  check earlier in the function already short-circuits legitimate
  formatting variations to sim=1.0, so falling to SequenceMatcher means
  strings are genuinely different. 0.5 closes the fencepost AND gives
  a small safety buffer.

Layer 3 fix — AcoustID verification (acoustid_verification.py:316)
  When title matches but artist doesn't AND expected artist isn't found
  anywhere in AcoustID's returned recordings:
    Old: always SKIP (let file through, assume cover/collab)
    New: FAIL if artist_sim < 0.3 (clear mismatch)
         SKIP if artist_sim >= 0.3 (ambiguous — cover/collab/formatting)

  The 0.3 cutoff catches hard mismatches like Maduk/Tom Walker (sim ~0.2)
  while preserving benefit-of-the-doubt for borderline artist formatting
  differences. Legitimate covers and collabs where the expected artist
  appears anywhere in AcoustID's recordings still PASS via the existing
  secondary-match loop above.

Both fixes are defense-in-depth — either alone would have caught this
bug. Together they close the pre-download AND post-download gaps.

All 292 tests pass. Version bumped to 2.39 with changelog entries.
This commit is contained in:
Broque Thomas 2026-04-22 10:32:55 -07:00
parent 0d0bbf38c9
commit 8f85b0c251
3 changed files with 70 additions and 8 deletions

View file

@ -296,9 +296,14 @@ class AcoustIDVerification:
logger.info(f"AcoustID verification PASSED - {msg}")
return VerificationResult.PASS, msg
# Title matches but artist doesn't — could be a cover or collab, skip
# Title matches but artist doesn't — could be a cover/collab OR a
# genuinely different track with the same name. Distinguish the
# two by checking whether the expected artist appears anywhere in
# AcoustID's returned recordings.
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim < ARTIST_MATCH_THRESHOLD:
# Check if the expected artist appears anywhere in the AcoustID results
# First: if the expected artist is present in ANY recording's
# metadata for this fingerprint, it's likely the right track
# (AcoustID's "best" match just picked the wrong variant).
for rec in recordings:
if _similarity(expected_artist_name, rec.get('artist', '')) >= ARTIST_MATCH_THRESHOLD:
msg = (
@ -308,10 +313,30 @@ class AcoustIDVerification:
logger.info(f"AcoustID verification PASSED (secondary match) - {msg}")
return VerificationResult.PASS, msg
# Expected artist wasn't found anywhere. Decide between:
# - FAIL: clear mismatch, e.g. "Tom Walker" (sim ~0.2) when
# expecting "Maduk" — different song with same name
# - SKIP: ambiguous, e.g. collab / alt credit / formatting
# difference (sim 0.3-0.6)
#
# The 0.3 cutoff catches hard mismatches while preserving the
# benefit of the doubt for borderline artist formatting.
CLEAR_MISMATCH_THRESHOLD = 0.3
if artist_sim < CLEAR_MISMATCH_THRESHOLD:
msg = (
f"Audio mismatch: file identified as '{matched_title}' by '{matched_artist}', "
f"expected '{expected_track_name}' by '{expected_artist_name}' "
f"(title={title_sim:.0%}, artist={artist_sim:.0%}) — "
f"expected artist not found in any AcoustID recording"
)
logger.warning(f"AcoustID verification FAILED (clear artist mismatch) - {msg}")
return VerificationResult.FAIL, msg
msg = (
f"Title matches but artist unclear: "
f"AcoustID='{matched_title}' by '{matched_artist}', "
f"expected '{expected_track_name}' by '{expected_artist_name}'"
f"expected '{expected_track_name}' by '{expected_artist_name}' "
f"(artist_sim={artist_sim:.0%} — ambiguous, could be cover/collab)"
)
logger.info(f"AcoustID verification SKIPPED - {msg}")
return VerificationResult.SKIP, msg

View file

@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent
logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, version-info endpoint, etc.
_SOULSYNC_BASE_VERSION = "2.38"
_SOULSYNC_BASE_VERSION = "2.39"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -22711,6 +22711,27 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
"sections": [
{
"title": "Fix Wrong-Artist Tracks Silently Downloading",
"description": "A critical bug where searching for a track could silently download a completely different artist's song with the same name",
"features": [
"• Example: searching 'Maduk — Leave A Light On' on Tidal was downloading Tom Walker's unrelated song of the same name, then embedding Maduk's metadata into Tom Walker's audio",
"• Root cause 1: candidate artist gate used `< 0.4` similarity but Maduk/Tom Walker scored exactly 0.400, slipping past the fencepost — raised to `< 0.5`",
"• Root cause 2: AcoustID verification returned SKIP (accept) instead of FAIL (quarantine) when title matched but artist was clearly different — now FAILs when artist similarity is below 0.3",
"• Preserves SKIP for the ambiguous 0.30.6 range (covers, collabs, formatting differences) so legitimate tracks aren't falsely quarantined",
"• Both pre-download candidate validation AND post-download verification are now fixed — defense in depth",
],
},
{
"title": "Tidal Search Falls Back on Long Queries",
"description": "Tidal's search chokes on long remix-credit queries — now retries with progressively-shortened variants when the original returns 0 results",
"features": [
"• Example: 'maduk transformations remixed fire away fred v remix' returned 0; now falls back to shorter queries until Tidal finds the track",
"• Up to 4 shortened variants tried, capped total 5 requests, 100ms between attempts",
"• Qualifier-safe: Live/Remix/Acoustic/Extended searches only accept fallback results that still contain the qualifier — studio version never replaces a '(Live)' request",
"• Returns empty if no variant preserves qualifiers — same outcome as before",
],
},
{
"title": "Manual Discovery Fixes Persist Across Restart",
"description": "When you manually fix a discovery match, the fix is now saved under your active metadata source instead of always 'spotify' — so Deezer/iTunes/Discogs/Hydrabase users' fixes actually survive restart and re-scan",
@ -27779,7 +27800,17 @@ def get_valid_candidates(results, spotify_track, query):
break
else:
_best_artist = max(_best_artist, SequenceMatcher(None, _ea_norm, _cand_artist).ratio())
if _best_artist < 0.4 and confidence < 0.85:
# Raised from 0.4 → 0.5 to close a fencepost bug: SequenceMatcher
# returns exactly 0.400 for "maduk" vs "tom walker" (5 chars vs
# 10 chars with 2 coincidental char matches), which bypassed the
# strict `< 0.4` check and let Tom Walker through as a candidate
# for a Maduk track. The word-boundary containment check above
# already short-circuits legitimate formatting variations
# ("Beatles"/"The Beatles", "Maduk"/"Maduk feat. X") to sim=1.0,
# so falling to SequenceMatcher means the strings are genuinely
# different. 0.5 gives a safer buffer without blocking real
# matches that would have scored above 0.85 anyway.
if _best_artist < 0.5 and confidence < 0.85:
continue
r.confidence = confidence
@ -27799,7 +27830,7 @@ def get_valid_candidates(results, spotify_track, query):
logger.warning(f"[{source_label}] No streaming results passed validation — falling through to filename matching")
# YouTube artist data is unreliable, allow fallback to filename-based matching
else:
logger.warning(f"[{source_label}] No streaming results passed validation (threshold: 0.60, artist gate: 0.40) — rejecting all candidates")
logger.warning(f"[{source_label}] No streaming results passed validation (threshold: 0.60, artist gate: 0.50) — rejecting all candidates")
return [] # Tidal/Qobuz/HiFi/Deezer have structured metadata; don't fall back to filename matching
# Uses the existing, powerful matching engine for scoring (Soulseek P2P results)

View file

@ -3599,6 +3599,12 @@ function closeHelperSearch() {
// ═══════════════════════════════════════════════════════════════════════════
const WHATS_NEW = {
'2.39': [
// --- April 22, 2026 ---
{ date: 'April 22, 2026' },
{ title: 'Fix Wrong-Artist Tracks Silently Downloading from Tidal', desc: 'A user reported that searching for "Leave A Light On" by Maduk on Tidal silently downloaded Tom Walker\'s (completely different) song of the same name, embedding Maduk metadata into Tom Walker\'s audio. Two layers of defense were failing: (1) the candidate artist gate used `< 0.4` similarity and "maduk" vs "tom walker" scored exactly 0.400, slipping past the fencepost — raised to `< 0.5`. (2) AcoustID verification correctly identified the mismatch but returned SKIP (accept) instead of FAIL (quarantine) when title matched but artist was clearly different and the expected artist was absent from every recording. Now returns FAIL when artist similarity < 0.3 (clear mismatch); preserves SKIP for the ambiguous 0.3-0.6 range (covers/collabs/formatting differences)', page: 'sync' },
{ title: 'Tidal Search Falls Back to Shortened Queries on 0 Results', desc: 'Tidal\'s search chokes on long queries with multiple qualifier words (e.g., "maduk transformations remixed fire away fred v remix" returns nothing, but dropping "fred v remix" works). Search now retries with up to 4 progressively-shortened variants when the original returns 0 results. Qualifier-safe: if the original query mentions Live/Remix/Acoustic/etc., fallback results must still contain those keywords in their track names — otherwise a shortened query could silently downgrade "(Live)" to the studio version. Returns ([], []) if no variant preserves the qualifiers, same as before', page: 'sync' },
],
'2.38': [
// --- April 21, 2026 (late) ---
{ date: 'April 21, 2026 (late)' },
@ -3792,12 +3798,12 @@ const WHATS_NEW = {
function _getCurrentVersion() {
const btn = document.querySelector('.version-button');
return btn ? btn.textContent.trim().replace('v', '') : '2.38';
return btn ? btn.textContent.trim().replace('v', '') : '2.39';
}
function _getLatestWhatsNewVersion() {
const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a));
return versions[0] || '2.38';
return versions[0] || '2.39';
}
function openWhatsNew() {