fix: keep the mirror stable across Lidarr upgrades and renames
Build and publish container / build (pull_request) Canceled after 6m18s
Build and publish container / build (pull_request) Canceled after 6m18s
Three defects in how the mirror tracked its source, all of which show up on a library that something else reorganises. Pruning probed the source tree for a mirror file's original name, trying each known extension in turn. A source saved as .FLAC was never found, so its mirror file was deleted as an orphan and re-encoded on the next pass, for ever. Pruning now works from the set of paths the pass actually accounted for, which cannot disagree with the walk over letter case or extension coverage. Two sources could also claim one mirror path -- 01 Song.flac beside a leftover 01 Song.mp3, which is what an interrupted upgrade leaves behind. Both encoded to the same destination, whichever finished last won the race, and every later pass found the other one stale. The best-quality source now wins, ties break on path, and the loser is logged. The source mtime was read after encoding rather than before. A file still being written when the pass reached it would be stamped with its final mtime while holding truncated audio, and would never be revisited. Adds regression tests for all three, plus the format-upgrade, album-rename and whole-library-deletion cases, each verified to fail before the change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
82c9da6d62
commit
7a57e03c7b
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
@@ -152,6 +153,100 @@ def test_existing_mp3_is_copied_not_re_encoded(tmp_path, make_flac):
|
||||
assert (mirror / "b.mp3").read_bytes() == (source / "b.mp3").read_bytes()
|
||||
|
||||
|
||||
def test_format_upgrade_replaces_rather_than_duplicating(tmp_path, make_flac):
|
||||
"""Lidarr replacing an MP3 with a FLAC must not leave two mirror files."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
flac = make_flac(source / "Album" / "01 Song.flac")
|
||||
subprocess.run(
|
||||
["ffmpeg", "-loglevel", "error", "-y", "-i", str(flac), str(source / "Album" / "01 Song.mp3")],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
flac.unlink()
|
||||
|
||||
run(source, mirror)
|
||||
assert sorted(p.name for p in (mirror / "Album").iterdir()) == ["01 Song.mp3"]
|
||||
|
||||
# The upgrade: the MP3 goes, a FLAC arrives at the same stem.
|
||||
(source / "Album" / "01 Song.mp3").unlink()
|
||||
make_flac(source / "Album" / "01 Song.flac", title="Upgraded")
|
||||
run(source, mirror)
|
||||
|
||||
assert sorted(p.name for p in (mirror / "Album").iterdir()) == ["01 Song.mp3"]
|
||||
|
||||
|
||||
def test_renamed_album_leaves_nothing_behind(tmp_path, make_flac):
|
||||
"""A Lidarr rename is a delete plus an add; the old tree must not linger."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Artist" / "Album (2019)" / "01 Song.flac")
|
||||
|
||||
run(source, mirror)
|
||||
(source / "Artist" / "Album (2019)").rename(source / "Artist" / "Album (2020)")
|
||||
run(source, mirror)
|
||||
|
||||
assert (mirror / "Artist" / "Album (2020)" / "01 Song.mp3").is_file()
|
||||
assert not (mirror / "Artist" / "Album (2019)").exists()
|
||||
|
||||
|
||||
def test_competing_sources_pick_the_lossless_one_and_stay_stable(tmp_path, make_flac, probe_tag):
|
||||
"""Both a FLAC and an MP3 at one stem: the FLAC wins, and stays won."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "a.flac", title="From FLAC")
|
||||
other = make_flac(tmp_path / "scratch" / "other.flac", title="From MP3")
|
||||
subprocess.run(
|
||||
["ffmpeg", "-loglevel", "error", "-y", "-i", str(other), str(source / "a.mp3")],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
# Age the loser well beyond the mtime tolerance, so "is it current?" gives
|
||||
# a definite answer for it rather than one that depends on the clock.
|
||||
older = time.time() - 3600
|
||||
os.utime(source / "a.mp3", (older, older))
|
||||
|
||||
run(source, mirror)
|
||||
assert probe_tag(mirror / "a.mp3", "title") == "From FLAC"
|
||||
|
||||
# The loser must not make the mirror look stale on the next pass, or every
|
||||
# run would re-encode for ever.
|
||||
first = (mirror / "a.mp3").stat().st_mtime_ns
|
||||
run(source, mirror)
|
||||
assert (mirror / "a.mp3").stat().st_mtime_ns == first
|
||||
|
||||
|
||||
def test_uppercase_extension_is_not_pruned_and_re_encoded(tmp_path, make_flac):
|
||||
"""A .FLAC source must not be treated as an orphan on the next pass."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
made = make_flac(source / "a.flac")
|
||||
made.rename(source / "a.FLAC")
|
||||
|
||||
run(source, mirror)
|
||||
first = (mirror / "a.mp3").stat().st_mtime_ns
|
||||
|
||||
run(source, mirror)
|
||||
assert (mirror / "a.mp3").is_file()
|
||||
assert (mirror / "a.mp3").stat().st_mtime_ns == first
|
||||
|
||||
|
||||
def test_whole_library_deleted_empties_the_mirror(tmp_path, make_flac):
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "A" / "one.flac")
|
||||
make_flac(source / "B" / "two.flac")
|
||||
|
||||
run(source, mirror)
|
||||
shutil.rmtree(source / "A")
|
||||
shutil.rmtree(source / "B")
|
||||
run(source, mirror)
|
||||
|
||||
assert list(mirror.rglob("*.mp3")) == []
|
||||
assert not (mirror / "A").exists()
|
||||
assert not (mirror / "B").exists()
|
||||
|
||||
|
||||
def test_dry_run_writes_nothing(tmp_path, make_flac):
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
|
||||
Reference in New Issue
Block a user