Files
lintunes/tests/test_round37.py
T
travandClaude Opus 5 81b7f6767c v0.20.0: the Rabbit's plays come home
andTunes phase 3. The R1 is now one more machine in the Round 38 play-count
model, and nothing lands on it that Android can't play.

Play counts: andTunes 0.2.0 counts a play on a natural finish (LinTunes'
rule) and keeps Music/andTunes/plays/andtunes-<install id>.json in exactly
the per-machine-totals journal shape, written beside the old one and renamed
over it. Each sync first folds it into <data_dir>/plays/ with a new
play_journal.merge_totals, the per-track max: the file now has two homes and
both desktops may bring it back, so the max converges and an older copy can
never pull a count down. Nothing is written when nothing moved, and
PlayJournal.load needed no change.

Format gate: the planner reuses export/web_support.conversion_for outright,
since Android's MediaPlayer decodes the browser's set. FairPlay is refused
and reported; ALAC, AIFF and oddities land as FLAC, converted once into a
per-track cache and size-diffed after that. With no ffmpeg, the export's
"sync without them?" question. A file ffmpeg can't read costs that song,
not the sync.

"Sync Playlist to Rabbit (Auxio)" is retired from the menu; device_sync's
helpers stay because export and andTunes import them.

The dev fixture grew a real ALAC track, a Protected AAC .m4p and an "Odd
Formats" playlist. Verified on the R1 with it: AIFF and ALAC arrived as
FLAC and played, the FairPlay track was refused, four plays came back on the
next sync and each track's effective count rose by exactly one, and a third
sync copied nothing.

Found along the way: USB re-enumeration can wedge gvfsd-mtp, after which
anything touching the mount (find_device, the GUI tests) hangs in an
uninterruptible wait. CLAUDE.md now has the recovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZCVBTJRFJ2XfMshu2gtUv
2026-09-11 15:20:44 -05:00

529 lines
23 KiB
Python

