Rockbox's MAX_PATH is 260, defined in firmware/include/fs_defines.h and used to size the directory entry buffer in dir.h. It bounds the path as the device sees it, so the directory the mirror is copied into spends part of the same budget; --device-prefix accounts for that and defaults to /Music. Over-budget paths are shortened from the deepest component outward. The track name carries the least navigational value and the artist directory the most, so the filename is cut first and the artist only if nothing else will serve. A shortened component keeps its extension and gains four hex digits of the original name: two long titles sharing a prefix cut to the same string otherwise, and a silent collision between two tracks is a worse outcome than an ugly filename. The result is stable. The same source always yields the same shortened name, so one pass does not rename what the last one wrote -- an unstable scheme would churn the whole mirror every six hours. A path too deeply nested to fit without reducing every component to nonsense is left alone and reported rather than mangled. Migration now tries more than one previous naming, because there is more than one. A mirror already running with --fat32-safe holds sanitised but unshortened paths, and matching only the original unsanitised name would have re-encoded every one of them instead of moving it. The checker gains the same two options, since it was measuring the mirror-relative path against a limit that applies to the device-absolute one, and so under-reported by the length of the destination directory.
110 lines
3.8 KiB
Python
Executable File
110 lines
3.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Report paths a FAT32 device will not accept, before copying rather than during.
|
|
|
|
Run this against the mirror before an rsync to a Rockbox iPod. rsync will
|
|
report the failures too, but scattered through a run of fifty thousand files,
|
|
where they are easy to lose.
|
|
|
|
Checks the four ways a name fails on FAT32: reserved characters, trailing dots
|
|
or spaces that FAT silently eats, components longer than 255 characters, and
|
|
names that differ only in case -- two files here, one file there, and the
|
|
second silently overwrites the first.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import sys
|
|
import unicodedata
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
RESERVED = re.compile(r'[<>:"\\|?*\x00-\x1f]')
|
|
COMPONENT_LIMIT = 255
|
|
# Rockbox's MAX_PATH, from firmware/include/fs_defines.h. It bounds the path as
|
|
# the device sees it, so the directory the mirror is copied into comes out of
|
|
# the same budget.
|
|
PATH_LIMIT = 260
|
|
DEVICE_PREFIX = "/Music"
|
|
|
|
|
|
def problems_with(relative, budget=PATH_LIMIT):
|
|
"""Return every reason this relative path is unfit for FAT32."""
|
|
found = []
|
|
for part in relative.parts:
|
|
if RESERVED.search(part):
|
|
found.append(f"reserved character in {part!r}")
|
|
if part != part.rstrip(". "):
|
|
found.append(f"trailing dot or space in {part!r}")
|
|
if len(part) > COMPONENT_LIMIT:
|
|
found.append(f"component of {len(part)} characters")
|
|
if len(str(relative)) > budget:
|
|
found.append(f"path of {len(str(relative))} characters, over a budget of {budget}")
|
|
return found
|
|
|
|
|
|
def walk(root):
|
|
"""Yield every file below a root, as a path relative to it."""
|
|
for base, _, names in os.walk(root):
|
|
for name in names:
|
|
yield Path(os.path.join(base, name)).relative_to(root)
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("root", help="directory to check, e.g. the mirror")
|
|
parser.add_argument("--limit", type=int, default=0, help="show at most this many")
|
|
parser.add_argument(
|
|
"--max-path",
|
|
type=int,
|
|
default=PATH_LIMIT,
|
|
help=f"longest path the device will take, from its root (default {PATH_LIMIT},"
|
|
" Rockbox's MAX_PATH)",
|
|
)
|
|
parser.add_argument(
|
|
"--device-prefix",
|
|
default=DEVICE_PREFIX,
|
|
help="directory the mirror is copied into on the device; its length comes out"
|
|
f" of the budget (default {DEVICE_PREFIX})",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
budget = max(0, args.max_path - len(args.device_prefix.strip("/")) - 2)
|
|
|
|
root = Path(args.root)
|
|
if not root.is_dir():
|
|
print(f"{root} is not a directory", file=sys.stderr)
|
|
return 2
|
|
|
|
faults = []
|
|
by_case = defaultdict(list)
|
|
total = 0
|
|
for relative in walk(root):
|
|
total += 1
|
|
# NFC first: the same name written by two systems is otherwise two
|
|
# different strings, and the collision check would miss it.
|
|
key = unicodedata.normalize("NFC", str(relative)).casefold()
|
|
by_case[key].append(relative)
|
|
for problem in problems_with(relative, budget):
|
|
faults.append((relative, problem))
|
|
|
|
for relative, group in sorted(by_case.items()):
|
|
if len(group) > 1:
|
|
names = ", ".join(str(path) for path in sorted(group))
|
|
faults.append((group[0], f"collides case-insensitively with: {names}"))
|
|
|
|
for relative, problem in faults[: args.limit or None]:
|
|
print(f"{relative}\t{problem}")
|
|
|
|
print(f"\n{len(faults)} problems across {total} files", file=sys.stderr)
|
|
if faults:
|
|
print(
|
|
"Run music-mirror with --fat32-safe to have the mirror named acceptably"
|
|
" in the first place; it shortens over-long paths as well.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1 if faults else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|