feat: name the mirror so a FAT32 device will take it, and copy album art
Build and publish container / build (pull_request) Successful in 2m18s

Two changes for playing the mirror on a Rockbox iPod, where the device is FAT32
and Rockbox reads a plain directory tree rather than a database.

--fat32-safe names mirror files acceptably: the reserved characters and control
characters become underscores, trailing dots and spaces are stripped because
FAT eats them silently and the name then round-trips as a different one, and a
component left empty becomes an underscore. Names differing only in case are
detected as collisions, since two files here are one file there and the second
would silently overwrite the first. "Kick Out the Epic Motherf**ker" is a real
example from a real library, and without this it simply never arrives.

Off by default. It renames files, and that should be a decision rather than a
surprise on somebody's next pass.

Turning it on does not re-encode anything. Every track whose name held a
reserved character changes path, and encoding those again would be hours of
work producing files that already exist byte for byte, so the run moves them
instead and logs each one. Prune then finds nothing left behind.

Album art is now also copied into the mirror as cover.jpg beside the tracks.
Rockbox searches the filesystem for art -- cover.jpg, folder.jpg and the rest,
in the track's directory or its parent -- and that search never looks at the
picture embedded in the tag, so a mirror that only embeds art displays none of
it on the device. Embedding continues for the Apple firmware; both are now
satisfied. A cover whose tracks have all been pruned is removed too, or its
directory would never look empty and never go.

Adds tools/check_fat32.py, which reports unacceptable paths before a copy
rather than during one: rsync reports them too, but scattered through fifty
thousand files where they are easy to lose. It exits non-zero so it can gate a
script.

