v0.8.0: export a playlist — as a folder, or as a whole website

File → Export Playlist…, and the same item on a playlist's right-click
menu. A sibling of device_sync: pure plan_export() first, then a worker
on a daemon thread sharing the status-bar progress widgets. The manifest
(index.html / .m3u) is written last, so an interrupted export never
leaves a page naming files that aren't there.

Two destinations. A folder gives the audio under "Artist - Title" names
plus an extended .m3u. A web mix gives a self-contained static site
reproducing the hand-made yearly mixes, with a dialog for title,
description and hero image.

audio.js and jQuery are gone. The old pages shipped ~293 KB: a build of
audio.js whose upstream hasn't moved since 2012, plus jQuery 3.2.1
(CVE-2019-11358, CVE-2020-11022, CVE-2020-11023 — unexploitable on a
static page, since nothing untrusted reaches a jQuery HTML sink, but
dead weight regardless). audio.js never used jQuery; jQuery was there
for ~25 lines of tracklist glue. Both are replaced by a dependency-free
player.js plus a player.css transcribed from the customized audio.js
skin, so the page looks identical — same 250px #c7b563 bar, same
player-graphics.gif (byte-identical: it's an animated GIF whose loading
frame is a spinner), same shortcuts. ~293 KB → ~6 KB. The Flash
fallback went too; audio.js gated it on !canPlayType("audio/mpeg;"),
unreachable since ~2010.

Conversion happens only when a browser genuinely can't decode a file,
never because of bitrate — a 320 kbps MP3 is copied verbatim. Everything
that does convert targets FLAC, so a conversion cannot cost a bit; a
test asserts the exported FLAC's decoded PCM hashes identical to the
ALAC source. Against the real library that's 105 Apple Lossless and 8
AIFF out of 21,382 tracks. DRM'd tracks are reported in a confirmation
dialog, never silently dropped and never attempted. ffmpeg is the CLI
binary here, not Qt's ffmpeg backend, so it's detected at runtime.

Verified in Chromium 151 and Firefox 153 over CDP/Marionette: every
exported format decodes including the converted FLAC, the player builds,
click / space / arrows / scrubber-seek / autoplay-next all work, and the
network log shows no request for jquery, audio.min.js or any .swf.

mix-example/ removed; the template reproduces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:03:01 -04:00
co-authored by Claude Opus 5
parent 211cb50a9e
commit afadf31a99
17 changed files with 1781 additions and 4 deletions
+525
View File
@@ -0,0 +1,525 @@
"""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.
"""
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", "My Mix.m3u"):
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_m3u_paths_point_into_audios(self, tmp_path):
dest, _ = self._export(
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))])
assert "audios/A - S.mp3" in (dest / "My Mix.m3u").read_text()
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()
assert not (dest / "Mix.m3u").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