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
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List the tracks a sync has to copy again because their album was re-levelled.
|
||||
|
||||
Reads rsync's `--out-format='%l %n'` output on stdin and writes mirror-relative
|
||||
paths on stdout, one per line, for feeding straight back to rsync as
|
||||
`--files-from`.
|
||||
|
||||
The problem it solves: a ReplayGain album gain is a property of every track on
|
||||
the record, so one track arriving or leaving changes the tags on all of its
|
||||
siblings. rsgain writes the new values into the padding its previous write left
|
||||
behind, which leaves both the file's size and its mtime untouched -- and size
|
||||
and mtime are exactly what rsync's quick check compares. It sees nothing to do,
|
||||
and the device keeps the old gains.
|
||||
|
||||
What rsync always can see is the track that arrived or left. So any album it
|
||||
touched has the rest of its tracks copied again, and nothing else does.
|
||||
|
||||
Tracks rsync has already dealt with are left out of the list. After a quality
|
||||
upgrade that replaces every track on a record, listing them again would send
|
||||
the whole album twice.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Under --out-format='%l %n' a transfer is reported as the size, a space and
|
||||
# the path. A removal is reported as "deleting <path>" whatever the format is.
|
||||
# Everything else on the stream is rsync talking to the operator -- the file
|
||||
# list preamble, the byte totals -- and is not a path.
|
||||
TRANSFER = re.compile(r"^(\d+) (.+)$")
|
||||
DELETION = re.compile(r"^deleting (.+)$")
|
||||
|
||||
# What the mirror is made of, and so the only thing worth copying again. A
|
||||
# cover is not rewritten by a re-level.
|
||||
MIRROR_SUFFIX = ".mp3"
|
||||
|
||||
|
||||
def touched(lines):
|
||||
"""Return the paths rsync transferred and the directories it changed."""
|
||||
transferred = set()
|
||||
directories = set()
|
||||
|
||||
for line in lines:
|
||||
line = line.rstrip("\n")
|
||||
deletion = DELETION.match(line)
|
||||
transfer = None if deletion else TRANSFER.match(line)
|
||||
if deletion:
|
||||
path = deletion.group(1)
|
||||
elif transfer:
|
||||
path = transfer.group(2)
|
||||
else:
|
||||
continue
|
||||
# Only a track can change an album's gains. rsync reports the
|
||||
# directories it creates and removes too, and a cover replaced on its
|
||||
# own is no reason to send the record again.
|
||||
if not path.endswith(MIRROR_SUFFIX):
|
||||
continue
|
||||
if transfer:
|
||||
transferred.add(path)
|
||||
directories.add(path.rpartition("/")[0])
|
||||
|
||||
return transferred, directories
|
||||
|
||||
|
||||
def remaining(mirror, transferred, directories):
|
||||
"""Return the tracks in those directories that rsync has not just copied."""
|
||||
paths = []
|
||||
for directory in sorted(directories):
|
||||
album = mirror / directory if directory else mirror
|
||||
if not album.is_dir():
|
||||
# Removed along with the last of its tracks. Nothing to copy.
|
||||
continue
|
||||
for track in sorted(album.glob(f"*{MIRROR_SUFFIX}")):
|
||||
relative = f"{directory}/{track.name}" if directory else track.name
|
||||
if relative not in transferred:
|
||||
paths.append(relative)
|
||||
return paths
|
||||
|
||||
|
||||
def main(argv=None, stream=None, out=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="touched_albums.py",
|
||||
description="List the tracks to copy again after an album was re-levelled.",
|
||||
)
|
||||
parser.add_argument("--mirror", required=True, type=Path, help="root of the MP3 mirror")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
transferred, directories = touched(stream if stream is not None else sys.stdin)
|
||||
for path in remaining(args.mirror, transferred, directories):
|
||||
print(path, file=out if out is not None else sys.stdout)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user