"""Round 37: File → Export Playlist… — a folder of files, or a web mix.
Two destinations share one planner. The folder variant copies files verbatim
under "Artist - Title" names next to an .m3u. The web variant additionally
writes a self-contained page (index.html + a dependency-free player.js/css,
replacing the abandoned audio.js + jQuery pair the hand-made mixes shipped)
and converts anything a browser can't decode.
The load-bearing claims under test:
* bitrate never triggers a conversion — only *unplayability* does;
* a conversion is always to FLAC, so it can never lose a bit;
* DRM'd tracks are reported, never silently dropped and never attempted;
* the manifest is written last, so a cancelled export leaves no page or m3u
promising files that aren't there. Since Round 42 the web variant's only
manifest is the page — a folder you upload has no use for a playlist file.
"""
import hashlib
import shutil
import subprocess
import pytest
from lintunes.export import exporter, web_support
from lintunes.export.exporter import ExportWorker, plan_export
from lintunes.models import Track
def _track(tid, name, artist, path, kind="MPEG audio file", total_time=180_000):
return Track(track_id=tid, name=name, artist=artist, location=str(path),
kind=kind, total_time=total_time)
def _audio(tmp_path, filename, data=b"x" * 100):
path = tmp_path / "local" / filename
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
def _run(plan, cancel_on_emit=None):
"""Drive a worker synchronously on the test thread so signals fire directly."""
worker = ExportWorker(plan)
out = {"finished": None, "cancelled": None, "failed": None, "progress": []}
worker.finished.connect(lambda d: out.__setitem__("finished", d))
worker.cancelled.connect(lambda d: out.__setitem__("cancelled", d))
worker.failed.connect(lambda m: out.__setitem__("failed", m))
def on_progress(done, total, label):
out["progress"].append((done, total, label))
if cancel_on_emit is not None and len(out["progress"]) >= cancel_on_emit:
worker.cancel()
worker.progress.connect(on_progress)
worker._run()
return out
def _pcm_hash(path):
"""SHA-256 of the decoded samples — format-independent audio identity."""
result = subprocess.run(
["ffmpeg", "-loglevel", "error", "-i", str(path),
"-f", "s32le", "-c:a", "pcm_s32le", "-"],
capture_output=True,
)
assert result.returncode == 0, result.stderr.decode()
return hashlib.sha256(result.stdout).hexdigest()
class TestPlayabilityGate:
@pytest.mark.parametrize("name", [
"a.mp3", "a.mpga", "a.m4a", "a.aac", "a.wav",
"a.flac", "a.ogg", "a.opus", "a.webm",
])
def test_browser_native_formats_are_copied_verbatim(self, name):
assert web_support.conversion_for(f"/music/{name}") == (None, None)
def test_bitrate_is_never_a_conversion_trigger(self):
# A 320 kbps MP3 is exactly as playable as a 96 kbps one.
assert web_support.conversion_for(
"/music/loud.mp3", "MPEG audio file") == (None, None)
def test_plain_aac_in_m4a_is_copied(self):
for kind in ("AAC audio file", "Matched AAC audio file",
"Purchased AAC audio file"):
assert web_support.conversion_for("/music/a.m4a", kind) == (None, None)
def test_apple_lossless_converts_despite_playable_suffix(self):
# .m4a is in the allow-list; only `kind` reveals this is ALAC.
assert web_support.conversion_for(
"/music/a.m4a", "Apple Lossless audio file") == ("flac", None)
def test_aiff_converts(self):
assert web_support.conversion_for("/music/a.aiff") == ("flac", None)
assert web_support.conversion_for("/music/a.aif") == ("flac", None)
def test_unknown_format_converts_rather_than_being_dropped(self):
assert web_support.conversion_for("/music/a.wma") == ("flac", None)
def test_drm_is_refused_never_converted(self):
for loc, kind in (("/music/a.m4p", ""),
("/music/a.m4a", "Protected AAC audio file")):
target, reason = web_support.conversion_for(loc, kind)
assert target is None
assert "copy-protected" in reason
def test_missing_location_is_refused(self):
assert web_support.conversion_for("") == (None, "it has no file")
class TestPlan:
def test_folder_plan_keeps_playlist_order(self, tmp_path):
tracks = [_track(i, f"Song {i}", "Artist", _audio(tmp_path, f"{i}.mp3"))
for i in (3, 1, 2)]
plan = plan_export("Mix", tracks, tmp_path / "out", exporter.FOLDER)
assert [i.display for i in plan.items] == [
"Artist - Song 3", "Artist - Song 1", "Artist - Song 2"]
def test_folder_names_are_artist_title(self, tmp_path):
plan = plan_export(
"Mix", [_track(1, "Lights Out", "Broadcast", _audio(tmp_path, "x.mp3"))],
tmp_path / "out", exporter.FOLDER)
assert plan.items[0].dest_name == "Broadcast - Lights Out.mp3"
def test_dest_layout_differs_by_kind(self, tmp_path):
tracks = [_track(1, "S", "A", _audio(tmp_path, "x.mp3"))]
folder = plan_export("My Mix", tracks, tmp_path / "out", exporter.FOLDER)
web = plan_export("My Mix", tracks, tmp_path / "out", exporter.WEB)
assert folder.audio_dir == folder.dest_dir == tmp_path / "out" / "My Mix"
assert web.audio_dir == tmp_path / "out" / "My Mix" / "audios"
def test_missing_files_are_skipped_with_a_reason(self, tmp_path):
tracks = [
_track(1, "Here", "A", _audio(tmp_path, "x.mp3")),
_track(2, "Gone", "A", tmp_path / "local" / "nope.mp3"),
_track(3, "Nowhere", "A", ""),
]
plan = plan_export("Mix", tracks, tmp_path / "out", exporter.FOLDER)
assert len(plan.items) == 1
assert [d for d, _why in plan.skipped] == ["A - Gone", "A - Nowhere"]
assert all("missing" in why for _d, why in plan.skipped)
def test_duplicate_track_ids_are_collapsed(self, tmp_path):
src = _audio(tmp_path, "x.mp3")
track = _track(1, "S", "A", src)
plan = plan_export("Mix", [track, track, track],
tmp_path / "out", exporter.FOLDER)
assert len(plan.items) == 1
def test_collisions_get_stable_suffixes_regardless_of_order(self, tmp_path):
a = _track(7, "Song", "Artist", _audio(tmp_path, "a.mp3"))
b = _track(9, "Song", "Artist", _audio(tmp_path, "b.mp3"))
forward = plan_export("Mix", [a, b], tmp_path / "o1", exporter.FOLDER)
reverse = plan_export("Mix", [b, a], tmp_path / "o2", exporter.FOLDER)
assert {i.dest_name for i in forward.items} == {
"Artist - Song [7].mp3", "Artist - Song [9].mp3"}
# Every member of the colliding group is suffixed, so the name a given
# track gets never depends on where it sits in the playlist.
assert {i.dest_name for i in reverse.items} == {
i.dest_name for i in forward.items}
def test_drm_only_skipped_for_web(self, tmp_path):
src = _audio(tmp_path, "locked.m4p")
tracks = [_track(1, "Locked", "A", src, kind="Protected AAC audio file")]
web = plan_export("Mix", tracks, tmp_path / "o1", exporter.WEB)
folder = plan_export("Mix", tracks, tmp_path / "o2", exporter.FOLDER)
assert web.items == [] and len(web.skipped) == 1
# A plain folder is for a real music player, which may well cope.
assert len(folder.items) == 1 and folder.skipped == []
def test_needs_ffmpeg_only_when_something_converts(self, tmp_path):
plain = plan_export(
"Mix", [_track(1, "S", "A", _audio(tmp_path, "x.mp3"))],
tmp_path / "o1", exporter.WEB)
lossless = plan_export(
"Mix", [_track(1, "S", "A", _audio(tmp_path, "y.m4a"),
kind="Apple Lossless audio file")],
tmp_path / "o2", exporter.WEB)
assert not plain.needs_ffmpeg
assert lossless.needs_ffmpeg
class TestFolderExport:
def test_writes_files_and_m3u(self, tmp_path):
tracks = [
_track(1, "Lights Out", "Broadcast", _audio(tmp_path, "a.mp3", b"aa"),
total_time=241_000),
_track(2, "Naomi", "Neutral Milk Hotel", _audio(tmp_path, "b.mp3", b"bbb"),
total_time=180_400),
]
plan = plan_export("Road Trip", tracks, tmp_path / "out", exporter.FOLDER)
out = _run(plan)
assert out["failed"] is None
assert out["finished"]["exported"] == 2
dest = tmp_path / "out" / "Road Trip"
assert (dest / "Broadcast - Lights Out.mp3").read_bytes() == b"aa"
assert (dest / "Neutral Milk Hotel - Naomi.mp3").read_bytes() == b"bbb"
m3u = (dest / "Road Trip.m3u").read_text().splitlines()
assert m3u == [
"#EXTM3U",
"#EXTINF:241,Broadcast - Lights Out",
"Broadcast - Lights Out.mp3",
"#EXTINF:180,Neutral Milk Hotel - Naomi",
"Neutral Milk Hotel - Naomi.mp3",
]
def test_no_web_assets_in_a_folder_export(self, tmp_path):
plan = plan_export(
"Mix", [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))],
tmp_path / "out", exporter.FOLDER)
_run(plan)
dest = tmp_path / "out" / "Mix"
assert not (dest / "index.html").exists()
assert not (dest / "player.js").exists()
def test_source_files_are_never_touched(self, tmp_path):
src = _audio(tmp_path, "a.mp3", b"original")
plan = plan_export("Mix", [_track(1, "S", "A", src)],
tmp_path / "out", exporter.FOLDER)
_run(plan)
assert src.exists() and src.read_bytes() == b"original"
class TestWebExport:
def _export(self, tmp_path, tracks, **details):
plan = plan_export("My Mix", tracks, tmp_path / "out", exporter.WEB)
for key, value in details.items():
setattr(plan, key, value)
out = _run(plan)
assert out["failed"] is None, out["failed"]
return tmp_path / "out" / "My Mix", out
def test_ships_a_self_contained_page(self, tmp_path):
dest, _ = self._export(
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))])
for name in ("index.html", "player.js", "player.css",
"player-graphics.gif", "placeholder.png"):
assert (dest / name).is_file(), f"missing {name}"
assert (dest / "audios" / "A - S.mp3").is_file()
def test_no_jquery_or_audiojs_anywhere(self, tmp_path):
dest, _ = self._export(
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))])
html = (dest / "index.html").read_text()
assert "jquery" not in html.lower()
assert "audio.min.js" not in html
assert ".swf" not in html
assert not list(dest.glob("*.swf"))
# The one remaining script tag is ours.
assert html.count("<script") == 1
assert 'src="./player.js"' in html
def test_player_graphics_ships_byte_identical(self, tmp_path):
# It's an animated GIF (the loading state is a spinner), not a flat
# sprite sheet, so it must not be regenerated or re-encoded.
dest, _ = self._export(
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))])
original = exporter.templates() / "player-graphics.gif"
assert (dest / "player-graphics.gif").read_bytes() == original.read_bytes()
assert b"NETSCAPE" in (dest / "player-graphics.gif").read_bytes()
def test_tracklist_is_in_playlist_order_and_resolves(self, tmp_path):
tracks = [_track(i, f"Song {i}", "A", _audio(tmp_path, f"{i}.mp3"))
for i in (3, 1, 2)]
dest, _ = self._export(tmp_path, tracks)
html = (dest / "index.html").read_text()
import re
srcs = re.findall(r'data-src="([^"]+)"', html)
assert srcs == ["./audios/A - Song 3.mp3",
"./audios/A - Song 1.mp3",
"./audios/A - Song 2.mp3"]
for src in srcs:
assert (dest / src[2:]).is_file(), f"{src} does not exist"
def test_track_names_are_html_escaped(self, tmp_path):
# sanitize_name strips <> from the *filename*; the visible label keeps
# them and must be escaped or the markup breaks.
tracks = [_track(1, "Rock & Roll", "AC<DC>", _audio(tmp_path, "a.mp3"))]
dest, _ = self._export(tmp_path, tracks)
html = (dest / "index.html").read_text()
assert "AC&lt;DC&gt; - Rock &amp; Roll" in html
assert "<DC>" not in html
def test_title_and_description_are_substituted(self, tmp_path):
dest, _ = self._export(
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))],
title="2025", description='hi <a href="http://x">there</a>')
html = (dest / "index.html").read_text()
assert "<title>2025</title>" in html
assert "<h1>2025</h1>" in html
# Deliberately raw: the mixes lean on inline links right here.
assert 'hi <a href="http://x">there</a>' in html
assert "$title" not in html and "$tracklist" not in html
def test_title_is_escaped(self, tmp_path):
dest, _ = self._export(
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))],
title="Me & You")
assert "<h1>Me &amp; You</h1>" in (dest / "index.html").read_text()
def test_placeholder_image_when_none_chosen(self, tmp_path):
dest, _ = self._export(
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))])
assert 'src="placeholder.png"' in (dest / "index.html").read_text()
assert (dest / "placeholder.png").is_file()
def test_chosen_image_is_copied_and_referenced(self, tmp_path):
image = tmp_path / "friend.png"
image.write_bytes(b"\x89PNG\r\n\x1a\n" + b"fake")
dest, _ = self._export(
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))], image=image)
assert (dest / "friend.png").read_bytes() == image.read_bytes()
assert 'src="friend.png"' in (dest / "index.html").read_text()
def test_no_m3u_beside_the_page(self, tmp_path):
"""A web mix is a folder you upload, not one you open in a player."""
dest, _ = self._export(
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))])
assert not list(dest.glob("*.m3u"))
def test_drm_track_is_reported_not_dropped_silently(self, tmp_path):
tracks = [
_track(1, "Fine", "A", _audio(tmp_path, "a.mp3")),
_track(2, "Locked", "A", _audio(tmp_path, "b.m4p")),
]
plan = plan_export("My Mix", tracks, tmp_path / "out", exporter.WEB)
out = _run(plan)
assert out["finished"]["skipped"] == 1
assert plan.skipped[0][0] == "A - Locked"
assert not (tmp_path / "out" / "My Mix" / "audios" / "A - Locked.m4p").exists()
class TestConversion:
def test_alac_becomes_bit_identical_flac(self, tmp_path):
if shutil.which("ffmpeg") is None:
pytest.skip("ffmpeg not available")
src = tmp_path / "local" / "lossless.m4a"
src.parent.mkdir(parents=True, exist_ok=True)
assert subprocess.run(
["ffmpeg", "-loglevel", "error", "-y", "-f", "lavfi",
"-i", "sine=frequency=440:duration=2", "-c:a", "alac", str(src)],
).returncode == 0
tracks = [_track(1, "Lossless", "A", src,
kind="Apple Lossless audio file")]
plan = plan_export("Mix", tracks, tmp_path / "out", exporter.WEB)
assert plan.items[0].dest_name == "A - Lossless.flac"
out = _run(plan)
assert out["failed"] is None, out["failed"]
assert out["finished"]["converted"] == 1
flac = tmp_path / "out" / "Mix" / "audios" / "A - Lossless.flac"
assert flac.is_file()
# The whole point of targeting FLAC: not one sample is lost.
assert _pcm_hash(flac) == _pcm_hash(src)
def test_playable_file_is_copied_byte_for_byte(self, mp3_file, tmp_path):
tracks = [_track(1, "S", "A", mp3_file)]
plan = plan_export("Mix", tracks, tmp_path / "out", exporter.WEB)
assert plan.items[0].convert_to is None
_run(plan)
copied = tmp_path / "out" / "Mix" / "audios" / "A - S.mp3"
assert copied.read_bytes() == mp3_file.read_bytes()
def test_ffmpeg_available_reflects_path(self, monkeypatch):
monkeypatch.setattr(web_support.shutil, "which", lambda _: None)
assert not web_support.ffmpeg_available()
monkeypatch.setattr(web_support.shutil, "which", lambda _: "/usr/bin/ffmpeg")
assert web_support.ffmpeg_available()
def test_missing_ffmpeg_fails_the_export_rather_than_writing_junk(self, tmp_path):
src = _audio(tmp_path, "lossless.m4a")
tracks = [_track(1, "S", "A", src, kind="Apple Lossless audio file")]
plan = plan_export("Mix", tracks, tmp_path / "out", exporter.WEB)
import lintunes.export.web_support as ws
original = ws.subprocess.Popen
def no_ffmpeg(*args, **kwargs):
raise FileNotFoundError("ffmpeg")
ws.subprocess.Popen = no_ffmpeg
try:
out = _run(plan)
finally:
ws.subprocess.Popen = original
assert out["failed"] is not None
assert "ffmpeg" in out["failed"]
# No manifest, so nothing claims the track was exported.
assert not (tmp_path / "out" / "Mix" / "index.html").exists()
class TestCancel:
def _many(self, tmp_path, count=4):
# Files bigger than one CHUNK so progress fires mid-copy.
return [_track(i, f"Song {i}", "A",
_audio(tmp_path, f"{i}.mp3", b"z" * (exporter.CHUNK + 10)))
for i in range(count)]
def test_cancel_stops_early(self, tmp_path):
plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out",
exporter.FOLDER)
out = _run(plan, cancel_on_emit=1)
assert out["cancelled"] is not None
assert out["finished"] is None
assert out["cancelled"]["exported"] < out["cancelled"]["total"]
def test_cancel_writes_no_manifest(self, tmp_path):
plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out",
exporter.WEB)
_run(plan, cancel_on_emit=1)
dest = tmp_path / "out" / "Mix"
assert not (dest / "index.html").exists()
def test_cancel_removes_the_partial_file(self, tmp_path):
plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out",
exporter.FOLDER)
_run(plan, cancel_on_emit=1)
dest = tmp_path / "out" / "Mix"
for path in dest.iterdir():
# Whatever survived is a whole file, never a truncated one.
assert path.stat().st_size == exporter.CHUNK + 10
def test_cancel_before_any_work_writes_nothing(self, tmp_path):
plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out",
exporter.FOLDER)
worker = ExportWorker(plan)
seen = {}
worker.cancelled.connect(lambda d: seen.update(d))
worker.cancel()
worker._run()
assert seen["exported"] == 0
assert not (tmp_path / "out" / "Mix" / "Mix.m3u").exists()
class TestVanishedSource:
def test_file_removed_after_planning_is_reported_not_fatal(self, tmp_path):
gone = _audio(tmp_path, "gone.mp3")
tracks = [_track(1, "Here", "A", _audio(tmp_path, "here.mp3")),
_track(2, "Gone", "A", gone)]
plan = plan_export("Mix", tracks, tmp_path / "out", exporter.FOLDER)
gone.unlink() # Syncthing moved it under us
out = _run(plan)
assert out["failed"] is None
assert out["finished"]["vanished"] == 1
assert out["finished"]["exported"] == 1
# The m3u lists only what is really there.
m3u = (tmp_path / "out" / "Mix" / "Mix.m3u").read_text()
assert "A - Here.mp3" in m3u
assert "A - Gone.mp3" not in m3u
class TestGuiWiring:
"""The menu plumbing, which sync never got a test for.
Export shares the status-bar progress widgets with device sync, so the
interlock between the two is the part worth pinning down.
"""
@pytest.fixture
def window(self, qapp, tmp_path):
from lintunes.gui.main_window import MainWindow
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Playlist
from lintunes.models.playlist import PlaylistType
from lintunes.preferences import Preferences
audio = _audio(tmp_path, "a.mp3")
library = Library(
tracks={1: _track(1, "Song", "Artist", audio)},
playlists={"abcd1234": Playlist(
name="My Mix", persistent_id="abcd1234",
playlist_type=PlaylistType.REGULAR, track_ids=[1])},
)
manager = LibraryManager(library, tmp_path / "data")
win = MainWindow(manager, Preferences(tmp_path / "data"))
yield win
win.close()
def test_export_action_is_in_the_file_menu(self, window):
texts = [a.text() for menu in window.menuBar().actions()
if menu.menu() for a in menu.menu().actions()]
assert "Export Playlist…" in texts
def test_action_needs_a_playlist_in_view(self, window):
window._refresh_export_action()
assert not window._export_action.isEnabled()
window._show_playlist("abcd1234")
window._refresh_export_action()
assert window._export_action.isEnabled()
def test_sidebar_context_menu_signal_reaches_the_window(self, window):
seen = []
window._sidebar.tree.export_requested.disconnect()
window._sidebar.tree.export_requested.connect(seen.append)
window._sidebar.tree.export_requested.emit("abcd1234")
assert seen == ["abcd1234"]
def test_a_running_export_blocks_both_export_and_sync(self, window, tmp_path):
window._show_playlist("abcd1234")
plan = plan_export("My Mix", [window._manager.library.tracks[1]],
tmp_path / "out", exporter.FOLDER)
window._export_worker = ExportWorker(plan, window)
window._export_worker._busy = True
try:
assert window._busy_worker() is window._export_worker
window._refresh_export_action()
assert not window._export_action.isEnabled()
window._refresh_connection_actions()
# The per-playlist Auxio sync was retired in Round 51; the
# andTunes sync is the one a running export must block.
assert not window._andtunes_action.isEnabled()
finally:
window._export_worker._busy = False
def test_export_of_an_empty_playlist_says_so_and_starts_nothing(
self, window, monkeypatch):
from lintunes.gui import main_window as mw
window._manager.library.playlists["abcd1234"].track_ids = []
window._show_playlist("abcd1234")
told = []
monkeypatch.setattr(mw.QMessageBox, "information",
lambda *a, **k: told.append(a[2]))
window._export_playlist("abcd1234")
assert told and "nothing to export" in told[0]
assert window._export_worker is None