98 lines
3.6 KiB
Python
98 lines
3.6 KiB
Python
#!/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())
|