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
527 lines
23 KiB
Python
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<DC> - Rock & 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 & 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
|