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.
121 lines
3.3 KiB
Python
121 lines
3.3 KiB
Python
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
# Ensure the project root is on sys.path when running tests.
|
|
ROOT = os.path.dirname(os.path.dirname(__file__))
|
|
if ROOT not in sys.path:
|
|
sys.path.insert(0, ROOT)
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def require_ffmpeg():
|
|
"""The tests exercise real encodes; there is little point faking them."""
|
|
for tool in ("ffmpeg", "ffprobe"):
|
|
if shutil.which(tool) is None:
|
|
pytest.skip(f"{tool} is not on PATH", allow_module_level=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def tight_umask():
|
|
"""Run a test under a umask that would otherwise make the mirror private."""
|
|
previous = os.umask(0o077)
|
|
yield
|
|
os.umask(previous)
|
|
|
|
|
|
@pytest.fixture
|
|
def owner_hostile_umask():
|
|
"""Return a callable applying a umask that masks off the owner's read bit.
|
|
|
|
Unusual, but it is what produces a mirror tree of mode 0300 -- writable and
|
|
enterable, unreadable to the very process that built it. Applied on demand
|
|
rather than for the whole test, because the source library is built by
|
|
something else entirely and the same umask would make the test's own
|
|
fixtures unreadable before the run under test even started.
|
|
"""
|
|
previous = os.umask(0o022)
|
|
yield lambda: os.umask(0o477)
|
|
os.umask(previous)
|
|
|
|
|
|
@pytest.fixture
|
|
def make_flac():
|
|
"""Return a factory writing a short tagged FLAC file."""
|
|
|
|
def factory(path, title="Test Title", artist="Test Artist", album="Test Album", seconds=1):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-nostdin",
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
f"sine=frequency=440:duration={seconds}",
|
|
"-metadata",
|
|
f"title={title}",
|
|
"-metadata",
|
|
f"artist={artist}",
|
|
"-metadata",
|
|
f"album={album}",
|
|
"-metadata",
|
|
"track=3",
|
|
str(path),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
return path
|
|
|
|
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."""
|
|
|
|
def reader(path, tag):
|
|
completed = subprocess.run(
|
|
[
|
|
"ffprobe",
|
|
"-v",
|
|
"error",
|
|
"-show_entries",
|
|
f"format_tags={tag}",
|
|
"-of",
|
|
"default=noprint_wrappers=1:nokey=1",
|
|
str(path),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return completed.stdout.strip()
|
|
|
|
return reader
|