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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user