v0.8.0: export a playlist — as a folder, or as a whole website

File → Export Playlist…, and the same item on a playlist's right-click
menu. A sibling of device_sync: pure plan_export() first, then a worker
on a daemon thread sharing the status-bar progress widgets. The manifest
(index.html / .m3u) is written last, so an interrupted export never
leaves a page naming files that aren't there.

Two destinations. A folder gives the audio under "Artist - Title" names
plus an extended .m3u. A web mix gives a self-contained static site
reproducing the hand-made yearly mixes, with a dialog for title,
description and hero image.

audio.js and jQuery are gone. The old pages shipped ~293 KB: a build of
audio.js whose upstream hasn't moved since 2012, plus jQuery 3.2.1
(CVE-2019-11358, CVE-2020-11022, CVE-2020-11023 — unexploitable on a
static page, since nothing untrusted reaches a jQuery HTML sink, but
dead weight regardless). audio.js never used jQuery; jQuery was there
for ~25 lines of tracklist glue. Both are replaced by a dependency-free
player.js plus a player.css transcribed from the customized audio.js
skin, so the page looks identical — same 250px #c7b563 bar, same
player-graphics.gif (byte-identical: it's an animated GIF whose loading
frame is a spinner), same shortcuts. ~293 KB → ~6 KB. The Flash
fallback went too; audio.js gated it on !canPlayType("audio/mpeg;"),
unreachable since ~2010.

Conversion happens only when a browser genuinely can't decode a file,
never because of bitrate — a 320 kbps MP3 is copied verbatim. Everything
that does convert targets FLAC, so a conversion cannot cost a bit; a
test asserts the exported FLAC's decoded PCM hashes identical to the
ALAC source. Against the real library that's 105 Apple Lossless and 8
AIFF out of 21,382 tracks. DRM'd tracks are reported in a confirmation
dialog, never silently dropped and never attempted. ffmpeg is the CLI
binary here, not Qt's ffmpeg backend, so it's detected at runtime.

Verified in Chromium 151 and Firefox 153 over CDP/Marionette: every
exported format decodes including the converted FLAC, the player builds,
click / space / arrows / scrubber-seek / autoplay-next all work, and the
network log shows no request for jquery, audio.min.js or any .swf.

