fix: copy albums whole when a re-level changes only their tags
Build and publish container / build (pull_request) Successful in 1m43s
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:
co-authored by
Claude Opus 5
parent
c63115f246
commit
9a3b4f9955
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user