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.
This commit is contained in:
2026-08-19 19:03:01 -04:00
parent e69917a290
commit 76b941bf68
17 changed files with 1781 additions and 4 deletions
+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 <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,
}
+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: