feat: name the mirror so a FAT32 device will take it, and copy album art
Build and publish container / build (pull_request) Successful in 2m18s
Build and publish container / build (pull_request) Successful in 2m18s
Two changes for playing the mirror on a Rockbox iPod, where the device is FAT32 and Rockbox reads a plain directory tree rather than a database. --fat32-safe names mirror files acceptably: the reserved characters and control characters become underscores, trailing dots and spaces are stripped because FAT eats them silently and the name then round-trips as a different one, and a component left empty becomes an underscore. Names differing only in case are detected as collisions, since two files here are one file there and the second would silently overwrite the first. "Kick Out the Epic Motherf**ker" is a real example from a real library, and without this it simply never arrives. Off by default. It renames files, and that should be a decision rather than a surprise on somebody's next pass. Turning it on does not re-encode anything. Every track whose name held a reserved character changes path, and encoding those again would be hours of work producing files that already exist byte for byte, so the run moves them instead and logs each one. Prune then finds nothing left behind. Album art is now also copied into the mirror as cover.jpg beside the tracks. Rockbox searches the filesystem for art -- cover.jpg, folder.jpg and the rest, in the track's directory or its parent -- and that search never looks at the picture embedded in the tag, so a mirror that only embeds art displays none of it on the device. Embedding continues for the Apple firmware; both are now satisfied. A cover whose tracks have all been pruned is removed too, or its directory would never look empty and never go. Adds tools/check_fat32.py, which reports unacceptable paths before a copy rather than during one: rsync reports them too, but scattered through fifty thousand files where they are easy to lose. It exits non-zero so it can gate a script. The README documents the rsync invocation, including why --modify-window=2 is required against FAT and why Rhythmbox must be kept out of the transfer -- rb_ipod_helpers_is_ipod() reads access-protocols from media-player-info and returns true on the USB id alone, without looking at the filesystem, so removing iPod_Control changes nothing.
This commit is contained in:
@@ -78,6 +78,23 @@ def make_flac():
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_cover():
|
||||
"""Return a factory writing a small JPEG beside an album's tracks."""
|
||||
|
||||
def factory(path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "lavfi", "-i", "color=c=red:s=64x64:d=1", "-frames:v", "1", str(path)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return path
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def probe_tag():
|
||||
"""Return a helper reading a single metadata tag from a file."""
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
|
||||
|
||||
import check_fat32 # noqa: E402
|
||||
|
||||
|
||||
def problems(name):
|
||||
return check_fat32.problems_with(Path(name))
|
||||
|
||||
|
||||
def test_a_reserved_character_is_a_problem():
|
||||
assert any("reserved" in p for p in problems("Album/Motherf**ker.mp3"))
|
||||
|
||||
|
||||
def test_a_trailing_dot_or_space_is_a_problem():
|
||||
"""FAT eats them silently, so the name round-trips as a different name."""
|
||||
assert any("trailing" in p for p in problems("Trailing Dot./x.mp3"))
|
||||
assert any("trailing" in p for p in problems("Space /x.mp3"))
|
||||
|
||||
|
||||
def test_an_ordinary_name_is_fine():
|
||||
assert problems("Artist/Album/01 Fine Track.mp3") == []
|
||||
|
||||
|
||||
def test_accents_are_fine():
|
||||
assert problems("Mötley Crüe/Album/Track.mp3") == []
|
||||
|
||||
|
||||
def test_an_over_long_path_is_a_problem():
|
||||
deep = "/".join("d" * 40 for _ in range(10)) + "/track.mp3"
|
||||
assert any("path of" in p for p in problems(deep))
|
||||
|
||||
|
||||
def test_case_collisions_are_reported(tmp_path):
|
||||
album = tmp_path / "Album"
|
||||
album.mkdir()
|
||||
(album / "Song.mp3").write_bytes(b"x")
|
||||
(album / "SONG.mp3").write_bytes(b"x")
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(Path(check_fat32.__file__)), str(tmp_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert completed.returncode == 1
|
||||
assert "collides case-insensitively" in completed.stdout
|
||||
|
||||
|
||||
def test_a_clean_tree_exits_zero(tmp_path):
|
||||
(tmp_path / "Album").mkdir()
|
||||
(tmp_path / "Album" / "Fine.mp3").write_bytes(b"x")
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(Path(check_fat32.__file__)), str(tmp_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert "0 problems" in completed.stderr
|
||||
@@ -443,3 +443,118 @@ def test_mirror_inside_source_is_refused(tmp_path, make_flac):
|
||||
|
||||
def test_missing_source_is_refused(tmp_path):
|
||||
assert run(tmp_path / "nope", tmp_path / "dst") == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("Kick Out the Epic Motherf**ker", "Kick Out the Epic Motherf__ker"),
|
||||
("Where Are You?", "Where Are You_"),
|
||||
("Song: Part 2", "Song_ Part 2"),
|
||||
('"Heroes"', "_Heroes_"),
|
||||
("trailing dot.", "trailing dot"),
|
||||
("trailing space ", "trailing space"),
|
||||
("...", "_"),
|
||||
("Mötley Crüe", "Mötley Crüe"),
|
||||
("perfectly ordinary", "perfectly ordinary"),
|
||||
],
|
||||
)
|
||||
def test_fat32_safe_names(name, expected):
|
||||
"""A name FAT32 will not take is a track that silently does not arrive."""
|
||||
assert music_mirror.fat32_safe(name) == expected
|
||||
|
||||
|
||||
def test_a_reserved_character_is_replaced_in_the_mirror_path(tmp_path, make_flac):
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Dada Life" / "Album" / "Kick Out the Epic Motherf**ker.flac")
|
||||
|
||||
run(source, mirror, "--fat32-safe")
|
||||
|
||||
assert (mirror / "Dada Life" / "Album" / "Kick Out the Epic Motherf__ker.mp3").is_file()
|
||||
|
||||
|
||||
def test_without_the_flag_the_name_is_left_alone(tmp_path, make_flac):
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "Where Are You?.flac")
|
||||
|
||||
run(source, mirror)
|
||||
|
||||
assert (mirror / "Album" / "Where Are You?.mp3").is_file()
|
||||
|
||||
|
||||
def test_an_existing_mirror_is_renamed_not_re_encoded(tmp_path, make_flac):
|
||||
"""Turning the flag on changes the path of every track holding a reserved
|
||||
character. Re-encoding those would be hours of work to produce files that
|
||||
already exist byte for byte."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "Where Are You?.flac")
|
||||
|
||||
run(source, mirror)
|
||||
before = mirror / "Album" / "Where Are You?.mp3"
|
||||
contents = before.read_bytes()
|
||||
stamp = before.stat().st_mtime_ns
|
||||
|
||||
run(source, mirror, "--fat32-safe")
|
||||
|
||||
after = mirror / "Album" / "Where Are You_.mp3"
|
||||
assert after.is_file()
|
||||
assert not before.exists()
|
||||
assert after.read_bytes() == contents, "it was re-encoded rather than moved"
|
||||
assert after.stat().st_mtime_ns == stamp
|
||||
|
||||
|
||||
def test_names_colliding_only_by_case_are_caught(tmp_path, make_flac):
|
||||
"""Two files here, one file on FAT32. Better found now than as a silent
|
||||
overwrite partway through the copy."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "Song.flac")
|
||||
make_flac(source / "Album" / "SONG.flac")
|
||||
|
||||
run(source, mirror, "--fat32-safe")
|
||||
|
||||
written = sorted(p.name for p in (mirror / "Album").glob("*.mp3"))
|
||||
assert len(written) == 1, written
|
||||
|
||||
|
||||
def test_the_album_cover_is_copied_beside_the_tracks(tmp_path, make_flac, make_cover):
|
||||
"""Rockbox looks for art on the filesystem; its search never touches the
|
||||
picture embedded in the tag."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "a.flac")
|
||||
make_cover(source / "Album" / "cover.jpg")
|
||||
|
||||
run(source, mirror)
|
||||
|
||||
assert (mirror / "Album" / "cover.jpg").is_file()
|
||||
|
||||
|
||||
def test_a_copied_cover_is_group_readable(tmp_path, make_flac, make_cover, tight_umask):
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "a.flac")
|
||||
make_cover(source / "Album" / "cover.jpg")
|
||||
|
||||
run(source, mirror)
|
||||
|
||||
assert (mirror / "Album" / "cover.jpg").stat().st_mode & stat.S_IRGRP
|
||||
|
||||
|
||||
def test_a_cover_left_without_tracks_is_pruned(tmp_path, make_flac, make_cover):
|
||||
"""Otherwise the directory never looks empty and never goes."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Gone" / "a.flac")
|
||||
make_cover(source / "Gone" / "cover.jpg")
|
||||
|
||||
run(source, mirror)
|
||||
assert (mirror / "Gone" / "cover.jpg").is_file()
|
||||
|
||||
shutil.rmtree(source / "Gone")
|
||||
run(source, mirror)
|
||||
|
||||
assert not (mirror / "Gone").exists()
|
||||
|
||||
Reference in New Issue
Block a user