diff --git a/core/library_reorganize.py b/core/library_reorganize.py index a5c9e4cd..574d5ee7 100644 --- a/core/library_reorganize.py +++ b/core/library_reorganize.py @@ -27,6 +27,7 @@ entirely. """ import os +import re import shutil import threading import time @@ -407,6 +408,18 @@ def _differentiators_in(norm_title: str) -> frozenset: return frozenset(t for t in norm_title.split() if t in _VERSION_DIFFERENTIATORS) +# Featured-artist credit: "(feat. X)" / "[ft X]" / a trailing "feat. X". The +# parenthesised form is stripped wherever it appears; the bare form only when +# something follows it (so a song literally named "The Feat" is left alone, and +# "Defeat"/"Lift" never trip the word-boundary). Case-insensitive. +_FEAT_RE = re.compile( + r"""\s*[\(\[]\s*(?:feat|ft|featuring)\b\.?[^)\]]*[\)\]] # (feat. X) / [ft. X] + | \s+(?:feat|ft|featuring)\b\.?\s+\S.*$ # trailing feat. X ... + """, + re.IGNORECASE | re.VERBOSE, +) + + def _normalize_title(value) -> str: """Lowercase + strip cosmetic punctuation and treat brackets / dashes / slashes as word separators so the same track named slightly @@ -418,10 +431,17 @@ def _normalize_title(value) -> str: - ``Don't Stop Believin'`` ↔ ``Don’t Stop Believin’`` - ``Swimming Pools (Drank) - Extended Version`` ↔ ``Swimming Pools (Drank) (Extended Version)`` + - ``The Chase (feat. Big Artist)`` ↔ ``The Chase`` (#914) """ if value is None: return '' - out = str(value).strip().lower() + out = str(value).strip() + # #914: drop featured-artist credits FIRST (while the parens are still here to + # bound the group). iTunes appends "(feat. X)" to track titles while a user's + # file is often just "The Chase" — the credit is metadata, not the song's + # identity, and leaving it in dropped the match ratio below the threshold so + # correctly-identified tracks reported as "not in the tracklist". + out = _FEAT_RE.sub('', out).lower() # Strip characters that don't carry meaning across providers. for ch in ('"', "'", '‘', '’', '“', '”', '.', ',', '!', '?', '(', ')', '[', ']', '{', '}'): diff --git a/tests/test_reorganize_feat_matching.py b/tests/test_reorganize_feat_matching.py new file mode 100644 index 00000000..8d784bbe --- /dev/null +++ b/tests/test_reorganize_feat_matching.py @@ -0,0 +1,66 @@ +"""Reorganize title matcher: featured-artist credits must not block a match (#914). + +iTunes appends "(feat. X)" to track titles while a user's file is often just the +bare title. Before the fix that extra credit dropped the substring ratio below the +match threshold, so a correctly-identified track was reported as "no matching track +in the iTunes tracklist". The credit is metadata, so it's stripped before scoring. +""" + +from __future__ import annotations + +from core.library_reorganize import _find_api_track, _normalize_title + + +# ── normalization ──────────────────────────────────────────────────────────── +def test_feat_paren_stripped_equals_bare(): + assert _normalize_title('The Chase (feat. Big Artist)') == _normalize_title('The Chase') + assert _normalize_title('The Chase (feat. Big Artist)') == 'the chase' + + +def test_feat_variants_all_stripped(): + for v in ('Song (feat. A)', 'Song (ft. A)', 'Song [ft A]', + 'Song (featuring A & B)', 'Song feat. A', 'Song ft. A & B'): + assert _normalize_title(v) == 'song', v + + +def test_feat_strip_preserves_version_differentiator(): + # The remix tag must survive so the hard-reject still distinguishes recordings. + assert _normalize_title('Song (feat. A) - Remix') == 'song remix' + + +def test_bare_feat_word_not_overstripped(): + # "The Feat" (nothing after) and words containing the letters are left alone. + assert _normalize_title('The Feat') == 'the feat' + assert _normalize_title('Defeat') == 'defeat' + assert _normalize_title('Lift Off') == 'lift off' + + +# ── matcher (the #914 failure) ─────────────────────────────────────────────── +def _api(name, tn): + return {'name': name, 'track_number': tn} + + +def test_bare_local_matches_feat_titled_api_track_without_tn(): + # The exact bug: long featured-artist name pushed the ratio below threshold and + # there was no track-number rescue. After stripping feat it's an EXACT match. + api = [_api('The Chase (feat. Somebody Very Famous)', 9)] + assert _find_api_track(api, 'The Chase', None) is api[0] + + +def test_bare_local_matches_feat_titled_api_track_with_tn(): + api = [_api('Money Trees (feat. Jay Rock)', 6), _api('Poetic Justice (feat. Drake)', 7)] + assert _find_api_track(api, 'Money Trees', 6) is api[0] + assert _find_api_track(api, 'Poetic Justice', 7) is api[1] + + +def test_feat_strip_does_not_cross_match_different_songs(): + # Stripping feat must not collapse two genuinely different titles together. + api = [_api('The Chase (feat. X)', 1), _api('The Race (feat. Y)', 2)] + assert _find_api_track(api, 'The Race', None) is api[1] + assert _find_api_track(api, 'Nonexistent Song', None) is None + + +def test_remix_still_hard_rejected_even_with_feat(): + # A bare "Song" must NOT match an API "Song (feat. X) [Remix]" — different recording. + api = [_api('Song (feat. X) - Remix', 1)] + assert _find_api_track(api, 'Song', 1) is None