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 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