feat: FAT32-safe mirror, album art, and a Rockbox sync script #6

Merged
lyrathorpe merged 3 commits from feat/fat32-safe-mirror into main 2026-08-25 11:08:55 +01:00
Owner

Why

The iPod is now running Rockbox on an iFlash card: a FAT32 device that reads a
plain directory tree and builds its own index from tags. Two things about the
mirror do not survive that trip.

FAT32-safe naming

--fat32-safe, off by default because it renames files and that should be a
decision rather than a surprise.

Problem Treatment
< > : " \ | ? * and control characters replaced with _
trailing dots and spaces stripped — FAT eats them silently, so the name round-trips as a different name
a component left empty becomes _
names differing only in case reported as a collision

Dada Life - Kick Out the Epic Motherf**ker is a real track in a real library.
Without this it never arrives on the device, and rsync's complaint about it is
one line somewhere in a run of 49,503 files.

Turning it on does not re-encode anything. Every track whose name held a
reserved character changes path. Encoding those again would be hours of work
producing files that already exist byte for byte, so the run moves them and
logs each one. A test asserts the bytes and the mtime survive, so a future
refactor cannot quietly turn this back into a re-encode.

Album art

Verified in apps/recorder/albumart.c: Rockbox's art search is filesystem-only
cover.jpg, folder.jpg, <album>.jpg, cover.bmp, in the track's
directory or its parent. That module never touches the picture embedded in the
tag.

The mirror embedded art and copied no image, so covers would not have shown.
A JPEG cover found beside the source is now copied in as cover.jpg as well as
being embedded — the Apple firmware reads the tag, Rockbox reads the file. A
cover whose tracks have all been pruned is removed too, or its directory would
never look empty and never go.

tools/check_fat32.py

Reports unacceptable paths before the copy: reserved characters, trailing
dots and spaces, over-long components and paths, and case-insensitive
collisions. Exits non-zero so it can gate a script.

Documented

The rsync invocation, in the existing iPod section:

python3 tools/check_fat32.py /mnt/tank/media/music-mp3
rsync -rtv --delete --modify-window=2 \
    /mnt/tank/media/music-mp3/ /media/IPOD/Music/

--modify-window=2 because FAT stores mtimes to two-second resolution and
rsync otherwise re-copies the whole library every run. -rt rather than -a
because owners and permissions mean nothing on FAT.

Also documented: 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
deleting iPod_Control does not help, which is the obvious thing to try.

Testing

51 tests, green locally and in docker build --target test. New coverage: nine
sanitiser cases, the flag on and off, rename-not-re-encode, case collisions,
cover copying, cover permissions, cover pruning, and seven for the checker.


Also: the transfer itself

tools/sync-to-ipod.sh — submit scrobbles, check, rsync, sync, unmount. The
only manual step left is Menu+Select then Select+Play.

The guards are the substance

rsync --delete is being aimed at a whole filesystem. It refuses unless the
destination exists, is its own mount point, and is a FAT filesystem; and
unless the mirror is non-empty and is not the destination. Emptying the wrong
directory is not a mistake that announces itself.

It also excludes /.rockbox, the scrobbler logs and the usual filesystem
metadata directories. The first draft of this script would have deleted the
Rockbox installation
on a sync to the card root, because the mirror does not
contain it.

The unmount is why this is a script rather than a README line. FAT32 has no
journal, disk mode is dumb mass storage, and an interrupted write is corruption
that needs fsck.vfat from another machine.

tools/submit_scrobbles.py

Sends the Rockbox scrobbler log to Last.fm, then sets it aside. Runs before
the copy — the plays already happened, and a failed transfer is no reason to
lose them too.

Verified against Rockbox's own writer in apps/plugins/lastfm_scrobbler.c:
AUDIOSCROBBLER 1.1, tab-separated, artist / album / title / tracknum / length / rating / timestamp / mbid, rated L listened or S skipped. Only L is a
play.

Three things that differ from every other Last.fm call in these projects:

  • It is a write method. Needs LASTFM_API_SECRET and a session key
    obtained once through the browser, not just the read-only key. Skipped with a
    note when unset.
  • Targets without a real-time clock get /.scrobbler-timeless.log with
    every timestamp set to zero. Counted and reported, never submitted —
    scrobbling them would mean inventing when they happened.
  • Signatures sort parameter names by ASCII, not numerically, so
    artist[10] precedes artist[1]. Getting that wrong yields an invalid
    signature and no other symptom, so there is a test pinning it.

The log is renamed rather than deleted once accepted. If Last.fm quietly
dropped something, the evidence is still on the device.

Testing

65 tests, green locally and in docker build --target test. shellcheck clean.

## Why The iPod is now running Rockbox on an iFlash card: a FAT32 device that reads a plain directory tree and builds its own index from tags. Two things about the mirror do not survive that trip. ## FAT32-safe naming `--fat32-safe`, off by default because it renames files and that should be a decision rather than a surprise. | Problem | Treatment | | ---------------------------------------- | ------------------------------- | | `< > : " \ \| ? *` and control characters | replaced with `_` | | trailing dots and spaces | stripped — FAT eats them silently, so the name round-trips as a *different* name | | a component left empty | becomes `_` | | names differing only in case | reported as a collision | `Dada Life - Kick Out the Epic Motherf**ker` is a real track in a real library. Without this it never arrives on the device, and rsync's complaint about it is one line somewhere in a run of 49,503 files. **Turning it on does not re-encode anything.** Every track whose name held a reserved character changes path. Encoding those again would be hours of work producing files that already exist byte for byte, so the run **moves** them and logs each one. A test asserts the bytes and the mtime survive, so a future refactor cannot quietly turn this back into a re-encode. ## Album art Verified in `apps/recorder/albumart.c`: Rockbox's art search is filesystem-only — `cover.jpg`, `folder.jpg`, `<album>.jpg`, `cover.bmp`, in the track's directory or its parent. That module never touches the picture embedded in the tag. The mirror embedded art and copied no image, so covers would not have shown. A JPEG cover found beside the source is now copied in as `cover.jpg` as well as being embedded — the Apple firmware reads the tag, Rockbox reads the file. A cover whose tracks have all been pruned is removed too, or its directory would never look empty and never go. ## tools/check_fat32.py Reports unacceptable paths **before** the copy: reserved characters, trailing dots and spaces, over-long components and paths, and case-insensitive collisions. Exits non-zero so it can gate a script. ## Documented The rsync invocation, in the existing iPod section: python3 tools/check_fat32.py /mnt/tank/media/music-mp3 rsync -rtv --delete --modify-window=2 \ /mnt/tank/media/music-mp3/ /media/IPOD/Music/ `--modify-window=2` because FAT stores mtimes to two-second resolution and rsync otherwise re-copies the whole library every run. `-rt` rather than `-a` because owners and permissions mean nothing on FAT. Also documented: 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 deleting `iPod_Control` does not help, which is the obvious thing to try. ## Testing 51 tests, green locally and in `docker build --target test`. New coverage: nine sanitiser cases, the flag on and off, rename-not-re-encode, case collisions, cover copying, cover permissions, cover pruning, and seven for the checker. --- ## Also: the transfer itself `tools/sync-to-ipod.sh` — submit scrobbles, check, rsync, sync, unmount. The only manual step left is Menu+Select then Select+Play. ### The guards are the substance `rsync --delete` is being aimed at a whole filesystem. It refuses unless the destination exists, is **its own mount point**, and is a FAT filesystem; and unless the mirror is non-empty and is not the destination. Emptying the wrong directory is not a mistake that announces itself. It also excludes `/.rockbox`, the scrobbler logs and the usual filesystem metadata directories. **The first draft of this script would have deleted the Rockbox installation** on a sync to the card root, because the mirror does not contain it. The unmount is why this is a script rather than a README line. FAT32 has no journal, disk mode is dumb mass storage, and an interrupted write is corruption that needs `fsck.vfat` from another machine. ## `tools/submit_scrobbles.py` Sends the Rockbox scrobbler log to Last.fm, then sets it aside. Runs **before** the copy — the plays already happened, and a failed transfer is no reason to lose them too. Verified against Rockbox's own writer in `apps/plugins/lastfm_scrobbler.c`: AUDIOSCROBBLER 1.1, tab-separated, `artist / album / title / tracknum / length / rating / timestamp / mbid`, rated `L` listened or `S` skipped. Only `L` is a play. Three things that differ from every other Last.fm call in these projects: - **It is a write method.** Needs `LASTFM_API_SECRET` and a session key obtained once through the browser, not just the read-only key. Skipped with a note when unset. - **Targets without a real-time clock** get `/.scrobbler-timeless.log` with every timestamp set to zero. Counted and reported, never submitted — scrobbling them would mean inventing when they happened. - **Signatures sort parameter names by ASCII, not numerically**, so `artist[10]` precedes `artist[1]`. Getting that wrong yields an invalid signature and no other symptom, so there is a test pinning it. The log is renamed rather than deleted once accepted. If Last.fm quietly dropped something, the evidence is still on the device. ## Testing 65 tests, green locally and in `docker build --target test`. `shellcheck` clean.
lyrathorpe added 1 commit 2026-08-25 10:31:14 +01:00
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
3141f7ca87
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.
lyrathorpe added 1 commit 2026-08-25 11:05:03 +01:00
feat: a sync script that submits scrobbles, copies, and unmounts cleanly
Build and publish container / build (pull_request) Canceled after 3m8s
802d91490f
tools/sync-to-ipod.sh does the whole transfer to a Rockbox device, so the only
manual part left is the disk-mode button sequence.

The guards are the substance rather than decoration. rsync --delete is being
aimed at a whole filesystem, so the destination must exist, be its own mount
point, and be a FAT filesystem; the mirror must be non-empty and must not be
the destination. Emptying the wrong directory is not a mistake that announces
itself.

It also excludes /.rockbox, the scrobbler logs and the usual filesystem
metadata directories. The mirror does not contain them, so a sync to the card
root would otherwise have deleted the Rockbox installation -- which the first
draft of this script would have done.

The unmount is why this is a script at all. FAT32 has no journal, the device is
reached through the Apple firmware's disk mode because Rockbox's own mass
storage is unreliable on an iFlash, and an interrupted write is corruption that
needs fsck.vfat from another machine.

tools/submit_scrobbles.py sends the Rockbox scrobbler log to Last.fm and sets
it aside. Rockbox writes it in AUDIOSCROBBLER 1.1: tab-separated, one line per
track, rated L for listened or S for skipped, and only the listened ones are a
play. It runs before the copy, because the plays already happened and a failed
transfer is no reason to lose them as well.

Two things there differ from every other Last.fm call in these projects.
Scrobbling is a write method, so it needs the API secret and a session key
obtained once through the browser rather than the read-only key. And a target
with no real-time clock gets /.scrobbler-timeless.log with every timestamp set
to zero; those are counted and reported but never sent, since submitting them
would mean inventing when they happened.

Signature generation sorts parameter names by the ASCII table rather than
numerically, so artist[10] precedes artist[1]. Sorting them the obvious way
produces an invalid signature and no other symptom, so there is a test for it.

The log is renamed rather than deleted once accepted, so that if Last.fm
quietly dropped something the evidence is still on the device.
lyrathorpe changed title from feat: name the mirror so a FAT32 device will take it, and copy album art to feat: FAT32-safe mirror, album art, and a Rockbox sync script 2026-08-25 11:05:31 +01:00
lyrathorpe added 1 commit 2026-08-25 11:08:15 +01:00
docs: show the FAT32 setting in the compose file
Build and publish container / build (pull_request) Successful in 1m42s
8c3e554c88
The option table listed it, but compose.yaml is what actually gets copied into
a TrueNAS Custom App, so an option absent from there is an option nobody finds.

Quoted deliberately: an unquoted yes or true is a YAML 1.1 boolean, and compose
rejects a boolean as an environment value outright.
lyrathorpe merged commit abf38be3b1 into main 2026-08-25 11:08:55 +01:00
lyrathorpe deleted branch feat/fat32-safe-mirror 2026-08-25 11:08:55 +01:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: lyrathorpe/music-mirror#6