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
+20
View File
@@ -121,6 +121,26 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
plus an Auxio-importable `.m3u`); it never deletes outside that folder and
only ever *reads* local library files.
- **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's
right-click menu). A sibling of `device_sync`, reusing its filename helpers
and `build_m3u`: pure `plan_export()` first, then `ExportWorker` on a daemon
thread. Two destinations — a **folder** (files as `Artist - Title.ext` plus an
`.m3u`) or a **web mix** (`index.html` + `audios/` + hero image, from
`templates/`). **The manifest is written last**, so an interrupted export
never leaves a page or m3u naming files that aren't there. `web_support.py`
is the format gate, shaped like `cast/support.py`: deny-by-default on the
suffix with the iTunes `kind` breaking the `.m4a` tie. **Bitrate never
triggers a conversion** — only unplayability does — and every conversion
targets **FLAC**, so it can't cost a bit; DRM'd tracks are reported, never
attempted. ffmpeg is the *CLI binary* here (not Qt's ffmpeg backend), so it's
detected at runtime and its absence is offered as "export without them".
The templates ship a ~180-line dependency-free `player.js`/`player.css` that
replaced audio.js + jQuery (abandoned since 2012; jQuery was only ever glue —
audio.js never used it). `player-graphics.gif` must ship **byte-identical**:
it's an animated GIF whose loading frame is a spinner, not a flat sprite
sheet. Registered in `setup.py` via `package_data`; loaded with
`importlib.resources` so an installed copy finds it.
- **`lintunes/cast/`** — Chromecast playback (Connections menu), using the
**media-receiver model**: `server.py` runs a `ThreadingHTTPServer` on an
ephemeral port for the life of a session and the device fetches the *original*
+5
View File
@@ -9,6 +9,11 @@ When a round closes, move its finished items to `tasks-done.md`.
- [ ] archive the done tasks in here to another file, this is crufty....
## Round 37 (2026-08-19) — Export Playlist: done, see tasks-done.md
Folder + web-mix export, audio.js/jQuery dropped for a dependency-free
player, lossless-only conversion. Round 36 below is still open.
## Round 36 — the merge rework (planned, Round 35 covered the speed half)
The three symptoms in Round 35 shared a root cause; that round fixed the
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.7.0"
__version__ = "0.8.0"
View File
+355
View File
@@ -0,0 +1,355 @@
"""Export a playlist to a folder of files, or to a self-contained web mix.
A sibling of ``device_sync``: same shape (pure planning function, then a
worker on a daemon thread reporting through Qt signals), same filename
helpers, same m3u writer. The differences are that the destination is an
ordinary directory rather than an MTP mount, and that the web variant may
have to transcode a track the browser can't decode (see ``web_support``).
Two invariants worth keeping:
* **Local music files are only ever read.** Like sync, this never moves,
rewrites or deletes anything in the library.
* **The manifest is written last** — ``index.html`` for a web mix, the
``.m3u`` for a folder. An interrupted export therefore never leaves behind
a playlist promising files that aren't there.
"""
import html
import os
import shutil
import string
import threading
from dataclasses import dataclass, field
from importlib import resources
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
from ..device_sync import (CHUNK, build_m3u, sanitize_name, track_display,
track_filename)
from . import web_support
FOLDER = "folder"
WEB = "web"
# Copied into a web mix alongside the audio. player-graphics.gif has to ship
# byte-identical: it is an animated GIF (the "loading" state is a spinner),
# not a flat sprite sheet.
_WEB_ASSETS = ("player.js", "player.css", "player-graphics.gif")
DEFAULT_DESCRIPTION = (
"Write something about this mix here — this paragraph is placeholder "
"text, and it's plain HTML, so <a href=\"https://example.com\">links</a> "
"and <br> work."
)
def templates():
"""The template directory, working from a checkout or an installed wheel."""
return resources.files(__package__) / "templates"
@dataclass
class ExportItem:
track_id: int
src: Path
dest_name: str # sanitized "Artist - Title.ext"
size: int
display: str # "Artist - Title", for the m3u and progress
secs: int
convert_to: str | None = None # None = byte copy; "flac" = transcode
@dataclass
class ExportPlan:
kind: str # FOLDER | WEB
playlist_name: str
dest_dir: Path # the folder that gets created
audio_dir: Path # dest_dir, or dest_dir/"audios" for a web mix
m3u_name: str
title: str = ""
description: str = ""
image: Path | None = None # None = ship the gray placeholder
items: list = field(default_factory=list)
skipped: list = field(default_factory=list) # (display, reason)
bytes_to_copy: int = 0
@property
def conversions(self) -> list:
return [i for i in self.items if i.convert_to]
@property
def needs_ffmpeg(self) -> bool:
return bool(self.conversions)
def plan_export(playlist_name: str, tracks: list, dest_parent: Path,
kind: str = FOLDER) -> ExportPlan:
"""Work out what an export would write. Pure — creates nothing.
`tracks` is the playlist's tracks in order; `dest_parent` is the folder
the user picked, inside which a folder named after the playlist is made.
"""
folder = sanitize_name(playlist_name)
dest_dir = Path(dest_parent) / folder
plan = ExportPlan(
kind=kind,
playlist_name=playlist_name,
dest_dir=dest_dir,
audio_dir=dest_dir / "audios" if kind == WEB else dest_dir,
m3u_name=folder + ".m3u",
title=playlist_name,
description=DEFAULT_DESCRIPTION,
)
items: dict[int, ExportItem] = {}
order: list[int] = []
seen_skipped: set[int] = set()
for track in tracks:
if track.track_id in items:
order.append(track.track_id) # a repeat of a track we have
continue
if track.track_id in seen_skipped:
continue
display = track_display(track)
src = Path(track.location) if track.location else None
if src is None or not src.is_file():
seen_skipped.add(track.track_id)
plan.skipped.append((display, "its file is missing"))
continue
convert_to = None
if kind == WEB:
convert_to, reason = web_support.conversion_for(
track.location, track.kind or "")
if reason:
seen_skipped.add(track.track_id)
plan.skipped.append((display, reason))
continue
dest_name = track_filename(track)
if convert_to:
dest_name = os.path.splitext(dest_name)[0] + "." + convert_to
items[track.track_id] = ExportItem(
track_id=track.track_id, src=src, dest_name=dest_name,
size=src.stat().st_size, display=display,
secs=round(track.total_time / 1000), convert_to=convert_to)
order.append(track.track_id)
# Disambiguate name collisions with a stable [track_id] suffix on every
# member of a colliding group, so a name never depends on playlist order.
by_name: dict[str, list[ExportItem]] = {}
for item in items.values():
by_name.setdefault(item.dest_name, []).append(item)
for group in by_name.values():
if len(group) > 1:
for item in group:
stem, ext = os.path.splitext(item.dest_name)
item.dest_name = f"{stem} [{item.track_id}]{ext}"
# Playlist order, duplicates collapsed to their first appearance: a web
# page can't list the same <li> twice and an m3u repeat is just noise.
emitted: set[int] = set()
for tid in order:
if tid in emitted:
continue
emitted.add(tid)
plan.items.append(items[tid])
plan.bytes_to_copy = sum(i.size for i in plan.items)
return plan
def build_tracklist(items: list) -> str:
"""The <ol> rows for the web mix, in playlist order."""
rows = []
for item in items:
src = "./audios/" + item.dest_name
rows.append(
f' <li><a href="#" data-src="{html.escape(src, quote=True)}">'
f'{html.escape(item.display)}</a></li>')
return "\n".join(rows)
def build_index_html(plan: ExportPlan, image_name: str | None) -> str:
"""Render index.html for `plan`.
The title and track names are escaped; the description deliberately is
**not**, because the hand-made mixes lean on inline links and <br> in
exactly that spot and the dialog says so.
"""
if image_name:
image_tag = (f' <img src="{html.escape(image_name, quote=True)}"'
f' width="400"><br>')
else:
image_tag = ""
tmpl = string.Template((templates() / "index.html.tmpl").read_text(encoding="utf-8"))
return tmpl.substitute(
title=html.escape(plan.title or plan.playlist_name),
description=plan.description,
tracklist=build_tracklist(plan.items),
image_tag=image_tag,
)
class _Cancelled(Exception):
"""Internal: unwinds _run when cancel() was requested."""
class ExportWorker(QObject):
"""Runs an ExportPlan on a daemon thread, reporting through signals.
Progress is emitted in KiB — a pyqtSignal(int) is a C int, and a mix of
lossless tracks overflows a byte count past 2 GiB. Threading matches the
rest of the codebase: a plain daemon thread, not a QThread.
Cancelling removes the in-flight partial file and skips the manifest
entirely, so a cancelled export leaves an obviously-incomplete folder
rather than a page that half works.
"""
progress = pyqtSignal(int, int, str) # done_kib, total_kib, label
finished = pyqtSignal(dict)
cancelled = pyqtSignal(dict)
failed = pyqtSignal(str)
def __init__(self, plan: ExportPlan, parent=None):
super().__init__(parent)
self._plan = plan
self._busy = False
self._cancel = threading.Event()
def busy(self) -> bool:
return self._busy
def cancel(self):
self._cancel.set()
def _check_cancel(self):
if self._cancel.is_set():
raise _Cancelled
def start(self):
if self._busy:
return
self._busy = True
threading.Thread(target=self._run_guarded, daemon=True).start()
def _run_guarded(self):
try:
self._run()
finally:
self._busy = False
def _run(self):
plan = self._plan
dest = None
completed: list[ExportItem] = []
vanished: set[int] = set()
converted = 0
try:
self._check_cancel()
plan.audio_dir.mkdir(parents=True, exist_ok=True)
total_kib = max(plan.bytes_to_copy // 1024, 1)
done = 0
for i, item in enumerate(plan.items, start=1):
self._check_cancel()
label = f"{i}/{len(plan.items)} {item.display}"
dest = plan.audio_dir / item.dest_name
if item.convert_to:
# ffmpeg reports no usable byte progress, so the bar jumps
# a whole track at a time here. Conversions are rare.
self.progress.emit(done // 1024, total_kib, label)
if not item.src.is_file():
vanished.add(item.track_id)
done += item.size
dest = None
continue
web_support.convert_to_flac(item.src, dest, self._cancel)
self._check_cancel()
converted += 1
done += item.size
self.progress.emit(done // 1024, total_kib, label)
else:
# A source gone since planning means Syncthing moved it
# under us — skip it, don't abort the whole export. Once
# open the fd pins the inode, so the read stays coherent.
try:
fsrc = open(item.src, "rb")
except FileNotFoundError:
vanished.add(item.track_id)
done += item.size
self.progress.emit(done // 1024, total_kib, label)
dest = None
continue
with fsrc, open(dest, "wb") as fdst:
while True:
chunk = fsrc.read(CHUNK)
if not chunk:
break
fdst.write(chunk)
done += len(chunk)
self.progress.emit(done // 1024, total_kib, label)
self._check_cancel()
try:
shutil.copystat(item.src, dest, follow_symlinks=True)
except OSError:
pass # mtime is a nicety; some filesystems refuse it
dest = None
completed.append(item)
self._check_cancel()
self._write_manifest(completed)
self.finished.emit({
"playlist": plan.playlist_name,
"kind": plan.kind,
"dest": str(plan.dest_dir),
"exported": len(completed),
"converted": converted,
"skipped": len(plan.skipped),
"vanished": len(vanished),
})
except _Cancelled:
if dest is not None:
try:
dest.unlink() # the mid-flight partial
except OSError:
pass
self.cancelled.emit({
"playlist": plan.playlist_name,
"dest": str(plan.dest_dir),
"exported": len(completed),
"total": len(plan.items),
})
except OSError as e:
if dest is not None:
try:
dest.unlink()
except OSError:
pass
self.failed.emit(f"Export failed: {e}")
def _write_manifest(self, completed: list):
"""The .m3u, plus the page and its assets for a web mix. Written last."""
plan = self._plan
entries = [(item.dest_name, item.secs, item.display) for item in completed]
if plan.kind == WEB:
for name in _WEB_ASSETS:
shutil.copyfile(templates() / name, plan.dest_dir / name)
image_name = None
if plan.image is not None and Path(plan.image).is_file():
image_name = sanitize_name(Path(plan.image).stem) \
+ Path(plan.image).suffix.lower()
shutil.copyfile(plan.image, plan.dest_dir / image_name)
else:
image_name = "placeholder.png"
shutil.copyfile(templates() / "placeholder.png",
plan.dest_dir / image_name)
# An m3u alongside the page costs nothing and lets the folder
# double as a plain music folder.
entries = [("audios/" + n, s, d) for n, s, d in entries]
(plan.dest_dir / "index.html").write_text(
build_index_html(plan, image_name), encoding="utf-8")
(plan.dest_dir / plan.m3u_name).write_text(
build_m3u(entries), encoding="utf-8")
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>$title</title>
<meta content="width=device-width, initial-scale=0.6" name="viewport">
<style>
body { color: #666; font-family: sans-serif; line-height: 1.4; font-size: .9em;}
h1 { color: #444; font-size: 1.2em; padding: 14px 2px 12px; margin: 0px; }
h1 em { font-style: normal; color: #999; }
a { color: #220d02; text-decoration: none; }
a:hover { color: white; background-color:#8c764a;}
* {
box-sizing: border-box;
}
.box{
float: right;
padding: 40px;
width:50%;
}
ol { padding: 0px; margin: 0px; list-style: decimal-leading-zero inside; color: #ccc; width: 400px; border-top: 1px solid #ccc; font-size: 0.9em; }
ol li { position: relative; margin: 0px; padding: 1px 2px 1px; border-bottom: 1px solid #ccc; cursor: pointer; }
ol li a { display: block; text-indent: -3.3ex; padding: 0px 0px 0px 20px; }
ol li a:hover { display: block; text-indent: -3.3ex; padding: 0px 0px 0px 20px; color: white; background-color:#8c764a;}
li.playing { color: #aaa; }
li.playing a { color: #000; }
li.playing:before { content: "\131b8"; width: 14px; height: 14px; padding: 3px; line-height: 14px; margin: 0px; position: absolute; left: -40px; top: 9px; color: #000; font-size: 2em; }
.notes { padding-left: 2em; }
.credit { font-style: italic; }
</style>
<link rel="stylesheet" href="./player.css">
<script src="./player.js" defer></script>
</head>
<body>
<div class="box">
<h1>$title</h1>
<audio preload></audio>
<ol>
$tracklist
</ol>
</div>
<div class="box">
$description
<!-- Per-track liner notes go here. The old mixes used a numbered list
matching the tracklist, e.g.:
<p class="notes">
1. Wrote to the guy who made this and he put it on Bandcamp for me!
<br><br>
3. Huge thanks to Pam for the recommendation.
</p>
-->
$image_tag
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+127
View File
@@ -0,0 +1,127 @@
/* The mix player skin.
*
* Lifted verbatim from the customized CSS string inside the audio.js build
* that shipped with trav's hand-made mixes, so an exported page looks exactly
* like the ones already online. Three things were dropped in the transcription
* because no browser ever applied them:
*
* - `border: 1px solid #ggg` — #ggg is not a color, so the declaration was
* always discarded as a parse error.
* - `background-image: -webkit-gradient(45deg, cyan, purple), color-stop(…)`
* — malformed even for the long-dead prefixed syntax.
* - `background-image: -moz-linear-gradient(…)` — Firefox removed the
* prefixed form years ago.
*
* What actually rendered was the flat #c7b563 bar underneath, which is what
* this file states plainly. The state classes are scoped to `.audiojs`
* (upstream wrote them bare) because the tracklist uses `li.playing` too, and
* a bare `.playing .pause` rule is a collision waiting to happen.
*/
.audiojs audio { position: absolute; left: -1px; }
.audiojs {
width: 250px;
height: 36px;
background: #c7b563;
overflow: hidden;
font-family: monospace;
font-size: 12px;
box-shadow: 1px 1px 8px rgba(240, 240, 240, 0.3);
}
.audiojs .play-pause {
width: 40px;
height: 40px;
padding: 4px 6px;
margin: 0px;
float: left;
overflow: hidden;
}
.audiojs p { display: none; width: 25px; height: 40px; margin: 0px; cursor: pointer; }
.audiojs .scrubber {
position: relative;
float: left;
width: 75px;
background: #5a5a5a;
height: 14px;
margin: 10px;
border-top: 1px solid #3f3f3f;
border-left: 0px;
border-bottom: 0px;
overflow: hidden;
cursor: pointer;
}
.audiojs .progress {
position: absolute; top: 0px; left: 0px;
height: 14px; width: 0px;
background: #ccc;
z-index: 1;
}
.audiojs .loaded {
position: absolute; top: 0px; left: 0px;
height: 14px; width: 0px;
background: #c7b563;
}
.audiojs .time {
float: left;
height: 36px;
line-height: 36px;
margin: 0px 0px 0px 6px;
padding: 0px 6px 0px 12px;
color: #ddd;
}
.audiojs .time em { padding: 0px 2px 0px 0px; color: #666666; font-style: normal; }
.audiojs .time strong { padding: 0px 0px 0px 2px; font-weight: normal; }
.audiojs .error-message {
float: left;
display: none;
margin: 0px 10px;
height: 36px;
width: 400px;
overflow: hidden;
line-height: 36px;
white-space: nowrap;
color: #fff;
text-overflow: ellipsis;
}
.audiojs .error-message a {
color: #eee;
text-decoration: none;
padding-bottom: 1px;
border-bottom: 1px solid #999;
white-space: normal;
}
/* One animated GIF, four 30x30 states stacked vertically. The `loading`
* frame is the animated one — that spinner is why this is a GIF and not a
* flat sprite sheet, so the file has to ship byte-identical. */
.audiojs .play { background: url("./player-graphics.gif") -2px -1px no-repeat; display: block; }
.audiojs .loading { background: url("./player-graphics.gif") -2px -31px no-repeat; }
.audiojs .error { background: url("./player-graphics.gif") -2px -61px no-repeat; }
.audiojs .pause { background: url("./player-graphics.gif") -2px -91px no-repeat; }
.audiojs.playing .play,
.audiojs.playing .loading,
.audiojs.playing .error { display: none; }
.audiojs.playing .pause { display: block; }
.audiojs.loading .play,
.audiojs.loading .pause,
.audiojs.loading .error { display: none; }
.audiojs.loading .loading { display: block; }
.audiojs.error .time,
.audiojs.error .play,
.audiojs.error .pause,
.audiojs.error .scrubber,
.audiojs.error .loading { display: none; }
.audiojs.error .error { display: block; }
.audiojs.error .play-pause p { cursor: auto; }
.audiojs.error .error-message { display: block; }
+186
View File
@@ -0,0 +1,186 @@
/* Mix player — no dependencies.
*
* Replaces the audio.js + jQuery pair the hand-made mixes used to ship. That
* stack existed to skin an <audio> element back when you couldn't style
* `<audio controls>`, and to fall back to Flash where HTML5 audio was
* missing. The Flash branch has been unreachable since roughly 2010 (audio.js
* gated it on `!canPlayType("audio/mpeg;")`, which no current browser
* answers falsely), and everything else here is standard DOM.
*
* The generated wrapper markup and the class names match audio.js exactly, so
* player.css is a straight transcription of the old skin.
*
* Expected page markup:
* <audio preload></audio>
* <ol><li><a href="#" data-src="./audios/x.mp3">Title — Artist</a></li>…</ol>
*/
(function () {
"use strict";
var WRAPPER =
'<div class="play-pause">' +
'<p class="play"></p><p class="pause"></p>' +
'<p class="loading"></p><p class="error"></p>' +
'</div>' +
'<div class="scrubber"><div class="progress"></div><div class="loaded"></div></div>' +
'<div class="time"><em class="played">00:00</em>/<strong class="duration">00:00</strong></div>' +
'<div class="error-message"></div>';
function clock(seconds) {
if (!isFinite(seconds) || seconds < 0) seconds = 0;
var m = Math.floor(seconds / 60), s = Math.floor(seconds % 60);
return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
}
function build(audio) {
var wrap = document.createElement("div");
wrap.className = "audiojs";
audio.parentNode.insertBefore(wrap, audio);
wrap.appendChild(audio);
// insertAdjacentHTML rather than innerHTML: appending would re-parse and
// replace the <audio> node we just moved in, losing its state.
wrap.insertAdjacentHTML("beforeend", WRAPPER);
var ui = {
wrap: wrap,
progress: wrap.querySelector(".progress"),
loaded: wrap.querySelector(".loaded"),
scrubber: wrap.querySelector(".scrubber"),
played: wrap.querySelector(".played"),
duration: wrap.querySelector(".duration"),
error: wrap.querySelector(".error-message")
};
function state(name) {
wrap.classList.remove("playing", "loading", "error");
if (name) wrap.classList.add(name);
}
function start() {
// play() rejects when a fresh load() interrupts it — which is just
// what holding an arrow key down does. Not an error worth reporting.
var p = audio.play();
if (p && p.catch) p.catch(function () {});
}
wrap.querySelector(".play-pause").addEventListener("click", function () {
if (wrap.classList.contains("error")) return;
if (audio.paused) start(); else audio.pause();
});
ui.scrubber.addEventListener("click", function (e) {
if (!isFinite(audio.duration)) return;
var box = ui.scrubber.getBoundingClientRect();
audio.currentTime = ((e.clientX - box.left) / box.width) * audio.duration;
});
audio.addEventListener("loadstart", function () { state("loading"); });
audio.addEventListener("play", function () { state("playing"); });
audio.addEventListener("pause", function () { state(null); });
audio.addEventListener("error", function () {
state("error");
ui.error.textContent = 'Error loading: "' +
(audio.currentSrc || audio.src || "") + '"';
});
audio.addEventListener("loadedmetadata", function () {
ui.duration.textContent = clock(audio.duration);
if (!audio.paused) state("playing"); else state(null);
});
audio.addEventListener("timeupdate", function () {
ui.played.textContent = clock(audio.currentTime);
if (isFinite(audio.duration) && audio.duration > 0) {
ui.progress.style.width =
(audio.currentTime / audio.duration) * 100 + "%";
}
});
function paintLoaded() {
// `buffered` is a list of ranges; the bar shows the leading one, as
// audio.js did. Driven from four events rather than `progress` alone
// because a local or well-cached file can finish buffering before the
// first `progress` ever fires, leaving the bar stuck at zero.
if (audio.buffered.length && isFinite(audio.duration) && audio.duration > 0) {
ui.loaded.style.width =
(audio.buffered.end(audio.buffered.length - 1) / audio.duration) * 100 + "%";
}
}
["progress", "loadeddata", "canplay", "timeupdate"].forEach(function (name) {
audio.addEventListener(name, paintLoaded);
});
return {
element: audio,
load: function (src) {
ui.progress.style.width = "0%";
ui.loaded.style.width = "0%";
ui.played.textContent = "00:00";
ui.duration.textContent = "00:00";
audio.src = src;
audio.load();
},
play: start,
playPause: function () {
if (audio.paused) start(); else audio.pause();
}
};
}
function init() {
var audio = document.querySelector("audio");
var items = Array.prototype.slice.call(document.querySelectorAll("ol li"));
if (!audio || !items.length) return;
var player = build(audio);
function select(li, autoplay) {
items.forEach(function (other) { other.classList.remove("playing"); });
li.classList.add("playing");
player.load(li.querySelector("a").getAttribute("data-src"));
if (autoplay) player.play();
}
items.forEach(function (li) {
li.addEventListener("click", function (e) {
e.preventDefault();
select(li, true);
});
});
// Advance to the next track, stopping at the end of the mix — the old
// page had the wrap-around commented out, so this matches it.
audio.addEventListener("ended", function () {
var current = document.querySelector("ol li.playing");
var next = current && current.nextElementSibling;
if (next) select(next, true);
});
document.addEventListener("keydown", function (e) {
var target = e.target || {};
var tag = (target.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || target.isContentEditable) return;
var current = document.querySelector("ol li.playing");
if (e.key === "ArrowRight") {
e.preventDefault();
select((current && current.nextElementSibling) || items[0], true);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
select((current && current.previousElementSibling) || items[items.length - 1], true);
} else if (e.key === " " || e.key === "Spacebar") {
e.preventDefault();
player.playPause();
}
});
// Cue the first track without playing it, as the original page did.
select(items[0], false);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
+111
View File
@@ -0,0 +1,111 @@
"""What a browser can play, and how to fix what it can't.
The gate for the web-mix export, modelled on ``lintunes/cast/support.py``:
deny-by-default on the suffix, with the iTunes ``kind`` string breaking the
tie inside the ``.m4a`` container (which holds plain AAC, Apple Lossless and
FairPlay-protected audio alike — the suffix alone can't tell them apart).
Two rules carry the whole design:
* **Bitrate is never a conversion trigger.** A 320 kbps MP3 is copied
byte-for-byte, because every browser plays it. Only *unplayable* formats
are touched.
* **Every conversion targets FLAC**, so a conversion can never cost a single
bit. ALAC and AIFF are both lossless already, so FLAC round-trips them
exactly (and AIFF, being uncompressed PCM, actually gets smaller). For the
rare lossy oddity FLAC is wasteful in bytes but still incapable of adding
generation loss, which is the property worth protecting.
FLAC in ``<audio>`` is native in Chrome 56+, Firefox 51+ and Safari 11+.
"""
import shutil
import subprocess
from pathlib import Path
# Audio containers every current browser decodes from an <audio> element.
_BROWSER_OK = {
".mp3", ".mpga", ".m4a", ".m4b", ".aac", ".wav",
".flac", ".ogg", ".oga", ".opus", ".webm",
}
# iTunes ``kind`` strings for things living in an .m4a that a browser can't
# decode. "Purchased AAC audio file" is deliberately absent: those are
# DRM-free iTunes Plus downloads and play fine.
_CONVERT_KINDS = {"Apple Lossless audio file"}
_REFUSED_KINDS = {"Protected AAC audio file": "it's copy-protected AAC"}
# Suffixes that are hopeless regardless of kind. FairPlay can't be decrypted,
# so there is nothing to convert — the track is reported, never attempted.
_REFUSED_SUFFIXES = {".m4p": "it's copy-protected AAC"}
def conversion_for(location: str, kind: str = "") -> tuple[str | None, str | None]:
"""How to get `location` onto a web page.
Returns ``(target_ext, refusal_reason)``:
* ``(None, None)`` — already playable, copy it verbatim.
* ``("flac", None)`` — needs a lossless transcode.
* ``(None, reason)`` — can't be exported at all; `reason` is phrased to
follow "skipped X because …".
"""
if not location:
return None, "it has no file"
if kind in _REFUSED_KINDS:
return None, _REFUSED_KINDS[kind]
suffix = Path(location).suffix.lower()
if suffix in _REFUSED_SUFFIXES:
return None, _REFUSED_SUFFIXES[suffix]
# Checked before _BROWSER_OK: ALAC wears a perfectly playable .m4a suffix.
if kind in _CONVERT_KINDS:
return "flac", None
if suffix in _BROWSER_OK:
return None, None
return "flac", None
def ffmpeg_available() -> bool:
"""Whether the ffmpeg *binary* is on PATH.
Separate from Qt Multimedia's ffmpeg backend, which is a shared library:
a machine can play audio in the app and still have no ``ffmpeg`` command.
"""
return shutil.which("ffmpeg") is not None
def convert_to_flac(src: Path, dest: Path, cancel=None) -> None:
"""Transcode `src` to FLAC at `dest`. Raises OSError on failure.
`-vn` drops the cover art: mutagen re-attaches tags downstream if we ever
want them, and a video stream in a FLAC container trips some decoders.
`-nostdin` stops ffmpeg from eating the terminal's stdin when lintunes is
launched from a shell.
"""
cmd = [
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-i", str(src), "-vn", "-c:a", "flac", "-compression_level", "5",
str(dest),
]
try:
proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE)
except (OSError, ValueError) as e:
raise OSError(f"couldn't run ffmpeg: {e}") from e
while True:
try:
_, err = proc.communicate(timeout=0.25)
break
except subprocess.TimeoutExpired:
if cancel is not None and cancel.is_set():
proc.kill()
proc.communicate()
dest.unlink(missing_ok=True)
return
if proc.returncode != 0:
dest.unlink(missing_ok=True)
detail = (err or b"").decode("utf-8", "replace").strip().splitlines()
raise OSError(f"ffmpeg failed on {src.name}"
+ (f": {detail[-1]}" if detail else ""))
+129
View File
@@ -0,0 +1,129 @@
"""The two dialogs behind File → Export Playlist…
`ExportKindDialog` asks folder-or-website; `WebMixDialog` collects the bits of
a web mix that can't be inferred — title, blurb, hero image. Per-track liner
notes are deliberately absent: the hand-made mixes carry a numbered paragraph
per track, which is far too much to type into a form, so the generated
index.html leaves a commented-out block in the right place instead.
"""
from pathlib import Path
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QHBoxLayout, QLabel,
QLineEdit, QPlainTextEdit, QPushButton, QRadioButton, QVBoxLayout)
from lintunes.export import exporter
_IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.gif *.webp)"
class ExportKindDialog(QDialog):
"""Folder of music files, or a self-contained web mix."""
def __init__(self, playlist_name: str, parent=None):
super().__init__(parent)
self.setWindowTitle("Export Playlist")
self.setMinimumWidth(420)
layout = QVBoxLayout(self)
layout.addWidget(QLabel(f"Export “{playlist_name}” as:"))
self._folder = QRadioButton("A folder of music files")
self._folder.setChecked(True)
layout.addWidget(self._folder)
hint = QLabel("The audio files, named “Artist - Title”, plus an .m3u "
"playlist. Files are copied exactly as they are.")
hint.setWordWrap(True)
hint.setIndent(24)
hint.setEnabled(False)
layout.addWidget(hint)
self._web = QRadioButton("A website")
layout.addWidget(self._web)
web_hint = QLabel(
"A self-contained page with a player and the tracklist, ready to "
"copy into a web directory. Anything a browser can't play is "
"converted to FLAC, losslessly.")
web_hint.setWordWrap(True)
web_hint.setIndent(24)
web_hint.setEnabled(False)
layout.addWidget(web_hint)
layout.addSpacing(8)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Continue")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def kind(self) -> str:
return exporter.WEB if self._web.isChecked() else exporter.FOLDER
class WebMixDialog(QDialog):
"""Title, description and hero image for a web mix."""
def __init__(self, playlist_name: str, parent=None):
super().__init__(parent)
self.setWindowTitle("Export Web Mix")
self.setMinimumWidth(520)
layout = QVBoxLayout(self)
form = QFormLayout()
form.setFieldGrowthPolicy(
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
self._title = QLineEdit(playlist_name)
form.addRow("Title:", self._title)
self._description = QPlainTextEdit()
self._description.setPlainText(exporter.DEFAULT_DESCRIPTION)
self._description.setMinimumHeight(140)
form.addRow("Description:", self._description)
image_row = QHBoxLayout()
self._image = QLineEdit()
self._image.setPlaceholderText("(a gray placeholder)")
self._image.setReadOnly(True)
image_row.addWidget(self._image, stretch=1)
browse = QPushButton("Browse…")
browse.clicked.connect(self._choose_image)
image_row.addWidget(browse)
clear = QPushButton("Clear")
clear.clicked.connect(lambda: self._image.clear())
image_row.addWidget(clear)
form.addRow("Image:", image_row)
layout.addLayout(form)
note = QLabel(
"The description is plain HTML — links and &lt;br&gt; work. "
"Per-track liner notes aren't asked for here; index.html has a "
"commented-out block in the right spot for them.")
note.setWordWrap(True)
note.setTextFormat(Qt.TextFormat.RichText)
note.setEnabled(False)
layout.addWidget(note)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Export")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _choose_image(self):
start = str(Path(self._image.text()).parent) if self._image.text() else ""
name, _filter = QFileDialog.getOpenFileName(
self, "Choose Image", start, _IMAGE_FILTER)
if name:
self._image.setText(name)
def values(self) -> dict:
text = self._image.text().strip()
return {
"title": self._title.text().strip(),
"description": self._description.toPlainText().strip(),
"image": Path(text) if text else None,
}
+196 -2
View File
@@ -13,11 +13,13 @@ from lintunes import device_sync, mpris, tagging, theme
from lintunes.art_search import AlbumArtFetcher
from lintunes.cast.controller import CastController
from lintunes.eventlog import log_control
from lintunes.export import exporter
from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor
from lintunes.player import Player
from lintunes.importers import file_importer
from lintunes.gui.album_art_dialog import AlbumArtDialog
from lintunes.gui.cast_dialog import ChromecastDialog
from lintunes.gui.export_dialog import ExportKindDialog, WebMixDialog
from lintunes.gui.sidebar import SidebarPanel
from lintunes.gui.library_view import LibraryView
from lintunes.gui.playlist_view import PlaylistView
@@ -152,17 +154,24 @@ class MainWindow(QMainWindow):
self.statusBar().addPermanentWidget(self._sync_progress)
self._sync_worker = None
self._sync_device_name = ""
# Export shares the status-bar progress widgets with device sync —
# only one of the two may run at a time (each guards on the other).
self._export_worker = None
# Separate instance from the playback inhibitor: pausing music
# mid-sync must not drop the sync's hold. Logout flag included so
# GNOME's shutdown/restart dialog names the transfer as the blocker.
self._sync_inhibitor = SleepInhibitor(
reason="Syncing a playlist to a device",
flags=INHIBIT_LOGOUT | INHIBIT_SUSPEND)
self._export_inhibitor = SleepInhibitor(
reason="Exporting a playlist",
flags=INHIBIT_LOGOUT | INHIBIT_SUSPEND)
# Wiring
self._transport.play_clicked.connect(self.play_pause)
self._sidebar.library_selected.connect(self._show_library)
self._sidebar.playlist_selected.connect(self._show_playlist)
self._sidebar.tree.export_requested.connect(self._export_playlist)
self._content.currentChanged.connect(self._update_totals)
for view in (self._library_view, self._playlist_view):
view.play_requested.connect(
@@ -270,6 +279,9 @@ class MainWindow(QMainWindow):
file_menu.addSeparator()
self._add_action(file_menu, "Add Files to Library…", "Ctrl+O",
self._open_files_dialog)
self._export_action = self._add_action(
file_menu, "Export Playlist…", "", self._export_playlist)
file_menu.aboutToShow.connect(self._refresh_export_action)
file_menu.addSeparator()
self._add_action(file_menu, "Quit", "Ctrl+Q", self.close)
@@ -339,11 +351,13 @@ class MainWindow(QMainWindow):
# ---- connections: device sync ----
def _refresh_connection_actions(self):
syncing = self._sync_worker is not None and self._sync_worker.busy()
# An export in flight owns the same status-bar widgets, so it blocks
# a sync just as another sync would.
busy = self._busy_worker() is not None
on_playlist = (self._content.currentWidget() is self._playlist_view
and bool(self._playlist_view.playlist_id))
self._sync_action.setEnabled(
not syncing and on_playlist
not busy and on_playlist
and device_sync.find_device() is not None)
if self._cast.is_connected():
@@ -439,7 +453,17 @@ class MainWindow(QMainWindow):
self._sync_label.hide()
self._sync_progress.hide()
def _busy_worker(self):
"""The transfer currently owning the status-bar progress widgets."""
for worker in (self._sync_worker, self._export_worker):
if worker is not None and worker.busy():
return worker
return None
def _confirm_cancel_sync(self):
if self._export_worker is not None and self._export_worker.busy():
self._confirm_cancel_export()
return
if self._sync_worker is None or not self._sync_worker.busy():
return
box = QMessageBox(self)
@@ -491,6 +515,176 @@ class MainWindow(QMainWindow):
self._hide_sync_widgets()
QMessageBox.warning(self, "Sync failed", message)
# ---- export ----
def _refresh_export_action(self):
"""Export needs a playlist in view and no transfer already running."""
self._export_action.setEnabled(
self._busy_worker() is None
and self._content.currentWidget() is self._playlist_view
and bool(self._playlist_view.playlist_id))
def _export_playlist(self, pid: str = ""):
# Reachable from the File menu (no argument) and from the sidebar
# context menu (which names the playlist that was right-clicked).
if self._busy_worker() is not None:
return
pid = pid or self._playlist_view.playlist_id
playlist = self._manager.library.playlists.get(pid)
if playlist is None:
return
tracks = [self._manager.library.tracks[tid]
for tid in playlist.track_ids
if tid in self._manager.library.tracks]
if not tracks:
QMessageBox.information(
self, "Export Playlist",
f"{playlist.name}” is empty — there is nothing to export.")
return
kind_dialog = ExportKindDialog(playlist.name, self)
if kind_dialog.exec() != ExportKindDialog.DialogCode.Accepted:
return
kind = kind_dialog.kind()
details = None
if kind == exporter.WEB:
web_dialog = WebMixDialog(playlist.name, self)
if web_dialog.exec() != WebMixDialog.DialogCode.Accepted:
return
details = web_dialog.values()
parent_dir = QFileDialog.getExistingDirectory(
self, "Export To", str(self._music_import_dir().parent))
if not parent_dir:
return
try:
plan = exporter.plan_export(
playlist.name, tracks, Path(parent_dir), kind)
except OSError as e:
QMessageBox.warning(self, "Export failed",
f"Couldn't read the playlist's files: {e}")
return
if details is not None:
plan.title = details["title"] or playlist.name
plan.description = details["description"]
plan.image = details["image"]
if not self._confirm_export(plan):
return
self._export_worker = exporter.ExportWorker(plan, self)
self._export_worker.progress.connect(self._on_export_progress)
self._export_worker.finished.connect(self._on_export_finished)
self._export_worker.cancelled.connect(self._on_export_cancelled)
self._export_worker.failed.connect(self._on_export_failed)
self._sync_label.setText("Exporting")
self._sync_progress.setRange(0, max(plan.bytes_to_copy // 1024, 1))
self._sync_progress.setValue(0)
self._sync_cancel.show()
self._sync_label.show()
self._sync_progress.show()
self._export_inhibitor.inhibit()
self._export_worker.start()
def _confirm_export(self, plan) -> bool:
"""Last look before writing: overwrite, ffmpeg, and skipped tracks."""
if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available():
names = "\n".join(f"{i.display}" for i in plan.conversions[:8])
more = (f"\n … and {len(plan.conversions) - 8} more"
if len(plan.conversions) > 8 else "")
answer = QMessageBox.question(
self, "FFmpeg not found",
f"{len(plan.conversions)} track(s) need converting before a "
f"browser can play them, but the ffmpeg command isn't "
f"installed:\n\n{names}{more}\n\nExport without them?")
if answer != QMessageBox.StandardButton.Yes:
return False
plan.skipped += [(i.display, "it needs ffmpeg to convert")
for i in plan.conversions]
plan.items = [i for i in plan.items if not i.convert_to]
plan.bytes_to_copy = sum(i.size for i in plan.items)
if not plan.items:
QMessageBox.information(
self, "Export Playlist",
"Nothing left to export once those are left out.")
return False
if plan.skipped:
names = "\n".join(f"{d}{why}" for d, why in plan.skipped[:8])
more = (f"\n … and {len(plan.skipped) - 8} more"
if len(plan.skipped) > 8 else "")
answer = QMessageBox.question(
self, "Some tracks can't be exported",
f"{len(plan.skipped)} of {len(plan.skipped) + len(plan.items)} "
f"track(s) will be left out:\n\n{names}{more}\n\nExport the "
f"other {len(plan.items)}?")
if answer != QMessageBox.StandardButton.Yes:
return False
if plan.dest_dir.exists():
answer = QMessageBox.question(
self, "Folder exists",
f"{plan.dest_dir.name}” already exists in that folder. "
f"Files with the same names will be overwritten.\n\nContinue?")
if answer != QMessageBox.StandardButton.Yes:
return False
return True
def _confirm_cancel_export(self):
if self._export_worker is None or not self._export_worker.busy():
return
box = QMessageBox(self)
box.setWindowTitle("Cancel export")
box.setText("Cancel the export?\n"
"The folder is left as-is, without a playlist file — "
"exporting again starts it over.")
cancel_button = box.addButton(
"Cancel Export", QMessageBox.ButtonRole.DestructiveRole)
keep = box.addButton("Keep Exporting", QMessageBox.ButtonRole.RejectRole)
box.setDefaultButton(keep)
box.exec()
# The export keeps running while the dialog is up — it may well have
# finished by now, in which case there is nothing to cancel.
if (box.clickedButton() is cancel_button
and self._export_worker is not None):
self._export_worker.cancel()
def _on_export_progress(self, done_kib, total_kib, label):
self._sync_progress.setRange(0, total_kib)
self._sync_progress.setValue(done_kib)
self._sync_label.setText(
f"Exporting · {device_sync.format_bytes(done_kib * 1024)} / "
f"{device_sync.format_bytes(total_kib * 1024)}")
def _on_export_finished(self, summary):
self._export_inhibitor.release()
self._hide_sync_widgets()
what = "web mix" if summary["kind"] == exporter.WEB else "folder"
msg = (f"Exported “{summary['playlist']}” as a {what}: "
f"{summary['exported']} tracks → {summary['dest']}")
if summary["converted"]:
msg += f", {summary['converted']} converted to FLAC"
if summary["skipped"]:
msg += f", {summary['skipped']} skipped"
if summary["vanished"]:
msg += (f", {summary['vanished']} changed under us "
"(export again to pick them up)")
self.statusBar().showMessage(msg, 12000)
def _on_export_cancelled(self, summary):
self._export_inhibitor.release()
self._hide_sync_widgets()
self.statusBar().showMessage(
f"Export cancelled — {summary['exported']} of {summary['total']} "
f"tracks written to {summary['dest']}.", 8000)
def _on_export_failed(self, message):
self._export_inhibitor.release()
self._hide_sync_widgets()
QMessageBox.warning(self, "Export failed", message)
# ---- view switching ----
def _show_library(self):
+7 -1
View File
@@ -152,6 +152,7 @@ class PlaylistTree(QTreeWidget):
the tree rebuilds on its playlists_changed signal."""
playlist_selected = pyqtSignal(str) # persistent_id
export_requested = pyqtSignal(str) # persistent_id
def __init__(self, manager, parent=None):
super().__init__(parent)
@@ -348,13 +349,16 @@ class PlaylistTree(QTreeWidget):
new_playlist = menu.addAction("New Playlist")
new_smart = menu.addAction("New Smart Playlist")
new_folder = menu.addAction("New Playlist Folder")
rename = delete = edit_smart = None
rename = delete = edit_smart = export = None
if kind in ("playlist", "folder", "smart"):
menu.addSeparator()
if kind == "smart":
edit_smart = menu.addAction("Edit Smart Playlist…")
rename = menu.addAction("Rename")
delete = menu.addAction("Delete")
if kind in ("playlist", "smart"):
menu.addSeparator()
export = menu.addAction("Export Playlist…")
chosen = menu.exec(self.viewport().mapToGlobal(pos))
if chosen is None:
@@ -370,6 +374,8 @@ class PlaylistTree(QTreeWidget):
text="untitled folder")
if ok and name.strip():
self._manager.create_folder(name.strip(), parent_pid)
elif export is not None and chosen is export:
self.export_requested.emit(pid)
elif rename is not None and chosen is rename:
self.editItem(item, 0)
elif delete is not None and chosen is delete:
+3
View File
@@ -12,6 +12,9 @@ setup(
name="lintunes",
version=_version,
packages=find_packages(),
# The web-mix templates (player.js/css, the player sprite GIF and
# the placeholder image) must ride along in an installed copy.
package_data={"lintunes.export": ["templates/*"]},
install_requires=[
"PyQt6>=6.8.0", # QAudioBufferOutput (visualizer) needs Qt 6.8+
"mutagen>=1.46",
+53
View File
@@ -1,5 +1,58 @@
## Done
### Round 37 (2026-08-19) — Export a playlist: a folder, or a whole website (v0.8.0)
`File → Export Playlist…`, and the same item on a playlist's right-click menu.
Two destinations behind one planner, built as a sibling of `device_sync` (same
shape: a pure `plan_export`, then a worker on a daemon thread reporting through
signals, sharing the status-bar progress widgets and the cancel button).
- [x] **Folder export** — the audio files under `Artist - Title.ext` names next
to an extended `.m3u`. No `.pls`; nothing reads it any more. Files are
copied byte-for-byte, never re-encoded.
- [x] **Web mix export** — a self-contained static site reproducing the
hand-made yearly mixes: `index.html`, `audios/`, a hero image, and a
dialog for title / description / image. The description is deliberately
passed through raw (the mixes lean on inline links right there); the title
and track labels are escaped. Per-track liner notes stay hand-edited —
`index.html` carries a commented-out block in the right spot.
- [x] **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 — not exploitable on a
static page, since nothing untrusted ever reaches a jQuery HTML sink, but
dead weight either way). The decisive detail: **audio.js never used
jQuery** — jQuery was there for ~25 lines of tracklist glue. Replaced by a
~180-line dependency-free `player.js` + a `player.css` transcribed from
the customized audio.js skin, so the page looks the same: same 250px
`#c7b563` bar, same `player-graphics.gif` (shipped byte-identical — it's
an *animated* GIF, the loading state is a spinner), same click-to-play,
autoplay-next and ←/→/space shortcuts. **~293 KB → ~6 KB.** The Flash
fallback went with it; audio.js gated it on `!canPlayType("audio/mpeg;")`,
unreachable in any browser since ~2010.
- [x] **Conversion only when a browser genuinely can't play it, and never lossy.**
`export/web_support.py` is a deny-by-default gate modelled on
`cast/support.py`, using the iTunes `kind` string to disambiguate `.m4a`
(plain AAC / Apple Lossless / FairPlay all wear that suffix). Bitrate is
**not** a trigger — 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 is 105 Apple Lossless + 8 AIFF out
of 21,382 tracks. DRM'd tracks (6 Protected AAC) are reported in a
confirmation dialog, never silently dropped and never attempted.
- [x] **ffmpeg is detected, not assumed** — it's the CLI binary, which is a
different thing from Qt Multimedia's ffmpeg backend. Missing, the export
offers to go ahead without the affected tracks.
- [x] **The manifest is written last.** A cancelled or interrupted export leaves
no `index.html` and no `.m3u`, so a half-finished folder never advertises
files that aren't there. Cancel also removes the in-flight partial file.
- [x] Verified in **Chromium 151 and Firefox 153** driven over CDP/Marionette:
all four exported formats decode (including the converted FLAC), the
player builds, the gold bar and sprite render, click / space / ←→ /
scrubber-seek / autoplay-next all work, and the network log shows no
request for jquery, audio.min.js or any `.swf`.
- [x] `mix-example/` removed — it was the reference for the template and the
template now reproduces it.
### Round 35 (2026-08-19) — Startup speed: the 21k-track library stops freezing (v0.7.0)
Reported as three separate complaints — "startup takes a minute or two and GNOME
+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