v0.8.0: export a playlist — as a folder, or as a whole website
File → Export Playlist…, and the same item on a playlist's right-click
menu. A sibling of device_sync: pure plan_export() first, then a worker
on a daemon thread sharing the status-bar progress widgets. The manifest
(index.html / .m3u) is written last, so an interrupted export never
leaves a page naming files that aren't there.
Two destinations. A folder gives the audio under "Artist - Title" names
plus an extended .m3u. A web mix gives a self-contained static site
reproducing the hand-made yearly mixes, with a dialog for title,
description and hero image.
audio.js and jQuery are gone. The old pages shipped ~293 KB: a build of
audio.js whose upstream hasn't moved since 2012, plus jQuery 3.2.1
(CVE-2019-11358, CVE-2020-11022, CVE-2020-11023 — unexploitable on a
static page, since nothing untrusted reaches a jQuery HTML sink, but
dead weight regardless). audio.js never used jQuery; jQuery was there
for ~25 lines of tracklist glue. Both are replaced by a dependency-free
player.js plus a player.css transcribed from the customized audio.js
skin, so the page looks identical — same 250px #c7b563 bar, same
player-graphics.gif (byte-identical: it's an animated GIF whose loading
frame is a spinner), same shortcuts. ~293 KB → ~6 KB. The Flash
fallback went too; audio.js gated it on !canPlayType("audio/mpeg;"),
unreachable since ~2010.
Conversion happens only when a browser genuinely can't decode a file,
never because of bitrate — a 320 kbps MP3 is copied verbatim. Everything
that does convert targets FLAC, so a conversion cannot cost a bit; a
test asserts the exported FLAC's decoded PCM hashes identical to the
ALAC source. Against the real library that's 105 Apple Lossless and 8
AIFF out of 21,382 tracks. DRM'd tracks are reported in a confirmation
dialog, never silently dropped and never attempted. ffmpeg is the CLI
binary here, not Qt's ffmpeg backend, so it's detected at runtime.
Verified in Chromium 151 and Firefox 153 over CDP/Marionette: every
exported format decodes including the converted FLAC, the player builds,
click / space / arrows / scrubber-seek / autoplay-next all work, and the
network log shows no request for jquery, audio.min.js or any .swf.
mix-example/ removed; the template reproduces it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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*
|
||||
|
||||
@@ -9,6 +9,11 @@ When a round closes, move its finished items to `tasks-done.md`.
|
||||
|
||||
- [ ] archive the done tasks in here to another file, this is crufty....
|
||||
|
||||
## Round 37 (2026-08-19) — Export Playlist: done, see tasks-done.md
|
||||
|
||||
Folder + web-mix export, audio.js/jQuery dropped for a dependency-free
|
||||
player, lossless-only conversion. Round 36 below is still open.
|
||||
|
||||
## Round 36 — the merge rework (planned, Round 35 covered the speed half)
|
||||
|
||||
The three symptoms in Round 35 shared a root cause; that round fixed the
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.7.0"
|
||||
__version__ = "0.8.0"
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Export a playlist to a folder of files, or to a self-contained web mix.
|
||||
|
||||
A sibling of ``device_sync``: same shape (pure planning function, then a
|
||||
worker on a daemon thread reporting through Qt signals), same filename
|
||||
helpers, same m3u writer. The differences are that the destination is an
|
||||
ordinary directory rather than an MTP mount, and that the web variant may
|
||||
have to transcode a track the browser can't decode (see ``web_support``).
|
||||
|
||||
Two invariants worth keeping:
|
||||
|
||||
* **Local music files are only ever read.** Like sync, this never moves,
|
||||
rewrites or deletes anything in the library.
|
||||
* **The manifest is written last** — ``index.html`` for a web mix, the
|
||||
``.m3u`` for a folder. An interrupted export therefore never leaves behind
|
||||
a playlist promising files that aren't there.
|
||||
"""
|
||||
|
||||
import html
|
||||
import os
|
||||
import shutil
|
||||
import string
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
from ..device_sync import (CHUNK, build_m3u, sanitize_name, track_display,
|
||||
track_filename)
|
||||
from . import web_support
|
||||
|
||||
FOLDER = "folder"
|
||||
WEB = "web"
|
||||
|
||||
# Copied into a web mix alongside the audio. player-graphics.gif has to ship
|
||||
# byte-identical: it is an animated GIF (the "loading" state is a spinner),
|
||||
# not a flat sprite sheet.
|
||||
_WEB_ASSETS = ("player.js", "player.css", "player-graphics.gif")
|
||||
|
||||
DEFAULT_DESCRIPTION = (
|
||||
"Write something about this mix here — this paragraph is placeholder "
|
||||
"text, and it's plain HTML, so <a href=\"https://example.com\">links</a> "
|
||||
"and <br> work."
|
||||
)
|
||||
|
||||
|
||||
def templates():
|
||||
"""The template directory, working from a checkout or an installed wheel."""
|
||||
return resources.files(__package__) / "templates"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportItem:
|
||||
track_id: int
|
||||
src: Path
|
||||
dest_name: str # sanitized "Artist - Title.ext"
|
||||
size: int
|
||||
display: str # "Artist - Title", for the m3u and progress
|
||||
secs: int
|
||||
convert_to: str | None = None # None = byte copy; "flac" = transcode
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportPlan:
|
||||
kind: str # FOLDER | WEB
|
||||
playlist_name: str
|
||||
dest_dir: Path # the folder that gets created
|
||||
audio_dir: Path # dest_dir, or dest_dir/"audios" for a web mix
|
||||
m3u_name: str
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
image: Path | None = None # None = ship the gray placeholder
|
||||
items: list = field(default_factory=list)
|
||||
skipped: list = field(default_factory=list) # (display, reason)
|
||||
bytes_to_copy: int = 0
|
||||
|
||||
@property
|
||||
def conversions(self) -> list:
|
||||
return [i for i in self.items if i.convert_to]
|
||||
|
||||
@property
|
||||
def needs_ffmpeg(self) -> bool:
|
||||
return bool(self.conversions)
|
||||
|
||||
|
||||
def plan_export(playlist_name: str, tracks: list, dest_parent: Path,
|
||||
kind: str = FOLDER) -> ExportPlan:
|
||||
"""Work out what an export would write. Pure — creates nothing.
|
||||
|
||||
`tracks` is the playlist's tracks in order; `dest_parent` is the folder
|
||||
the user picked, inside which a folder named after the playlist is made.
|
||||
"""
|
||||
folder = sanitize_name(playlist_name)
|
||||
dest_dir = Path(dest_parent) / folder
|
||||
plan = ExportPlan(
|
||||
kind=kind,
|
||||
playlist_name=playlist_name,
|
||||
dest_dir=dest_dir,
|
||||
audio_dir=dest_dir / "audios" if kind == WEB else dest_dir,
|
||||
m3u_name=folder + ".m3u",
|
||||
title=playlist_name,
|
||||
description=DEFAULT_DESCRIPTION,
|
||||
)
|
||||
|
||||
items: dict[int, ExportItem] = {}
|
||||
order: list[int] = []
|
||||
seen_skipped: set[int] = set()
|
||||
for track in tracks:
|
||||
if track.track_id in items:
|
||||
order.append(track.track_id) # a repeat of a track we have
|
||||
continue
|
||||
if track.track_id in seen_skipped:
|
||||
continue
|
||||
display = track_display(track)
|
||||
src = Path(track.location) if track.location else None
|
||||
if src is None or not src.is_file():
|
||||
seen_skipped.add(track.track_id)
|
||||
plan.skipped.append((display, "its file is missing"))
|
||||
continue
|
||||
convert_to = None
|
||||
if kind == WEB:
|
||||
convert_to, reason = web_support.conversion_for(
|
||||
track.location, track.kind or "")
|
||||
if reason:
|
||||
seen_skipped.add(track.track_id)
|
||||
plan.skipped.append((display, reason))
|
||||
continue
|
||||
dest_name = track_filename(track)
|
||||
if convert_to:
|
||||
dest_name = os.path.splitext(dest_name)[0] + "." + convert_to
|
||||
items[track.track_id] = ExportItem(
|
||||
track_id=track.track_id, src=src, dest_name=dest_name,
|
||||
size=src.stat().st_size, display=display,
|
||||
secs=round(track.total_time / 1000), convert_to=convert_to)
|
||||
order.append(track.track_id)
|
||||
|
||||
# Disambiguate name collisions with a stable [track_id] suffix on every
|
||||
# member of a colliding group, so a name never depends on playlist order.
|
||||
by_name: dict[str, list[ExportItem]] = {}
|
||||
for item in items.values():
|
||||
by_name.setdefault(item.dest_name, []).append(item)
|
||||
for group in by_name.values():
|
||||
if len(group) > 1:
|
||||
for item in group:
|
||||
stem, ext = os.path.splitext(item.dest_name)
|
||||
item.dest_name = f"{stem} [{item.track_id}]{ext}"
|
||||
|
||||
# Playlist order, duplicates collapsed to their first appearance: a web
|
||||
# page can't list the same <li> twice and an m3u repeat is just noise.
|
||||
emitted: set[int] = set()
|
||||
for tid in order:
|
||||
if tid in emitted:
|
||||
continue
|
||||
emitted.add(tid)
|
||||
plan.items.append(items[tid])
|
||||
plan.bytes_to_copy = sum(i.size for i in plan.items)
|
||||
return plan
|
||||
|
||||
|
||||
def build_tracklist(items: list) -> str:
|
||||
"""The <ol> rows for the web mix, in playlist order."""
|
||||
rows = []
|
||||
for item in items:
|
||||
src = "./audios/" + item.dest_name
|
||||
rows.append(
|
||||
f' <li><a href="#" data-src="{html.escape(src, quote=True)}">'
|
||||
f'{html.escape(item.display)}</a></li>')
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def build_index_html(plan: ExportPlan, image_name: str | None) -> str:
|
||||
"""Render index.html for `plan`.
|
||||
|
||||
The title and track names are escaped; the description deliberately is
|
||||
**not**, because the hand-made mixes lean on inline links and <br> in
|
||||
exactly that spot and the dialog says so.
|
||||
"""
|
||||
if image_name:
|
||||
image_tag = (f' <img src="{html.escape(image_name, quote=True)}"'
|
||||
f' width="400"><br>')
|
||||
else:
|
||||
image_tag = ""
|
||||
tmpl = string.Template((templates() / "index.html.tmpl").read_text(encoding="utf-8"))
|
||||
return tmpl.substitute(
|
||||
title=html.escape(plan.title or plan.playlist_name),
|
||||
description=plan.description,
|
||||
tracklist=build_tracklist(plan.items),
|
||||
image_tag=image_tag,
|
||||
)
|
||||
|
||||
|
||||
class _Cancelled(Exception):
|
||||
"""Internal: unwinds _run when cancel() was requested."""
|
||||
|
||||
|
||||
class ExportWorker(QObject):
|
||||
"""Runs an ExportPlan on a daemon thread, reporting through signals.
|
||||
|
||||
Progress is emitted in KiB — a pyqtSignal(int) is a C int, and a mix of
|
||||
lossless tracks overflows a byte count past 2 GiB. Threading matches the
|
||||
rest of the codebase: a plain daemon thread, not a QThread.
|
||||
|
||||
Cancelling removes the in-flight partial file and skips the manifest
|
||||
entirely, so a cancelled export leaves an obviously-incomplete folder
|
||||
rather than a page that half works.
|
||||
"""
|
||||
|
||||
progress = pyqtSignal(int, int, str) # done_kib, total_kib, label
|
||||
finished = pyqtSignal(dict)
|
||||
cancelled = pyqtSignal(dict)
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, plan: ExportPlan, parent=None):
|
||||
super().__init__(parent)
|
||||
self._plan = plan
|
||||
self._busy = False
|
||||
self._cancel = threading.Event()
|
||||
|
||||
def busy(self) -> bool:
|
||||
return self._busy
|
||||
|
||||
def cancel(self):
|
||||
self._cancel.set()
|
||||
|
||||
def _check_cancel(self):
|
||||
if self._cancel.is_set():
|
||||
raise _Cancelled
|
||||
|
||||
def start(self):
|
||||
if self._busy:
|
||||
return
|
||||
self._busy = True
|
||||
threading.Thread(target=self._run_guarded, daemon=True).start()
|
||||
|
||||
def _run_guarded(self):
|
||||
try:
|
||||
self._run()
|
||||
finally:
|
||||
self._busy = False
|
||||
|
||||
def _run(self):
|
||||
plan = self._plan
|
||||
dest = None
|
||||
completed: list[ExportItem] = []
|
||||
vanished: set[int] = set()
|
||||
converted = 0
|
||||
try:
|
||||
self._check_cancel()
|
||||
plan.audio_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
total_kib = max(plan.bytes_to_copy // 1024, 1)
|
||||
done = 0
|
||||
for i, item in enumerate(plan.items, start=1):
|
||||
self._check_cancel()
|
||||
label = f"{i}/{len(plan.items)} {item.display}"
|
||||
dest = plan.audio_dir / item.dest_name
|
||||
if item.convert_to:
|
||||
# ffmpeg reports no usable byte progress, so the bar jumps
|
||||
# a whole track at a time here. Conversions are rare.
|
||||
self.progress.emit(done // 1024, total_kib, label)
|
||||
if not item.src.is_file():
|
||||
vanished.add(item.track_id)
|
||||
done += item.size
|
||||
dest = None
|
||||
continue
|
||||
web_support.convert_to_flac(item.src, dest, self._cancel)
|
||||
self._check_cancel()
|
||||
converted += 1
|
||||
done += item.size
|
||||
self.progress.emit(done // 1024, total_kib, label)
|
||||
else:
|
||||
# A source gone since planning means Syncthing moved it
|
||||
# under us — skip it, don't abort the whole export. Once
|
||||
# open the fd pins the inode, so the read stays coherent.
|
||||
try:
|
||||
fsrc = open(item.src, "rb")
|
||||
except FileNotFoundError:
|
||||
vanished.add(item.track_id)
|
||||
done += item.size
|
||||
self.progress.emit(done // 1024, total_kib, label)
|
||||
dest = None
|
||||
continue
|
||||
with fsrc, open(dest, "wb") as fdst:
|
||||
while True:
|
||||
chunk = fsrc.read(CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
fdst.write(chunk)
|
||||
done += len(chunk)
|
||||
self.progress.emit(done // 1024, total_kib, label)
|
||||
self._check_cancel()
|
||||
try:
|
||||
shutil.copystat(item.src, dest, follow_symlinks=True)
|
||||
except OSError:
|
||||
pass # mtime is a nicety; some filesystems refuse it
|
||||
dest = None
|
||||
completed.append(item)
|
||||
|
||||
self._check_cancel()
|
||||
self._write_manifest(completed)
|
||||
self.finished.emit({
|
||||
"playlist": plan.playlist_name,
|
||||
"kind": plan.kind,
|
||||
"dest": str(plan.dest_dir),
|
||||
"exported": len(completed),
|
||||
"converted": converted,
|
||||
"skipped": len(plan.skipped),
|
||||
"vanished": len(vanished),
|
||||
})
|
||||
except _Cancelled:
|
||||
if dest is not None:
|
||||
try:
|
||||
dest.unlink() # the mid-flight partial
|
||||
except OSError:
|
||||
pass
|
||||
self.cancelled.emit({
|
||||
"playlist": plan.playlist_name,
|
||||
"dest": str(plan.dest_dir),
|
||||
"exported": len(completed),
|
||||
"total": len(plan.items),
|
||||
})
|
||||
except OSError as e:
|
||||
if dest is not None:
|
||||
try:
|
||||
dest.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
self.failed.emit(f"Export failed: {e}")
|
||||
|
||||
def _write_manifest(self, completed: list):
|
||||
"""The .m3u, plus the page and its assets for a web mix. Written last."""
|
||||
plan = self._plan
|
||||
entries = [(item.dest_name, item.secs, item.display) for item in completed]
|
||||
|
||||
if plan.kind == WEB:
|
||||
for name in _WEB_ASSETS:
|
||||
shutil.copyfile(templates() / name, plan.dest_dir / name)
|
||||
image_name = None
|
||||
if plan.image is not None and Path(plan.image).is_file():
|
||||
image_name = sanitize_name(Path(plan.image).stem) \
|
||||
+ Path(plan.image).suffix.lower()
|
||||
shutil.copyfile(plan.image, plan.dest_dir / image_name)
|
||||
else:
|
||||
image_name = "placeholder.png"
|
||||
shutil.copyfile(templates() / "placeholder.png",
|
||||
plan.dest_dir / image_name)
|
||||
# An m3u alongside the page costs nothing and lets the folder
|
||||
# double as a plain music folder.
|
||||
entries = [("audios/" + n, s, d) for n, s, d in entries]
|
||||
(plan.dest_dir / "index.html").write_text(
|
||||
build_index_html(plan, image_name), encoding="utf-8")
|
||||
|
||||
(plan.dest_dir / plan.m3u_name).write_text(
|
||||
build_m3u(entries), encoding="utf-8")
|
||||
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>$title</title>
|
||||
<meta content="width=device-width, initial-scale=0.6" name="viewport">
|
||||
<style>
|
||||
body { color: #666; font-family: sans-serif; line-height: 1.4; font-size: .9em;}
|
||||
h1 { color: #444; font-size: 1.2em; padding: 14px 2px 12px; margin: 0px; }
|
||||
h1 em { font-style: normal; color: #999; }
|
||||
a { color: #220d02; text-decoration: none; }
|
||||
a:hover { color: white; background-color:#8c764a;}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.box{
|
||||
float: right;
|
||||
padding: 40px;
|
||||
width:50%;
|
||||
}
|
||||
|
||||
ol { padding: 0px; margin: 0px; list-style: decimal-leading-zero inside; color: #ccc; width: 400px; border-top: 1px solid #ccc; font-size: 0.9em; }
|
||||
ol li { position: relative; margin: 0px; padding: 1px 2px 1px; border-bottom: 1px solid #ccc; cursor: pointer; }
|
||||
ol li a { display: block; text-indent: -3.3ex; padding: 0px 0px 0px 20px; }
|
||||
ol li a:hover { display: block; text-indent: -3.3ex; padding: 0px 0px 0px 20px; color: white; background-color:#8c764a;}
|
||||
li.playing { color: #aaa; }
|
||||
li.playing a { color: #000; }
|
||||
li.playing:before { content: "\131b8"; width: 14px; height: 14px; padding: 3px; line-height: 14px; margin: 0px; position: absolute; left: -40px; top: 9px; color: #000; font-size: 2em; }
|
||||
|
||||
.notes { padding-left: 2em; }
|
||||
.credit { font-style: italic; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="./player.css">
|
||||
<script src="./player.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>$title</h1>
|
||||
<audio preload></audio>
|
||||
<ol>
|
||||
$tracklist
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
$description
|
||||
|
||||
<!-- Per-track liner notes go here. The old mixes used a numbered list
|
||||
matching the tracklist, e.g.:
|
||||
|
||||
<p class="notes">
|
||||
1. Wrote to the guy who made this and he put it on Bandcamp for me!
|
||||
<br><br>
|
||||
3. Huge thanks to Pam for the recommendation.
|
||||
</p>
|
||||
-->
|
||||
|
||||
$image_tag
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 712 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
@@ -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; }
|
||||
@@ -0,0 +1,186 @@
|
||||
/* Mix player — no dependencies.
|
||||
*
|
||||
* Replaces the audio.js + jQuery pair the hand-made mixes used to ship. That
|
||||
* stack existed to skin an <audio> element back when you couldn't style
|
||||
* `<audio controls>`, and to fall back to Flash where HTML5 audio was
|
||||
* missing. The Flash branch has been unreachable since roughly 2010 (audio.js
|
||||
* gated it on `!canPlayType("audio/mpeg;")`, which no current browser
|
||||
* answers falsely), and everything else here is standard DOM.
|
||||
*
|
||||
* The generated wrapper markup and the class names match audio.js exactly, so
|
||||
* player.css is a straight transcription of the old skin.
|
||||
*
|
||||
* Expected page markup:
|
||||
* <audio preload></audio>
|
||||
* <ol><li><a href="#" data-src="./audios/x.mp3">Title — Artist</a></li>…</ol>
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var WRAPPER =
|
||||
'<div class="play-pause">' +
|
||||
'<p class="play"></p><p class="pause"></p>' +
|
||||
'<p class="loading"></p><p class="error"></p>' +
|
||||
'</div>' +
|
||||
'<div class="scrubber"><div class="progress"></div><div class="loaded"></div></div>' +
|
||||
'<div class="time"><em class="played">00:00</em>/<strong class="duration">00:00</strong></div>' +
|
||||
'<div class="error-message"></div>';
|
||||
|
||||
function clock(seconds) {
|
||||
if (!isFinite(seconds) || seconds < 0) seconds = 0;
|
||||
var m = Math.floor(seconds / 60), s = Math.floor(seconds % 60);
|
||||
return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
|
||||
}
|
||||
|
||||
function build(audio) {
|
||||
var wrap = document.createElement("div");
|
||||
wrap.className = "audiojs";
|
||||
audio.parentNode.insertBefore(wrap, audio);
|
||||
wrap.appendChild(audio);
|
||||
// insertAdjacentHTML rather than innerHTML: appending would re-parse and
|
||||
// replace the <audio> node we just moved in, losing its state.
|
||||
wrap.insertAdjacentHTML("beforeend", WRAPPER);
|
||||
|
||||
var ui = {
|
||||
wrap: wrap,
|
||||
progress: wrap.querySelector(".progress"),
|
||||
loaded: wrap.querySelector(".loaded"),
|
||||
scrubber: wrap.querySelector(".scrubber"),
|
||||
played: wrap.querySelector(".played"),
|
||||
duration: wrap.querySelector(".duration"),
|
||||
error: wrap.querySelector(".error-message")
|
||||
};
|
||||
|
||||
function state(name) {
|
||||
wrap.classList.remove("playing", "loading", "error");
|
||||
if (name) wrap.classList.add(name);
|
||||
}
|
||||
|
||||
function start() {
|
||||
// play() rejects when a fresh load() interrupts it — which is just
|
||||
// what holding an arrow key down does. Not an error worth reporting.
|
||||
var p = audio.play();
|
||||
if (p && p.catch) p.catch(function () {});
|
||||
}
|
||||
|
||||
wrap.querySelector(".play-pause").addEventListener("click", function () {
|
||||
if (wrap.classList.contains("error")) return;
|
||||
if (audio.paused) start(); else audio.pause();
|
||||
});
|
||||
|
||||
ui.scrubber.addEventListener("click", function (e) {
|
||||
if (!isFinite(audio.duration)) return;
|
||||
var box = ui.scrubber.getBoundingClientRect();
|
||||
audio.currentTime = ((e.clientX - box.left) / box.width) * audio.duration;
|
||||
});
|
||||
|
||||
audio.addEventListener("loadstart", function () { state("loading"); });
|
||||
audio.addEventListener("play", function () { state("playing"); });
|
||||
audio.addEventListener("pause", function () { state(null); });
|
||||
audio.addEventListener("error", function () {
|
||||
state("error");
|
||||
ui.error.textContent = 'Error loading: "' +
|
||||
(audio.currentSrc || audio.src || "") + '"';
|
||||
});
|
||||
|
||||
audio.addEventListener("loadedmetadata", function () {
|
||||
ui.duration.textContent = clock(audio.duration);
|
||||
if (!audio.paused) state("playing"); else state(null);
|
||||
});
|
||||
|
||||
audio.addEventListener("timeupdate", function () {
|
||||
ui.played.textContent = clock(audio.currentTime);
|
||||
if (isFinite(audio.duration) && audio.duration > 0) {
|
||||
ui.progress.style.width =
|
||||
(audio.currentTime / audio.duration) * 100 + "%";
|
||||
}
|
||||
});
|
||||
|
||||
function paintLoaded() {
|
||||
// `buffered` is a list of ranges; the bar shows the leading one, as
|
||||
// audio.js did. Driven from four events rather than `progress` alone
|
||||
// because a local or well-cached file can finish buffering before the
|
||||
// first `progress` ever fires, leaving the bar stuck at zero.
|
||||
if (audio.buffered.length && isFinite(audio.duration) && audio.duration > 0) {
|
||||
ui.loaded.style.width =
|
||||
(audio.buffered.end(audio.buffered.length - 1) / audio.duration) * 100 + "%";
|
||||
}
|
||||
}
|
||||
["progress", "loadeddata", "canplay", "timeupdate"].forEach(function (name) {
|
||||
audio.addEventListener(name, paintLoaded);
|
||||
});
|
||||
|
||||
return {
|
||||
element: audio,
|
||||
load: function (src) {
|
||||
ui.progress.style.width = "0%";
|
||||
ui.loaded.style.width = "0%";
|
||||
ui.played.textContent = "00:00";
|
||||
ui.duration.textContent = "00:00";
|
||||
audio.src = src;
|
||||
audio.load();
|
||||
},
|
||||
play: start,
|
||||
playPause: function () {
|
||||
if (audio.paused) start(); else audio.pause();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function init() {
|
||||
var audio = document.querySelector("audio");
|
||||
var items = Array.prototype.slice.call(document.querySelectorAll("ol li"));
|
||||
if (!audio || !items.length) return;
|
||||
|
||||
var player = build(audio);
|
||||
|
||||
function select(li, autoplay) {
|
||||
items.forEach(function (other) { other.classList.remove("playing"); });
|
||||
li.classList.add("playing");
|
||||
player.load(li.querySelector("a").getAttribute("data-src"));
|
||||
if (autoplay) player.play();
|
||||
}
|
||||
|
||||
items.forEach(function (li) {
|
||||
li.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
select(li, true);
|
||||
});
|
||||
});
|
||||
|
||||
// Advance to the next track, stopping at the end of the mix — the old
|
||||
// page had the wrap-around commented out, so this matches it.
|
||||
audio.addEventListener("ended", function () {
|
||||
var current = document.querySelector("ol li.playing");
|
||||
var next = current && current.nextElementSibling;
|
||||
if (next) select(next, true);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
var target = e.target || {};
|
||||
var tag = (target.tagName || "").toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || target.isContentEditable) return;
|
||||
|
||||
var current = document.querySelector("ol li.playing");
|
||||
if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
select((current && current.nextElementSibling) || items[0], true);
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
select((current && current.previousElementSibling) || items[items.length - 1], true);
|
||||
} else if (e.key === " " || e.key === "Spacebar") {
|
||||
e.preventDefault();
|
||||
player.playPause();
|
||||
}
|
||||
});
|
||||
|
||||
// Cue the first track without playing it, as the original page did.
|
||||
select(items[0], false);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,111 @@
|
||||
"""What a browser can play, and how to fix what it can't.
|
||||
|
||||
The gate for the web-mix export, modelled on ``lintunes/cast/support.py``:
|
||||
deny-by-default on the suffix, with the iTunes ``kind`` string breaking the
|
||||
tie inside the ``.m4a`` container (which holds plain AAC, Apple Lossless and
|
||||
FairPlay-protected audio alike — the suffix alone can't tell them apart).
|
||||
|
||||
Two rules carry the whole design:
|
||||
|
||||
* **Bitrate is never a conversion trigger.** A 320 kbps MP3 is copied
|
||||
byte-for-byte, because every browser plays it. Only *unplayable* formats
|
||||
are touched.
|
||||
* **Every conversion targets FLAC**, so a conversion can never cost a single
|
||||
bit. ALAC and AIFF are both lossless already, so FLAC round-trips them
|
||||
exactly (and AIFF, being uncompressed PCM, actually gets smaller). For the
|
||||
rare lossy oddity FLAC is wasteful in bytes but still incapable of adding
|
||||
generation loss, which is the property worth protecting.
|
||||
|
||||
FLAC in ``<audio>`` is native in Chrome 56+, Firefox 51+ and Safari 11+.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# Audio containers every current browser decodes from an <audio> element.
|
||||
_BROWSER_OK = {
|
||||
".mp3", ".mpga", ".m4a", ".m4b", ".aac", ".wav",
|
||||
".flac", ".ogg", ".oga", ".opus", ".webm",
|
||||
}
|
||||
|
||||
# iTunes ``kind`` strings for things living in an .m4a that a browser can't
|
||||
# decode. "Purchased AAC audio file" is deliberately absent: those are
|
||||
# DRM-free iTunes Plus downloads and play fine.
|
||||
_CONVERT_KINDS = {"Apple Lossless audio file"}
|
||||
_REFUSED_KINDS = {"Protected AAC audio file": "it's copy-protected AAC"}
|
||||
|
||||
# Suffixes that are hopeless regardless of kind. FairPlay can't be decrypted,
|
||||
# so there is nothing to convert — the track is reported, never attempted.
|
||||
_REFUSED_SUFFIXES = {".m4p": "it's copy-protected AAC"}
|
||||
|
||||
|
||||
def conversion_for(location: str, kind: str = "") -> tuple[str | None, str | None]:
|
||||
"""How to get `location` onto a web page.
|
||||
|
||||
Returns ``(target_ext, refusal_reason)``:
|
||||
|
||||
* ``(None, None)`` — already playable, copy it verbatim.
|
||||
* ``("flac", None)`` — needs a lossless transcode.
|
||||
* ``(None, reason)`` — can't be exported at all; `reason` is phrased to
|
||||
follow "skipped X because …".
|
||||
"""
|
||||
if not location:
|
||||
return None, "it has no file"
|
||||
if kind in _REFUSED_KINDS:
|
||||
return None, _REFUSED_KINDS[kind]
|
||||
suffix = Path(location).suffix.lower()
|
||||
if suffix in _REFUSED_SUFFIXES:
|
||||
return None, _REFUSED_SUFFIXES[suffix]
|
||||
# Checked before _BROWSER_OK: ALAC wears a perfectly playable .m4a suffix.
|
||||
if kind in _CONVERT_KINDS:
|
||||
return "flac", None
|
||||
if suffix in _BROWSER_OK:
|
||||
return None, None
|
||||
return "flac", None
|
||||
|
||||
|
||||
def ffmpeg_available() -> bool:
|
||||
"""Whether the ffmpeg *binary* is on PATH.
|
||||
|
||||
Separate from Qt Multimedia's ffmpeg backend, which is a shared library:
|
||||
a machine can play audio in the app and still have no ``ffmpeg`` command.
|
||||
"""
|
||||
return shutil.which("ffmpeg") is not None
|
||||
|
||||
|
||||
def convert_to_flac(src: Path, dest: Path, cancel=None) -> None:
|
||||
"""Transcode `src` to FLAC at `dest`. Raises OSError on failure.
|
||||
|
||||
`-vn` drops the cover art: mutagen re-attaches tags downstream if we ever
|
||||
want them, and a video stream in a FLAC container trips some decoders.
|
||||
`-nostdin` stops ffmpeg from eating the terminal's stdin when lintunes is
|
||||
launched from a shell.
|
||||
"""
|
||||
cmd = [
|
||||
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
|
||||
"-i", str(src), "-vn", "-c:a", "flac", "-compression_level", "5",
|
||||
str(dest),
|
||||
]
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE)
|
||||
except (OSError, ValueError) as e:
|
||||
raise OSError(f"couldn't run ffmpeg: {e}") from e
|
||||
|
||||
while True:
|
||||
try:
|
||||
_, err = proc.communicate(timeout=0.25)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
if cancel is not None and cancel.is_set():
|
||||
proc.kill()
|
||||
proc.communicate()
|
||||
dest.unlink(missing_ok=True)
|
||||
return
|
||||
if proc.returncode != 0:
|
||||
dest.unlink(missing_ok=True)
|
||||
detail = (err or b"").decode("utf-8", "replace").strip().splitlines()
|
||||
raise OSError(f"ffmpeg failed on {src.name}"
|
||||
+ (f": {detail[-1]}" if detail else ""))
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
"""Round 37: File → Export Playlist… — a folder of files, or a web mix.
|
||||
|
||||
Two destinations share one planner. The folder variant copies files verbatim
|
||||
under "Artist - Title" names next to an .m3u. The web variant additionally
|
||||
writes a self-contained page (index.html + a dependency-free player.js/css,
|
||||
replacing the abandoned audio.js + jQuery pair the hand-made mixes shipped)
|
||||
and converts anything a browser can't decode.
|
||||
|
||||
The load-bearing claims under test:
|
||||
|
||||
* bitrate never triggers a conversion — only *unplayability* does;
|
||||
* a conversion is always to FLAC, so it can never lose a bit;
|
||||
* DRM'd tracks are reported, never silently dropped and never attempted;
|
||||
* the manifest is written last, so a cancelled export leaves no page or m3u
|
||||
promising files that aren't there.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from lintunes.export import exporter, web_support
|
||||
from lintunes.export.exporter import ExportWorker, plan_export
|
||||
from lintunes.models import Track
|
||||
|
||||
|
||||
def _track(tid, name, artist, path, kind="MPEG audio file", total_time=180_000):
|
||||
return Track(track_id=tid, name=name, artist=artist, location=str(path),
|
||||
kind=kind, total_time=total_time)
|
||||
|
||||
|
||||
def _audio(tmp_path, filename, data=b"x" * 100):
|
||||
path = tmp_path / "local" / filename
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
def _run(plan, cancel_on_emit=None):
|
||||
"""Drive a worker synchronously on the test thread so signals fire directly."""
|
||||
worker = ExportWorker(plan)
|
||||
out = {"finished": None, "cancelled": None, "failed": None, "progress": []}
|
||||
worker.finished.connect(lambda d: out.__setitem__("finished", d))
|
||||
worker.cancelled.connect(lambda d: out.__setitem__("cancelled", d))
|
||||
worker.failed.connect(lambda m: out.__setitem__("failed", m))
|
||||
|
||||
def on_progress(done, total, label):
|
||||
out["progress"].append((done, total, label))
|
||||
if cancel_on_emit is not None and len(out["progress"]) >= cancel_on_emit:
|
||||
worker.cancel()
|
||||
|
||||
worker.progress.connect(on_progress)
|
||||
worker._run()
|
||||
return out
|
||||
|
||||
|
||||
def _pcm_hash(path):
|
||||
"""SHA-256 of the decoded samples — format-independent audio identity."""
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-loglevel", "error", "-i", str(path),
|
||||
"-f", "s32le", "-c:a", "pcm_s32le", "-"],
|
||||
capture_output=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr.decode()
|
||||
return hashlib.sha256(result.stdout).hexdigest()
|
||||
|
||||
|
||||
class TestPlayabilityGate:
|
||||
@pytest.mark.parametrize("name", [
|
||||
"a.mp3", "a.mpga", "a.m4a", "a.aac", "a.wav",
|
||||
"a.flac", "a.ogg", "a.opus", "a.webm",
|
||||
])
|
||||
def test_browser_native_formats_are_copied_verbatim(self, name):
|
||||
assert web_support.conversion_for(f"/music/{name}") == (None, None)
|
||||
|
||||
def test_bitrate_is_never_a_conversion_trigger(self):
|
||||
# A 320 kbps MP3 is exactly as playable as a 96 kbps one.
|
||||
assert web_support.conversion_for(
|
||||
"/music/loud.mp3", "MPEG audio file") == (None, None)
|
||||
|
||||
def test_plain_aac_in_m4a_is_copied(self):
|
||||
for kind in ("AAC audio file", "Matched AAC audio file",
|
||||
"Purchased AAC audio file"):
|
||||
assert web_support.conversion_for("/music/a.m4a", kind) == (None, None)
|
||||
|
||||
def test_apple_lossless_converts_despite_playable_suffix(self):
|
||||
# .m4a is in the allow-list; only `kind` reveals this is ALAC.
|
||||
assert web_support.conversion_for(
|
||||
"/music/a.m4a", "Apple Lossless audio file") == ("flac", None)
|
||||
|
||||
def test_aiff_converts(self):
|
||||
assert web_support.conversion_for("/music/a.aiff") == ("flac", None)
|
||||
assert web_support.conversion_for("/music/a.aif") == ("flac", None)
|
||||
|
||||
def test_unknown_format_converts_rather_than_being_dropped(self):
|
||||
assert web_support.conversion_for("/music/a.wma") == ("flac", None)
|
||||
|
||||
def test_drm_is_refused_never_converted(self):
|
||||
for loc, kind in (("/music/a.m4p", ""),
|
||||
("/music/a.m4a", "Protected AAC audio file")):
|
||||
target, reason = web_support.conversion_for(loc, kind)
|
||||
assert target is None
|
||||
assert "copy-protected" in reason
|
||||
|
||||
def test_missing_location_is_refused(self):
|
||||
assert web_support.conversion_for("") == (None, "it has no file")
|
||||
|
||||
|
||||
class TestPlan:
|
||||
def test_folder_plan_keeps_playlist_order(self, tmp_path):
|
||||
tracks = [_track(i, f"Song {i}", "Artist", _audio(tmp_path, f"{i}.mp3"))
|
||||
for i in (3, 1, 2)]
|
||||
plan = plan_export("Mix", tracks, tmp_path / "out", exporter.FOLDER)
|
||||
assert [i.display for i in plan.items] == [
|
||||
"Artist - Song 3", "Artist - Song 1", "Artist - Song 2"]
|
||||
|
||||
def test_folder_names_are_artist_title(self, tmp_path):
|
||||
plan = plan_export(
|
||||
"Mix", [_track(1, "Lights Out", "Broadcast", _audio(tmp_path, "x.mp3"))],
|
||||
tmp_path / "out", exporter.FOLDER)
|
||||
assert plan.items[0].dest_name == "Broadcast - Lights Out.mp3"
|
||||
|
||||
def test_dest_layout_differs_by_kind(self, tmp_path):
|
||||
tracks = [_track(1, "S", "A", _audio(tmp_path, "x.mp3"))]
|
||||
folder = plan_export("My Mix", tracks, tmp_path / "out", exporter.FOLDER)
|
||||
web = plan_export("My Mix", tracks, tmp_path / "out", exporter.WEB)
|
||||
assert folder.audio_dir == folder.dest_dir == tmp_path / "out" / "My Mix"
|
||||
assert web.audio_dir == tmp_path / "out" / "My Mix" / "audios"
|
||||
|
||||
def test_missing_files_are_skipped_with_a_reason(self, tmp_path):
|
||||
tracks = [
|
||||
_track(1, "Here", "A", _audio(tmp_path, "x.mp3")),
|
||||
_track(2, "Gone", "A", tmp_path / "local" / "nope.mp3"),
|
||||
_track(3, "Nowhere", "A", ""),
|
||||
]
|
||||
plan = plan_export("Mix", tracks, tmp_path / "out", exporter.FOLDER)
|
||||
assert len(plan.items) == 1
|
||||
assert [d for d, _why in plan.skipped] == ["A - Gone", "A - Nowhere"]
|
||||
assert all("missing" in why for _d, why in plan.skipped)
|
||||
|
||||
def test_duplicate_track_ids_are_collapsed(self, tmp_path):
|
||||
src = _audio(tmp_path, "x.mp3")
|
||||
track = _track(1, "S", "A", src)
|
||||
plan = plan_export("Mix", [track, track, track],
|
||||
tmp_path / "out", exporter.FOLDER)
|
||||
assert len(plan.items) == 1
|
||||
|
||||
def test_collisions_get_stable_suffixes_regardless_of_order(self, tmp_path):
|
||||
a = _track(7, "Song", "Artist", _audio(tmp_path, "a.mp3"))
|
||||
b = _track(9, "Song", "Artist", _audio(tmp_path, "b.mp3"))
|
||||
forward = plan_export("Mix", [a, b], tmp_path / "o1", exporter.FOLDER)
|
||||
reverse = plan_export("Mix", [b, a], tmp_path / "o2", exporter.FOLDER)
|
||||
assert {i.dest_name for i in forward.items} == {
|
||||
"Artist - Song [7].mp3", "Artist - Song [9].mp3"}
|
||||
# Every member of the colliding group is suffixed, so the name a given
|
||||
# track gets never depends on where it sits in the playlist.
|
||||
assert {i.dest_name for i in reverse.items} == {
|
||||
i.dest_name for i in forward.items}
|
||||
|
||||
def test_drm_only_skipped_for_web(self, tmp_path):
|
||||
src = _audio(tmp_path, "locked.m4p")
|
||||
tracks = [_track(1, "Locked", "A", src, kind="Protected AAC audio file")]
|
||||
web = plan_export("Mix", tracks, tmp_path / "o1", exporter.WEB)
|
||||
folder = plan_export("Mix", tracks, tmp_path / "o2", exporter.FOLDER)
|
||||
assert web.items == [] and len(web.skipped) == 1
|
||||
# A plain folder is for a real music player, which may well cope.
|
||||
assert len(folder.items) == 1 and folder.skipped == []
|
||||
|
||||
def test_needs_ffmpeg_only_when_something_converts(self, tmp_path):
|
||||
plain = plan_export(
|
||||
"Mix", [_track(1, "S", "A", _audio(tmp_path, "x.mp3"))],
|
||||
tmp_path / "o1", exporter.WEB)
|
||||
lossless = plan_export(
|
||||
"Mix", [_track(1, "S", "A", _audio(tmp_path, "y.m4a"),
|
||||
kind="Apple Lossless audio file")],
|
||||
tmp_path / "o2", exporter.WEB)
|
||||
assert not plain.needs_ffmpeg
|
||||
assert lossless.needs_ffmpeg
|
||||
|
||||
|
||||
class TestFolderExport:
|
||||
def test_writes_files_and_m3u(self, tmp_path):
|
||||
tracks = [
|
||||
_track(1, "Lights Out", "Broadcast", _audio(tmp_path, "a.mp3", b"aa"),
|
||||
total_time=241_000),
|
||||
_track(2, "Naomi", "Neutral Milk Hotel", _audio(tmp_path, "b.mp3", b"bbb"),
|
||||
total_time=180_400),
|
||||
]
|
||||
plan = plan_export("Road Trip", tracks, tmp_path / "out", exporter.FOLDER)
|
||||
out = _run(plan)
|
||||
assert out["failed"] is None
|
||||
assert out["finished"]["exported"] == 2
|
||||
|
||||
dest = tmp_path / "out" / "Road Trip"
|
||||
assert (dest / "Broadcast - Lights Out.mp3").read_bytes() == b"aa"
|
||||
assert (dest / "Neutral Milk Hotel - Naomi.mp3").read_bytes() == b"bbb"
|
||||
|
||||
m3u = (dest / "Road Trip.m3u").read_text().splitlines()
|
||||
assert m3u == [
|
||||
"#EXTM3U",
|
||||
"#EXTINF:241,Broadcast - Lights Out",
|
||||
"Broadcast - Lights Out.mp3",
|
||||
"#EXTINF:180,Neutral Milk Hotel - Naomi",
|
||||
"Neutral Milk Hotel - Naomi.mp3",
|
||||
]
|
||||
|
||||
def test_no_web_assets_in_a_folder_export(self, tmp_path):
|
||||
plan = plan_export(
|
||||
"Mix", [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))],
|
||||
tmp_path / "out", exporter.FOLDER)
|
||||
_run(plan)
|
||||
dest = tmp_path / "out" / "Mix"
|
||||
assert not (dest / "index.html").exists()
|
||||
assert not (dest / "player.js").exists()
|
||||
|
||||
def test_source_files_are_never_touched(self, tmp_path):
|
||||
src = _audio(tmp_path, "a.mp3", b"original")
|
||||
plan = plan_export("Mix", [_track(1, "S", "A", src)],
|
||||
tmp_path / "out", exporter.FOLDER)
|
||||
_run(plan)
|
||||
assert src.exists() and src.read_bytes() == b"original"
|
||||
|
||||
|
||||
class TestWebExport:
|
||||
def _export(self, tmp_path, tracks, **details):
|
||||
plan = plan_export("My Mix", tracks, tmp_path / "out", exporter.WEB)
|
||||
for key, value in details.items():
|
||||
setattr(plan, key, value)
|
||||
out = _run(plan)
|
||||
assert out["failed"] is None, out["failed"]
|
||||
return tmp_path / "out" / "My Mix", out
|
||||
|
||||
def test_ships_a_self_contained_page(self, tmp_path):
|
||||
dest, _ = self._export(
|
||||
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))])
|
||||
for name in ("index.html", "player.js", "player.css",
|
||||
"player-graphics.gif", "placeholder.png", "My Mix.m3u"):
|
||||
assert (dest / name).is_file(), f"missing {name}"
|
||||
assert (dest / "audios" / "A - S.mp3").is_file()
|
||||
|
||||
def test_no_jquery_or_audiojs_anywhere(self, tmp_path):
|
||||
dest, _ = self._export(
|
||||
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))])
|
||||
html = (dest / "index.html").read_text()
|
||||
assert "jquery" not in html.lower()
|
||||
assert "audio.min.js" not in html
|
||||
assert ".swf" not in html
|
||||
assert not list(dest.glob("*.swf"))
|
||||
# The one remaining script tag is ours.
|
||||
assert html.count("<script") == 1
|
||||
assert 'src="./player.js"' in html
|
||||
|
||||
def test_player_graphics_ships_byte_identical(self, tmp_path):
|
||||
# It's an animated GIF (the loading state is a spinner), not a flat
|
||||
# sprite sheet, so it must not be regenerated or re-encoded.
|
||||
dest, _ = self._export(
|
||||
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))])
|
||||
original = exporter.templates() / "player-graphics.gif"
|
||||
assert (dest / "player-graphics.gif").read_bytes() == original.read_bytes()
|
||||
assert b"NETSCAPE" in (dest / "player-graphics.gif").read_bytes()
|
||||
|
||||
def test_tracklist_is_in_playlist_order_and_resolves(self, tmp_path):
|
||||
tracks = [_track(i, f"Song {i}", "A", _audio(tmp_path, f"{i}.mp3"))
|
||||
for i in (3, 1, 2)]
|
||||
dest, _ = self._export(tmp_path, tracks)
|
||||
html = (dest / "index.html").read_text()
|
||||
import re
|
||||
srcs = re.findall(r'data-src="([^"]+)"', html)
|
||||
assert srcs == ["./audios/A - Song 3.mp3",
|
||||
"./audios/A - Song 1.mp3",
|
||||
"./audios/A - Song 2.mp3"]
|
||||
for src in srcs:
|
||||
assert (dest / src[2:]).is_file(), f"{src} does not exist"
|
||||
|
||||
def test_track_names_are_html_escaped(self, tmp_path):
|
||||
# sanitize_name strips <> from the *filename*; the visible label keeps
|
||||
# them and must be escaped or the markup breaks.
|
||||
tracks = [_track(1, "Rock & Roll", "AC<DC>", _audio(tmp_path, "a.mp3"))]
|
||||
dest, _ = self._export(tmp_path, tracks)
|
||||
html = (dest / "index.html").read_text()
|
||||
assert "AC<DC> - Rock & Roll" in html
|
||||
assert "<DC>" not in html
|
||||
|
||||
def test_title_and_description_are_substituted(self, tmp_path):
|
||||
dest, _ = self._export(
|
||||
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))],
|
||||
title="2025", description='hi <a href="http://x">there</a>')
|
||||
html = (dest / "index.html").read_text()
|
||||
assert "<title>2025</title>" in html
|
||||
assert "<h1>2025</h1>" in html
|
||||
# Deliberately raw: the mixes lean on inline links right here.
|
||||
assert 'hi <a href="http://x">there</a>' in html
|
||||
assert "$title" not in html and "$tracklist" not in html
|
||||
|
||||
def test_title_is_escaped(self, tmp_path):
|
||||
dest, _ = self._export(
|
||||
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))],
|
||||
title="Me & You")
|
||||
assert "<h1>Me & You</h1>" in (dest / "index.html").read_text()
|
||||
|
||||
def test_placeholder_image_when_none_chosen(self, tmp_path):
|
||||
dest, _ = self._export(
|
||||
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))])
|
||||
assert 'src="placeholder.png"' in (dest / "index.html").read_text()
|
||||
assert (dest / "placeholder.png").is_file()
|
||||
|
||||
def test_chosen_image_is_copied_and_referenced(self, tmp_path):
|
||||
image = tmp_path / "friend.png"
|
||||
image.write_bytes(b"\x89PNG\r\n\x1a\n" + b"fake")
|
||||
dest, _ = self._export(
|
||||
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))], image=image)
|
||||
assert (dest / "friend.png").read_bytes() == image.read_bytes()
|
||||
assert 'src="friend.png"' in (dest / "index.html").read_text()
|
||||
|
||||
def test_m3u_paths_point_into_audios(self, tmp_path):
|
||||
dest, _ = self._export(
|
||||
tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))])
|
||||
assert "audios/A - S.mp3" in (dest / "My Mix.m3u").read_text()
|
||||
|
||||
def test_drm_track_is_reported_not_dropped_silently(self, tmp_path):
|
||||
tracks = [
|
||||
_track(1, "Fine", "A", _audio(tmp_path, "a.mp3")),
|
||||
_track(2, "Locked", "A", _audio(tmp_path, "b.m4p")),
|
||||
]
|
||||
plan = plan_export("My Mix", tracks, tmp_path / "out", exporter.WEB)
|
||||
out = _run(plan)
|
||||
assert out["finished"]["skipped"] == 1
|
||||
assert plan.skipped[0][0] == "A - Locked"
|
||||
assert not (tmp_path / "out" / "My Mix" / "audios" / "A - Locked.m4p").exists()
|
||||
|
||||
|
||||
class TestConversion:
|
||||
def test_alac_becomes_bit_identical_flac(self, tmp_path):
|
||||
if shutil.which("ffmpeg") is None:
|
||||
pytest.skip("ffmpeg not available")
|
||||
src = tmp_path / "local" / "lossless.m4a"
|
||||
src.parent.mkdir(parents=True, exist_ok=True)
|
||||
assert subprocess.run(
|
||||
["ffmpeg", "-loglevel", "error", "-y", "-f", "lavfi",
|
||||
"-i", "sine=frequency=440:duration=2", "-c:a", "alac", str(src)],
|
||||
).returncode == 0
|
||||
|
||||
tracks = [_track(1, "Lossless", "A", src,
|
||||
kind="Apple Lossless audio file")]
|
||||
plan = plan_export("Mix", tracks, tmp_path / "out", exporter.WEB)
|
||||
assert plan.items[0].dest_name == "A - Lossless.flac"
|
||||
out = _run(plan)
|
||||
assert out["failed"] is None, out["failed"]
|
||||
assert out["finished"]["converted"] == 1
|
||||
|
||||
flac = tmp_path / "out" / "Mix" / "audios" / "A - Lossless.flac"
|
||||
assert flac.is_file()
|
||||
# The whole point of targeting FLAC: not one sample is lost.
|
||||
assert _pcm_hash(flac) == _pcm_hash(src)
|
||||
|
||||
def test_playable_file_is_copied_byte_for_byte(self, mp3_file, tmp_path):
|
||||
tracks = [_track(1, "S", "A", mp3_file)]
|
||||
plan = plan_export("Mix", tracks, tmp_path / "out", exporter.WEB)
|
||||
assert plan.items[0].convert_to is None
|
||||
_run(plan)
|
||||
copied = tmp_path / "out" / "Mix" / "audios" / "A - S.mp3"
|
||||
assert copied.read_bytes() == mp3_file.read_bytes()
|
||||
|
||||
def test_ffmpeg_available_reflects_path(self, monkeypatch):
|
||||
monkeypatch.setattr(web_support.shutil, "which", lambda _: None)
|
||||
assert not web_support.ffmpeg_available()
|
||||
monkeypatch.setattr(web_support.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||
assert web_support.ffmpeg_available()
|
||||
|
||||
def test_missing_ffmpeg_fails_the_export_rather_than_writing_junk(self, tmp_path):
|
||||
src = _audio(tmp_path, "lossless.m4a")
|
||||
tracks = [_track(1, "S", "A", src, kind="Apple Lossless audio file")]
|
||||
plan = plan_export("Mix", tracks, tmp_path / "out", exporter.WEB)
|
||||
import lintunes.export.web_support as ws
|
||||
original = ws.subprocess.Popen
|
||||
|
||||
def no_ffmpeg(*args, **kwargs):
|
||||
raise FileNotFoundError("ffmpeg")
|
||||
|
||||
ws.subprocess.Popen = no_ffmpeg
|
||||
try:
|
||||
out = _run(plan)
|
||||
finally:
|
||||
ws.subprocess.Popen = original
|
||||
assert out["failed"] is not None
|
||||
assert "ffmpeg" in out["failed"]
|
||||
# No manifest, so nothing claims the track was exported.
|
||||
assert not (tmp_path / "out" / "Mix" / "index.html").exists()
|
||||
|
||||
|
||||
class TestCancel:
|
||||
def _many(self, tmp_path, count=4):
|
||||
# Files bigger than one CHUNK so progress fires mid-copy.
|
||||
return [_track(i, f"Song {i}", "A",
|
||||
_audio(tmp_path, f"{i}.mp3", b"z" * (exporter.CHUNK + 10)))
|
||||
for i in range(count)]
|
||||
|
||||
def test_cancel_stops_early(self, tmp_path):
|
||||
plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out",
|
||||
exporter.FOLDER)
|
||||
out = _run(plan, cancel_on_emit=1)
|
||||
assert out["cancelled"] is not None
|
||||
assert out["finished"] is None
|
||||
assert out["cancelled"]["exported"] < out["cancelled"]["total"]
|
||||
|
||||
def test_cancel_writes_no_manifest(self, tmp_path):
|
||||
plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out",
|
||||
exporter.WEB)
|
||||
_run(plan, cancel_on_emit=1)
|
||||
dest = tmp_path / "out" / "Mix"
|
||||
assert not (dest / "index.html").exists()
|
||||
assert not (dest / "Mix.m3u").exists()
|
||||
|
||||
def test_cancel_removes_the_partial_file(self, tmp_path):
|
||||
plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out",
|
||||
exporter.FOLDER)
|
||||
_run(plan, cancel_on_emit=1)
|
||||
dest = tmp_path / "out" / "Mix"
|
||||
for path in dest.iterdir():
|
||||
# Whatever survived is a whole file, never a truncated one.
|
||||
assert path.stat().st_size == exporter.CHUNK + 10
|
||||
|
||||
def test_cancel_before_any_work_writes_nothing(self, tmp_path):
|
||||
plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out",
|
||||
exporter.FOLDER)
|
||||
worker = ExportWorker(plan)
|
||||
seen = {}
|
||||
worker.cancelled.connect(lambda d: seen.update(d))
|
||||
worker.cancel()
|
||||
worker._run()
|
||||
assert seen["exported"] == 0
|
||||
assert not (tmp_path / "out" / "Mix" / "Mix.m3u").exists()
|
||||
|
||||
|
||||
class TestVanishedSource:
|
||||
def test_file_removed_after_planning_is_reported_not_fatal(self, tmp_path):
|
||||
gone = _audio(tmp_path, "gone.mp3")
|
||||
tracks = [_track(1, "Here", "A", _audio(tmp_path, "here.mp3")),
|
||||
_track(2, "Gone", "A", gone)]
|
||||
plan = plan_export("Mix", tracks, tmp_path / "out", exporter.FOLDER)
|
||||
gone.unlink() # Syncthing moved it under us
|
||||
out = _run(plan)
|
||||
assert out["failed"] is None
|
||||
assert out["finished"]["vanished"] == 1
|
||||
assert out["finished"]["exported"] == 1
|
||||
# The m3u lists only what is really there.
|
||||
m3u = (tmp_path / "out" / "Mix" / "Mix.m3u").read_text()
|
||||
assert "A - Here.mp3" in m3u
|
||||
assert "A - Gone.mp3" not in m3u
|
||||
|
||||
|
||||
class TestGuiWiring:
|
||||
"""The menu plumbing, which sync never got a test for.
|
||||
|
||||
Export shares the status-bar progress widgets with device sync, so the
|
||||
interlock between the two is the part worth pinning down.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def window(self, qapp, tmp_path):
|
||||
from lintunes.gui.main_window import MainWindow
|
||||
from lintunes.library_manager import LibraryManager
|
||||
from lintunes.models import Library, Playlist
|
||||
from lintunes.models.playlist import PlaylistType
|
||||
from lintunes.preferences import Preferences
|
||||
|
||||
audio = _audio(tmp_path, "a.mp3")
|
||||
library = Library(
|
||||
tracks={1: _track(1, "Song", "Artist", audio)},
|
||||
playlists={"abcd1234": Playlist(
|
||||
name="My Mix", persistent_id="abcd1234",
|
||||
playlist_type=PlaylistType.REGULAR, track_ids=[1])},
|
||||
)
|
||||
manager = LibraryManager(library, tmp_path / "data")
|
||||
win = MainWindow(manager, Preferences(tmp_path / "data"))
|
||||
yield win
|
||||
win.close()
|
||||
|
||||
def test_export_action_is_in_the_file_menu(self, window):
|
||||
texts = [a.text() for menu in window.menuBar().actions()
|
||||
if menu.menu() for a in menu.menu().actions()]
|
||||
assert "Export Playlist…" in texts
|
||||
|
||||
def test_action_needs_a_playlist_in_view(self, window):
|
||||
window._refresh_export_action()
|
||||
assert not window._export_action.isEnabled()
|
||||
window._show_playlist("abcd1234")
|
||||
window._refresh_export_action()
|
||||
assert window._export_action.isEnabled()
|
||||
|
||||
def test_sidebar_context_menu_signal_reaches_the_window(self, window):
|
||||
seen = []
|
||||
window._sidebar.tree.export_requested.disconnect()
|
||||
window._sidebar.tree.export_requested.connect(seen.append)
|
||||
window._sidebar.tree.export_requested.emit("abcd1234")
|
||||
assert seen == ["abcd1234"]
|
||||
|
||||
def test_a_running_export_blocks_both_export_and_sync(self, window, tmp_path):
|
||||
window._show_playlist("abcd1234")
|
||||
plan = plan_export("My Mix", [window._manager.library.tracks[1]],
|
||||
tmp_path / "out", exporter.FOLDER)
|
||||
window._export_worker = ExportWorker(plan, window)
|
||||
window._export_worker._busy = True
|
||||
try:
|
||||
assert window._busy_worker() is window._export_worker
|
||||
window._refresh_export_action()
|
||||
assert not window._export_action.isEnabled()
|
||||
window._refresh_connection_actions()
|
||||
assert not window._sync_action.isEnabled()
|
||||
finally:
|
||||
window._export_worker._busy = False
|
||||
|
||||
def test_export_of_an_empty_playlist_says_so_and_starts_nothing(
|
||||
self, window, monkeypatch):
|
||||
from lintunes.gui import main_window as mw
|
||||
window._manager.library.playlists["abcd1234"].track_ids = []
|
||||
window._show_playlist("abcd1234")
|
||||
told = []
|
||||
monkeypatch.setattr(mw.QMessageBox, "information",
|
||||
lambda *a, **k: told.append(a[2]))
|
||||
window._export_playlist("abcd1234")
|
||||
assert told and "nothing to export" in told[0]
|
||||
assert window._export_worker is None
|
||||
Reference in New Issue
Block a user