From 0c1dd6c2a9c532ac5df11084c78511684e5e94f1 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 9 Jun 2026 22:28:53 -0700 Subject: [PATCH] Delete: resolve the real on-disk file when DB metadata uses curly quotes (#833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/library/path_resolve.py | 86 +++++++++++++++++++++++ tests/test_path_resolve_confusables.py | 96 ++++++++++++++++++++++++++ web_server.py | 12 ++-- 3 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 core/library/path_resolve.py create mode 100644 tests/test_path_resolve_confusables.py diff --git a/core/library/path_resolve.py b/core/library/path_resolve.py new file mode 100644 index 00000000..341692e3 --- /dev/null +++ b/core/library/path_resolve.py @@ -0,0 +1,86 @@ +"""Confusable-tolerant filesystem path resolution (#833). + +the-hang-man: a track titled "I'm Upset" was written to disk with an ASCII +apostrophe (U+0027) but the library DB stored the title with a typographic one +(U+2019, the form Spotify/Apple metadata uses). Deleting rebuilt the unlink +target from the DB path, so ``os.path.exists`` compared U+2019-bytes against a +U+0027 filename — always a miss — and the file survived ("could not be deleted"). + +The same byte-exact mismatch hits any on-disk operation that starts from stored +metadata (delete, sidecar cleanup, dead-file checks). The fix is to resolve the +*real* on-disk name: descend the path component by component, taking an exact +match when present and otherwise folding a small set of typographic confusables +(curly vs straight quotes, en/em dash, ellipsis, nbsp) for the comparison ONLY. +We never rename — we just find the file that's actually there. + +Case is deliberately preserved: on a case-sensitive dataset (ext4/ZFS) two +tracks can differ only by case, so folding case could delete the wrong file. +The reported failure is purely typographic, so that's all we fold. +""" + +from __future__ import annotations + +import os +import unicodedata + +# Typographic characters that routinely differ between DB metadata (Unicode, +# from streaming-service catalogs) and the ASCII filename on disk. Folded to a +# common form for COMPARISON only. +_CONFUSABLES = { + '‘': "'", '’': "'", 'ʼ': "'", '′': "'", # ‘ ’ ʼ ′ → ' + '“': '"', '”': '"', '″': '"', # “ ” ″ → " + '–': '-', '—': '-', '‒': '-', '―': '-', # – — ‒ ― → - + '…': '...', # … → ... + ' ': ' ', # nbsp → space +} + + +def fold_confusables(name: str) -> str: + """Fold typographic confusables + NFC-normalize so a DB name and the real + on-disk name compare equal despite curly-vs-straight quotes, dashes, etc. + Case and everything else are left untouched.""" + if not name: + return '' + name = unicodedata.normalize('NFC', name) + for bad, good in _CONFUSABLES.items(): + if bad in name: + name = name.replace(bad, good) + return name + + +def find_on_disk(base_dir: str, suffix_parts): + """Descend ``base_dir`` following ``suffix_parts`` (the path components of a + stored file path). Each component is matched exactly when it exists, else by + confusable-folded comparison against the directory's real entries. Returns + the real absolute path, or None if any component can't be resolved. + + Exact matches always win — the folded scan only runs for a component that + isn't present byte-for-byte, so this never changes behaviour for paths that + already resolve. + """ + if not base_dir or not os.path.isdir(base_dir): + return None + current = base_dir + for part in suffix_parts: + if not part: + continue + exact = os.path.join(current, part) + if os.path.exists(exact): + current = exact + continue + target = fold_confusables(part) + match = None + try: + for entry in os.listdir(current): + if fold_confusables(entry) == target: + match = os.path.join(current, entry) + break + except OSError: + return None + if match is None: + return None + current = match + return current if current != base_dir else None + + +__all__ = ['fold_confusables', 'find_on_disk'] diff --git a/tests/test_path_resolve_confusables.py b/tests/test_path_resolve_confusables.py new file mode 100644 index 00000000..4d012d51 --- /dev/null +++ b/tests/test_path_resolve_confusables.py @@ -0,0 +1,96 @@ +"""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 diff --git a/web_server.py b/web_server.py index 8f17ced1..3f97b988 100644 --- a/web_server.py +++ b/web_server.py @@ -11163,14 +11163,18 @@ def _resolve_library_file_path(file_path): path_parts = file_path.replace('\\', '/').split('/') # Try progressively shorter path suffixes against each candidate directory - # (skip index 0 to avoid drive letter issues) + # (skip index 0 to avoid drive letter issues). find_on_disk matches each + # component exactly when present, else folds typographic confusables (#833: + # curly U+2019 apostrophe in DB metadata vs ASCII U+0027 on disk) — exact + # matches always win, so paths that already resolved are unaffected. + from core.library.path_resolve import find_on_disk for base_dir in [transfer_dir, download_dir] + list(library_dirs): if not base_dir or not os.path.isdir(base_dir): continue for i in range(1, len(path_parts)): - candidate = os.path.join(base_dir, *path_parts[i:]) - if os.path.exists(candidate): - return candidate + found = find_on_disk(base_dir, path_parts[i:]) + if found: + return found return None