fix: copy albums whole when a re-level changes only their tags
Build and publish container / build (pull_request) Successful in 1m43s

A ReplayGain album gain belongs to the whole record, so a track arriving or
leaving rewrites the tags on every one of its siblings. rsgain fits the new
values into the padding its previous write left behind, which changes neither
the file's size nor its mtime:

  before: 277757 bytes, mtime 1577880000, album gain 3.75 dB
  after:  277757 bytes, mtime 1577880000, album gain 6.25 dB

Those are the two things rsync's quick check compares, so the siblings are
invisible to it and the device keeps the old gains indefinitely.

The track that arrived or left is always visible. So sync-to-ipod.sh now runs a
second pass over the albums the first one touched, with --ignore-times to
defeat the same quick check. touched_albums.py derives the list from rsync's
own report of what it moved, which costs no extra traversal of either tree, and
leaves out the tracks the first pass has already copied so a whole-album
quality upgrade is not sent twice. Albums whose file set has not changed are
left alone.

A dry run reports how many further tracks are involved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Emma Thorpe
2026-08-28 17:05:08 +01:00
co-authored by Claude Opus 5
parent c63115f246
commit 9a3b4f9955
5 changed files with 393 additions and 6 deletions
+86
View File
@@ -5,6 +5,7 @@ protecting against emptying the wrong directory -- a mistake that does not
announce itself.
"""
import os
import shutil
import subprocess
from pathlib import Path
@@ -346,3 +347,88 @@ def test_counting_can_be_asked_for(mirror, tmp_path):
assert result.returncode == 0, result.stderr
assert "files to copy" in result.stderr
# A ReplayGain re-level rewrites a track's tags in the padding the previous
# write left behind, so neither the size nor the mtime changes -- and those are
# the two things rsync's quick check compares.
def stale_copy(mirror, destination, relative, current, previous):
"""Put a file on the device that differs only in content from the mirror's."""
source = mirror / relative
source.parent.mkdir(parents=True, exist_ok=True)
source.write_bytes(current)
device = destination / relative
device.parent.mkdir(parents=True, exist_ok=True)
device.write_bytes(previous)
os.utime(device, (source.stat().st_atime, source.stat().st_mtime))
return device
def test_a_tag_only_change_reaches_the_device_with_the_track_that_caused_it(
mirror, tmp_path
):
"""The new track is visible to rsync; its re-levelled sibling is not, and
would otherwise keep the old album gain on the device forever."""
destination = tmp_path / "dest"
destination.mkdir()
sibling = stale_copy(mirror, destination, "Album/sibling.mp3", b"NEW", b"OLD")
result = run("-f", "-S", "-U", str(mirror), str(destination))
assert result.returncode == 0, result.stderr
assert sibling.read_bytes() == b"NEW"
assert (destination / "Album" / "track.mp3").is_file()
def test_a_deletion_also_relevels_what_is_left_behind(mirror, tmp_path):
destination = tmp_path / "dest"
destination.mkdir()
sibling = stale_copy(mirror, destination, "Album/sibling.mp3", b"NEW", b"OLD")
(destination / "Album" / "gone.mp3").write_bytes(b"old")
result = run("-f", "-S", "-U", str(mirror), str(destination))
assert result.returncode == 0, result.stderr
assert sibling.read_bytes() == b"NEW"
assert not (destination / "Album" / "gone.mp3").exists()
def test_an_untouched_album_is_not_copied_again(mirror, tmp_path):
"""The second pass is scoped to albums that changed. An album whose file
set is the same is left where it is, which is the whole point of not
running --ignore-times over the library."""
destination = tmp_path / "dest"
destination.mkdir()
quiet = stale_copy(mirror, destination, "Quiet/only.mp3", b"NEW", b"OLD")
(destination / "Album").mkdir()
shutil.copy2(mirror / "Album" / "track.mp3", destination / "Album" / "track.mp3")
result = run("-f", "-S", "-U", str(mirror), str(destination))
assert result.returncode == 0, result.stderr
assert quiet.read_bytes() == b"OLD"
def test_a_dry_run_says_how_many_extra_tracks_are_involved(mirror, tmp_path):
destination = tmp_path / "dest"
destination.mkdir()
stale_copy(mirror, destination, "Album/sibling.mp3", b"NEW", b"OLD")
result = run("-f", "-S", "-U", "-n", str(mirror), str(destination))
assert result.returncode == 0, result.stderr
assert "and 1 more in those albums" in result.stderr
def test_a_first_sync_does_not_copy_anything_twice(mirror, tmp_path):
"""Everything is transferred by the main pass, so there is nothing left for
the second one and it must not announce itself."""
destination = tmp_path / "dest"
destination.mkdir()
result = run("-f", "-S", "-U", str(mirror), str(destination))
assert result.returncode == 0, result.stderr
assert "re-levelled" not in result.stderr
+128
View File
@@ -0,0 +1,128 @@
"""A ReplayGain re-level rewrites a track's tags without changing its size or
its mtime, which is precisely the pair rsync's quick check compares. These
cover the list that is fed back to rsync to copy those tracks anyway."""
import io
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
import touched_albums # noqa: E402
def album(root, name, *tracks):
directory = root / name
directory.mkdir(parents=True, exist_ok=True)
for track in tracks:
(directory / track).write_bytes(b"x")
return directory
def listing(mirror, *lines):
return touched_albums.remaining(mirror, *touched_albums.touched(list(lines)))
def test_the_siblings_of_a_new_track_are_listed(tmp_path):
album(tmp_path, "Artist/Album", "01.mp3", "02.mp3", "03.mp3")
assert listing(tmp_path, "4096 Artist/Album/03.mp3") == [
"Artist/Album/01.mp3",
"Artist/Album/02.mp3",
]
def test_a_track_rsync_just_copied_is_not_copied_twice(tmp_path):
"""A quality upgrade replaces every track on the record. Listing them again
would send the album across twice."""
album(tmp_path, "Artist/Album", "01.mp3", "02.mp3")
assert listing(tmp_path, "4096 Artist/Album/01.mp3", "4096 Artist/Album/02.mp3") == []
def test_a_deletion_relevels_what_is_left(tmp_path):
album(tmp_path, "Artist/Album", "01.mp3", "02.mp3")
assert listing(tmp_path, "deleting Artist/Album/03.mp3") == [
"Artist/Album/01.mp3",
"Artist/Album/02.mp3",
]
def test_an_album_deleted_outright_lists_nothing(tmp_path):
assert listing(tmp_path, "deleting Artist/Gone/01.mp3") == []
def test_untouched_albums_are_left_alone(tmp_path):
album(tmp_path, "Artist/Changed", "01.mp3", "02.mp3")
album(tmp_path, "Artist/Quiet", "01.mp3", "02.mp3")
assert listing(tmp_path, "4096 Artist/Changed/01.mp3") == ["Artist/Changed/02.mp3"]
def test_a_replaced_cover_is_not_an_album_change(tmp_path):
"""Only a track can change an album's gains, and covers are replaced often
enough that treating one as a re-level would copy records for nothing."""
album(tmp_path, "Artist/Album", "01.mp3", "02.mp3")
assert listing(tmp_path, "17408 Artist/Album/cover.jpg") == []
def test_directories_are_not_mistaken_for_tracks(tmp_path):
album(tmp_path, "Artist/Album", "01.mp3")
assert listing(tmp_path, "4096 Artist/Album/", "deleting Artist/Old/") == []
def test_rsync_talking_to_the_operator_is_not_a_path(tmp_path):
album(tmp_path, "Artist/Album", "01.mp3", "02.mp3")
assert (
listing(
tmp_path,
"sending incremental file list",
"",
"sent 1,234 bytes received 56 bytes 2,580.00 bytes/sec",
"total size is 7,890 speedup is 6.12",
)
== []
)
def test_a_track_at_the_mirror_root_does_not_pull_in_the_whole_tree(tmp_path):
"""Nothing writes a mirror this way, but the directory of a root-level file
is the root, and recursing from there would be the whole library."""
(tmp_path / "loose.mp3").write_bytes(b"x")
(tmp_path / "other.mp3").write_bytes(b"x")
album(tmp_path, "Artist/Album", "01.mp3")
assert listing(tmp_path, "4096 loose.mp3") == ["other.mp3"]
def test_the_paths_are_written_one_per_line(tmp_path):
"""They are fed straight back to rsync as --files-from."""
album(tmp_path, "Artist/Album", "01.mp3", "02.mp3")
out = io.StringIO()
touched_albums.main(
["--mirror", str(tmp_path)],
stream=["4096 Artist/Album/01.mp3"],
out=out,
)
assert out.getvalue() == "Artist/Album/02.mp3\n"
def test_it_runs_as_a_script(tmp_path):
album(tmp_path, "Artist/Album", "01.mp3", "02.mp3")
completed = subprocess.run(
[sys.executable, touched_albums.__file__, "--mirror", str(tmp_path)],
input="4096 Artist/Album/01.mp3\n",
capture_output=True,
text=True,
)
assert completed.returncode == 0
assert completed.stdout == "Artist/Album/02.mp3\n"