From afadf31a994c8d19bb27f165cd414d3f931494f2 Mon Sep 17 00:00:00 2001 From: trav Date: Wed, 19 Aug 2026 19:03:01 -0400 Subject: [PATCH] =?UTF-8?q?v0.8.0:=20export=20a=20playlist=20=E2=80=94=20a?= =?UTF-8?q?s=20a=20folder,=20or=20as=20a=20whole=20website?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 20 + TASKS.md | 5 + lintunes/__init__.py | 2 +- lintunes/export/__init__.py | 0 lintunes/export/exporter.py | 355 ++++++++++++ lintunes/export/templates/index.html.tmpl | 63 +++ lintunes/export/templates/placeholder.png | Bin 0 -> 712 bytes lintunes/export/templates/player-graphics.gif | Bin 0 -> 7878 bytes lintunes/export/templates/player.css | 127 +++++ lintunes/export/templates/player.js | 186 +++++++ lintunes/export/web_support.py | 111 ++++ lintunes/gui/export_dialog.py | 129 +++++ lintunes/gui/main_window.py | 198 ++++++- lintunes/gui/sidebar.py | 8 +- setup.py | 3 + tasks-done.md | 53 ++ tests/test_round37.py | 525 ++++++++++++++++++ 17 files changed, 1781 insertions(+), 4 deletions(-) create mode 100644 lintunes/export/__init__.py create mode 100644 lintunes/export/exporter.py create mode 100644 lintunes/export/templates/index.html.tmpl create mode 100644 lintunes/export/templates/placeholder.png create mode 100755 lintunes/export/templates/player-graphics.gif create mode 100644 lintunes/export/templates/player.css create mode 100644 lintunes/export/templates/player.js create mode 100644 lintunes/export/web_support.py create mode 100644 lintunes/gui/export_dialog.py create mode 100644 tests/test_round37.py 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'
    1. ' + f'{html.escape(item.display)}
    2. ') + 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 0000000000000000000000000000000000000000..4dffb3e15b8ffa07d1f21dd7a667921b2d1ea74a GIT binary patch literal 712 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 h>`6)%JW0a5CrMuLB=LXQlYlVvQLSV literal 0 HcmV?d00001 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