From ece79515c0b522f1b7f710d5cad4cbe2760f7282 Mon Sep 17 00:00:00 2001 From: Emma Thorpe Date: Tue, 25 Aug 2026 11:52:03 +0100 Subject: [PATCH] fix: cost the device prefix exactly rather than approximately The budget subtracted the prefix length plus two, on the assumption of a leading and a trailing slash. That is right for /Music and wrong for an empty prefix, where there is only one slash -- losing a character at the card root, which is exactly where the longest paths sit. Computed from the prefix as it will actually appear instead: /Music/ costs seven characters and gives a mirror-relative budget of 253, the root costs one and gives 259. Worth being exact about because the reverse error is worse. A checker comparing mirror-relative paths against the flat 260 passes everything between 253 and 260, and those are precisely the paths closest to the edge. --- README.md | 11 ++++++++++- music_mirror.py | 14 +++++++++++++- tests/test_music_mirror.py | 22 ++++++++++++++++++++++ tools/check_fat32.py | 14 +++++++++++++- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dd9ba4f..12df336 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,16 @@ run would move rather than what it would delete. Rockbox's `MAX_PATH` is 260, from `firmware/include/fs_defines.h`, and it bounds the path *as the device sees it*. The directory the mirror is copied into comes out of the same budget, so `--device-prefix` (default `/Music`) is subtracted -from `--max-path` to get what a mirror-relative path may spend. +from `--max-path` to get what a mirror-relative path may spend: + +| Destination on the device | Mirror-relative budget | +| ------------------------- | ---------------------- | +| `/Music/` | 253 | +| the card root | 259 | + +Worth being exact about, because a checker that measures mirror-relative paths +against the flat 260 quietly passes everything from 253 to 260 — and those are +the paths most likely to be near the edge in the first place. Over-budget paths are shortened from the **deepest component outward**: the track name carries the least navigational value and the artist directory the diff --git a/music_mirror.py b/music_mirror.py index 86cf13d..9ff0968 100644 --- a/music_mirror.py +++ b/music_mirror.py @@ -167,6 +167,18 @@ def fat32_safe(component): return cleaned or "_" +def device_prefix_length(prefix): + """Return the on-device prefix as it will actually appear, with slashes. + + "/Music" costs seven characters -- the leading slash, the name, and the + separator before the mirror's own path -- while an empty prefix costs one. + Approximating that loses a character at the root, which is precisely where + the longest paths are. + """ + cleaned = prefix.strip("/") + return f"/{cleaned}/" if cleaned else "/" + + def shorten_component(component, budget): """Return a component of at most `budget` characters, cut from the middle. @@ -783,7 +795,7 @@ def main(argv=None): # The device's limit covers the whole path it will see, so what the mirror # may spend is that less the directory it gets copied into. - budget = max(0, args.max_path - len(args.device_prefix.strip("/")) - 2) + budget = max(0, args.max_path - len(device_prefix_length(args.device_prefix))) if args.fat32_safe: logger.info( "paths are limited to %d characters, from --max-path %d less the %r prefix", diff --git a/tests/test_music_mirror.py b/tests/test_music_mirror.py index 0f0a4d7..16bbbe9 100644 --- a/tests/test_music_mirror.py +++ b/tests/test_music_mirror.py @@ -764,3 +764,25 @@ def test_a_long_title_keeps_both_ends(): assert len(str(fitted)) <= 253 assert fitted.name.startswith("The Beatles - Abbey Road - 09 - The Long One") assert fitted.name.endswith("The End.mp3") + + +@pytest.mark.parametrize( + ("prefix", "expected"), + [("/Music", 253), ("Music", 253), ("/Music/", 253), ("", 259), ("/", 259)], +) +def test_the_device_prefix_is_costed_exactly(prefix, expected): + """A mirror-relative path of 253 characters becomes 260 on the device once + /Music/ is in front of it, which is the whole of the limit. Approximating + the prefix loses a character at the root, where the longest paths are.""" + assert 260 - len(music_mirror.device_prefix_length(prefix)) == expected + + +def test_the_budget_is_reported_so_it_can_be_checked(tmp_path, make_flac, caplog): + source = tmp_path / "src" + mirror = tmp_path / "dst" + make_flac(source / "a.flac") + + with caplog.at_level("INFO"): + run(source, mirror, "--fat32-safe") + + assert "limited to 253 characters" in caplog.text diff --git a/tools/check_fat32.py b/tools/check_fat32.py index 7ba0fa5..1c851f2 100755 --- a/tools/check_fat32.py +++ b/tools/check_fat32.py @@ -28,6 +28,18 @@ PATH_LIMIT = 260 DEVICE_PREFIX = "/Music" +def device_prefix_length(prefix): + """Return the on-device prefix as it will actually appear, with slashes. + + "/Music" costs seven characters -- the leading slash, the name, and the + separator before the mirror's own path -- while an empty prefix costs one. + Approximating that loses a character at the root, which is precisely where + the longest paths are. + """ + cleaned = prefix.strip("/") + return f"/{cleaned}/" if cleaned else "/" + + def problems_with(relative, budget=PATH_LIMIT): """Return every reason this relative path is unfit for FAT32.""" found = [] @@ -68,7 +80,7 @@ def main(argv=None): f" of the budget (default {DEVICE_PREFIX})", ) args = parser.parse_args(argv) - budget = max(0, args.max_path - len(args.device_prefix.strip("/")) - 2) + budget = max(0, args.max_path - len(device_prefix_length(args.device_prefix))) root = Path(args.root) if not root.is_dir():