Files
lintunes/tests/test_round37.py
T
travandClaude Opus 5 a61cbaa369 v0.11.1: a web mix stops shipping an .m3u
It was there on the theory that it cost nothing and let the folder double as a
plain music folder. But a web mix is a folder you upload, and a stray playlist
file next to index.html is one more thing to explain to whoever receives it.

plan.m3u_name is now "" for WEB, so the plan doesn't name a file it won't
write, and index.html is simply the last thing written — which is what the
manifest-last invariant always meant for that branch. Folder exports keep
their m3u unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBSM2bFC6UToiEg8BE4dqj
2026-08-22 15:22:01 -04:00

527 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()
assert not window._sync_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