Build and publish container / build (pull_request) Successful in 3m8s
Lidarr renames an artist or album folder, music-mirror prunes the old path and encodes the new one, and every entry in Apple Music pointing at the old path dies. Music offers no view of those, and the exclamation mark only appears once a track is touched. Reads the XML from Music's own Export Library rather than asking Music itself. A broken track makes AppleScript's `location` raise instead of returning a value, so a bulk query dies on the first one with error -1728 and a per-track loop costs an Apple event apiece. Checked against a single directory walk rather than a test per file. On a fifty-thousand-track library that is around seven thousand directory reads instead of fifty thousand stat calls, and over SMB each of those stats is a network round trip -- which is the difference between seconds and minutes. Two comparisons have to be loosened or most of the library reads as missing. macOS stores filenames decomposed while the share composes them, so the umlauts in Motley Crue are two different byte strings depending on which side wrote the name; both are normalised to NFC. And the share is very likely case-insensitive, so a file is not missing because someone capitalised it differently. The test stage now copies tools/ as well, since the suite covers this and the runtime image deliberately does not carry it.
169 lines
5.6 KiB
Python
169 lines
5.6 KiB
Python
import os
|
|
import plistlib
|
|
import sys
|
|
import unicodedata
|
|
import urllib.parse
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
|
|
|
|
import find_missing_tracks # noqa: E402
|
|
|
|
|
|
def write_library(path, tracks):
|
|
"""Write a Library.xml in the shape Music exports."""
|
|
path.write_bytes(plistlib.dumps({"Tracks": {str(i): t for i, t in enumerate(tracks)}}))
|
|
return path
|
|
|
|
|
|
def track(root, name, filename=None):
|
|
"""A local track entry. Omit filename for a streaming entry with no file."""
|
|
entry = {"Name": name, "Artist": "An Artist", "Album": "An Album"}
|
|
if filename is not None:
|
|
entry["Location"] = "file://" + urllib.parse.quote(str(root / filename))
|
|
return entry
|
|
|
|
|
|
def missing_from(library, roots):
|
|
with open(library, "rb") as handle:
|
|
loaded = plistlib.load(handle)
|
|
present = find_missing_tracks.existing_under(roots)
|
|
gone = []
|
|
for entry in loaded["Tracks"].values():
|
|
path = find_missing_tracks.location_of(entry)
|
|
if path is not None and find_missing_tracks.key_for(path) not in present:
|
|
gone.append(entry["Name"])
|
|
return sorted(gone)
|
|
|
|
|
|
def test_a_deleted_file_is_reported(tmp_path):
|
|
(tmp_path / "kept.mp3").write_bytes(b"x")
|
|
library = write_library(
|
|
tmp_path / "Library.xml",
|
|
[track(tmp_path, "Kept", "kept.mp3"), track(tmp_path, "Gone", "gone.mp3")],
|
|
)
|
|
|
|
assert missing_from(library, [tmp_path]) == ["Gone"]
|
|
|
|
|
|
def test_a_decomposed_filename_is_not_reported_missing(tmp_path):
|
|
"""macOS stores filenames decomposed and most everything else composes them,
|
|
so the umlauts in Motley Crue are two different byte strings depending on
|
|
which side wrote the name."""
|
|
composed = unicodedata.normalize("NFC", "Mötley Crüe.mp3")
|
|
(tmp_path / composed).write_bytes(b"x")
|
|
library = write_library(
|
|
tmp_path / "Library.xml",
|
|
[track(tmp_path, "Umlauts", unicodedata.normalize("NFD", "Mötley Crüe.mp3"))],
|
|
)
|
|
|
|
assert missing_from(library, [tmp_path]) == []
|
|
|
|
|
|
def test_a_case_difference_is_not_reported_missing(tmp_path):
|
|
"""The share is very likely case-insensitive, and a file is not missing
|
|
because someone capitalised it differently."""
|
|
(tmp_path / "Hells Bells.mp3").write_bytes(b"x")
|
|
library = write_library(
|
|
tmp_path / "Library.xml", [track(tmp_path, "Bells", "hells bells.MP3")]
|
|
)
|
|
|
|
assert missing_from(library, [tmp_path]) == []
|
|
|
|
|
|
def test_a_track_with_no_file_is_not_a_missing_file(tmp_path):
|
|
"""A streaming entry has never had a file to lose."""
|
|
library = write_library(tmp_path / "Library.xml", [track(tmp_path, "Streamed", None)])
|
|
|
|
assert missing_from(library, [tmp_path]) == []
|
|
|
|
|
|
def test_the_walk_finds_files_nested_below_the_root(tmp_path):
|
|
nested = tmp_path / "Artist" / "Album"
|
|
nested.mkdir(parents=True)
|
|
(nested / "deep.mp3").write_bytes(b"x")
|
|
|
|
found = find_missing_tracks.existing_under([tmp_path])
|
|
|
|
assert find_missing_tracks.key_for(nested / "deep.mp3") in found
|
|
|
|
|
|
def test_the_root_is_derived_from_the_tracks(tmp_path):
|
|
paths = [tmp_path / "a" / "one.mp3", tmp_path / "b" / "two.mp3"]
|
|
|
|
assert find_missing_tracks.roots_of(paths, None) == [tmp_path]
|
|
|
|
|
|
def test_an_explicit_root_overrides_the_derived_one(tmp_path):
|
|
"""Worth using: tracks spread beyond the mirror can derive a common parent
|
|
of / and send the walk across the whole disk."""
|
|
paths = [tmp_path / "a" / "one.mp3"]
|
|
|
|
assert find_missing_tracks.roots_of(paths, ["/Volumes/music-mp3"]) == [
|
|
Path("/Volumes/music-mp3")
|
|
]
|
|
|
|
|
|
def test_the_whole_library_missing_is_called_out(tmp_path, capsys):
|
|
"""Almost always an unmounted share rather than an empty library."""
|
|
library = write_library(
|
|
tmp_path / "Library.xml", [track(tmp_path / "elsewhere", "Gone", "gone.mp3")]
|
|
)
|
|
|
|
find_missing_tracks.main([str(library), "--root", str(tmp_path)])
|
|
|
|
assert "not mounted" in capsys.readouterr().err
|
|
|
|
|
|
def test_a_healthy_library_says_nothing_alarming(tmp_path, capsys):
|
|
(tmp_path / "kept.mp3").write_bytes(b"x")
|
|
library = write_library(tmp_path / "Library.xml", [track(tmp_path, "Kept", "kept.mp3")])
|
|
|
|
find_missing_tracks.main([str(library), "--root", str(tmp_path)])
|
|
|
|
captured = capsys.readouterr()
|
|
assert "0 of 1 local tracks are missing" in captured.err
|
|
assert "not mounted" not in captured.err
|
|
assert captured.out == ""
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("location", "expected"),
|
|
[
|
|
("file:///music/a%20b.mp3", Path("/music/a b.mp3")),
|
|
("file:///music/plain.mp3", Path("/music/plain.mp3")),
|
|
("https://example.invalid/stream", None),
|
|
("", None),
|
|
],
|
|
)
|
|
def test_locations_are_decoded(location, expected):
|
|
assert find_missing_tracks.location_of({"Location": location} if location else {}) == expected
|
|
|
|
|
|
def test_the_walk_costs_one_read_per_directory_not_one_per_file(tmp_path):
|
|
"""The whole point. Over SMB a per-file check is a round trip per track."""
|
|
for album in range(20):
|
|
directory = tmp_path / f"Album {album}"
|
|
directory.mkdir()
|
|
for index in range(25):
|
|
(directory / f"{index}.mp3").write_bytes(b"x")
|
|
|
|
reads = {"n": 0}
|
|
real_walk = os.walk
|
|
|
|
def counting_walk(*args, **kwargs):
|
|
for entry in real_walk(*args, **kwargs):
|
|
reads["n"] += 1
|
|
yield entry
|
|
|
|
find_missing_tracks.os.walk = counting_walk
|
|
try:
|
|
found = find_missing_tracks.existing_under([tmp_path])
|
|
finally:
|
|
find_missing_tracks.os.walk = real_walk
|
|
|
|
assert len(found) == 500
|
|
assert reads["n"] == 21 # the root and its twenty albums, not 500 files
|