diff --git a/core/imports/album_matching.py b/core/imports/album_matching.py index f29d6b56..fce847ad 100644 --- a/core/imports/album_matching.py +++ b/core/imports/album_matching.py @@ -156,8 +156,11 @@ def score_file_against_track( score = 0.0 # Title similarity (TITLE_WEIGHT). Falls back to filename stem when - # the file has no title tag. + # the file has no title tag — strip a leading track-number prefix off that + # stem (#890) so "01 - Sun It Rises" scores against "Sun It Rises". title = file_tags.get('title') or os.path.splitext(os.path.basename(file_path))[0] + from core.imports.paths import strip_leading_track_number + title = strip_leading_track_number(title) track_name = track.get('name', '') score += similarity(title, track_name) * TITLE_WEIGHT diff --git a/core/imports/context.py b/core/imports/context.py index 8f80d9f1..9ed2a909 100644 --- a/core/imports/context.py +++ b/core/imports/context.py @@ -183,7 +183,12 @@ def get_import_clean_title( if not title: track_info = get_import_track_info(context) title = _first_value(track_info, "name", "title", default="") - return str(title or default) + title = str(title or default) + # #890: strip a leading track-number prefix that leaked from a filename stem + # (e.g. "01 - Sun It Rises" → "Sun It Rises") so it matches the canonical title. + # Conservative — clean source titles ("7 Rings" etc.) pass through untouched. + from core.imports.paths import strip_leading_track_number + return strip_leading_track_number(title) def get_import_clean_album( diff --git a/core/imports/paths.py b/core/imports/paths.py index 69de9cc7..f65c6765 100644 --- a/core/imports/paths.py +++ b/core/imports/paths.py @@ -158,6 +158,33 @@ def clean_track_title(track_title: str, artist_name: str) -> str: return cleaned if cleaned else original +# A leading track-number prefix is EITHER a zero-padded number (01, 04, 099 — no +# real song title starts with one), OR a plain number followed by a real separator +# AND a space ("3 - ", "12. "). Deliberately NOT a bare "number space word", so it +# leaves "7 Rings", "99 Luftballons", "50 Ways to Leave Your Lover" and +# "1-800-273-8255" untouched. +_TRACK_NUM_PREFIX_RE = re.compile(r"^\s*(?:0\d{1,2}[\s._)\-]*|\d{1,3}\s*[._)\-]\s+)(?=\S)") + + +def strip_leading_track_number(title: str) -> str: + """Conservatively remove a leading track-number prefix from a track title. + + Fixes #890 — files named ``01 - Sun It Rises.flac`` whose stem leaks into the + title as ``01 - Sun It Rises``, which then never matches the canonical + ``Sun It Rises`` (false "missing"). Only strips an unambiguous track-number + prefix; a coincidental leading number that's part of the title is preserved, and + it never reduces a title to empty or a bare number.""" + s = (title or "").strip() + if not s: + return title or "" + stripped = _TRACK_NUM_PREFIX_RE.sub("", s, count=1).strip() + # Keep the original if stripping left nothing real — empty, a bare number, or + # only punctuation (e.g. "01 - " → "-"). A real title has a letter/digit. + if stripped.isdigit() or not re.search(r"[^\W_]", stripped): + return s + return stripped + + def get_album_type_display(raw_type, track_count) -> str: diff --git a/tests/imports/test_track_number_strip.py b/tests/imports/test_track_number_strip.py new file mode 100644 index 00000000..ece899f5 --- /dev/null +++ b/tests/imports/test_track_number_strip.py @@ -0,0 +1,77 @@ +"""#890: a leading track number leaking from a filename stem into the title +("01 - Sun It Rises") makes the track never match the canonical "Sun It Rises", +so it reads as a false "missing". strip_leading_track_number removes the prefix — +conservatively, so titles that merely START with a number are left alone. +""" + +from __future__ import annotations + +import pytest + +from core.imports.context import get_import_clean_title +from core.imports.paths import strip_leading_track_number + + +# ── the bug: track-number prefixes get stripped ─────────────────────────────── +@pytest.mark.parametrize("dirty,clean", [ + ("01 - Sun It Rises", "Sun It Rises"), # the screenshot + ("04 - Tiger Mountain Peasant Song", "Tiger Mountain Peasant Song"), + ("05 - Quiet Houses", "Quiet Houses"), + ("07 - Heard Them Stirring", "Heard Them Stirring"), + ("01 Sun It Rises", "Sun It Rises"), # zero-padded, no separator + ("3 - Title", "Title"), # plain number + separator + space + ("12. Some Song", "Some Song"), # dot separator + ("10 - Track Ten", "Track Ten"), + ("09) Closing Time", "Closing Time"), # paren separator + (" 02 - Spaced Out ", "Spaced Out"), # messy whitespace +]) +def test_strips_track_number_prefix(dirty, clean): + assert strip_leading_track_number(dirty) == clean + + +# ── the guard: titles that legitimately start with a number are UNTOUCHED ────── +@pytest.mark.parametrize("title", [ + "7 Rings", + "99 Luftballons", + "50 Ways to Leave Your Lover", + "1-800-273-8255", # number-with-dashes is part of the title + "1979", + "9 to 5", + "4 Minutes", + "8 Mile", + "21 Guns", + "24 Hour Party People", # no separator → not a track number + "0 to 100", + "Sun It Rises", # no leading number at all +]) +def test_preserves_real_titles(title): + assert strip_leading_track_number(title) == title + + +# ── degenerate inputs ───────────────────────────────────────────────────────── +def test_never_reduces_to_empty_or_bare_number(): + assert strip_leading_track_number("01") == "01" # bare number → keep + assert strip_leading_track_number("01 - ") == "01 -" # nothing left → keep original (trimmed) + assert strip_leading_track_number("") == "" + assert strip_leading_track_number(None) == "" + + +def test_only_strips_one_prefix(): + # A title that legitimately follows the number keeps its own leading number. + assert strip_leading_track_number("01 - 24 Hour Party People") == "24 Hour Party People" + + +# ── the chokepoint: every import path resolves its title through here ────────── +def test_get_import_clean_title_strips_filename_leak(): + # original_search['title'] came from the filename stem (no embedded tag). + ctx = {"original_search_result": {"title": "01 - Sun It Rises"}} + assert get_import_clean_title(ctx) == "Sun It Rises" + + +def test_get_import_clean_title_leaves_clean_source_title(): + ctx = {"original_search_result": {"title": "7 Rings"}} + assert get_import_clean_title(ctx) == "7 Rings" + + +def test_get_import_clean_title_default_untouched(): + assert get_import_clean_title({}, default="Unknown Track") == "Unknown Track"