#914: Reorganize matches bare local titles to iTunes '(feat. X)' tracks

iTunes appends featured-artist credits to track titles ('The Chase (feat. Y)') while the user's
file is often just 'The Chase'. _normalize_title only stripped the parens, keeping 'feat y' as
words, so the title-match ratio fell below the 0.6 substring floor — and with no track-number
rescue the track was reported 'no matching track in the iTunes tracklist' even though it was the
right song.

Strip feat/ft/featuring credits (parenthesised anywhere, or a bare trailing 'feat. X') before
normalizing, so both sides reduce to the same title and match exactly. Guarded so 'The Feat',
'Defeat', 'Lift' aren't touched, and version differentiators (Remix) still hard-reject.

Tests: 8 new (strip variants + the exact no-tn failure + cross-match/remix regressions); 63
existing reorganize tests still green.
This commit is contained in:
BoulderBadgeDad 2026-06-23 14:27:31 -07:00
parent fc2c38ad97
commit 9d16abf952
2 changed files with 87 additions and 1 deletions

View file

@ -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'`` ``Dont 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 ('"', "'", '', '', '', '', '.', ',', '!', '?',
'(', ')', '[', ']', '{', '}'):

View file

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