diff --git a/CLAUDE.md b/CLAUDE.md
index 5bfbfe4..23ed4c5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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*
diff --git a/TASKS.md b/TASKS.md
index acff4c0..f89e077 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -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
diff --git a/lintunes/__init__.py b/lintunes/__init__.py
index 6a09e47..87127ca 100644
--- a/lintunes/__init__.py
+++ b/lintunes/__init__.py
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
-__version__ = "0.7.0"
+__version__ = "0.8.0"
diff --git a/lintunes/export/__init__.py b/lintunes/export/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lintunes/export/exporter.py b/lintunes/export/exporter.py
new file mode 100644
index 0000000..e22a791
--- /dev/null
+++ b/lintunes/export/exporter.py
@@ -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 links "
+ "and 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
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 rows for the web mix, in playlist order."""
+ rows = []
+ for item in items:
+ src = "./audios/" + item.dest_name
+ rows.append(
+ f' '
+ f'{html.escape(item.display)} ')
+ 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 in
+ exactly that spot and the dialog says so.
+ """
+ if image_name:
+ image_tag = (f' ')
+ 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")
diff --git a/lintunes/export/templates/index.html.tmpl b/lintunes/export/templates/index.html.tmpl
new file mode 100644
index 0000000..4f0ae7e
--- /dev/null
+++ b/lintunes/export/templates/index.html.tmpl
@@ -0,0 +1,63 @@
+
+
+
+
+ $title
+
+
+
+
+
+
+
+
$title
+
+
+$tracklist
+
+
+
+
+ $description
+
+
+
+$image_tag
+
+
+
diff --git a/lintunes/export/templates/placeholder.png b/lintunes/export/templates/placeholder.png
new file mode 100644
index 0000000..4dffb3e
Binary files /dev/null and b/lintunes/export/templates/placeholder.png differ
diff --git a/lintunes/export/templates/player-graphics.gif b/lintunes/export/templates/player-graphics.gif
new file mode 100755
index 0000000..62166ba
Binary files /dev/null and b/lintunes/export/templates/player-graphics.gif differ
diff --git a/lintunes/export/templates/player.css b/lintunes/export/templates/player.css
new file mode 100644
index 0000000..91ad3e9
--- /dev/null
+++ b/lintunes/export/templates/player.css
@@ -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; }
diff --git a/lintunes/export/templates/player.js b/lintunes/export/templates/player.js
new file mode 100644
index 0000000..a745466
--- /dev/null
+++ b/lintunes/export/templates/player.js
@@ -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 element back when you couldn't style
+ * ``, 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:
+ *
+ * Title — Artist …
+ */
+(function () {
+ "use strict";
+
+ var WRAPPER =
+ '' +
+ '' +
+ '00:00 /00:00
' +
+ '
';
+
+ 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 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();
+ }
+})();
diff --git a/lintunes/export/web_support.py b/lintunes/export/web_support.py
new file mode 100644
index 0000000..7660d8f
--- /dev/null
+++ b/lintunes/export/web_support.py
@@ -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 ```` 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 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 ""))
diff --git a/lintunes/gui/export_dialog.py b/lintunes/gui/export_dialog.py
new file mode 100644
index 0000000..27d48de
--- /dev/null
+++ b/lintunes/gui/export_dialog.py
@@ -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 <br> 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,
+ }
diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py
index bbe221b..8ac1fdc 100644
--- a/lintunes/gui/main_window.py
+++ b/lintunes/gui/main_window.py
@@ -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):
diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py
index 19c52c4..4921963 100644
--- a/lintunes/gui/sidebar.py
+++ b/lintunes/gui/sidebar.py
@@ -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:
diff --git a/setup.py b/setup.py
index 4084abd..e417847 100644
--- a/setup.py
+++ b/setup.py
@@ -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",
diff --git a/tasks-done.md b/tasks-done.md
index a1bb311..a69985d 100644
--- a/tasks-done.md
+++ b/tasks-done.md
@@ -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
diff --git a/tests/test_round37.py b/tests/test_round37.py
new file mode 100644
index 0000000..3b21a81
--- /dev/null
+++ b/tests/test_round37.py
@@ -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("