The README documents the rsync invocation, including why --modify-window=2 is
required against FAT and why Rhythmbox must be kept out of the transfer --
rb_ipod_helpers_is_ipod() reads access-protocols from media-player-info and
returns true on the USB id alone, without looking at the filesystem, so
removing iPod_Control changes nothing.
This commit is contained in:
Emma Thorpe
2026-08-25 10:29:55 +01:00
parent d5f4329f46
commit 3141f7ca87
7 changed files with 479 additions and 9 deletions
+117 -9
View File
@@ -71,12 +71,23 @@ SOURCE_PRIORITY = [
# Looked for in the source directory when a file has no embedded picture.
COVER_NAMES = ("cover.jpg", "folder.jpg", "front.jpg", "cover.png", "folder.png")
# What a copied cover is called in the mirror. Rockbox looks for album art on
# the filesystem -- cover.jpg, folder.jpg and friends beside the track -- and
# its search never touches the picture embedded in the tag, so a mirror that
# only embeds art shows none of it on the device.
MIRROR_COVER = "cover.jpg"
# Files the mirror is allowed to contain, and therefore allowed to delete.
MIRROR_SUFFIX = ".mp3"
# Filesystems disagree about mtime precision; SMB in particular rounds.
MTIME_TOLERANCE_SECONDS = 2
# What FAT32 refuses in a filename, plus the control characters. The mirror is
# copied onto a FAT32 device, and one of these in a path means a track that
# never arrives -- "Kick Out the Epic Motherf**ker" is a real example.
FAT32_RESERVED = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
# The mirror exists to be read back by something else -- an SMB share, another
# account on the box -- so everything written into it has to be group-readable.
# Neither writer manages that unaided: tempfile.mkstemp forces 0600 whatever the
@@ -126,9 +137,30 @@ def parse_interval(interval):
return value * {"": 1, "s": 1, "m": 60, "h": 3600, "d": 86400}[match.group(2)]
def mirror_path_for(source, source_root, mirror_root):
def fat32_safe(component):
"""Return a single path component FAT32 will accept.
The mirror exists to be copied onto a FAT32 device, and a name that device
will not take is a track that silently does not arrive. Cheaper to produce
an acceptable name here than to discover the problem partway through
copying fifty thousand files.
Handled: the reserved characters, control characters, and the trailing dots
and spaces that FAT quietly eats -- a name ending in one round-trips as a
different name, which is worse than being rejected outright.
"""
cleaned = FAT32_RESERVED.sub("_", component).rstrip(". ")
# Stripping can empty a component outright: a directory named "..." is
# legal on ext4 and nothing at all on FAT.
return cleaned or "_"
def mirror_path_for(source, source_root, mirror_root, safe=False):
"""Return the mirror path corresponding to a source file."""
return (mirror_root / source.relative_to(source_root)).with_suffix(MIRROR_SUFFIX)
relative = source.relative_to(source_root).with_suffix(MIRROR_SUFFIX)
if safe:
relative = Path(*(fat32_safe(part) for part in relative.parts))
return mirror_root / relative
def is_current(source, mirror):
@@ -138,6 +170,30 @@ def is_current(source, mirror):
return abs(source.stat().st_mtime - mirror.stat().st_mtime) <= MTIME_TOLERANCE_SECONDS
@functools.lru_cache(maxsize=4096)
def mirror_cover(source_directory, mirror_directory):
"""Put a copy of the album's cover beside its tracks in the mirror.
Cached per directory: an album's tracks all ask for the same file, and the
answer cannot change within a pass.
"""
cover = find_cover(source_directory)
if cover is None or cover.suffix.lower() not in (".jpg", ".jpeg"):
# Only JPEG is copied. Rockbox will read a BMP too, but converting a
# PNG is ffmpeg work for a file nothing else in the pass needs.
return None
destination = mirror_directory / MIRROR_COVER
try:
if destination.is_file() and destination.stat().st_size == cover.stat().st_size:
return destination
shutil.copy2(cover, destination)
make_group_readable(destination)
except OSError as error:
logger.warning("could not copy cover for %s: %s", source_directory, error)
return None
return destination
def make_group_readable(path):
"""Add the group-read bit to a mirror file, leaving the rest of the mode alone."""
mode = path.stat().st_mode
@@ -228,6 +284,7 @@ def encode(source, mirror, quality_args, dry_run):
return Result("encoded", mirror)
mirror.parent.mkdir(parents=True, exist_ok=True)
mirror_cover(source.parent, mirror.parent)
# Probing costs an ffprobe process per file, so only ask when the answer
# can change the command. With no cover file beside the track, `-map
# 0:v:0?` carries embedded art if there is any and shrugs if there is not.
@@ -275,6 +332,7 @@ def copy(source, mirror, dry_run):
return Result("copied", mirror)
mirror.parent.mkdir(parents=True, exist_ok=True)
mirror_cover(source.parent, mirror.parent)
# Through a temporary file and a rename, for the same reason encodes go
# that way, and a sharper one: copy2 reproduces the source's mtime as well
@@ -299,8 +357,26 @@ def copy(source, mirror, dry_run):
return Result("copied", mirror)
def process(source, mirror, quality_args, dry_run):
def adopt_existing(source, mirror, previous):
"""Move an already-encoded file to its new name. Returns whether it moved.
Turning on FAT32-safe naming changes the path of every track whose name
held a reserved character. Without this the run would encode them all again
and then prune the originals -- hours of work to produce files that already
exist, byte for byte, under the old name.
"""
if previous == mirror or not previous.is_file() or not is_current(source, previous):
return False
mirror.parent.mkdir(parents=True, exist_ok=True)
os.replace(previous, mirror)
logger.info("renamed %s -> %s", previous.name, mirror.name)
return True
def process(source, mirror, quality_args, dry_run, previous=None):
"""Bring one source file's mirror entry up to date."""
if previous is not None and not dry_run and not mirror.exists():
adopt_existing(source, mirror, previous)
if is_current(source, mirror):
# A mirror written before this bit was set has a correct mtime, so
# nothing else in the pass would ever revisit it. Top it up here
@@ -324,7 +400,7 @@ def find_sources(root):
yield path
def plan(scan_root, source_root, mirror_root):
def plan(scan_root, source_root, mirror_root, safe=False):
"""Map each mirror path to the one source that should produce it.
Two sources can want the same mirror path -- `01 Song.flac` alongside a
@@ -335,12 +411,20 @@ def plan(scan_root, source_root, mirror_root):
the outcome stable and predictable instead.
"""
chosen = {}
# Keyed case-insensitively when the target is FAT32, because two names
# differing only in case are two files here and one file there. Detecting
# that now beats discovering it as a silent overwrite during the copy.
seen = {}
for source in find_sources(scan_root):
mirror = mirror_path_for(source, source_root, mirror_root)
rival = chosen.get(mirror)
mirror = mirror_path_for(source, source_root, mirror_root, safe)
key = str(mirror).casefold() if safe else str(mirror)
rival_path = seen.get(key)
rival = chosen.get(rival_path) if rival_path else None
if rival is None:
seen[key] = mirror
chosen[mirror] = source
continue
mirror = rival_path
winner, loser = sorted((source, rival), key=source_rank)
logger.warning("%s and %s both map to %s; using %s", rival, source, mirror, winner)
chosen[mirror] = winner
@@ -374,6 +458,13 @@ def prune(mirror_root, expected, dry_run):
mirror.unlink(missing_ok=True)
if not dry_run:
# A cover copied for an album whose tracks have all gone is an orphan
# too, and while it sits there the directory never looks empty.
for cover in sorted(mirror_root.rglob(MIRROR_COVER)):
if not any(cover.parent.glob(f"*{MIRROR_SUFFIX}")):
logger.info("removing orphan %s", cover)
cover.unlink(missing_ok=True)
# Deepest first, so a directory emptied by the loop above is caught.
for directory in sorted(mirror_root.rglob("*"), reverse=True):
if directory.is_dir() and not any(directory.iterdir()):
@@ -382,7 +473,9 @@ def prune(mirror_root, expected, dry_run):
return removed
def run_once(scan_root, source_root, mirror_root, quality_args, jobs, dry_run, do_prune):
def run_once(
scan_root, source_root, mirror_root, quality_args, jobs, dry_run, do_prune, safe=False
):
"""Run a single pass. Returns the number of failures.
`scan_root` is what gets walked and `source_root` is what mirror paths are
@@ -393,11 +486,18 @@ def run_once(scan_root, source_root, mirror_root, quality_args, jobs, dry_run, d
counts = {"encoded": 0, "copied": 0, "skipped": 0, "failed": 0}
failures = []
work = plan(scan_root, source_root, mirror_root)
work = plan(scan_root, source_root, mirror_root, safe)
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
futures = [
pool.submit(process, source, mirror, quality_args, dry_run)
pool.submit(
process,
source,
mirror,
quality_args,
dry_run,
mirror_path_for(source, source_root, mirror_root) if safe else None,
)
for mirror, source in work.items()
]
for future in concurrent.futures.as_completed(futures):
@@ -492,6 +592,13 @@ def build_parser():
default=None,
help="limit the pass to one directory below --source; skips pruning",
)
parser.add_argument(
"--fat32-safe",
action="store_true",
default=os.getenv("MUSIC_MIRROR_FAT32_SAFE", "").lower() in ("1", "true", "yes"),
help="name mirror files so a FAT32 device will accept them"
" (env MUSIC_MIRROR_FAT32_SAFE)",
)
parser.add_argument(
"--no-prune",
action="store_true",
@@ -575,6 +682,7 @@ def main(argv=None):
args.jobs,
args.dry_run,
do_prune,
args.fat32_safe,
)
if interval is None or stopping:
return 1 if failures else 0