soulsync/tests/matching/test_album_context_title.py
Broque Thomas e7ecaca3fd 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.
2026-05-14 12:14:31 -07:00

168 lines
7.2 KiB
Python

"""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'