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.
This commit is contained in:
parent
66724186b1
commit
0c1dd6c2a9
3 changed files with 190 additions and 4 deletions
86
core/library/path_resolve.py
Normal file
86
core/library/path_resolve.py
Normal file
|
|
@ -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']
|
||||
96
tests/test_path_resolve_confusables.py
Normal file
96
tests/test_path_resolve_confusables.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue