Commit Graph
5 Commits
Author SHA1 Message Date
Emma Thorpe 19b5c750b7 fix: accept a destination inside the device, and say so in --help
The script required the destination to be its own mount point, while the
documentation and its own usage text both told the user to pass
/media/IPOD/Music. The documented invocation was rejected.

A subdirectory is the better target, so the guard was what was wrong. --delete
is confined to it, and the device path budget is now derived from it -- the
part of the destination below its mount point -- rather than configured, so the
budget cannot disagree with where the files are actually going. The check that
matters is that the destination sits on a FAT filesystem, which is also what
catches an unmounted device: /media/IPOD/Music then resolves to the host's own
root filesystem, and emptying that is the outcome all of these guards exist to
prevent.

Three further faults found while testing the guards rather than reasoning about
them:

Stripping the trailing slash from "/" left an empty string, so the guard
refusing the host root never fired and the user got "destination is not a
directory" instead.

die() printed only its first argument, so the second half of the non-FAT
message -- the half saying to check whether the device is mounted -- was
silently dropped.

--help was not handled at all. Only -h reached the usage text, and it exited 2
to stderr, which is right for misuse and wrong for someone asking a question.
Help now goes to stdout and exits zero.

The usage text carries the guidance rather than only the README, since the
question it answers is asked at the terminal.
2026-08-25 12:21:09 +01:00
Emma Thorpe ece79515c0 fix: cost the device prefix exactly rather than approximately
Build and publish container / build (pull_request) Successful in 3m54s
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.
2026-08-25 11:52:03 +01:00
Emma Thorpe d5f67c6de5 feat: shorten paths that exceed the device's limit
Build and publish container / build (pull_request) Successful in 2m16s
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.
2026-08-25 11:46:26 +01:00
Emma Thorpe 802d91490f feat: a sync script that submits scrobbles, copies, and unmounts cleanly
Build and publish container / build (pull_request) Canceled after 3m8s
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.
2026-08-25 11:05:00 +01:00
Emma Thorpe 3141f7ca87 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.
2026-08-25 10:29:55 +01:00