4 Commits
Author SHA1 Message Date
lyrathorpe 8ec3e4c637 chore(release): v0.2.1 2026-08-25 10:39:14 +00:00
lyrathorpe d6233c2995 Merge pull request 'fix: report renames honestly in a dry run' (#7) from fix/dry-run-renames into main
Build and publish container / build (push) Successful in 1m18s
Reviewed-on: #7
2026-08-25 11:37:57 +01:00
Emma Thorpe 3f50577de6 fix: report renames honestly in a dry run
Build and publish container / build (pull_request) Successful in 1m38s
--dry-run described a FAT32 rename as an encode. process() skipped the rename
whenever dry_run was set, then found no file at the target and fell through to
the encode path, so a preview of enabling --fat32-safe announced a full
re-encode of every track whose name held a reserved character. The real run
moves those files in a moment. A preview that inverts the cost of the thing
being previewed is worse than no preview at all.

Prune compounded it. With nothing renamed, the pre-sanitisation files are still
on disk, and they were reported as orphans due for deletion -- so the same dry
run claimed the library would be re-encoded and the originals thrown away,
neither of which is true.

Renames are now their own outcome: reported as "would rename" in a dry run,
counted separately from encodes in the pass summary, and excluded from the
orphan list when a dry run leaves them in place.
2026-08-25 11:34:42 +01:00
lyrathorpe 1b0097f910 chore(release): v0.2.0 2026-08-25 10:11:48 +00:00
4 changed files with 87 additions and 7 deletions
+7
View File
@@ -229,6 +229,13 @@ producing files that already exist byte for byte. The run moves them instead,
and says so. Prune then finds nothing to remove because nothing was left
behind.
Renames are counted apart from encodes in the pass summary, and `--dry-run`
reports `would rename` rather than `would encode` — the difference between the
two is a minute against an afternoon, so a preview that conflated them would be
worse than no preview. A dry run also does not list the pre-rename files as
orphans: nothing was moved, so they are still there, but they are what a real
run would move rather than what it would delete.
### Album art
Rockbox looks for cover art **on the filesystem**`cover.jpg`, `folder.jpg`
+24 -6
View File
@@ -357,7 +357,7 @@ def copy(source, mirror, dry_run):
return Result("copied", mirror)
def adopt_existing(source, mirror, previous):
def adopt_existing(source, mirror, previous, dry_run=False):
"""Move an already-encoded file to its new name. Returns whether it moved.
Turning on FAT32-safe naming changes the path of every track whose name
@@ -367,6 +367,9 @@ def adopt_existing(source, mirror, previous):
"""
if previous == mirror or not previous.is_file() or not is_current(source, previous):
return False
if dry_run:
logger.info("would rename %s -> %s", previous.name, mirror.name)
return True
mirror.parent.mkdir(parents=True, exist_ok=True)
os.replace(previous, mirror)
logger.info("renamed %s -> %s", previous.name, mirror.name)
@@ -375,8 +378,12 @@ def adopt_existing(source, mirror, previous):
def process(source, mirror, quality_args, dry_run, previous=None):
"""Bring one source file's mirror entry up to date."""
if previous is not None and not dry_run and not mirror.exists():
adopt_existing(source, mirror, previous)
# Counted separately from an encode, and reported in a dry run, because the
# difference between moving a file and re-encoding it is the difference
# between a minute and an afternoon.
if previous is not None and not mirror.exists():
if adopt_existing(source, mirror, previous, dry_run):
return Result("renamed", mirror)
if is_current(source, mirror):
# A mirror written before this bit was set has a correct mtime, so
# nothing else in the pass would ever revisit it. Top it up here
@@ -483,7 +490,7 @@ def run_once(
"""
started = time.monotonic()
logger.info("pass starting with %d concurrent encoders", jobs)
counts = {"encoded": 0, "copied": 0, "skipped": 0, "failed": 0}
counts = {"encoded": 0, "copied": 0, "renamed": 0, "skipped": 0, "failed": 0}
failures = []
work = plan(scan_root, source_root, mirror_root, safe)
@@ -506,16 +513,27 @@ def run_once(
if result.action == "failed":
failures.append(result)
removed = prune(mirror_root, set(work), dry_run) if do_prune else 0
expected = set(work)
if safe and dry_run:
# Nothing was actually renamed, so the pre-sanitisation files are still
# on disk. They are not orphans -- they are the files a real run would
# move -- and reporting them for deletion would misrepresent the pass
# twice over.
expected |= {
mirror_path_for(source, source_root, mirror_root) for source in work.values()
}
removed = prune(mirror_root, expected, dry_run) if do_prune else 0
for failure in failures:
logger.error("failed: %s: %s", failure.path, failure.error)
logger.info(
"pass complete in %.1fs: %d encoded, %d copied, %d up to date, %d removed, %d failed",
"pass complete in %.1fs: %d encoded, %d copied, %d renamed, %d up to date,"
" %d removed, %d failed",
time.monotonic() - started,
counts["encoded"],
counts["copied"],
counts["renamed"],
counts["skipped"],
removed,
counts["failed"],
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "music-mirror"
version = "0.1.2"
version = "0.2.1"
description = "Maintain a lossy MP3 mirror of a lossless music library"
readme = "README.md"
requires-python = ">=3.11"
+55
View File
@@ -558,3 +558,58 @@ def test_a_cover_left_without_tracks_is_pruned(tmp_path, make_flac, make_cover):
run(source, mirror)
assert not (mirror / "Gone").exists()
def test_a_dry_run_reports_a_rename_not_an_encode(tmp_path, make_flac, caplog):
"""The difference between moving a file and re-encoding it is the
difference between a minute and an afternoon, so a dry run must not
describe the first as the second."""
source = tmp_path / "src"
mirror = tmp_path / "dst"
make_flac(source / "Album" / "Where Are You?.flac")
run(source, mirror)
with caplog.at_level("INFO"):
run(source, mirror, "--fat32-safe", "--dry-run")
assert "would rename" in caplog.text
assert "would encode" not in caplog.text
def test_a_dry_run_does_not_call_the_old_paths_orphans(tmp_path, make_flac, caplog):
"""Nothing was renamed, so they are still there -- but they are the files a
real run would move, not files it would delete."""
source = tmp_path / "src"
mirror = tmp_path / "dst"
make_flac(source / "Album" / "Where Are You?.flac")
run(source, mirror)
with caplog.at_level("INFO"):
run(source, mirror, "--fat32-safe", "--dry-run")
assert "would remove orphan" not in caplog.text
def test_a_dry_run_moves_nothing(tmp_path, make_flac):
source = tmp_path / "src"
mirror = tmp_path / "dst"
make_flac(source / "Album" / "Where Are You?.flac")
run(source, mirror)
run(source, mirror, "--fat32-safe", "--dry-run")
assert (mirror / "Album" / "Where Are You?.mp3").is_file()
assert not (mirror / "Album" / "Where Are You_.mp3").exists()
def test_renames_are_counted_separately_from_encodes(tmp_path, make_flac, caplog):
source = tmp_path / "src"
mirror = tmp_path / "dst"
make_flac(source / "Album" / "Where Are You?.flac")
run(source, mirror)
with caplog.at_level("INFO"):
run(source, mirror, "--fat32-safe")
assert "1 renamed" in caplog.text
assert "0 encoded" in caplog.text