soulsync/tests/test_path_resolve_confusables.py
BoulderBadgeDad 0c1dd6c2a9 Delete: resolve the real on-disk file when DB metadata uses curly quotes (#833)
the-hang-man: tracks with an apostrophe (e.g. "I'm Upset") deleted the DB row
but left the file. The library DB stored the title with U+2019 (the curly form
Spotify/Apple metadata uses) while the file was written to disk with U+0027
(ASCII). _resolve_library_file_path compared the curly path byte-for-byte via
os.path.exists, missed every time, and reported "could not be deleted".

Fix: resolve confusable-tolerantly. New core/library/path_resolve.find_on_disk
descends the path component by component, taking an exact match when present and
otherwise folding a small set of typographic look-alikes (curly vs straight
quotes, en/em dash, ellipsis, nbsp) for the comparison ONLY — it never renames,
just finds the file that's actually there. Exact matches always win per
component, so paths that already resolved are byte-for-byte unaffected. This
also fixes existing mismatched files (no re-import) and every caller of
_resolve_library_file_path (sidecar cleanup, dead-file checks, streaming), not
just delete.

Case is deliberately NOT folded: a case-sensitive dataset (ext4/ZFS) can hold
names differing only by case, and folding could resolve the wrong file. The
reported failure is purely typographic.

Tests: real temp-file fixtures exercising the actual byte mismatch — curly-DB →
ascii-disk resolves, exact still works, confusable in a folder component, exact
wins when both encodings present, genuinely-different name does NOT collide,
missing file → None. 10 new tests; 949 resolver-adjacent tests pass.
2026-06-09 22:28:53 -07:00

96 lines
3.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Confusable-tolerant path resolution (#833, the-hang-man).
The library DB stored a track title with a curly apostrophe (U+2019); the file
was written to disk with an ASCII one (U+0027). Delete rebuilt the unlink path
from the DB value, so os.path.exists missed and the file survived. find_on_disk
resolves the real on-disk name despite typographic confusables — with REAL temp
files, not mocks, so it exercises the actual byte-level mismatch.
"""
from __future__ import annotations
import os
from core.library.path_resolve import fold_confusables, find_on_disk
CURLY = chr(0x2019) # right single quotation mark
ASCII = chr(0x27) # ' ascii apostrophe
# ── fold_confusables ────────────────────────────────────────────────────────
def test_curly_and_straight_apostrophe_fold_equal():
assert fold_confusables(f"I{CURLY}m Upset") == fold_confusables(f"I{ASCII}m Upset")
assert fold_confusables(f"I{CURLY}m Upset") == "I'm Upset"
def test_other_confusables_fold():
assert fold_confusables('Rock Roll') == 'Rock - Roll' # en dash
assert fold_confusables('Rock — Roll') == 'Rock - Roll' # em dash
assert fold_confusables('A “B” C') == 'A "B" C' # smart quotes
def test_fold_preserves_case_and_plain_text():
# Case must NOT be folded — case-sensitive datasets can hold names that
# differ only by case, and folding could pick the wrong file.
assert fold_confusables('Track NAME.mp3') == 'Track NAME.mp3'
assert fold_confusables('') == ''
# ── find_on_disk against real files ─────────────────────────────────────────
def test_finds_ascii_file_from_curly_db_path(tmp_path):
# On disk: ASCII apostrophe. DB/query: curly. This is the exact #833 case.
album = tmp_path / 'Drake' / 'Scorpion'
album.mkdir(parents=True)
real = album / f"01 - I{ASCII}m Upset.mp3"
real.write_text('audio')
suffix = ['Drake', 'Scorpion', f"01 - I{CURLY}m Upset.mp3"]
found = find_on_disk(str(tmp_path), suffix)
assert found is not None
assert os.path.samefile(found, real)
def test_exact_match_still_works(tmp_path):
real = tmp_path / 'Artist' / 'Album' / 'Track.mp3'
real.parent.mkdir(parents=True)
real.write_text('audio')
found = find_on_disk(str(tmp_path), ['Artist', 'Album', 'Track.mp3'])
assert found is not None and os.path.samefile(found, real)
def test_confusable_in_folder_component(tmp_path):
# The apostrophe can be in a folder name (album/artist), not just the file.
folder = tmp_path / f"Guns N{ASCII} Roses"
folder.mkdir()
real = folder / 'track.mp3'
real.write_text('audio')
found = find_on_disk(str(tmp_path), [f"Guns N{CURLY} Roses", 'track.mp3'])
assert found is not None and os.path.samefile(found, real)
def test_returns_none_for_genuinely_missing_file(tmp_path):
(tmp_path / 'Artist').mkdir()
assert find_on_disk(str(tmp_path), ['Artist', 'Nope.mp3']) is None
def test_does_not_match_a_different_track(tmp_path):
# Folding apostrophes must not collapse two genuinely different names.
(tmp_path / 'Some Other Song.mp3').write_text('x')
assert find_on_disk(str(tmp_path), [f"I{CURLY}m Upset.mp3"]) is None
def test_exact_wins_over_folded_when_both_present(tmp_path):
# If the byte-exact file exists, it's chosen even when a folded sibling also
# exists — no accidental cross-match.
exact = tmp_path / f"I{CURLY}m Upset.mp3"
other = tmp_path / f"I{ASCII}m Upset.mp3"
exact.write_text('curly')
other.write_text('ascii')
found = find_on_disk(str(tmp_path), [f"I{CURLY}m Upset.mp3"])
assert found is not None and os.path.samefile(found, exact)
def test_bad_base_dir_returns_none(tmp_path):
assert find_on_disk(str(tmp_path / 'does-not-exist'), ['x.mp3']) is None