mix-example/ removed; the template reproduces it.
This commit is contained in:
2026-08-19 19:03:01 -04:00
parent e69917a290
commit 76b941bf68
17 changed files with 1781 additions and 4 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.7.0"
__version__ = "0.8.0"
View File
+355
View File
@@ -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")
+63
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+127
View File
@@ -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; }
+186
View File
@@ -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();
}
})();
+111
View File
@@ -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 ""))
+129
View File
@@ -0,0 +1,129 @@
"""The two dialogs behind File → Export Playlist…
`ExportKindDialog` asks folder-or-website; `WebMixDialog` collects the bits of
a web mix that can't be inferred — title, blurb, hero image. Per-track liner
notes are deliberately absent: the hand-made mixes carry a numbered paragraph
per track, which is far too much to type into a form, so the generated
index.html leaves a commented-out block in the right place instead.
"""
from pathlib import Path
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QHBoxLayout, QLabel,
QLineEdit, QPlainTextEdit, QPushButton, QRadioButton, QVBoxLayout)
from lintunes.export import exporter
_IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.gif *.webp)"
class ExportKindDialog(QDialog):
"""Folder of music files, or a self-contained web mix."""
def __init__(self, playlist_name: str, parent=None):
super().__init__(parent)
self.setWindowTitle("Export Playlist")
self.setMinimumWidth(420)
layout = QVBoxLayout(self)
layout.addWidget(QLabel(f"Export “{playlist_name}” as:"))
self._folder = QRadioButton("A folder of music files")
self._folder.setChecked(True)
layout.addWidget(self._folder)
hint = QLabel("The audio files, named “Artist - Title”, plus an .m3u "
"playlist. Files are copied exactly as they are.")
hint.setWordWrap(True)
hint.setIndent(24)
hint.setEnabled(False)
layout.addWidget(hint)
self._web = QRadioButton("A website")
layout.addWidget(self._web)
web_hint = QLabel(
"A self-contained page with a player and the tracklist, ready to "
"copy into a web directory. Anything a browser can't play is "
"converted to FLAC, losslessly.")
web_hint.setWordWrap(True)
web_hint.setIndent(24)
web_hint.setEnabled(False)
layout.addWidget(web_hint)
layout.addSpacing(8)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Continue")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def kind(self) -> str:
return exporter.WEB if self._web.isChecked() else exporter.FOLDER
class WebMixDialog(QDialog):
"""Title, description and hero image for a web mix."""
def __init__(self, playlist_name: str, parent=None):
super().__init__(parent)
self.setWindowTitle("Export Web Mix")
self.setMinimumWidth(520)
layout = QVBoxLayout(self)
form = QFormLayout()
form.setFieldGrowthPolicy(
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
self._title = QLineEdit(playlist_name)
form.addRow("Title:", self._title)
self._description = QPlainTextEdit()
self._description.setPlainText(exporter.DEFAULT_DESCRIPTION)
self._description.setMinimumHeight(140)
form.addRow("Description:", self._description)
image_row = QHBoxLayout()
self._image = QLineEdit()
self._image.setPlaceholderText("(a gray placeholder)")
self._image.setReadOnly(True)
image_row.addWidget(self._image, stretch=1)
browse = QPushButton("Browse…")
browse.clicked.connect(self._choose_image)
image_row.addWidget(browse)
clear = QPushButton("Clear")
clear.clicked.connect(lambda: self._image.clear())
image_row.addWidget(clear)
form.addRow("Image:", image_row)
layout.addLayout(form)
note = QLabel(
"The description is plain HTML — links and &lt;br&gt; work. "
"Per-track liner notes aren't asked for here; index.html has a "
"commented-out block in the right spot for them.")
note.setWordWrap(True)
note.setTextFormat(Qt.TextFormat.RichText)
note.setEnabled(False)
layout.addWidget(note)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Export")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _choose_image(self):
start = str(Path(self._image.text()).parent) if self._image.text() else ""
name, _filter = QFileDialog.getOpenFileName(
self, "Choose Image", start, _IMAGE_FILTER)
if name:
self._image.setText(name)
def values(self) -> dict:
text = self._image.text().strip()
return {
"title": self._title.text().strip(),
"description": self._description.toPlainText().strip(),
"image": Path(text) if text else None,
}
+196 -2
View File
@@ -13,11 +13,13 @@ from lintunes import device_sync, mpris, tagging, theme
from lintunes.art_search import AlbumArtFetcher
from lintunes.cast.controller import CastController
from lintunes.eventlog import log_control
from lintunes.export import exporter
from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor
from lintunes.player import Player
from lintunes.importers import file_importer
from lintunes.gui.album_art_dialog import AlbumArtDialog
from lintunes.gui.cast_dialog import ChromecastDialog
from lintunes.gui.export_dialog import ExportKindDialog, WebMixDialog
from lintunes.gui.sidebar import SidebarPanel
from lintunes.gui.library_view import LibraryView
from lintunes.gui.playlist_view import PlaylistView
@@ -152,17 +154,24 @@ class MainWindow(QMainWindow):
self.statusBar().addPermanentWidget(self._sync_progress)
self._sync_worker = None
self._sync_device_name = ""
# Export shares the status-bar progress widgets with device sync —
# only one of the two may run at a time (each guards on the other).
self._export_worker = None
# Separate instance from the playback inhibitor: pausing music
# mid-sync must not drop the sync's hold. Logout flag included so
# GNOME's shutdown/restart dialog names the transfer as the blocker.
self._sync_inhibitor = SleepInhibitor(
reason="Syncing a playlist to a device",
flags=INHIBIT_LOGOUT | INHIBIT_SUSPEND)
self._export_inhibitor = SleepInhibitor(
reason="Exporting a playlist",
flags=INHIBIT_LOGOUT | INHIBIT_SUSPEND)
# Wiring
self._transport.play_clicked.connect(self.play_pause)
self._sidebar.library_selected.connect(self._show_library)
self._sidebar.playlist_selected.connect(self._show_playlist)
self._sidebar.tree.export_requested.connect(self._export_playlist)
self._content.currentChanged.connect(self._update_totals)
for view in (self._library_view, self._playlist_view):
view.play_requested.connect(
@@ -270,6 +279,9 @@ class MainWindow(QMainWindow):
file_menu.addSeparator()
self._add_action(file_menu, "Add Files to Library…", "Ctrl+O",
self._open_files_dialog)
self._export_action = self._add_action(
file_menu, "Export Playlist…", "", self._export_playlist)
file_menu.aboutToShow.connect(self._refresh_export_action)
file_menu.addSeparator()
self._add_action(file_menu, "Quit", "Ctrl+Q", self.close)
@@ -339,11 +351,13 @@ class MainWindow(QMainWindow):
# ---- connections: device sync ----
def _refresh_connection_actions(self):
syncing = self._sync_worker is not None and self._sync_worker.busy()
# An export in flight owns the same status-bar widgets, so it blocks
# a sync just as another sync would.
busy = self._busy_worker() is not None
on_playlist = (self._content.currentWidget() is self._playlist_view
and bool(self._playlist_view.playlist_id))
self._sync_action.setEnabled(
not syncing and on_playlist
not busy and on_playlist
and device_sync.find_device() is not None)
if self._cast.is_connected():
@@ -439,7 +453,17 @@ class MainWindow(QMainWindow):
self._sync_label.hide()
self._sync_progress.hide()
def _busy_worker(self):
"""The transfer currently owning the status-bar progress widgets."""
for worker in (self._sync_worker, self._export_worker):
if worker is not None and worker.busy():
return worker
return None
def _confirm_cancel_sync(self):
if self._export_worker is not None and self._export_worker.busy():
self._confirm_cancel_export()
return
if self._sync_worker is None or not self._sync_worker.busy():
return
box = QMessageBox(self)
@@ -491,6 +515,176 @@ class MainWindow(QMainWindow):
self._hide_sync_widgets()
QMessageBox.warning(self, "Sync failed", message)
# ---- export ----
def _refresh_export_action(self):
"""Export needs a playlist in view and no transfer already running."""
self._export_action.setEnabled(
self._busy_worker() is None
and self._content.currentWidget() is self._playlist_view
and bool(self._playlist_view.playlist_id))
def _export_playlist(self, pid: str = ""):
# Reachable from the File menu (no argument) and from the sidebar
# context menu (which names the playlist that was right-clicked).
if self._busy_worker() is not None:
return
pid = pid or self._playlist_view.playlist_id
playlist = self._manager.library.playlists.get(pid)
if playlist is None:
return
tracks = [self._manager.library.tracks[tid]
for tid in playlist.track_ids
if tid in self._manager.library.tracks]
if not tracks:
QMessageBox.information(
self, "Export Playlist",
f"“{playlist.name}” is empty — there is nothing to export.")
return
kind_dialog = ExportKindDialog(playlist.name, self)
if kind_dialog.exec() != ExportKindDialog.DialogCode.Accepted:
return
kind = kind_dialog.kind()
details = None
if kind == exporter.WEB:
web_dialog = WebMixDialog(playlist.name, self)
if web_dialog.exec() != WebMixDialog.DialogCode.Accepted:
return
details = web_dialog.values()
parent_dir = QFileDialog.getExistingDirectory(
self, "Export To", str(self._music_import_dir().parent))
if not parent_dir:
return
try:
plan = exporter.plan_export(
playlist.name, tracks, Path(parent_dir), kind)
except OSError as e:
QMessageBox.warning(self, "Export failed",
f"Couldn't read the playlist's files: {e}")
return
if details is not None:
plan.title = details["title"] or playlist.name
plan.description = details["description"]
plan.image = details["image"]
if not self._confirm_export(plan):
return
self._export_worker = exporter.ExportWorker(plan, self)
self._export_worker.progress.connect(self._on_export_progress)
self._export_worker.finished.connect(self._on_export_finished)
self._export_worker.cancelled.connect(self._on_export_cancelled)
self._export_worker.failed.connect(self._on_export_failed)
self._sync_label.setText("Exporting")
self._sync_progress.setRange(0, max(plan.bytes_to_copy // 1024, 1))
self._sync_progress.setValue(0)
self._sync_cancel.show()
self._sync_label.show()
self._sync_progress.show()
self._export_inhibitor.inhibit()
self._export_worker.start()
def _confirm_export(self, plan) -> bool:
"""Last look before writing: overwrite, ffmpeg, and skipped tracks."""
if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available():
names = "\n".join(f" • {i.display}" for i in plan.conversions[:8])
more = (f"\n … and {len(plan.conversions) - 8} more"
if len(plan.conversions) > 8 else "")
answer = QMessageBox.question(
self, "FFmpeg not found",
f"{len(plan.conversions)} track(s) need converting before a "
f"browser can play them, but the ffmpeg command isn't "
f"installed:\n\n{names}{more}\n\nExport without them?")
if answer != QMessageBox.StandardButton.Yes:
return False
plan.skipped += [(i.display, "it needs ffmpeg to convert")
for i in plan.conversions]
plan.items = [i for i in plan.items if not i.convert_to]
plan.bytes_to_copy = sum(i.size for i in plan.items)
if not plan.items:
QMessageBox.information(
self, "Export Playlist",
"Nothing left to export once those are left out.")
return False
if plan.skipped:
names = "\n".join(f" • {d} — {why}" for d, why in plan.skipped[:8])
more = (f"\n … and {len(plan.skipped) - 8} more"
if len(plan.skipped) > 8 else "")
answer = QMessageBox.question(
self, "Some tracks can't be exported",
f"{len(plan.skipped)} of {len(plan.skipped) + len(plan.items)} "
f"track(s) will be left out:\n\n{names}{more}\n\nExport the "
f"other {len(plan.items)}?")
if answer != QMessageBox.StandardButton.Yes:
return False
if plan.dest_dir.exists():
answer = QMessageBox.question(
self, "Folder exists",
f"“{plan.dest_dir.name}” already exists in that folder. "
f"Files with the same names will be overwritten.\n\nContinue?")
if answer != QMessageBox.StandardButton.Yes:
return False
return True
def _confirm_cancel_export(self):
if self._export_worker is None or not self._export_worker.busy():
return
box = QMessageBox(self)
box.setWindowTitle("Cancel export")
box.setText("Cancel the export?\n"
"The folder is left as-is, without a playlist file — "
"exporting again starts it over.")
cancel_button = box.addButton(
"Cancel Export", QMessageBox.ButtonRole.DestructiveRole)
keep = box.addButton("Keep Exporting", QMessageBox.ButtonRole.RejectRole)
box.setDefaultButton(keep)
box.exec()
# The export keeps running while the dialog is up — it may well have
# finished by now, in which case there is nothing to cancel.
if (box.clickedButton() is cancel_button
and self._export_worker is not None):
self._export_worker.cancel()
def _on_export_progress(self, done_kib, total_kib, label):
self._sync_progress.setRange(0, total_kib)
self._sync_progress.setValue(done_kib)
self._sync_label.setText(
f"Exporting · {device_sync.format_bytes(done_kib * 1024)} / "
f"{device_sync.format_bytes(total_kib * 1024)}")
def _on_export_finished(self, summary):
self._export_inhibitor.release()
self._hide_sync_widgets()
what = "web mix" if summary["kind"] == exporter.WEB else "folder"
msg = (f"Exported “{summary['playlist']}” as a {what}: "
f"{summary['exported']} tracks → {summary['dest']}")
if summary["converted"]:
msg += f", {summary['converted']} converted to FLAC"
if summary["skipped"]:
msg += f", {summary['skipped']} skipped"
if summary["vanished"]:
msg += (f", {summary['vanished']} changed under us "
"(export again to pick them up)")
self.statusBar().showMessage(msg, 12000)
def _on_export_cancelled(self, summary):
self._export_inhibitor.release()
self._hide_sync_widgets()
self.statusBar().showMessage(
f"Export cancelled — {summary['exported']} of {summary['total']} "
f"tracks written to {summary['dest']}.", 8000)
def _on_export_failed(self, message):
self._export_inhibitor.release()
self._hide_sync_widgets()
QMessageBox.warning(self, "Export failed", message)
# ---- view switching ----
def _show_library(self):
+7 -1
View File
@@ -152,6 +152,7 @@ class PlaylistTree(QTreeWidget):
the tree rebuilds on its playlists_changed signal."""
playlist_selected = pyqtSignal(str) # persistent_id
export_requested = pyqtSignal(str) # persistent_id
def __init__(self, manager, parent=None):
super().__init__(parent)
@@ -348,13 +349,16 @@ class PlaylistTree(QTreeWidget):
new_playlist = menu.addAction("New Playlist")
new_smart = menu.addAction("New Smart Playlist")
new_folder = menu.addAction("New Playlist Folder")
rename = delete = edit_smart = None
rename = delete = edit_smart = export = None
if kind in ("playlist", "folder", "smart"):
menu.addSeparator()
if kind == "smart":
edit_smart = menu.addAction("Edit Smart Playlist…")
rename = menu.addAction("Rename")
delete = menu.addAction("Delete")
if kind in ("playlist", "smart"):
menu.addSeparator()
export = menu.addAction("Export Playlist…")
chosen = menu.exec(self.viewport().mapToGlobal(pos))
if chosen is None:
@@ -370,6 +374,8 @@ class PlaylistTree(QTreeWidget):
text="untitled folder")
if ok and name.strip():
self._manager.create_folder(name.strip(), parent_pid)
elif export is not None and chosen is export:
self.export_requested.emit(pid)
elif rename is not None and chosen is rename:
self.editItem(item, 0)
elif delete is not None and chosen is delete: