soulsync/core/matching/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

195 lines
6.6 KiB
Python

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