8 Commits
Author SHA1 Message Date
lyrathorpe cdbe471166 chore(release): v0.4.0 2026-08-26 12:28:36 +00:00
lyrathorpe 61e0031262 Merge pull request 'feat: destination handling, live progress and interrupt safety for sync-to-ipod' (#9) from fix/sync-destination-subdirectory into main
Build and publish container / build (push) Successful in 5m59s
Reviewed-on: #9
2026-08-26 13:22:38 +01:00
Emma Thorpe c99b423b72 feat: rebuild the Rockbox database during the sync, off the mirror
Build and publish container / build (pull_request) Successful in 6m36s
The on-device database commit does not work at this library size. It sorts the
whole index in whatever memory core_alloc_maximum() can scrape together, and on
fifty thousand tracks it runs for hours or aborts with a data abort -- observed
across several builds including stable.

Rockbox ships a host-side builder for exactly this, and the sync is the moment
the library changes, so it belongs here. MUSIC_MIRROR_DATABASE_TOOL points at
it; the step is skipped with a note when unset, as the scrobbler step is.

The scan runs against a scratch root -- a real .rockbox beside a symlink
standing in for wherever the music lands on the device -- so the paths recorded
are the ones Rockbox will look up, while the bytes are read from the mirror
rather than over USB. Only the dozen .tcd files cross to the card. Verified:
scanning through the symlink records /Music/... paths while reading from
somewhere else entirely.

The scratch root is kept between runs because the builder is incremental. A
second pass over unchanged files performs no metadata reads and finishes in a
fraction of a second, so only the first build pays the full cost.

That cost, measured rather than guessed: about 49 reads and 43 seeks per file,
the parser probing the head for ID3v2 and the tail for ID3v1. Two thousand
files in half a second on local disk. Over SMB the opens and the head/tail
split are real round trips, making a first full scan minutes rather than
seconds -- still preferable to an on-device commit that does not finish. There
is nothing to parallelise: the tool is single-threaded and two instances cannot
produce one database.
2026-08-26 13:20:20 +01:00
Emma Thorpe 9091c4d049 docs: stop overstating the risk of interrupting a sync
Build and publish container / build (pull_request) Successful in 3m16s
The README claimed that interrupting left buffers unwritten and therefore
corruption. That is wrong. The kernel flushes dirty pages within
dirty_expire_centisecs, thirty seconds by default, and umount syncs before it
returns, so losing data requires interrupting and pulling the card inside that
window and skipping the unmount.

The trap is still worth having, for a smaller and more honest reason: it
removes a manual step and makes the exit deterministic, so the same "safe to
disconnect" appears whichever way the run ends. What actually loses data is
pulling the card without unmounting at all, which has nothing to do with
whether the transfer was interrupted.
2026-08-25 12:54:19 +01:00
Emma Thorpe 8228c81b5c fix: flush and unmount even when the sync is interrupted
Build and publish container / build (pull_request) Canceled after 2m10s
rsync itself is safe under interruption. It writes to a hidden temporary file
and renames it into place only once complete, and by default deletes any
partial file when interrupted -- verified both in the manual and by killing a
transfer and inspecting what was left, which was nothing. --partial is
deliberately absent and there is now a test asserting it stays that way. An
unclean kill can leave a hidden .track.mp3.XXXXXX behind; it is unplayable, it
is not in the source, and the next run's --delete removes it.

The script was not safe. Ctrl-C killed it before the sync and the unmount,
leaving a journal-less FAT filesystem holding dirty buffers -- which is the
exact corruption the script exists to prevent, arrived at by the most likely
route a person would take.

INT and TERM are now trapped. Both the normal path and the interrupt path call
the same finish function, so the flush and the unmount cannot drift apart, and
an interrupted run exits 130 rather than pretending to have succeeded.

The test is structural rather than timed. Reproducing a mid-transfer signal
needs a payload large enough to be slow, and a test that depends on winning a
race is a test that fails in CI for reasons that have nothing to do with the
code. The behaviour was verified by hand: SIGTERM mid-transfer gave exit 130,
the flush ran, and the destination held no short files and no leftover
temporaries.
2026-08-25 12:51:42 +01:00
Emma Thorpe d6ef70922c perf: cut round trips over a network mount, and allow skipping the count
Build and publish container / build (pull_request) Successful in 2m39s
The transfer is metadata-bound rather than throughput-bound. Fifty thousand
files is fifty thousand round trips, and the counting pass added for the
percentage doubles that.

--whole-file is already implied when both ends are local paths, which an SMB or
FAT mount is, but stating it records that the delta algorithm is deliberately
unwanted here: it would read every destination file back over USB to checksum
it, in order to avoid resending an MP3 that has changed in its entirety anyway.

--omit-dir-times drops one setattr per directory. Across six thousand album
folders on a FAT card that is six thousand operations spent on timestamps
nothing reads.

-Q skips the counting pass. The percentage and the estimate are worth a second
walk of a local tree and frequently are not worth one of a network mount, so
that is now a choice rather than a fixed cost.

The README covers the part that is not an rsync flag at all: SMB defaults to a
one second attribute cache, so nearly every stat goes to the wire, twice. An
actimeo of sixty on the mount does more than any of the above, and closes most
of the gap that would otherwise argue for moving to NFS.
2026-08-25 12:44:53 +01:00
Emma Thorpe 131c80f5de feat: estimate the time remaining from bytes and observed rate
Build and publish container / build (pull_request) Successful in 2m13s
rsync reports each file's size with %l as it completes, which is all an
estimate needs: bytes done over time elapsed is the same arithmetic rsync would
do internally, and requires nothing it does not already print. The scan pass now
sums those sizes as well as counting files, so both a percentage and an estimate
have a real denominator.

The rate is measured over a trailing thirty seconds rather than the whole run,
so it follows a device that slows down instead of averaging the slowdown away --
which for a card reader that thermally throttles, or a USB link that renegotiates
after an hour, is the difference between a useful estimate and a reassuring one.

Below two seconds no rate is reported at all. The first handful of files arrive
microseconds apart, and dividing by that window produces a rate in the gigabytes
per second and an estimate of zero, which is worse than showing nothing.

Directory entries are excluded from the byte total as well as the file count.
rsync reports them with a 4096 inode size, which across six thousand album
directories is several megabytes of transfer that never happens.
2026-08-25 12:40:32 +01:00
Emma Thorpe 37b841f009 feat: show which album is copying, and how far through
Build and publish container / build (pull_request) Successful in 2m33s
The transfer looked hung. rsync prints nothing while it builds its file list,
which on fifty thousand files over USB is several minutes of silence, and
--info=progress2 does not help: with incremental recursion its percentage is
computed against a list rsync has not finished discovering, so it moves
backwards as often as forwards.

The script now counts what needs copying first and says so, then renders its
own single line that rewrites in place, showing the album currently going
across and a percentage against a total that is actually known. Counting costs
a second pass over the tree. That is the price of a percentage meaning
something, and it is cheaper than staring at a blank terminal wondering whether
the thing has died.

Directories are excluded from the count. rsync reports those too, and including
them puts the figure past a hundred per cent.

Piped to a log the line becomes a plain one every thirty seconds, because a log
full of carriage returns and escape codes is not a log anybody reads.
2026-08-25 12:31:13 +01:00
6 changed files with 716 additions and 16 deletions
+111
View File
@@ -163,6 +163,117 @@ and the various filesystem metadata directories from deletion — the mirror doe
not contain them, and without the exclusion a sync to the card root would
remove the Rockbox install.
Progress is a single line that rewrites itself:
```
[ 24%] 12,345/49,600 3.2 GiB/13.1 GiB 4.4 MiB/s ETA 38m12s King Gizzard / Petro…
```
The estimate comes from rsync's `%l`, which gives each file's size as it
completes. Bytes done over time elapsed is the same arithmetic rsync would do,
and needs nothing it does not already print. The rate is measured over a
trailing thirty seconds rather than the whole run, so it follows a device that
slows down instead of averaging the slowdown away — and it is suppressed
entirely for the first two seconds, where the window is microseconds wide and
would report gigabytes per second.
rsync says nothing at all while it builds its file list, which on fifty
thousand files over USB is minutes of apparent hang, and its own `progress2`
percentage is computed against a list it has not finished discovering. So the
script counts first — files and bytes both, a second pass over the tree, which
is what a percentage and an estimate that mean something cost — and renders the
rest itself. Piped to a log it prints a plain line every thirty seconds
instead, with no carriage returns, and a summary at the end either way.
### The Rockbox database
Point `MUSIC_MIRROR_DATABASE_TOOL` at Rockbox's host-side builder and the sync
rebuilds the database itself, so it never has to happen on the device.
```sh
git clone --depth 1 https://github.com/Rockbox/rockbox.git
cd rockbox && mkdir build-db && cd build-db
../tools/configure --target=ipodvideo --type=d && make -j$(nproc)
```
It needs a native compiler and SDL2 development headers, not the ARM
cross-toolchain, and `tools/configure` detects `__aarch64__` correctly. On a
distribution without `/usr/bin/perl` or `gcc-ar` — NixOS, say — patch the
shebangs in `tools/*.pl` and pass `AR=ar`.
Building it here rather than on the device is not merely faster. The on-device
commit sorts the whole index in whatever memory `core_alloc_maximum()` can
scrape together; on a fifty-thousand-track library it runs for hours or aborts
outright.
The scan runs against a scratch root — a real `.rockbox` beside a symlink
standing in for wherever the music lands on the device — so the paths recorded
are the ones Rockbox will look up, while the **bytes are read from the mirror
rather than over USB**. Only the dozen `.tcd` files cross to the card.
Cost, measured: the parser makes about 49 reads and 43 seeks per file, probing
the head for ID3v2 and the tail for ID3v1. On a local disk that is 2,000 files
in half a second. Over SMB, readahead absorbs most of the reads but the opens
and the head/tail split are real round trips, so a first full scan is minutes
rather than seconds. It is a one-time cost: the builder is incremental, and the
scratch root is kept between runs, so a later pass over unchanged files does no
metadata reads at all.
If minutes is still too many, run the builder where the mirror is local — on
the NAS — and copy the `.tcd` files across. There is nothing to parallelise:
the tool is single-threaded, and two instances cannot produce one database.
### If the sync is interrupted
No partially copied track is ever left under a name Rockbox would play. rsync
writes to a hidden temporary file and only renames it into place once the file
is complete, and *"by default, rsync will delete any partially transferred file
if the transfer is interrupted"*. `--partial` is deliberately not used, and
there is a test asserting it never will be.
After an unclean kill or a power cut a hidden `.track.mp3.XXXXXX` can survive.
It is not playable, it is not in the source, and the next run's `--delete`
removes it.
Interrupting does not, by itself, endanger the filesystem. The kernel flushes
dirty pages within `dirty_expire_centisecs` — thirty seconds by default — and
`umount` always syncs before it returns. Losing data needs you to interrupt,
*and* pull the card inside that window, *and* skip the unmount.
The script still traps `INT` and `TERM` and flushes and unmounts on the way
out, exiting 130. Not because a Ctrl-C is dangerous, but because it removes the
manual step and makes the exit deterministic — you get the same "safe to
disconnect" either way, rather than having to remember which path you took.
Both paths call the same function, so they cannot drift apart.
What genuinely does lose data is pulling the cable or the card without
unmounting at all, interrupted or not. FAT32 has no journal. Wait for the
unmount line.
### Making it faster over a network mount
The transfer is metadata-bound, not throughput-bound: 49,600 files means 49,600
round trips, and the counting pass doubles that. In rough order of what it is
worth doing:
| Lever | Why |
| ----- | --- |
| Mount the source with `actimeo=60,cache=loose` | SMB defaults to a **one second** attribute cache, so nearly every `stat` goes to the wire — twice, once per pass. This is the single biggest change and it is a mount option, not an rsync flag. |
| Put the card in a reader for the first load | USB 2.0 through an iPod in disk mode is the floor for the destination. No amount of source tuning gets past it. |
| `-Q` | Skips the counting pass entirely. Costs the percentage and the estimate, saves a whole walk of the tree. |
| `--whole-file`, `--omit-dir-times` | Already set. The first stops rsync checksumming destination files it is about to overwrite whole; the second drops a setattr per directory, 6,150 of them. |
**NFS instead of SMB** is worth trying but is not the big win it looks like.
Its attribute caching defaults are far more generous than SMB's — `acregmax` of
sixty seconds against `actimeo=1` — which is precisely the gap that
`actimeo=60` closes on the mount you already have. Bulk read throughput between
the two is much of a muchness on a gigabit link. Try the mount option first; it
is one line and needs no change on the NAS.
And if the destination is the iPod rather than a card reader, none of this
matters much: the source can feed data faster than USB 2.0 through an iPod will
take it either way.
The unmount is the point of doing this in a script. FAT32 has no journal and
the device is reached through disk mode, so an interrupted write is corruption
that needs `fsck.vfat` from another machine.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "music-mirror"
version = "0.3.0"
version = "0.4.0"
description = "Maintain a lossy MP3 mirror of a lossless music library"
readme = "README.md"
requires-python = ">=3.11"
+180
View File
@@ -0,0 +1,180 @@
import io
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
import rsync_progress # noqa: E402
class NotATerminal(io.StringIO):
def isatty(self):
return False
class Terminal(io.StringIO):
def isatty(self):
return True
def run(lines, total=0, out=None, bytes_expected=0):
out = out or NotATerminal()
rsync_progress.main(
["--total", str(total), "--bytes", str(bytes_expected)],
stream=io.StringIO(lines),
out=out,
)
return out.getvalue()
def test_the_artist_and_album_are_pulled_from_the_path():
assert rsync_progress.album_of("Pendulum/Immersion/01 - Watercolour.mp3") == (
"Pendulum / Immersion"
)
def test_a_shallower_path_degrades_rather_than_failing():
assert rsync_progress.album_of("Pendulum/loose.mp3") == "Pendulum"
assert rsync_progress.album_of("loose.mp3") == ""
def test_directories_are_not_counted():
"""rsync reports them too, and counting them puts the percentage past 100."""
output = run("Artist/\nArtist/Album/\nArtist/Album/track.mp3\n", total=1)
assert "1/1" in output
assert "100%" in output
def test_the_percentage_tracks_the_total():
output = run("".join(f"A/B/{i}.mp3\n" for i in range(5)), total=10)
assert "5/10" in output
assert "50%" in output
def test_without_a_total_it_counts_instead_of_guessing():
output = run("A/B/one.mp3\nA/B/two.mp3\n")
assert "2 files" in output
assert "%" not in output
def test_a_final_line_is_always_printed():
"""Otherwise the last state of a rewriting line is whatever it happened to
be when the interval last elapsed."""
output = run("A/B/one.mp3\n", total=1)
assert output.endswith("\n")
assert "1/1" in output
def test_nothing_transferred_still_reports():
output = run("", total=0)
assert "0 files" in output
def test_a_log_gets_no_carriage_returns():
"""A non-terminal filling with \\r and escape codes is unreadable."""
output = run("".join(f"A/B/{i}.mp3\n" for i in range(50)), total=50)
assert "\r" not in output
assert "\033" not in output
def test_a_terminal_rewrites_one_line():
output = run("".join(f"A/B/{i}.mp3\n" for i in range(50)), total=50, out=Terminal())
assert "\r\033[2K" in output
@pytest.mark.parametrize(
("text", "width", "expected"),
[
("short", 20, "short"),
("King Gizzard / PetroDragonic Apocalypse", 20, "…agonic Apocalypse"),
],
)
def test_long_labels_are_trimmed_from_the_left(text, width, expected):
"""The album is the informative end, so the artist is what gets cut."""
trimmed = rsync_progress.fit(text, width)
assert len(trimmed) <= width
if len(text) > width:
assert trimmed.startswith("")
assert text.endswith(trimmed.lstrip(""))
def test_the_size_and_path_are_parsed():
assert rsync_progress.parse("5000 Artist/Album/Track.mp3\n") == (
5000,
"Artist/Album/Track.mp3",
)
def test_a_filename_containing_spaces_survives():
"""Splitting on every space would lose most of the library."""
assert rsync_progress.parse("1234 Artist/An Album/A Track With Spaces.mp3") == (
1234,
"Artist/An Album/A Track With Spaces.mp3",
)
def test_a_bare_path_is_tolerated():
"""In case this is fed --out-format='%n' by something older."""
assert rsync_progress.parse("Artist/Album/Track.mp3") == (0, "Artist/Album/Track.mp3")
def test_directory_sizes_do_not_inflate_the_total():
"""rsync reports directories with a 4096 inode size, which is several
megabytes of nothing across six thousand albums."""
output = run("4096 Artist/\n4096 Artist/Album/\n5000 Artist/Album/t.mp3\n", total=1)
assert "4.9 KiB" in output
assert "12" not in output.split("Artist")[0]
def test_a_rate_is_not_reported_until_it_means_something():
"""The first files arrive microseconds apart and would give a rate in the
gigabytes per second and an ETA of zero."""
rate = rsync_progress.Rate()
rate.add(100.0, 0)
rate.add(100.5, 5_000_000)
assert rate.per_second() == 0.0
def test_a_rate_over_a_long_enough_window_is_reported():
rate = rsync_progress.Rate()
rate.add(100.0, 0)
rate.add(110.0, 10_000_000)
assert rate.per_second() == pytest.approx(1_000_000)
def test_the_window_forgets_the_distant_past():
"""So the estimate follows a device that slows down rather than averaging
the slowdown away."""
rate = rsync_progress.Rate(window=30.0)
for second in range(0, 100, 10):
rate.add(float(second), second * 1_000_000)
rate.add(200.0, 100_000_000)
assert rate.samples[0][0] >= 90.0
@pytest.mark.parametrize(
("seconds", "expected"),
[(0, "0s"), (45, "45s"), (60, "1m00s"), (1092, "18m12s"), (7500, "2h05m")],
)
def test_durations_read_without_arithmetic(seconds, expected):
assert rsync_progress.human_duration(seconds) == expected
def test_a_summary_is_printed_at_the_end():
output = run("5000000 A/B/one.mp3\n", total=1, bytes_expected=5000000)
assert "copied 4.8 MiB in" in output
+97
View File
@@ -174,3 +174,100 @@ def test_the_help_says_how_to_reach_and_leave_disk_mode():
assert "Menu+Select" in help_text
assert "holding Play" in help_text
def test_quick_mode_skips_the_counting_pass(mirror, tmp_path):
"""Over SMB the walk is the expensive part, and doing it twice for a
percentage is not always the trade you want."""
destination = tmp_path / "dest"
destination.mkdir()
result = run("-f", "-S", "-U", "-Q", str(mirror), str(destination))
assert result.returncode == 0, result.stderr
assert "skipping the count" in result.stderr
assert "files to copy" not in result.stderr
assert (destination / "Album" / "track.mp3").is_file()
def test_the_delta_algorithm_is_disabled(mirror, tmp_path):
"""It would read every destination file back over USB to checksum it, to
avoid resending an MP3 that has changed in its entirety anyway."""
script = SCRIPT.read_text()
assert "--whole-file" in script
def test_directory_timestamps_are_not_set(mirror, tmp_path):
"""One setattr round trip per directory, across six thousand albums, to set
timestamps nothing reads."""
script = SCRIPT.read_text()
assert "--omit-dir-times" in script
def test_an_interrupt_is_trapped_so_the_filesystem_is_flushed():
"""Ctrl-C during a transfer would otherwise skip the sync and the unmount,
leaving a journal-less FAT filesystem with dirty buffers -- which is the
corruption this script exists to prevent.
Structural rather than timed: reproducing a mid-transfer signal needs a
payload large enough to be slow, and a test that depends on losing a race
is a test that fails in CI for no reason.
"""
script = SCRIPT.read_text()
assert "trap interrupted INT TERM" in script
assert "exit 130" in script
def test_the_flush_and_unmount_happen_on_every_exit_path():
script = SCRIPT.read_text()
# Both the normal path and the interrupt path go through the same function,
# so one cannot drift from the other.
assert script.count("finish\n") >= 2
assert "--partial" not in script, "rsync must delete partial files, not keep them"
def test_the_database_step_is_skipped_without_a_tool(mirror, tmp_path, monkeypatch):
"""Opt-in, like the scrobbler: absent configuration is not an error."""
destination = tmp_path / "dest"
destination.mkdir()
monkeypatch.delenv("MUSIC_MIRROR_DATABASE_TOOL", raising=False)
result = run("-f", "-S", "-U", str(mirror), str(destination))
assert result.returncode == 0, result.stderr
assert "no database tool configured" in result.stderr
def test_a_missing_database_tool_is_refused(mirror, tmp_path, monkeypatch):
destination = tmp_path / "dest"
destination.mkdir()
monkeypatch.setenv("MUSIC_MIRROR_DATABASE_TOOL", str(tmp_path / "nonexistent"))
result = run("-f", "-S", "-U", str(mirror), str(destination))
assert result.returncode == 1
assert "not executable" in result.stderr
def test_the_database_step_can_be_skipped(mirror, tmp_path, monkeypatch):
destination = tmp_path / "dest"
destination.mkdir()
monkeypatch.setenv("MUSIC_MIRROR_DATABASE_TOOL", str(tmp_path / "nonexistent"))
result = run("-f", "-S", "-U", "-B", str(mirror), str(destination))
assert result.returncode == 0, result.stderr
assert "not executable" not in result.stderr
def test_the_scan_reads_from_the_mirror_not_the_device():
"""The whole point: tags come off the mirror, only the .tcd files go over
USB. Reading 49,600 files through an iPod's USB bridge is the slow path."""
script = SCRIPT.read_text()
assert 'ln -s "$mirror"' in script
assert 'cd "$scratch"' in script
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Render rsync's per-file output as a single updating status line.
Fed the size and path rsync reports with --out-format='%l %n', one per line.
The size is what makes an estimate possible: rsync's own rate is not exposed
per file, but bytes completed over time elapsed is the same arithmetic and
needs nothing rsync does not already print. Prints one
line that rewrites itself, showing how far through the transfer is and which
album is currently going across, rather than either scrolling fifty thousand
filenames past or -- as rsync does while it builds its file list -- saying
nothing at all for several minutes.
Falls back to periodic plain lines when stderr is not a terminal, so a log does
not fill up with carriage returns.
"""
import argparse
import collections
import os
import shutil
import sys
import time
# The estimate is taken over a trailing window rather than the whole run, so it
# follows a device that slows down instead of averaging the slowdown away.
RATE_WINDOW_SECONDS = 30.0
# Below this the window is too narrow to divide by: the first few files arrive
# in microseconds and produce a rate in the gigabytes per second, and an ETA of
# nothing at all. Better to show neither until the figure means something.
RATE_MINIMUM_SPAN_SECONDS = 2.0
def parse(line):
"""Return (bytes, path) for one line of rsync output.
Tolerates a bare path, in case someone runs this against --out-format='%n'.
"""
line = line.rstrip("\n")
size, separator, path = line.partition(" ")
if separator and size.isdigit():
return int(size), path
return 0, line
def human_bytes(count):
size = float(count)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if size < 1024 or unit == "TiB":
return f"{size:.1f} {unit}"
size /= 1024
def human_duration(seconds):
"""Return a duration nobody has to do arithmetic on."""
seconds = int(seconds)
if seconds < 60:
return f"{seconds}s"
if seconds < 3600:
return f"{seconds // 60}m{seconds % 60:02d}s"
return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
class Rate:
"""Bytes per second over a trailing window."""
def __init__(self, window=RATE_WINDOW_SECONDS):
self.window = window
self.samples = collections.deque()
def add(self, when, total_bytes):
self.samples.append((when, total_bytes))
while len(self.samples) > 2 and when - self.samples[0][0] > self.window:
self.samples.popleft()
def per_second(self):
if len(self.samples) < 2:
return 0.0
(first_time, first_bytes), (last_time, last_bytes) = (
self.samples[0],
self.samples[-1],
)
elapsed = last_time - first_time
if elapsed < RATE_MINIMUM_SPAN_SECONDS:
return 0.0
return (last_bytes - first_bytes) / elapsed
def album_of(path):
"""Return "Artist / Album" for a mirror-relative path."""
parts = [part for part in path.strip("/").split("/") if part]
if len(parts) >= 3:
return f"{parts[0]} / {parts[1]}"
if len(parts) == 2:
return parts[0]
return ""
def fit(text, width):
"""Trim to the terminal, from the left: the album matters more than the artist."""
if width <= 1 or len(text) <= width:
return text
return "" + text[-(width - 1) :]
def render(done, total, copied, expected, rate, label, width):
"""Build the status line, giving whatever room is left to the album."""
if total > 0:
share = min(100, done * 100 // total)
head = f"[{share:>3}%] {done:,}/{total:,}"
else:
head = f"[{done:,} files]"
if expected > 0:
head += f" {human_bytes(copied)}/{human_bytes(expected)}"
if rate > 0:
head += f" {human_bytes(rate)}/s"
remaining = expected - copied
if remaining > 0:
head += f" ETA {human_duration(remaining / rate)}"
head += " "
return head + fit(label, max(0, width - len(head)))
def main(argv=None, stream=None, out=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--total", type=int, default=0, help="files expected")
parser.add_argument("--bytes", type=int, default=0, help="bytes expected")
parser.add_argument("--interval", type=float, default=0.1, help="seconds between redraws")
args = parser.parse_args(argv)
stream = stream or sys.stdin
out = out or sys.stderr
interactive = out.isatty()
width = shutil.get_terminal_size((100, 24)).columns - 1
done = 0
copied = 0
rate = Rate()
started = time.monotonic()
rate.add(started, 0)
last_drawn = 0.0
label = ""
for line in stream:
size, path = parse(line)
# rsync reports directories too, with a trailing slash and an inode
# size. Counting them puts the percentage past a hundred and the byte
# total well over what will actually be transferred.
if not path or path.endswith("/"):
continue
done += 1
copied += size
label = album_of(path) or os.path.basename(path)
now = time.monotonic()
rate.add(now, copied)
if interactive:
if now - last_drawn >= args.interval:
out.write(
"\r\033[2K"
+ render(done, args.total, copied, args.bytes, rate.per_second(),
label, width)
)
out.flush()
last_drawn = now
elif now - last_drawn >= 30:
out.write(
render(done, args.total, copied, args.bytes, rate.per_second(), label, width)
+ "\n"
)
out.flush()
last_drawn = now
elapsed = max(1e-9, time.monotonic() - started)
if interactive:
out.write("\r\033[2K")
summary = render(done, args.total, copied, args.bytes, 0, label, width).rstrip()
out.write(f"{summary}\n")
if copied:
out.write(
f"copied {human_bytes(copied)} in {human_duration(elapsed)}"
f" at {human_bytes(copied / elapsed)}/s\n"
)
out.flush()
return 0
if __name__ == "__main__":
sys.exit(main())
+137 -15
View File
@@ -22,10 +22,20 @@ usage() {
usage: sync-to-ipod.sh [options] <mirror> <destination>
-n dry run; show what would change and touch nothing
-Q skip the counting pass; no percentage or estimate, but one less walk
of the source tree, which over SMB is the expensive part
-f copy even if the FAT32 check finds unacceptable paths
-S skip submitting the Rockbox scrobbler log to Last.fm
-B skip rebuilding the Rockbox database
-U leave the destination mounted afterwards
Rebuilding the database needs MUSIC_MIRROR_DATABASE_TOOL pointing at Rockbox's
host-side builder (tools/database, built with ./tools/configure --type=d). It
is skipped with a note when unset. The scan reads tags from the mirror rather
than from the device, so it costs seconds rather than the hours an on-device
commit takes -- and on a large library the on-device commit may not finish at
all.
Submitting scrobbles needs LASTFM_API_KEY and LASTFM_API_SECRET; it is skipped
with a note when they are unset. Scrobbling is a write method and needs the
secret, unlike the read-only calls elsewhere in these projects.
@@ -45,6 +55,12 @@ The destination must be on a mounted FAT filesystem. That check is also what
catches an unmounted device: /media/IPOD/Music then resolves to the host's own
root filesystem, and this refuses to empty that.
Progress is one line that rewrites itself, showing the album currently going
across, how far through the transfer is, the rate, and an estimate of what is
left. Working the totals out first means a second pass over the tree, which is
the price of figures that mean something; rsync's own percentage is computed
against a file list it is still building.
Reach the device with the Apple firmware's disk mode: Menu+Select to reboot,
then immediately Select+Play. Power off afterwards by holding Play.
USAGE
@@ -52,17 +68,21 @@ USAGE
}
dry_run=false
quick=false
force=false
unmount=true
scrobble=true
database=true
for argument in "$@"; do
[ "$argument" = "--help" ] && usage help
done
while getopts ":nfSUh" option; do
while getopts ":nQfSBUh" option; do
case "$option" in
n) dry_run=true ;;
Q) quick=true ;;
f) force=true ;;
S) scrobble=false ;;
B) database=false ;;
U) unmount=false ;;
h) usage help ;;
*) usage ;;
@@ -146,36 +166,138 @@ fi
# asking for them produces a screenful of errors and a non-zero exit.
# --modify-window=2 because FAT stores mtimes to two-second resolution, without
# which every file looks changed and the whole library is copied every time.
# --whole-file is already the default when both ends are local paths, and an
# SMB or FAT mount counts as one, but stating it documents that the delta
# algorithm is deliberately not wanted: it would read every destination file
# back over USB to compute a checksum, to save sending an MP3 that has changed
# entirely anyway.
#
# --omit-dir-times drops a setattr round trip per directory. Across six
# thousand album folders on a FAT card that is six thousand operations to set
# timestamps nothing reads.
options=(--recursive --times --delete --modify-window=2 --whole-file --omit-dir-times)
# --delete removes tracks whose source has gone, which is the point. It would
# also remove everything on the device that the mirror does not contain -- and
# if the destination is the card root that means /.rockbox, the Rockbox install
# itself. Excluded paths are not deleted unless --delete-excluded is given,
# which it never is here.
options=(--recursive --times --delete --modify-window=2 --human-readable --info=progress2)
for owned in "/.rockbox" "/.scrobbler.log" "/.scrobbler.log.*" "/.playlist_control" \
"/System Volume Information" "/.Spotlight-V100" "/.Trashes" "/.fseventsd"; do
options+=(--exclude "$owned")
done
$dry_run && options+=(--dry-run --verbose)
printf 'sync-to-ipod: %s -> %s\n' "$mirror" "$destination" >&2
rsync "${options[@]}" "$mirror/" "$destination/"
if $dry_run; then
rsync "${options[@]}" --dry-run --verbose "$mirror/" "$destination/"
printf 'sync-to-ipod: dry run, nothing was written\n' >&2
exit 0
fi
sync
if $unmount; then
device=$(findmnt -no SOURCE --target "$destination")
printf 'sync-to-ipod: unmounting %s\n' "$device" >&2
if command -v udisksctl >/dev/null 2>&1; then
udisksctl unmount -b "$device"
else
umount -- "$destination"
fi
printf 'sync-to-ipod: safe to disconnect\n' >&2
# rsync says nothing at all while it builds its file list, which on fifty
# thousand files over USB is minutes of apparent hang. Counting first costs a
# second pass over the tree but means the transfer can show a real percentage
# rather than a number that grows as rsync discovers more work.
total=0
total_bytes=0
if $quick; then
printf 'sync-to-ipod: skipping the count; no percentage or estimate\n' >&2
else
printf 'sync-to-ipod: still mounted; unmount before disconnecting\n' >&2
printf 'sync-to-ipod: working out what needs copying...\n' >&2
# %l is the file's size, which is what makes an estimate possible.
# Directories are dropped: rsync reports those too, with an inode size that
# would inflate the total by several megabytes of nothing.
counted=$(rsync "${options[@]}" --dry-run --out-format='%l %n' "$mirror/" "$destination/" |
awk '!/\/$/ { files++; bytes += $1 } END { print files + 0, bytes + 0 }')
total=${counted% *}
total_bytes=${counted#* }
printf 'sync-to-ipod: %s files to copy\n' "$total" >&2
fi
# Flushing and unmounting is the whole reason this is a script, so it has to
# happen on the way out whichever way that is. Ctrl-C during a transfer would
# otherwise leave a FAT filesystem with dirty buffers and no journal, which is
# the corruption this exists to avoid.
finish() {
sync
if $unmount; then
device=$(findmnt -no SOURCE --target "$destination" 2>/dev/null || true)
if [ -n "$device" ]; then
printf 'sync-to-ipod: unmounting %s\n' "$device" >&2
if command -v udisksctl >/dev/null 2>&1; then
udisksctl unmount -b "$device" || umount -- "$destination" || true
else
umount -- "$destination" || true
fi
printf 'sync-to-ipod: safe to disconnect\n' >&2
fi
else
printf 'sync-to-ipod: still mounted; unmount before disconnecting\n' >&2
fi
}
interrupted() {
trap - INT TERM
printf '\nsync-to-ipod: interrupted -- rsync leaves no partial files, but the\n' >&2
printf 'sync-to-ipod: filesystem still needs flushing before you pull anything\n' >&2
finish
exit 130
}
trap interrupted INT TERM
rsync "${options[@]}" --out-format='%l %n' "$mirror/" "$destination/" |
python3 "$here/rsync_progress.py" --total "$total" --bytes "$total_bytes"
status=${PIPESTATUS[0]}
[ "$status" -eq 0 ] || die "rsync exited $status"
# Rockbox reads its database from .tcd files in .rockbox. Building them here
# rather than on the device is not just faster: the on-device commit sorts the
# whole index in whatever memory it can scrape together, and on a large library
# it runs for hours or dies outright.
#
# The scan reads tags through a scratch root -- a real .rockbox beside a symlink
# standing in for where the music lands on the device -- so the paths recorded
# match what Rockbox will look up, while the bytes are read from the mirror
# instead of over USB. The scratch is kept between runs because the builder is
# incremental: a second pass over unchanged files does no work at all.
rebuild_database() {
local tool=${MUSIC_MIRROR_DATABASE_TOOL:-}
if [ -z "$tool" ]; then
printf 'sync-to-ipod: no database tool configured, skipping the database\n' >&2
return 0
fi
[ -x "$tool" ] || die "$tool is not executable"
local device_rockbox="$mounted_on/.rockbox"
if [ ! -d "$device_rockbox" ]; then
printf 'sync-to-ipod: no .rockbox on the device, skipping the database\n' >&2
return 0
fi
local scratch="${XDG_CACHE_HOME:-$HOME/.cache}/music-mirror/database"
mkdir -p "$scratch/.rockbox"
# Rebuild the symlink layout each time; the mirror path or the device
# prefix may have changed since the last run.
find "$scratch" -maxdepth 1 -type l -delete
if [ "$device_prefix" = "/" ]; then
ln -s "$mirror"/* "$scratch/" 2>/dev/null || true
else
local under=${device_prefix#/}
rm -rf "${scratch:?}/${under%%/*}"
mkdir -p "$scratch/$(dirname "$under")"
ln -s "$mirror" "$scratch/$under"
fi
printf 'sync-to-ipod: building the database from the mirror...\n' >&2
( cd "$scratch" && "$tool" ) >/dev/null || die "the database build failed"
cp -- "$scratch"/.rockbox/*.tcd "$device_rockbox/" ||
die "could not copy the database onto the device"
printf 'sync-to-ipod: database copied to %s\n' "$device_rockbox" >&2
}
$database && rebuild_database
finish