Fix MTV Unplugged & live-album false-quarantine pipeline

Closes #589. Tracks from MTV Unplugged / Live At / unplugged albums
consistently failed AcoustID verification with "Version mismatch:
expected (live) but file is (original)". Two upstream bugs fed into
the false positive — the AcoustID gate itself was correctly catching
the wrong file Tidal had selected. Codex diagnosed all three layers,
this fixes the two upstream causes and leaves the verifier alone.

Bug 1 — album-scoped library check false-misses owned albums

`core/downloads/master.py:184` scored "Shy Away (MTV Unplugged Live)"
(source title from playlist) vs "Shy Away" (local DB stored title)
with raw string similarity. Massive length asymmetry → ~0.3 → below
the 0.7 threshold → marked missing. Combined with the
`allow_duplicates and batch_is_album` short-circuit that disables
the global fallback for album downloads, the user's already-owned
album re-triggered every track for download. Explains the screenshot
showing "0 found / 7 missing" on an album the user manually placed.

New pure helper `core/matching/album_context_title.py:strip_redundant_album_suffix`
strips trailing parenthetical / bracket / dash suffixes whose tokens
are fully subsumed by the album context — at least one version
marker (live / unplugged / acoustic / session / concert / tour)
overlapping with the album, and every other token is either a
known marker, a year, a tolerated noise word, or a word from the
album title. Album-context-implied "live" added when the album
mentions unplugged / concert / tour / session.

Wired into the album-confirmed scope ONLY (not global matching).
Compares both raw and normalized source titles per album track and
takes the max similarity, so the helper returning the input
unchanged (when album doesn't imply version context) preserves
the pre-fix behavior.

Bug 2 — Tidal qualifier filter only ran on fallback searches

`core/tidal_download_client.py:345` set `is_fallback = attempt_idx > 0`
and only filtered when `is_fallback and required_qualifiers`. Primary
search returned all results unfiltered, so a query for "Shy Away
(MTV Unplugged Live)" could accept the studio cut if Tidal happened
to rank it first. Now the qualifier filter applies to BOTH primary
and fallback search attempts — log message updated to indicate
which path triggered.

Bug 3 — qualifier check ignored album.name

The legacy `_track_name_contains_qualifiers` only inspected the
track name. For concert / unplugged releases the live signal
typically lives in the album title, not the track title. New
`_track_matches_qualifiers` accepts a track object and inspects
both `track.name` AND `track.album.name`. Legacy helper preserved
to keep its existing test contract.

AcoustID version-mismatch gate at core/acoustid_verification.py
left intact — it correctly catches genuinely-wrong files that slip
through upstream filters. The In My Feelings (Instrumental) test
that pins this behavior continues to pass.

19 tests on the album-context helper covering MTV Unplugged
variants, dash/parens/brackets suffix shapes, year tolerance,
plural-form markers, the implied-live set, anti-regression cases
(instrumental/remix on a studio album must NOT be stripped),
empty/none defensive paths.

13 tests on the Tidal qualifier helper covering legacy
track-name-only behavior preserved, qualifier in track name alone,
qualifier in album name alone (the MTV Unplugged scenario),
multi-qualifier requirements, no-qualifiers always passes,
defensive against missing track.album, word-boundary avoiding
substring false-matches, _extract_qualifiers picking up live +
unplugged from the user's exact reporter query.

Full suite: 3053 passed.
pull/596/head
Broque Thomas 13 hours ago
parent a3ab0a8637
commit e7ecaca3fd

@ -174,14 +174,33 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
elif album_tracks_map:
# Album-scoped matching: check against known album tracks first
track_name_lower = track_name.lower().strip()
# Direct title match
# Issue #589 — strip suffixes that just repeat the album
# context (e.g. "Shy Away (MTV Unplugged Live)" on a
# "MTV Unplugged" album → "Shy Away") so album-owned
# tracks don't false-miss when the local DB stored the
# base title. Only fires inside the album-confirmed
# scope; global matching elsewhere is unchanged.
from core.matching.album_context_title import strip_redundant_album_suffix
_album_name_for_strip = (batch_album_context or {}).get('name', '')
_normalized_source_title = strip_redundant_album_suffix(
track_name, _album_name_for_strip
).lower().strip()
# Direct title match (try both raw and normalized)
if track_name_lower in album_tracks_map:
found, confidence = True, 1.0
elif _normalized_source_title and _normalized_source_title in album_tracks_map:
found, confidence = True, 1.0
else:
# Fuzzy match against album tracks using string similarity
# Fuzzy match against album tracks using string similarity.
# Compare BOTH the raw and normalized source titles —
# whichever scores higher wins. Preserves strict
# matching when the album doesn't imply version
# context (helper returns the input unchanged).
best_sim = 0.0
for db_title_lower, _db_track in album_tracks_map.items():
sim = db._string_similarity(track_name_lower, db_title_lower)
sim_raw = db._string_similarity(track_name_lower, db_title_lower)
sim_norm = db._string_similarity(_normalized_source_title, db_title_lower) if _normalized_source_title else 0.0
sim = max(sim_raw, sim_norm)
if sim > best_sim:
best_sim = sim
if best_sim >= 0.7:

@ -0,0 +1,195 @@
"""Strip redundant album-context suffixes from track titles.
Issue #589 — MTV Unplugged albums (and similar live-concert / session
releases) have source-side track titles like ``"Shy Away (MTV Unplugged
Live)"`` while the local DB stored title is just ``"Shy Away"``. The
album-scoped library check at ``core/downloads/master.py`` compares
the two with raw string similarity, the length asymmetry tanks the
score, and tracks the user already owns get marked missing.
This helper normalizes a track title by stripping the parenthetical
or dash suffix when its tokens are fully subsumed by the album
context: at least one version marker (live / unplugged / acoustic /
session / etc) is present in BOTH the suffix AND the album title, and
every other suffix token is either a known marker, a year, a
connecting noise word, or a word that appears in the album title.
Pure function. No I/O. Tests at the function boundary.
"""
from __future__ import annotations
import re
from typing import Iterable, Tuple
# Version-marker keywords. When the album title contains any of these,
# stripping is enabled. Singular forms — plurals get matched separately
# via stem expansion below.
_VERSION_MARKERS = (
'live',
'unplugged',
'acoustic',
'session',
'concert',
'tour',
)
# Markers that are implied "live" context — when the album mentions any
# of these, a bare ``live`` token in the suffix counts as album context
# even if the album title doesn't literally say "live". MTV Unplugged
# albums are live recordings; same for "in concert" / "tour" releases.
_IMPLIES_LIVE = ('unplugged', 'concert', 'tour', 'session')
# Connecting / filler words that don't carry meaning by themselves.
_NOISE_TOKENS = frozenset({
'version', 'edition', 'recording', 'recordings', 'remaster',
'remastered', 'mix',
'the', 'a', 'an', 'from', 'at', 'in', 'on', 'for', 'of',
'and', 'or', 'with', 'by',
'vol', 'pt', 'part', 'no',
})
_SUFFIX_PATTERNS: Tuple[re.Pattern, ...] = (
re.compile(r'\s*\(([^()]+)\)\s*$'),
re.compile(r'\s*\[([^\[\]]+)\]\s*$'),
re.compile(r'\s+-\s+(.+?)\s*$'),
)
_YEAR_RE = re.compile(r'^(?:19|20)\d{2}$')
_TOKEN_RE = re.compile(r'\w+')
def _normalize(text: str) -> str:
return (text or '').lower().strip()
def _tokenize(text: str) -> set:
return set(_TOKEN_RE.findall(_normalize(text)))
def _expand_marker_set(markers: Iterable[str]) -> set:
"""Expand each marker into its singular + plural forms."""
out = set()
for marker in markers:
out.add(marker)
if not marker.endswith('s'):
out.add(marker + 's')
return out
_EXPANDED_MARKERS = _expand_marker_set(_VERSION_MARKERS)
def album_context_markers(album_title: str) -> Tuple[str, ...]:
"""Return the version markers present in the album title (singular form)."""
if not album_title:
return ()
album_tokens = _tokenize(album_title)
found = []
for marker in _VERSION_MARKERS:
if marker in album_tokens or (marker + 's') in album_tokens:
found.append(marker)
return tuple(found)
def _suffix_is_album_redundant(
inner: str,
album_tokens: set,
album_markers: Tuple[str, ...],
) -> bool:
"""Decide whether a suffix's tokens are all subsumed by album context.
Three requirements:
1. The suffix contains at least one version-marker token. Stops
a generic "feat. X" suffix from being stripped because the
album happened to be live.
2. The shared marker matches one the album implies either
literally in the album title, OR via the implied-live set
(unplugged/concert/tour albums imply "live").
3. Every other suffix token is either a marker, a year, a
tolerated noise word, or a word that appears in the album
title. If any token falls outside, the suffix carries
info beyond album context (featured artist, different
version, etc) keep it on.
"""
if not inner:
return False
suffix_tokens = _tokenize(inner)
if not suffix_tokens:
return False
# Markers the album effectively implies (literal + implied-live).
implied_markers = set(album_markers)
if any(m in implied_markers for m in _IMPLIES_LIVE):
implied_markers.add('live')
suffix_markers = suffix_tokens & _EXPANDED_MARKERS
if not suffix_markers:
return False
# At least one marker must overlap with album-implied set. Plural
# tolerance — strip trailing 's' for the comparison.
def _stem(tok: str) -> str:
return tok[:-1] if tok.endswith('s') and len(tok) > 1 else tok
if not any(_stem(t) in implied_markers for t in suffix_markers):
return False
# Every remaining suffix token must be subsumed.
for tok in suffix_tokens:
if tok in _EXPANDED_MARKERS:
continue
if _YEAR_RE.match(tok):
continue
if tok in _NOISE_TOKENS:
continue
if tok in album_tokens:
continue
return False
return True
def strip_redundant_album_suffix(track_title: str, album_title: str) -> str:
"""Strip a trailing parenthetical/bracket/dash suffix from `track_title`
when the suffix duplicates context already implied by `album_title`.
Examples:
- ("Shy Away (MTV Unplugged Live)", "MTV Unplugged") "Shy Away"
- ("Only If For A Night (MTV Unplugged, 2012 / Live)",
"Ceremonials (Live At MTV Unplugged)") "Only If For A Night"
- ("In My Feelings (Instrumental)", "Scorpion")
unchanged (instrumental NOT implied by studio album)
- ("Hello (Live - feat. Other)", "Live At Wembley")
unchanged (suffix carries featured-artist beyond album context)
- ("Shy Away", "MTV Unplugged") unchanged (no suffix)
Pure function never raises, returns the input unchanged on any
edge / unexpected input.
"""
if not track_title:
return track_title or ''
album_markers = album_context_markers(album_title)
if not album_markers:
return track_title
album_tokens = _tokenize(album_title)
stripped = track_title
# Stacked suffixes ("Track (MTV Unplugged) [Live]") — peel one at a
# time. Bound the loop defensively.
for _ in range(4):
peeled = None
for pattern in _SUFFIX_PATTERNS:
m = pattern.search(stripped)
if not m:
continue
inner = m.group(1)
if _suffix_is_album_redundant(inner, album_tokens, album_markers):
peeled = stripped[: m.start()].rstrip()
break
if peeled is None:
return stripped
stripped = peeled
return stripped

@ -278,6 +278,27 @@ class TidalDownloadClient(DownloadSourcePlugin):
return False
return True
@classmethod
def _track_matches_qualifiers(cls, track, qualifiers: List[str]) -> bool:
"""Issue #589 — qualifier check must inspect both track.name AND
track.album.name. For MTV Unplugged-style releases the live /
unplugged signal lives in the album title, not the track title.
A track passes if every required qualifier appears as a whole
word in either the track name OR its album name.
"""
if not qualifiers:
return True
track_name = (getattr(track, 'name', '') or '').lower()
album = getattr(track, 'album', None)
album_name = (getattr(album, 'name', '') or '').lower() if album else ''
haystack = f"{track_name} {album_name}".strip()
if not haystack:
return False
for kw in qualifiers:
if not re.search(r'\b' + re.escape(kw) + r'\b', haystack):
return False
return True
@staticmethod
def _generate_shortened_queries(original: str) -> List[str]:
variants: List[str] = []
@ -342,25 +363,35 @@ class TidalDownloadClient(DownloadSourcePlugin):
found = await loop.run_in_executor(None, _search)
if found:
# Issue #589 — qualifier filter applies to ALL
# search attempts, not just fallbacks. If the
# primary query carries "live" / "unplugged" /
# etc, the studio cut should never be accepted
# just because Tidal returned it first. The
# filter inspects both track.name AND
# track.album.name (the live signal often lives
# in the album title for concert releases).
is_fallback = attempt_idx > 0
if is_fallback and required_qualifiers:
if required_qualifiers:
filtered = [
t for t in found
if self._track_name_contains_qualifiers(getattr(t, 'name', ''), required_qualifiers)
if self._track_matches_qualifiers(t, required_qualifiers)
]
if filtered:
tidal_tracks = filtered
successful_query = attempt_query
logger.info(
f"Tidal fallback kept {len(filtered)}/{len(found)} tracks "
f"after qualifier filter {required_qualifiers} for '{attempt_query}'"
f"Tidal {'fallback' if is_fallback else 'primary'} kept "
f"{len(filtered)}/{len(found)} tracks after qualifier filter "
f"{required_qualifiers} for '{attempt_query}'"
)
break
else:
any_fallback_filtered_out = True
logger.debug(
f"Tidal fallback '{attempt_query}' returned {len(found)} tracks "
f"but none matched original qualifiers {required_qualifiers}"
f"Tidal {'fallback' if is_fallback else 'primary'} "
f"'{attempt_query}' returned {len(found)} tracks but none "
f"matched required qualifiers {required_qualifiers}"
f"trying next variant"
)
if attempt_idx < len(queries_to_try) - 1:

@ -0,0 +1,168 @@
"""Tests for the album-context-aware track-title stripping helper.
Issue #589 — MTV Unplugged track titles like ``"Shy Away (MTV Unplugged
Live)"`` got false-rejected by the album-scoped library check because
the local DB stored title is just ``"Shy Away"``. The pure helper here
strips the redundant suffix when (and only when) the album title
implies the same context.
"""
from core.matching.album_context_title import (
album_context_markers,
strip_redundant_album_suffix,
)
# ──────────────────────────────────────────────────────────────────────
# album_context_markers
# ──────────────────────────────────────────────────────────────────────
def test_mtv_unplugged_album_carries_unplugged_marker():
# 'mtv' isn't a version marker on its own — the unplugged token is
# the load-bearing one. Implied-live logic adds 'live' coverage too.
markers = album_context_markers('MTV Unplugged')
assert 'unplugged' in markers
def test_live_at_album_carries_live_marker():
markers = album_context_markers('Live At Wembley')
assert 'live' in markers
def test_studio_album_has_no_markers():
assert album_context_markers('Scorpion') == ()
assert album_context_markers('DAMN.') == ()
assert album_context_markers('') == ()
assert album_context_markers(None) == ()
def test_acoustic_session_album_marker():
assert 'acoustic' in album_context_markers('Acoustic Sessions Vol. 2')
assert 'session' in album_context_markers('Acoustic Sessions Vol. 2')
# ──────────────────────────────────────────────────────────────────────
# strip_redundant_album_suffix — the headline cases from #589
# ──────────────────────────────────────────────────────────────────────
def test_strips_mtv_unplugged_live_suffix_when_album_is_mtv_unplugged():
assert strip_redundant_album_suffix('Shy Away (MTV Unplugged Live)', 'MTV Unplugged') == 'Shy Away'
def test_strips_complex_mtv_unplugged_suffix_with_year():
# Reporter case 2: "Only If For A Night (MTV Unplugged, 2012 / Live)"
assert strip_redundant_album_suffix(
'Only If For A Night (MTV Unplugged, 2012 / Live)',
'Ceremonials (Live At MTV Unplugged)',
) == 'Only If For A Night'
def test_strips_dash_style_live_suffix_when_album_is_live():
assert strip_redundant_album_suffix(
'Bohemian Rhapsody - Live At Wembley',
'Live At Wembley Stadium',
) == 'Bohemian Rhapsody'
def test_strips_brackets_live_suffix():
assert strip_redundant_album_suffix(
'Hello [Live]',
'Live At The Royal Albert Hall',
) == 'Hello'
# ──────────────────────────────────────────────────────────────────────
# Negative cases — must NOT strip when it would mask a genuine variant
# ──────────────────────────────────────────────────────────────────────
def test_does_not_strip_instrumental_when_album_is_studio():
# Critical anti-regression — keeping AcoustID's vocal/instrumental
# gate working downstream. Don't drop the marker just because the
# title is on a studio album.
assert strip_redundant_album_suffix(
'In My Feelings (Instrumental)',
'Scorpion',
) == 'In My Feelings (Instrumental)'
def test_does_not_strip_remix_when_album_is_studio():
assert strip_redundant_album_suffix(
'Hello (Acoustic Remix)',
'Scorpion',
) == 'Hello (Acoustic Remix)'
def test_does_not_strip_live_when_album_does_not_imply_live():
# User's "Live At Wembley" might be a single-track release on an
# otherwise-studio album. Don't strip.
assert strip_redundant_album_suffix(
'Hello (Live At Wembley)',
'Greatest Hits',
) == 'Hello (Live At Wembley)'
def test_does_not_strip_when_suffix_carries_extra_context():
# Suffix has both the album marker AND a featured-artist credit;
# the credit isn't album context, so keep the suffix.
assert strip_redundant_album_suffix(
'Track Name (Live - feat. Other Artist)',
'Live At Wembley',
) == 'Track Name (Live - feat. Other Artist)'
def test_no_suffix_returns_unchanged():
assert strip_redundant_album_suffix('Shy Away', 'MTV Unplugged') == 'Shy Away'
def test_empty_or_none_inputs_handled():
assert strip_redundant_album_suffix('', 'MTV Unplugged') == ''
assert strip_redundant_album_suffix(None, 'MTV Unplugged') == ''
assert strip_redundant_album_suffix('Shy Away', '') == 'Shy Away'
assert strip_redundant_album_suffix('Shy Away', None) == 'Shy Away'
# ──────────────────────────────────────────────────────────────────────
# Stacked-suffix cases
# ──────────────────────────────────────────────────────────────────────
def test_strips_stacked_redundant_suffixes():
# Some sources double up: parens + brackets, both album-context
assert strip_redundant_album_suffix(
'Track Name (Live) [Unplugged]',
'MTV Unplugged Live',
) == 'Track Name'
def test_stops_stripping_when_remaining_suffix_is_genuine():
# Outer is redundant (live → album-context), inner is not (remix)
assert strip_redundant_album_suffix(
'Track Name (Remix) (Live)',
'Live At Wembley',
) == 'Track Name (Remix)'
# ──────────────────────────────────────────────────────────────────────
# Year + connector tolerance
# ──────────────────────────────────────────────────────────────────────
def test_year_in_suffix_does_not_block_stripping():
assert strip_redundant_album_suffix(
'Track Name (Live, 2012)',
'Live At Wembley',
) == 'Track Name'
def test_version_word_in_suffix_does_not_block_stripping():
# "Live Version" is still album-context (just the word "version"
# in there). Strip.
assert strip_redundant_album_suffix(
'Track Name (Live Version)',
'Live At Wembley',
) == 'Track Name'
def test_session_marker_preserved_for_acoustic_session_album():
assert strip_redundant_album_suffix(
'Hello (Acoustic Session)',
'Acoustic Sessions Vol. 2',
) == 'Hello'

@ -0,0 +1,123 @@
"""Tests for Tidal qualifier filtering across primary + fallback search.
Issue #589 — when a download query carries a version qualifier ("live",
"unplugged", "acoustic", etc), the qualifier filter must apply to BOTH
the primary search AND fallback variants. Previously it only fired on
fallbacks, so a primary search for "Shy Away (MTV Unplugged Live)" that
happened to surface the studio cut first would accept the wrong file
and only get caught by AcoustID downstream.
Also covers the album-context extension: for concert / unplugged
releases the live signal lives in the album title, not the track
title. The filter inspects both ``track.name`` AND ``track.album.name``.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from core.tidal_download_client import TidalDownloadClient
def _make_track(name: str, album_name: str = ''):
"""Build a minimal duck-typed track object matching what the Tidal
SDK returns: a `name` attribute and an `album` attribute with its
own `name`."""
track = MagicMock()
track.name = name
track.album = MagicMock()
track.album.name = album_name
return track
# ──────────────────────────────────────────────────────────────────────
# _track_name_contains_qualifiers — legacy track-only behavior preserved
# ──────────────────────────────────────────────────────────────────────
def test_legacy_helper_passes_when_track_name_has_qualifier():
assert TidalDownloadClient._track_name_contains_qualifiers(
'Shy Away (MTV Unplugged Live)', ['live']
) is True
def test_legacy_helper_fails_when_track_name_lacks_qualifier():
assert TidalDownloadClient._track_name_contains_qualifiers(
'Shy Away', ['live']
) is False
def test_legacy_helper_passes_when_no_qualifiers_required():
assert TidalDownloadClient._track_name_contains_qualifiers(
'Anything', []
) is True
# ──────────────────────────────────────────────────────────────────────
# _track_matches_qualifiers — new helper inspects track + album
# ──────────────────────────────────────────────────────────────────────
def test_qualifier_in_track_name_alone_passes():
track = _make_track('Shy Away (Live)', 'DAMN.')
assert TidalDownloadClient._track_matches_qualifiers(track, ['live']) is True
def test_qualifier_in_album_name_alone_passes():
# MTV Unplugged scenario — track titled "Shy Away" but album
# carries the live context. Pre-fix this returned False because
# only track.name was checked.
track = _make_track('Shy Away', 'MTV Unplugged Live')
assert TidalDownloadClient._track_matches_qualifiers(track, ['live']) is True
def test_qualifier_missing_from_both_fails():
# User asked for live, Tidal returned the studio cut on a studio
# album. Must reject so the search keeps looking.
track = _make_track('Shy Away', 'Trench')
assert TidalDownloadClient._track_matches_qualifiers(track, ['live']) is False
def test_unplugged_qualifier_in_album_name():
track = _make_track('Only If For A Night', 'MTV Unplugged')
assert TidalDownloadClient._track_matches_qualifiers(track, ['unplugged']) is True
def test_multiple_qualifiers_all_required():
# Both "live" AND "acoustic" must be present somewhere
track = _make_track('Hello', 'Live Acoustic Sessions')
assert TidalDownloadClient._track_matches_qualifiers(track, ['live', 'acoustic']) is True
track2 = _make_track('Hello', 'Live Sessions') # missing acoustic
assert TidalDownloadClient._track_matches_qualifiers(track2, ['live', 'acoustic']) is False
def test_no_qualifiers_required_always_passes():
track = _make_track('Anything', 'Anything')
assert TidalDownloadClient._track_matches_qualifiers(track, []) is True
def test_track_with_no_album_attribute():
# Defensive — duck-typed tracks may not all have album. Use a
# plain object instead of MagicMock so missing .album is real.
class BareTrack:
name = 'Live Track'
album = None
assert TidalDownloadClient._track_matches_qualifiers(BareTrack(), ['live']) is True
assert TidalDownloadClient._track_matches_qualifiers(BareTrack(), ['unplugged']) is False
def test_track_with_empty_name_and_album():
class BareTrack:
name = ''
album = None
assert TidalDownloadClient._track_matches_qualifiers(BareTrack(), ['live']) is False
def test_word_boundary_avoids_false_match_on_substring():
# "session" should NOT match "obsession"
track = _make_track('Obsession', 'Pop Hits')
assert TidalDownloadClient._track_matches_qualifiers(track, ['session']) is False
def test_extract_qualifiers_picks_up_live_unplugged():
quals = TidalDownloadClient._extract_qualifiers('Shy Away (MTV Unplugged Live)')
assert 'live' in quals
assert 'unplugged' in quals

@ -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: 'MTV Unplugged & Live Albums No Longer False-Quarantine', desc: 'github issue #589: tracks from live / unplugged / concert albums (MTV Unplugged, Live At Wembley, etc) consistently failed AcoustID verification with "Version mismatch: expected (live) but file is (original)". two upstream bugs fed into the false positive — the AcoustID gate itself was correctly catching the wrong file Tidal had selected. fix 1: album-scoped library check at `core/downloads/master.py` was scoring "Shy Away (MTV Unplugged Live)" (source) vs "Shy Away" (local DB) with raw string similarity → ~0.3 → marked missing → re-downloaded even though user already owned it. new pure helper `core/matching/album_context_title.py:strip_redundant_album_suffix` strips suffixes whose tokens are fully subsumed by the album context (live/unplugged/acoustic/session markers + tolerated noise + album-title words). only fires inside the album-confirmed scope so global matching elsewhere is unchanged. fix 2: `core/tidal_download_client.py` qualifier filter only ran on FALLBACK searches — primary search returned all results unfiltered, so a query for "Shy Away (MTV Unplugged Live)" could accept the studio cut if Tidal ranked it first. now applies to both primary and fallback. fix 3: qualifier check now inspects both `track.name` AND `track.album.name` — for concert / unplugged releases the live signal often lives in the album title, not the track. AcoustID version-mismatch gate left intact (still correctly catches genuinely-wrong files). 19 tests on the album-context helper + 13 tests on the tidal qualifier helper pin every shape: MTV Unplugged variants, dash-style suffixes, brackets, year tolerance, plural-form markers, anti-regression cases (instrumental/remix on a studio album must NOT be stripped), defensive non-dict / missing-album inputs.', page: 'downloads' },
{ title: 'Deezer: Contributing Artist Tagging Now Consistent', desc: 'github issue #588: contributors tagging worked for some tracks but silently dropped them for others — most reproducibly for tracks whose ALBUM was fetched before the per-track post-process ran. trace: `core/deezer_client.py:get_track_details` cache check used `track_position` as the "full payload" sentinel, but BOTH `/track/<id>` AND `/album/<id>/tracks` set that field. only `/track/<id>` sets the `contributors` array. when album-tracks data hit the cache first, `get_track_details` returned the partial record → `_build_enhanced_track` found no contributors → the metadata-source contributors-upgrade silently fell back to single-artist. fix: lifted cache-validity to a pure helper `_is_full_track_payload` that requires BOTH `track_position` AND `contributors` key presence (empty list `[]` is valid — single-artist tracks fetched via `/track/<id>` carry it explicitly). partial cache hits now fall through to a fresh `/track/<id>` fetch. 11 boundary tests pin every shape: full payload, single-artist with empty contributors list, partial album-tracks shape, search-result shape, none/non-dict, cache-hit/cache-miss/api-failure paths.', page: 'downloads' },
{ title: 'Server Playlists: Find & Add Now Persists As A Permanent Match', desc: 'github issue #585: when a spotify track name had a versioned suffix not present in the local file (e.g. "Iron Man - 2012 - Remaster" vs "Iron Man") the auto-matcher missed the pair. user could click Find & Add to manually pick the right local file — that worked, file got added to the plex playlist — but the source spotify track stayed in Missing while the added file showed up under Extra, because the matcher had no record of the user-confirmed pairing. on the next sync the source track would re-quarantine and try to download all over again. fix: every Find & Add selection now writes a `(spotify_track_id → server_track_id)` override into `sync_match_cache` at confidence=1.0. the matching algorithm runs an override pass BEFORE the existing exact and fuzzy passes, so any user-confirmed pair short-circuits straight to "matched" without going through normalization at all. covers every kind of mismatch — dash-suffix remasters, covers / karaoke versions, alt masters, cross-language titles, typo\'d local files, anything. logic lifted to `core/sync/match_overrides.py` (pure helpers `resolve_match_overrides` + `record_manual_match`). 18 boundary tests pin: cache-hit pairs, cache-miss falls through, stale-cache (server track removed) handled gracefully, two sources pointing at same server track (UNIQUE-violation defense), str/int id coercion, partial cache hits, defensive against non-dict inputs and DB exceptions. legacy entries without `source_track_id` (non-mirrored playlists) just skip the override path. works across plex / jellyfin / navidrome.', page: 'sync' },
{ title: 'Quarantine Management — See, Approve, Delete Files Without Touching The Filesystem', desc: 'github issue #584: quarantined files used to just sit in `ss_quarantine/` with a thin sidecar — no UI, no recovery, no way to see what got dropped or why. new **Quarantine** tab on the existing Library History modal (downloads page → Download History button) lists every quarantined file with the same row chrome as the Downloads + Server Imports tabs: thumb placeholder, expected track + artist, original filename, trigger badge (Duration / AcoustID / Bit Depth), relative time, expandable details panel showing the full failure reason. three per-row actions: **Approve** (restores the file, re-runs post-processing with ONLY the failing check skipped, lands in your library with full tags + lyrics + scan), **Recover** (legacy fallback for entries quarantined before this PR with thin sidecars — moves to Staging so you finish via Import flow), **Delete** (permanent removal of file + sidecar). all three use the themed soulsync confirm modal + toast feedback (no native browser alert / confirm). per-check bypass means approving a duration-mismatch file still runs AcoustID; approving an AcoustID failure still runs bit-depth — other quality gates stay live so you can only override one trigger at a time. files that fail a different check after approval get re-quarantined with the new trigger label so you can decide again. sidecar now persists the full json-safe context so approve has everything the pipeline needs to re-process. download modal status differentiates "🛡️ Quarantined" from "❌ Failed" so recoverable files are visible at a glance. logic lifted to pure helpers in `core/imports/quarantine.py` (list / delete / approve / recover_to_staging / serialize_quarantine_context) with 27 boundary tests covering orphan files / orphan sidecars / corrupt sidecars / collision-safe filename restoration / full-context vs thin-sidecar dispatch / json round-trip safety. four new endpoints. pipeline change is per-check conditionals at the existing quarantine sites — no blanket skip-all flag.', page: 'downloads' },

Loading…
Cancel
Save