v0.48.0: web mixes upload to a web server (rsync over ssh), and the player shuffles

Export Web Mix gets an 'Upload to web server' box: the mix is built in a
cache staging folder and mirrored with rsync --delete into <path>/<slug>/
on the host set in Preferences → Integrations → Web Server (config.json,
this machine only). An ssh check asks before replacing an existing mix;
a status window follows check, export and upload. The web player gains a
shuffle button (and the S key).
This commit is contained in:
2026-10-10 23:23:56 -07:00
parent d4cf3f95c2
commit e9488c5289
11 changed files with 1308 additions and 25 deletions
+17
View File
@@ -644,6 +644,23 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
untouched export still renders the original `#8c764a`. Templates are untouched export still renders the original `#8c764a`. Templates are
registered in `setup.py` via `package_data`; loaded with registered in `setup.py` via `package_data`; loaded with
`importlib.resources` so an installed copy finds them. `importlib.resources` so an installed copy finds them.
**Upload to web server** (Round 81, `export/upload.py`): a ticked box in
the web-mix dialog builds the mix in `$XDG_CACHE_HOME/lintunes/webmix/
<slug>/` (no folder picker) and mirrors it with `rsync -a --checksum
--delete` over ssh to `<host>:<path>/<slug>/`. rsync, not scp: scp re-sends
everything and never deletes, so a song taken out of the playlist would
stay online forever. Host and path live in **`config.json`** only (the
host is usually this machine's `~/.ssh/config` alias, and trav's server
must never appear in the repo or the synced share — tests use
`example-host`). `--delete` only ever reaches the slug folder
(`remote_dir` validates the slug), staging is wiped first
(`reset_staging`, guarded to stay inside the staging root) so a removed
song can't survive the mirror, and ssh runs with `BatchMode=yes`, since
nobody can answer a prompt. Steps: `ServerCheckWorker` (`ssh test -d`; an
existing folder asks before replacing it), `ExportWorker`, then
`UploadWorker`, all shown in `gui/web_upload_progress.py`. The web
player has a **shuffle** button (and the S key): a shuffled walk that starts at
the current song and plays every song once.
- **`lintunes/cassette/`** — friend library sharing (Round 63+; the spec is - **`lintunes/cassette/`** — friend library sharing (Round 63+; the spec is
`Cassette friend library.md`). LinTunes never talks to a friend: it drives `Cassette friend library.md`). LinTunes never talks to a friend: it drives
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.47.1" __version__ = "0.48.0"
+4 -3
View File
@@ -123,13 +123,14 @@ class ExportPlan:
def plan_export(playlist_name: str, tracks: list, dest_parent: Path, def plan_export(playlist_name: str, tracks: list, dest_parent: Path,
kind: str = FOLDER) -> ExportPlan: kind: str = FOLDER, folder: str | None = None) -> ExportPlan:
"""Work out what an export would write. Pure — creates nothing. """Work out what an export would write. Pure — creates nothing.
`tracks` is the playlist's tracks in order; `dest_parent` is the folder `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. the user picked, inside which a folder named after the playlist is made
(or `folder`, when given — an upload names it by its URL slug).
""" """
folder = sanitize_name(playlist_name) folder = folder or sanitize_name(playlist_name)
dest_dir = Path(dest_parent) / folder dest_dir = Path(dest_parent) / folder
plan = ExportPlan( plan = ExportPlan(
kind=kind, kind=kind,
+22 -1
View File
@@ -27,7 +27,7 @@
.audiojs audio { position: absolute; left: -1px; } .audiojs audio { position: absolute; left: -1px; }
.audiojs { .audiojs {
width: 250px; width: 286px;
height: 36px; height: 36px;
background: #eee; background: #eee;
color: #000; color: #000;
@@ -88,6 +88,27 @@
.audiojs .time em { padding: 0px 2px 0px 0px; color: #000; font-style: normal; } .audiojs .time em { padding: 0px 2px 0px 0px; color: #000; font-style: normal; }
.audiojs .time strong { padding: 0px 0px 0px 2px; font-weight: normal; } .audiojs .time strong { padding: 0px 0px 0px 2px; font-weight: normal; }
/* Shuffle: black like the other glyphs while off, the accent while on
* (the same fill as the progress bar, with the text color picked for it). */
.audiojs .shuffle {
float: left;
width: 26px;
height: 26px;
margin: 5px 0px;
display: flex;
align-items: center;
justify-content: center;
color: #000;
cursor: pointer;
border-radius: 3px;
}
.audiojs .shuffle.on {
background: var(--accent, #8c764a);
color: var(--accent-text, #fff);
}
.audiojs .shuffle:focus-visible { outline: 1px dotted #000; }
.audiojs.error .shuffle { display: none; }
.audiojs .error-message { .audiojs .error-message {
float: left; float: left;
display: none; display: none;
+64 -7
View File
@@ -13,6 +13,12 @@
* Expected page markup: * Expected page markup:
* <audio preload></audio> * <audio preload></audio>
* <ol><li><a href="#" data-src="./audios/x.mp3">Title — Artist</a></li>…</ol> * <ol><li><a href="#" data-src="./audios/x.mp3">Title — Artist</a></li>…</ol>
*
* Shuffle (the button after the clock, or the S key) walks a shuffled copy of
* the tracklist that starts at the song playing when it was switched on, so
* every song plays once before the walk ends — like LinTunes' own shuffle,
* and unlike picking a random next song, which repeats. Off again, play
* carries on down the list from wherever it is.
*/ */
(function () { (function () {
"use strict"; "use strict";
@@ -24,7 +30,14 @@
'</div>' + '</div>' +
'<div class="scrubber"><div class="progress"></div><div class="loaded"></div></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="time"><em class="played">00:00</em>/<strong class="duration">00:00</strong></div>' +
'<div class="error-message"></div>'; '<div class="error-message"></div>' +
'<div class="shuffle" role="button" tabindex="0" aria-pressed="false" title="Shuffle (S)">' +
'<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">' +
'<path d="M3 7h3.5c2.5 0 3.8 1.2 5.5 5s3 5 5.5 5H20M3 17h3.5c1.4 0 2.4-.4 3.3-1.3M14.2 8.3c.9-.9 1.9-1.3 3.3-1.3H20" ' +
'fill="none" stroke="currentColor" stroke-width="2"/>' +
'<path d="M18 4l3 3-3 3zM18 14l3 3-3 3z" fill="currentColor"/>' +
'</svg>' +
'</div>';
function clock(seconds) { function clock(seconds) {
if (!isFinite(seconds) || seconds < 0) seconds = 0; if (!isFinite(seconds) || seconds < 0) seconds = 0;
@@ -48,7 +61,8 @@
scrubber: wrap.querySelector(".scrubber"), scrubber: wrap.querySelector(".scrubber"),
played: wrap.querySelector(".played"), played: wrap.querySelector(".played"),
duration: wrap.querySelector(".duration"), duration: wrap.querySelector(".duration"),
error: wrap.querySelector(".error-message") error: wrap.querySelector(".error-message"),
shuffle: wrap.querySelector(".shuffle")
}; };
function state(name) { function state(name) {
@@ -112,6 +126,7 @@
return { return {
element: audio, element: audio,
shuffleButton: ui.shuffle,
load: function (src) { load: function (src) {
ui.progress.style.width = "0%"; ui.progress.style.width = "0%";
ui.loaded.style.width = "0%"; ui.loaded.style.width = "0%";
@@ -133,6 +148,44 @@
if (!audio || !items.length) return; if (!audio || !items.length) return;
var player = build(audio); var player = build(audio);
var order = null; // the shuffled walk, or null for list order
function shuffledFrom(first) {
var rest = items.filter(function (li) { return li !== first; });
for (var i = rest.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = rest[i]; rest[i] = rest[j]; rest[j] = t;
}
return first ? [first].concat(rest) : rest;
}
function setShuffle(on) {
var current = document.querySelector("ol li.playing");
order = on ? shuffledFrom(current || items[0]) : null;
player.shuffleButton.classList.toggle("on", on);
player.shuffleButton.setAttribute("aria-pressed", on ? "true" : "false");
}
// The song after/before `current` in the walk; null past either end.
function step(current, delta) {
var list = order || items;
var i = list.indexOf(current);
return i < 0 ? null : list[i + delta] || null;
}
function first() { return (order || items)[0]; }
function last() { var list = order || items; return list[list.length - 1]; }
player.shuffleButton.addEventListener("click", function () {
setShuffle(!order);
});
player.shuffleButton.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
setShuffle(!order);
}
});
function select(li, autoplay) { function select(li, autoplay) {
items.forEach(function (other) { other.classList.remove("playing"); }); items.forEach(function (other) { other.classList.remove("playing"); });
@@ -148,11 +201,12 @@
}); });
}); });
// Advance to the next track, stopping at the end of the mix — the old // Advance to the next track, stopping at the end of the mix (or of the
// page had the wrap-around commented out, so this matches it. // shuffled walk) — the old page had the wrap-around commented out, so
// this matches it.
audio.addEventListener("ended", function () { audio.addEventListener("ended", function () {
var current = document.querySelector("ol li.playing"); var current = document.querySelector("ol li.playing");
var next = current && current.nextElementSibling; var next = current && step(current, 1);
if (next) select(next, true); if (next) select(next, true);
}); });
@@ -164,10 +218,13 @@
var current = document.querySelector("ol li.playing"); var current = document.querySelector("ol li.playing");
if (e.key === "ArrowRight") { if (e.key === "ArrowRight") {
e.preventDefault(); e.preventDefault();
select((current && current.nextElementSibling) || items[0], true); select((current && step(current, 1)) || first(), true);
} else if (e.key === "ArrowLeft") { } else if (e.key === "ArrowLeft") {
e.preventDefault(); e.preventDefault();
select((current && current.previousElementSibling) || items[items.length - 1], true); select((current && step(current, -1)) || last(), true);
} else if ((e.key === "s" || e.key === "S") && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
setShuffle(!order);
} else if (e.key === " " || e.key === "Spacebar") { } else if (e.key === " " || e.key === "Spacebar") {
e.preventDefault(); e.preventDefault();
player.playPause(); player.playPause();
+269
View File
@@ -0,0 +1,269 @@
"""Upload a web mix to a web server — rsync over ssh (Round 81).
The export dialog's "Upload to web server" box builds the mix in a staging
folder under the cache, then mirrors it to ``<host>:<path>/<slug>/``. Why
rsync and not scp: scp re-sends every file every time and never removes one,
so a song taken out of the playlist would stay on the server forever. rsync
sends only what changed, and ``--delete`` makes the mix's folder match the
export exactly.
Rules worth keeping:
* **The server lives in ``config.json``**, never preferences.json or the repo.
The host is usually an alias from this machine's ``~/.ssh/config``, which is
this machine's business.
* **``--delete`` only ever reaches one mix's folder.** The destination is
always ``<path>/<slug>/`` with a validated slug — never the configured root.
* **Staging is rebuilt from scratch** on every export (``reset_staging``), or a
song removed from the playlist would linger there and survive the mirror.
Hence ``--checksum``: rebuilt files may carry new mtimes, and checksums still
keep unchanged songs from being re-sent.
* **``BatchMode=yes``**: there is no terminal to answer a password or
host-key prompt, so ssh must fail fast rather than hang invisibly.
"""
import os
import re
import shlex
import shutil
import subprocess
import threading
import unicodedata
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
HOST_KEY = "webmix_host"
PATH_KEY = "webmix_path"
_SSH_OPTIONS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]
_ERROR_TAIL = 8 # lines of ssh/rsync output kept for a failure message
class UploadError(Exception):
pass
def server_settings(config: dict) -> tuple[str, str] | None:
"""(host, path) from config.json, or None if either is blank."""
host = (config.get(HOST_KEY) or "").strip()
path = (config.get(PATH_KEY) or "").strip()
if not host or not path:
return None
return host, path
def slugify(title: str) -> str:
"""'Summer Mix 2026!' → 'summer-mix-2026'. May return "" (all symbols)."""
text = unicodedata.normalize("NFKD", title or "")
text = text.encode("ascii", "ignore").decode("ascii").lower()
return re.sub(r"[^a-z0-9]+", "-", text).strip("-")
def valid_slug(slug: str) -> bool:
return bool(slug) and slug not in (".", "..") and "/" not in slug \
and "\0" not in slug
def remote_dir(path: str, slug: str) -> str:
"""The mix's folder on the server. A leading ``~/`` means the home
directory, which is where ssh and rsync resolve a relative path anyway —
quoting would otherwise stop the shell expanding it."""
if not valid_slug(slug):
raise UploadError(f"“{slug}” can't be a folder name on the server")
path = path.strip()
if path == "~":
path = ""
elif path.startswith("~/"):
path = path[2:]
path = path.rstrip("/")
if not path:
return slug
return path + "/" + slug
def staging_root() -> Path:
base = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))
return Path(base) / "lintunes" / "webmix"
def reset_staging(slug: str) -> Path:
"""Remove a previous build of this mix, and return its folder."""
root = Path(os.path.abspath(staging_root()))
target = Path(os.path.abspath(root / slug))
if not valid_slug(slug) or target == root \
or not target.is_relative_to(root):
raise UploadError(f"{target} is not inside {root}")
if target.exists():
shutil.rmtree(target)
return target
def exists_command(host: str, rdir: str) -> list[str]:
return ["ssh", *_SSH_OPTIONS, host, "test", "-d", shlex.quote(rdir)]
def rsync_command(host: str, local_dir: Path, rdir: str) -> list[str]:
# -s: the remote path is passed whole, never re-split by the remote
# shell (spaces in the configured folder).
return ["rsync", "-a", "-s", "--checksum", "--delete",
"--chmod=D755,F644", "--info=progress2",
"-e", shlex.join(["ssh", *_SSH_OPTIONS]),
str(local_dir).rstrip("/") + "/",
f"{host}:{rdir}/"]
# " 1,234,567 42% 1.23MB/s 0:00:03 (xfr#2, to-chk=5/9)"
_PERCENT = re.compile(r"^\s*[\d,.]+[KMGTP]?\s+(\d{1,3})%(?:\s|$)")
_XFR = re.compile(r"xfr#(\d+)")
def parse_progress2(line: str) -> int | None:
m = _PERCENT.match(line)
return min(int(m.group(1)), 100) if m else None
def _missing_tool(name: str) -> str:
return (f"The {name} command isn't installed, so LinTunes can't reach "
f"the server.")
class _ProcessWorker(QObject):
"""One subprocess on a daemon thread; cancel() kills it."""
failed = pyqtSignal(str)
cancelled = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._busy = False
self._cancel = threading.Event()
self._proc = None
self._lock = threading.Lock()
def busy(self) -> bool:
return self._busy
def cancel(self):
self._cancel.set()
with self._lock:
if self._proc is not None and self._proc.poll() is None:
self._proc.kill()
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()
except OSError as e:
self.failed.emit(str(e))
finally:
self._busy = False
def _spawn(self, argv: list[str]):
with self._lock:
if self._cancel.is_set():
return None
self._proc = subprocess.Popen(
argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
return self._proc
def _run(self):
raise NotImplementedError
class ServerCheckWorker(_ProcessWorker):
"""Does the mix's folder already exist on the server? Also the first
proof the connection works, before any export time is spent."""
result = pyqtSignal(bool)
def __init__(self, host: str, rdir: str, parent=None):
super().__init__(parent)
self._host = host
self._rdir = rdir
def _run(self):
if shutil.which("ssh") is None:
self.failed.emit(_missing_tool("ssh"))
return
proc = self._spawn(exists_command(self._host, self._rdir))
if proc is None:
self.cancelled.emit()
return
out = proc.communicate()[0].decode("utf-8", "replace")
if self._cancel.is_set():
self.cancelled.emit()
elif proc.returncode in (0, 1):
self.result.emit(proc.returncode == 0)
else:
tail = "\n".join(out.strip().splitlines()[-_ERROR_TAIL:])
self.failed.emit(
f"Couldn't connect to {self._host}"
+ (f":\n{tail}" if tail else "."))
class UploadWorker(_ProcessWorker):
"""rsync the staged mix to the server, reporting a percentage."""
progress = pyqtSignal(int, str) # percent, rsync's own progress line
finished = pyqtSignal(dict)
def __init__(self, host: str, local_dir: Path, rdir: str, parent=None):
super().__init__(parent)
self._host = host
self._local = Path(local_dir)
self._rdir = rdir
def _run(self):
if shutil.which("rsync") is None:
self.failed.emit(_missing_tool("rsync"))
return
proc = self._spawn(rsync_command(self._host, self._local, self._rdir))
if proc is None:
self.cancelled.emit()
return
tail: list[str] = []
files = 0
buf = b""
while True:
chunk = proc.stdout.read1(4096)
if not chunk:
break
buf += chunk
# progress2 rewrites one line with \r; errors end with \n.
parts = re.split(rb"[\r\n]", buf)
buf = parts.pop()
for raw in parts:
self._line(raw, tail)
m = _XFR.search(raw.decode("utf-8", "replace"))
if m:
files = int(m.group(1))
if buf:
self._line(buf, tail)
proc.wait()
if self._cancel.is_set():
self.cancelled.emit()
elif proc.returncode == 0:
self.finished.emit({"host": self._host, "dest": self._rdir,
"files_sent": files})
else:
text = "\n".join(tail[-_ERROR_TAIL:])
self.failed.emit(
f"Upload to {self._host} failed (rsync exit "
f"{proc.returncode})" + (f":\n{text}" if text else "."))
def _line(self, raw: bytes, tail: list):
line = raw.decode("utf-8", "replace").strip()
if not line:
return
percent = parse_progress2(line)
if percent is not None:
self.progress.emit(percent, line)
else:
tail.append(line)
+80 -6
View File
@@ -1,7 +1,8 @@
"""The two dialogs behind File → Export Playlist… """The two dialogs behind File → Export Playlist…
`ExportKindDialog` asks folder-or-website; `WebMixDialog` collects the bits of `ExportKindDialog` asks folder-or-website; `WebMixDialog` collects the bits of
a web mix that can't be inferred — title, blurb, hero image, accent color. a web mix that can't be inferred — title, blurb, hero image, accent color —
and whether to upload it to the web server set in Preferences (Round 81).
Per-track liner notes are deliberately absent: the hand-made mixes carry a numbered paragraph 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 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. index.html leaves a commented-out block in the right place instead.
@@ -12,12 +13,12 @@ from pathlib import Path
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QColorDialog, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QCheckBox, QColorDialog, QDialog, QDialogButtonBox, QFileDialog,
QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QRadioButton, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit,
QVBoxLayout) QPushButton, QRadioButton, QVBoxLayout)
from lintunes import theme from lintunes import theme
from lintunes.export import exporter from lintunes.export import exporter, upload
_IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.gif *.webp)" _IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.gif *.webp)"
@@ -72,10 +73,15 @@ class WebMixDialog(QDialog):
The accent isn't remembered between exports: it opens on The accent isn't remembered between exports: it opens on
``exporter.DEFAULT_ACCENT`` every time, so an export nobody touched comes ``exporter.DEFAULT_ACCENT`` every time, so an export nobody touched comes
out looking exactly like the mixes already online. out looking exactly like the mixes already online.
`config` is config.json's contents, read for the web server. The upload
box is never ticked by default, and a ticked box with no server set up
keeps the dialog open rather than failing later.
""" """
def __init__(self, playlist_name: str, parent=None): def __init__(self, playlist_name: str, parent=None, config=None):
super().__init__(parent) super().__init__(parent)
self._server = upload.server_settings(config or {})
self.setWindowTitle("Export Web Mix") self.setWindowTitle("Export Web Mix")
self.setMinimumWidth(520) self.setMinimumWidth(520)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -129,6 +135,29 @@ class WebMixDialog(QDialog):
note.setEnabled(False) note.setEnabled(False)
layout.addWidget(note) layout.addWidget(note)
layout.addSpacing(8)
self._upload = QCheckBox("Upload to web server")
layout.addWidget(self._upload)
upload_form = QFormLayout()
upload_form.setFieldGrowthPolicy(
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
upload_form.setContentsMargins(24, 0, 0, 0)
# The slug follows the title until it is edited by hand.
self._slug = QLineEdit(upload.slugify(playlist_name))
self._slug_edited = False
self._slug.textEdited.connect(self._on_slug_edited)
self._slug.textChanged.connect(self._refresh_upload_note)
self._title.textEdited.connect(self._on_title_edited)
upload_form.addRow("Folder on server:", self._slug)
layout.addLayout(upload_form)
self._upload_note = QLabel()
self._upload_note.setWordWrap(True)
self._upload_note.setIndent(24)
self._upload_note.setEnabled(False)
layout.addWidget(self._upload_note)
self._upload.toggled.connect(self._refresh_upload_note)
self._refresh_upload_note()
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel) | QDialogButtonBox.StandardButton.Cancel)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Export") buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Export")
@@ -136,6 +165,49 @@ class WebMixDialog(QDialog):
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
layout.addWidget(buttons) layout.addWidget(buttons)
def _on_title_edited(self, text: str):
if not self._slug_edited:
self._slug.setText(upload.slugify(text))
def _on_slug_edited(self, _text: str):
self._slug_edited = True
def _refresh_upload_note(self):
on = self._upload.isChecked()
self._slug.setEnabled(on)
if self._server is None:
text = ("Not set up yet — fill in the web server in Preferences → "
"Integrations → Web Server.")
else:
host, path = self._server
slug = self._slug.text().strip() or "…"
try:
where = f"{host}:{upload.remote_dir(path, slug)}"
except upload.UploadError:
where = f"{host}:{path.rstrip('/')}/{slug}"
text = (f"Uploads to {where} instead of a folder here. If a mix "
"with that folder name is already on the server, it is "
"replaced by this one.")
self._upload_note.setText(text)
def accept(self):
if self._upload.isChecked():
if self._server is None:
QMessageBox.warning(
self, "Web server not set up",
"Fill in the web server details in Preferences → "
"Integrations to upload this mix, or uncheck "
"“Upload to web server” to export it to a folder here.")
return
if not upload.valid_slug(self._slug.text().strip()):
QMessageBox.warning(
self, "Folder on server",
"Give the mix a folder name for the server — letters, "
"numbers and dashes work best, since it becomes part "
"of the web address.")
return
super().accept()
def _choose_image(self): def _choose_image(self):
start = str(Path(self._image.text()).parent) if self._image.text() else "" start = str(Path(self._image.text()).parent) if self._image.text() else ""
name, _filter = QFileDialog.getOpenFileName( name, _filter = QFileDialog.getOpenFileName(
@@ -161,4 +233,6 @@ class WebMixDialog(QDialog):
"description": self._description.toPlainText().strip(), "description": self._description.toPlainText().strip(),
"image": Path(text) if text else None, "image": Path(text) if text else None,
"accent": self._accent, "accent": self._accent,
"upload": self._upload.isChecked(),
"slug": self._slug.text().strip(),
} }
+229 -6
View File
@@ -18,7 +18,7 @@ from lintunes.art_search import AlbumArtFetcher
from lintunes.cast.controller import CastController from lintunes.cast.controller import CastController
from lintunes.eventlog import log_control from lintunes.eventlog import log_control
from lintunes.config import load_config, save_config from lintunes.config import load_config, save_config
from lintunes.export import exporter from lintunes.export import exporter, upload
from lintunes.fingerprint import TrackIdentifier, fpcalc_available from lintunes.fingerprint import TrackIdentifier, fpcalc_available
from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor
from lintunes.player import Player from lintunes.player import Player
@@ -44,6 +44,7 @@ from lintunes.gui.playlist_view import PlaylistView
from lintunes.gui.transport import TransportBar from lintunes.gui.transport import TransportBar
from lintunes.gui.url_import_dialog import UrlImportDialog from lintunes.gui.url_import_dialog import UrlImportDialog
from lintunes.gui.url_import_progress import UrlImportProgress from lintunes.gui.url_import_progress import UrlImportProgress
from lintunes.gui import web_upload_progress as web_progress
from lintunes.gui.info_dialog import InfoDialog from lintunes.gui.info_dialog import InfoDialog
from lintunes.gui.music_folder_dialog import MusicFolderDialog from lintunes.gui.music_folder_dialog import MusicFolderDialog
from lintunes.gui.preferences_dialog import PreferencesDialog from lintunes.gui.preferences_dialog import PreferencesDialog
@@ -217,6 +218,7 @@ class MainWindow(QMainWindow):
self._sync_label.hide() self._sync_label.hide()
# A click brings back a hidden link-import progress window. # A click brings back a hidden link-import progress window.
self._sync_label.clicked.connect(self._show_url_progress) self._sync_label.clicked.connect(self._show_url_progress)
self._sync_label.clicked.connect(self._show_web_progress)
self.statusBar().addPermanentWidget(self._sync_label) self.statusBar().addPermanentWidget(self._sync_label)
self._sync_progress = QProgressBar() self._sync_progress = QProgressBar()
self._sync_progress.setFixedWidth(160) self._sync_progress.setFixedWidth(160)
@@ -234,6 +236,13 @@ class MainWindow(QMainWindow):
# Export shares the status-bar progress widgets with device sync — # 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). # only one of the two may run at a time (each guards on the other).
self._export_worker = None self._export_worker = None
# Export Web Mix → Upload to web server: check the server, export into
# staging, rsync. _web_upload holds the run's host/folder/plan while
# it lasts; the window is non-modal and outlives a Hide.
self._server_check = None
self._upload_worker = None
self._web_upload = None
self._web_progress = None
# Identify Track… works through its selection one at a time, each # Identify Track… works through its selection one at a time, each
# behind its own review dialog. # behind its own review dialog.
self._identify_queue = [] self._identify_queue = []
@@ -586,7 +595,8 @@ class MainWindow(QMainWindow):
"""The transfer currently owning the status-bar progress widgets.""" """The transfer currently owning the status-bar progress widgets."""
for worker in (self._andtunes_planner, self._andtunes_worker, for worker in (self._andtunes_planner, self._andtunes_worker,
self._export_worker, self._url_worker, self._export_worker, self._url_worker,
self._bpm_worker): self._bpm_worker, self._server_check,
self._upload_worker):
if worker is not None and worker.busy(): if worker is not None and worker.busy():
return worker return worker
return None return None
@@ -599,6 +609,9 @@ class MainWindow(QMainWindow):
if self._url_worker is not None and self._url_worker.busy(): if self._url_worker is not None and self._url_worker.busy():
self._confirm_cancel_url_import() self._confirm_cancel_url_import()
return return
if self._web_upload is not None:
self._confirm_stop_web_upload()
return
if self._export_worker is not None and self._export_worker.busy(): if self._export_worker is not None and self._export_worker.busy():
self._confirm_cancel_export() self._confirm_cancel_export()
return return
@@ -937,10 +950,13 @@ class MainWindow(QMainWindow):
details = None details = None
if kind == exporter.WEB: if kind == exporter.WEB:
web_dialog = WebMixDialog(playlist.name, self) web_dialog = WebMixDialog(playlist.name, self, load_config())
if web_dialog.exec() != WebMixDialog.DialogCode.Accepted: if web_dialog.exec() != WebMixDialog.DialogCode.Accepted:
return return
details = web_dialog.values() details = web_dialog.values()
if details["upload"]:
self._export_and_upload(playlist, tracks, details)
return
start_dir = self._music_import_dir() start_dir = self._music_import_dir()
parent_dir = QFileDialog.getExistingDirectory( parent_dir = QFileDialog.getExistingDirectory(
@@ -964,7 +980,9 @@ class MainWindow(QMainWindow):
if not self._confirm_export(plan): if not self._confirm_export(plan):
return return
self._start_export(plan)
def _start_export(self, plan):
self._export_worker = exporter.ExportWorker(plan, self) self._export_worker = exporter.ExportWorker(plan, self)
self._export_worker.progress.connect(self._on_export_progress) self._export_worker.progress.connect(self._on_export_progress)
self._export_worker.finished.connect(self._on_export_finished) self._export_worker.finished.connect(self._on_export_finished)
@@ -979,7 +997,194 @@ class MainWindow(QMainWindow):
self._export_inhibitor.inhibit() self._export_inhibitor.inhibit()
self._export_worker.start() self._export_worker.start()
def _confirm_export(self, plan) -> bool: def _export_and_upload(self, playlist, tracks, details):
"""The upload half of Export Web Mix: no folder picker — the mix is
built in the cache's staging folder, then mirrored to the server."""
server = upload.server_settings(load_config())
if server is None: # the dialog checks; Preferences may not have
QMessageBox.warning(self, "Web server not set up",
"Fill in the web server in Preferences → "
"Integrations, then export again.")
return
host, path = server
slug = details["slug"]
try:
rdir = upload.remote_dir(path, slug)
plan = exporter.plan_export(
playlist.name, tracks, upload.staging_root(), exporter.WEB,
folder=slug)
except upload.UploadError as e:
QMessageBox.warning(self, "Export failed", str(e))
return
except OSError as e:
QMessageBox.warning(self, "Export failed",
f"Couldn't read the playlist's files: {e}")
return
plan.title = details["title"] or playlist.name
plan.description = details["description"]
plan.image = details["image"]
plan.accent = details["accent"]
if not self._confirm_export(plan, local=False):
return
self._web_upload = {"host": host, "rdir": rdir, "slug": slug,
"plan": plan, "step": web_progress.CHECK}
if self._web_progress is not None:
self._web_progress.close()
self._web_progress.deleteLater()
self._web_progress = web_progress.WebUploadProgress(
plan.title, f"{host}:{rdir}", self)
self._web_progress.stop_requested.connect(
self._confirm_stop_web_upload)
self._web_progress.show()
self._web_progress.begin(web_progress.CHECK, "Connecting…", busy=True)
self._server_check = upload.ServerCheckWorker(host, rdir, self)
self._server_check.result.connect(self._on_server_checked)
self._server_check.failed.connect(
lambda msg: self._web_upload_failed(web_progress.CHECK, msg))
self._server_check.cancelled.connect(self._web_upload_stopped)
self._sync_label.setText(f"Checking {host}…")
self._sync_progress.setRange(0, 0)
self._sync_cancel.show()
self._sync_label.show()
self._sync_progress.show()
self._server_check.start()
def _on_server_checked(self, exists: bool):
run = self._web_upload
if run is None:
return
if exists:
self._web_progress.complete(web_progress.CHECK,
"Connected — folder exists")
answer = QMessageBox.question(
self, "Replace mix on server?",
f"“{run['slug']}” already exists on {run['host']}.\n\n"
"Uploading replaces it with this mix, deleting any files in "
"that folder that aren't part of it. Continue?")
if answer != QMessageBox.StandardButton.Yes:
self._web_upload_stopped()
return
else:
self._web_progress.complete(web_progress.CHECK,
"Connected — new folder")
try:
upload.reset_staging(run["slug"])
except (upload.UploadError, OSError) as e:
self._web_upload_failed(web_progress.EXPORT,
f"Couldn't clear the staging folder: {e}")
return
run["step"] = web_progress.EXPORT
self._web_progress.begin(web_progress.EXPORT)
self._start_export(run["plan"])
def _start_upload(self, summary):
run = self._web_upload
run["step"] = web_progress.UPLOAD
run["export"] = summary
self._web_progress.complete(
web_progress.EXPORT, f"{summary['exported']} tracks")
self._web_progress.begin(web_progress.UPLOAD, "Starting…")
self._upload_worker = upload.UploadWorker(
run["host"], run["plan"].dest_dir, run["rdir"], self)
self._upload_worker.progress.connect(self._on_upload_progress)
self._upload_worker.finished.connect(self._on_upload_finished)
self._upload_worker.failed.connect(
lambda msg: self._web_upload_failed(web_progress.UPLOAD, msg))
self._upload_worker.cancelled.connect(self._web_upload_stopped)
self._sync_label.setText(f"Uploading to {run['host']}")
self._sync_progress.setRange(0, 100)
self._sync_progress.setValue(0)
self._sync_cancel.show()
self._sync_label.show()
self._sync_progress.show()
self._export_inhibitor.inhibit()
self._upload_worker.start()
def _on_upload_progress(self, percent: int, line: str):
if self._web_upload is None:
return
self._web_progress.progress(web_progress.UPLOAD, percent, line)
self._sync_progress.setRange(0, 100)
self._sync_progress.setValue(percent)
self._sync_label.setText(
f"Uploading to {self._web_upload['host']} · {percent}%")
def _on_upload_finished(self, result):
run, self._web_upload = self._web_upload, None
self._export_inhibitor.release()
self._hide_sync_widgets()
if run is None:
return
sent = result.get("files_sent", 0)
self._web_progress.complete(
web_progress.UPLOAD,
f"{sent} file{'s' if sent != 1 else ''} sent")
summary = run.get("export", {})
msg = (f"Uploaded “{summary.get('playlist', run['slug'])}” to "
f"{run['host']}:{run['rdir']}")
extras = []
if summary.get("converted"):
extras.append(f"{summary['converted']} converted to FLAC")
if summary.get("skipped"):
extras.append(f"{summary['skipped']} skipped")
if summary.get("vanished"):
extras.append(f"{summary['vanished']} changed under us "
"(export again to pick them up)")
if extras:
msg += " — " + ", ".join(extras)
self._web_progress.finish(msg)
self.statusBar().showMessage(msg, 12000)
sounds.play_done(self._prefs)
def _web_upload_failed(self, step: int, message: str):
run, self._web_upload = self._web_upload, None
self._export_inhibitor.release()
self._hide_sync_widgets()
if run is None:
return
if step == web_progress.UPLOAD:
message += ("\n\nThe mix's folder on the server may be "
"half-updated; uploading again finishes it.")
self._web_progress.fail(step, message)
self._show_web_progress()
self.statusBar().showMessage("Web mix upload failed", 8000)
def _web_upload_stopped(self, *_args):
run, self._web_upload = self._web_upload, None
self._export_inhibitor.release()
self._hide_sync_widgets()
if run is None:
return
self._web_progress.fail(run["step"], "Stopped — nothing more was "
"sent. Export again to finish.", "Stopped")
self.statusBar().showMessage("Web mix upload stopped", 6000)
def _confirm_stop_web_upload(self):
run = self._web_upload
if run is None:
return
answer = QMessageBox.question(
self, "Stop upload",
"Stop exporting and uploading this mix?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No)
if answer != QMessageBox.StandardButton.Yes or self._web_upload is None:
return
for worker in (self._server_check, self._export_worker,
self._upload_worker):
if worker is not None and worker.busy():
worker.cancel()
def _show_web_progress(self):
if self._web_progress is not None and (
self._web_upload is not None or self._web_progress.ended):
self._web_progress.show()
self._web_progress.raise_()
self._web_progress.activateWindow()
def _confirm_export(self, plan, local: bool = True) -> bool:
"""Last look before writing: overwrite, ffmpeg, and skipped tracks.""" """Last look before writing: overwrite, ffmpeg, and skipped tracks."""
if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available(): if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available():
names = "\n".join(f" • {i.display}" for i in plan.conversions[:8]) names = "\n".join(f" • {i.display}" for i in plan.conversions[:8])
@@ -1014,7 +1219,7 @@ class MainWindow(QMainWindow):
if answer != QMessageBox.StandardButton.Yes: if answer != QMessageBox.StandardButton.Yes:
return False return False
if plan.dest_dir.exists(): if local and plan.dest_dir.exists():
answer = QMessageBox.question( answer = QMessageBox.question(
self, "Folder exists", self, "Folder exists",
f"“{plan.dest_dir.name}” already exists in that folder. " f"“{plan.dest_dir.name}” already exists in that folder. "
@@ -1043,6 +1248,10 @@ class MainWindow(QMainWindow):
self._export_worker.cancel() self._export_worker.cancel()
def _on_export_progress(self, done_kib, total_kib, label): def _on_export_progress(self, done_kib, total_kib, label):
if self._web_upload is not None:
self._web_progress.progress(
web_progress.EXPORT, done_kib * 100 // max(total_kib, 1),
label)
self._sync_progress.setRange(0, total_kib) self._sync_progress.setRange(0, total_kib)
self._sync_progress.setValue(done_kib) self._sync_progress.setValue(done_kib)
self._sync_label.setText( self._sync_label.setText(
@@ -1050,6 +1259,9 @@ class MainWindow(QMainWindow):
f"{device_sync.format_bytes(total_kib * 1024)}") f"{device_sync.format_bytes(total_kib * 1024)}")
def _on_export_finished(self, summary): def _on_export_finished(self, summary):
if self._web_upload is not None:
self._start_upload(summary)
return
self._export_inhibitor.release() self._export_inhibitor.release()
self._hide_sync_widgets() self._hide_sync_widgets()
what = "web mix" if summary["kind"] == exporter.WEB else "folder" what = "web mix" if summary["kind"] == exporter.WEB else "folder"
@@ -1066,6 +1278,9 @@ class MainWindow(QMainWindow):
sounds.play_done(self._prefs) sounds.play_done(self._prefs)
def _on_export_cancelled(self, summary): def _on_export_cancelled(self, summary):
if self._web_upload is not None:
self._web_upload_stopped()
return
self._export_inhibitor.release() self._export_inhibitor.release()
self._hide_sync_widgets() self._hide_sync_widgets()
self.statusBar().showMessage( self.statusBar().showMessage(
@@ -1073,6 +1288,9 @@ class MainWindow(QMainWindow):
f"tracks written to {summary['dest']}.", 8000) f"tracks written to {summary['dest']}.", 8000)
def _on_export_failed(self, message): def _on_export_failed(self, message):
if self._web_upload is not None:
self._web_upload_failed(web_progress.EXPORT, message)
return
self._export_inhibitor.release() self._export_inhibitor.release()
self._hide_sync_widgets() self._hide_sync_widgets()
QMessageBox.warning(self, "Export failed", message) QMessageBox.warning(self, "Export failed", message)
@@ -2106,7 +2324,9 @@ class MainWindow(QMainWindow):
# it is simply stopped below. # it is simply stopped below.
busy = self._busy_worker() busy = self._busy_worker()
if busy is not None and busy not in (self._export_worker, if busy is not None and busy not in (self._export_worker,
self._url_worker): self._url_worker,
self._server_check,
self._upload_worker):
box = QMessageBox(self) box = QMessageBox(self)
box.setWindowTitle("Sync in progress") box.setWindowTitle("Sync in progress")
box.setText( box.setText(
@@ -2125,6 +2345,9 @@ class MainWindow(QMainWindow):
if self._url_worker is not None: if self._url_worker is not None:
self._url_worker.cancel() self._url_worker.cancel()
self._url_worker.cleanup() self._url_worker.cleanup()
for worker in (self._server_check, self._upload_worker):
if worker is not None:
worker.cancel() # kills ssh/rsync rather than orphan it
self._inhibitor.release() self._inhibitor.release()
self._sync_inhibitor.release() self._sync_inhibitor.release()
# Before the player: this stops the device and kills the HTTP server it # Before the player: this stops the device and kills the HTTP server it
+45 -1
View File
@@ -13,6 +13,7 @@ from PyQt6.QtGui import QFont, QColor, QFontInfo
from lintunes import theme from lintunes import theme
from lintunes.config import load_config, save_config from lintunes.config import load_config, save_config
from lintunes.export import upload
from lintunes.gui import visualizer_styles from lintunes.gui import visualizer_styles
from lintunes.gui.range_slider import RangeSlider from lintunes.gui.range_slider import RangeSlider
from lintunes.gui.laser_diagram import LaserDiagram from lintunes.gui.laser_diagram import LaserDiagram
@@ -122,7 +123,8 @@ class PreferencesDialog(QDialog):
self._build_visualizer_group(), self._build_visualizer_group(),
self._build_colors_group()]), self._build_colors_group()]),
("Integrations", [self._build_lastfm_group(), ("Integrations", [self._build_lastfm_group(),
self._build_acoustid_group()])): self._build_acoustid_group(),
self._build_web_server_group()])):
self._add_page(title, groups) self._add_page(title, groups)
self.pages_list.currentRowChanged.connect(self._show_page) self.pages_list.currentRowChanged.connect(self._show_page)
self._style_headings() self._style_headings()
@@ -932,6 +934,48 @@ class PreferencesDialog(QDialog):
form.addRow("AcoustID key:", self._acoustid_key) form.addRow("AcoustID key:", self._acoustid_key)
return group return group
def _build_web_server_group(self) -> QGroupBox:
"""Where Export Web Mix uploads to. config.json, not preferences: the
host is usually an ssh alias on *this* machine, and nothing about the
server should reach the synced share."""
group = QGroupBox("Web Server")
form = QFormLayout(group)
hint = QLabel(
"Export Web Mix can upload a mix straight to a web server over "
"ssh (rsync). Pick a folder on the server that's used only for "
"LinTunes mixes: each mix goes into its own folder inside it, and "
"uploading a mix again makes that folder match the export "
"exactly, deleting anything else in it. Needs ssh key login; a "
"password prompt can't be answered from here. Saved on this "
"computer only.")
hint.setWordWrap(True)
form.addRow(hint)
config = load_config()
self._web_host = QLineEdit(config.get(upload.HOST_KEY, ""))
self._web_host.setPlaceholderText("myserver")
self._web_host.setToolTip(
"An ssh host — a Host alias from ~/.ssh/config works, "
"as does user@example.com")
self._web_host.editingFinished.connect(self._save_web_server)
form.addRow("Host:", self._web_host)
self._web_path = QLineEdit(config.get(upload.PATH_KEY, ""))
self._web_path.setPlaceholderText("/var/www/html/mixes")
self._web_path.setToolTip(
"The full path of the mixes folder on the server. It must "
"already exist.")
self._web_path.editingFinished.connect(self._save_web_server)
form.addRow("Folder:", self._web_path)
return group
def _save_web_server(self):
config = load_config()
values = {upload.HOST_KEY: self._web_host.text().strip(),
upload.PATH_KEY: self._web_path.text().strip()}
if all(config.get(k, "") == v for k, v in values.items()):
return
config.update(values)
save_config(config)
def _save_acoustid_key(self): def _save_acoustid_key(self):
key = self._acoustid_key.text().strip() key = self._acoustid_key.text().strip()
if key != self._prefs.acoustid.get("api_key", ""): if key != self._prefs.acoustid.get("api_key", ""):
+110
View File
@@ -0,0 +1,110 @@
"""The progress window for Export Web Mix → Upload to web server (Round 81).
Three steps, one row each: check the server (does the mix's folder exist,
and can we reach it at all), export into the staging folder, upload with
rsync. Non-modal, like the link-import list: Hide only closes it, the work
carries on, and clicking the status-bar line brings it back. Stop asks
MainWindow to cancel whichever step is running.
"""
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtWidgets import (
QDialog, QGridLayout, QHBoxLayout, QLabel, QProgressBar, QPushButton,
QVBoxLayout,
)
CHECK, EXPORT, UPLOAD = range(3)
STEP_NAMES = ("Check server", "Export", "Upload")
WAITING = "Waiting"
class WebUploadProgress(QDialog):
stop_requested = pyqtSignal()
def __init__(self, title: str, where: str, parent=None):
super().__init__(parent)
self.setWindowTitle("Uploading Web Mix")
self.setModal(False)
self.setMinimumWidth(520)
self.ended = False
layout = QVBoxLayout(self)
heading = QLabel(f"“{title}” → {where}")
heading.setWordWrap(True)
layout.addWidget(heading)
grid = QGridLayout()
self._status: list[QLabel] = []
self._bars: list[QProgressBar] = []
for row, name in enumerate(STEP_NAMES):
grid.addWidget(QLabel(name), row, 0)
bar = QProgressBar()
bar.setRange(0, 100)
bar.setValue(0)
bar.setTextVisible(False)
grid.addWidget(bar, row, 1)
status = QLabel(WAITING)
grid.addWidget(status, row, 2)
self._bars.append(bar)
self._status.append(status)
grid.setColumnStretch(1, 1)
layout.addLayout(grid)
self._detail = QLabel()
self._detail.setWordWrap(True)
self._detail.setEnabled(False)
layout.addWidget(self._detail)
buttons = QHBoxLayout()
buttons.addStretch(1)
self._stop = QPushButton("Stop")
self._stop.clicked.connect(self.stop_requested)
self._hide = QPushButton("Hide")
self._hide.setToolTip("The upload carries on. Click the progress "
"in the status bar to see this again.")
self._hide.clicked.connect(self.close)
buttons.addWidget(self._stop)
buttons.addWidget(self._hide)
layout.addLayout(buttons)
def status(self, step: int) -> str:
return self._status[step].text()
def begin(self, step: int, text: str = "Working…", busy: bool = False):
bar = self._bars[step]
if busy:
bar.setRange(0, 0) # no measurable progress (ssh check)
else:
bar.setRange(0, 100)
bar.setValue(0)
self._status[step].setText(text)
def progress(self, step: int, percent: int, detail: str = ""):
bar = self._bars[step]
bar.setRange(0, 100)
bar.setValue(max(0, min(percent, 100)))
self._status[step].setText(f"{percent}%")
if detail:
self._detail.setText(detail)
def complete(self, step: int, text: str = "Done"):
bar = self._bars[step]
bar.setRange(0, 100)
bar.setValue(100)
self._status[step].setText(text)
def fail(self, step: int, message: str, text: str = "Failed"):
bar = self._bars[step]
bar.setRange(0, 100)
self._status[step].setText(text)
self.finish(message)
def finish(self, message: str):
"""Every step that never started stays as it is; the window is done."""
self.ended = True
self._detail.setText(message)
self._detail.setEnabled(True)
for step, bar in enumerate(self._bars):
if bar.maximum() == 0:
bar.setRange(0, 100)
self._stop.setEnabled(False)
self._hide.setText("Close")
+467
View File
@@ -0,0 +1,467 @@
"""Round 81: Export Web Mix can upload to a web server, and the player shuffles.
- The server (host + folder) lives in config.json, per machine.
- Upload = rsync mirror over ssh into <folder>/<slug>/, never the folder
itself, from a staging tree that is rebuilt every time.
- No test ever runs ssh or rsync: Popen and which are stubbed.
"""
import io
import time
from pathlib import Path
import pytest
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes import config
from lintunes.export import exporter, upload
from lintunes.models import Track
HOST = "example-host"
def _track(tid, path):
return Track(track_id=tid, name=f"Song {tid}", artist="Artist",
location=str(path), kind="MPEG audio file",
total_time=180_000)
def _audio(tmp_path, name):
path = tmp_path / "local" / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"x" * 100)
return path
@pytest.fixture(autouse=True)
def _xdg(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
# ---- pure helpers ----
@pytest.mark.parametrize("title, slug", [
("Summer Mix 2026!", "summer-mix-2026"),
("Étoile Filante", "etoile-filante"),
(" rock / roll ", "rock-roll"),
("🎵 Night 🎵", "night"),
("!!!", ""),
])
def test_slugify(title, slug):
assert upload.slugify(title) == slug
@pytest.mark.parametrize("slug, ok", [
("mix", True), ("", False), (".", False), ("..", False), ("a/b", False)])
def test_valid_slug(slug, ok):
assert upload.valid_slug(slug) is ok
def test_server_settings_needs_both():
assert upload.server_settings({}) is None
assert upload.server_settings({upload.HOST_KEY: HOST}) is None
assert upload.server_settings(
{upload.HOST_KEY: f" {HOST} ", upload.PATH_KEY: "/srv/mixes "}) \
== (HOST, "/srv/mixes")
def test_remote_dir_is_always_inside_the_folder():
assert upload.remote_dir("/srv/mixes/", "mix") == "/srv/mixes/mix"
assert upload.remote_dir("~/www/mixes", "mix") == "www/mixes/mix"
assert upload.remote_dir("~", "mix") == "mix"
for bad in ("", "..", "a/b"):
with pytest.raises(upload.UploadError):
upload.remote_dir("/srv/mixes", bad)
def test_exists_command_quotes_the_path():
argv = upload.exists_command(HOST, "/srv/my mixes/a")
assert argv[0] == "ssh" and "BatchMode=yes" in argv
assert argv[-3:] == ["test", "-d", "'/srv/my mixes/a'"]
assert HOST in argv
def test_rsync_command_mirrors_into_the_slug_folder(tmp_path):
argv = upload.rsync_command(HOST, tmp_path / "mix", "/srv/mixes/mix")
assert argv[0] == "rsync"
assert "--delete" in argv and "--checksum" in argv
assert "BatchMode=yes" in argv[argv.index("-e") + 1]
assert argv[-2] == str(tmp_path / "mix") + "/" # contents, not folder
assert argv[-1] == f"{HOST}:/srv/mixes/mix/"
def test_reset_staging_stays_inside_the_staging_root(tmp_path):
root = upload.staging_root()
built = root / "mix" / "audios"
built.mkdir(parents=True)
(built / "old.mp3").write_bytes(b"x")
target = upload.reset_staging("mix")
assert target == root / "mix" and not target.exists()
assert root.exists()
for bad in ("", "..", "../x"):
with pytest.raises(upload.UploadError):
upload.reset_staging(bad)
@pytest.mark.parametrize("line, percent", [
(" 32,768 0% 0.00kB/s 0:00:00 (xfr#0, to-chk=10/12)", 0),
(" 1,234,567 42% 1.23MB/s 0:00:03 (xfr#2, to-chk=5/9)", 42),
(" 12.34M 100% 10.00MB/s 0:00:01", 100),
("rsync: [Receiver] mkdir \"/srv/x\" failed: No such file (2)", None),
("sending incremental file list", None),
])
def test_parse_progress2(line, percent):
assert upload.parse_progress2(line) == percent
def test_plan_export_folder_override(tmp_path):
tracks = [_track(1, _audio(tmp_path, "a.mp3"))]
plan = exporter.plan_export("My Mix", tracks, tmp_path / "out",
exporter.WEB, folder="my-mix")
assert plan.dest_dir == tmp_path / "out" / "my-mix"
assert plan.audio_dir == tmp_path / "out" / "my-mix" / "audios"
assert exporter.plan_export("My Mix", tracks, tmp_path / "out",
exporter.WEB).dest_dir.name == "My Mix"
# ---- workers, with a fake subprocess ----
class _FakeProc:
def __init__(self, output: bytes, returncode: int):
self.stdout = io.BufferedReader(io.BytesIO(output))
self.returncode = None
self._code = returncode
self.killed = False
def poll(self):
return self.returncode
def wait(self):
self.returncode = self._code
return self._code
def communicate(self):
self.returncode = self._code
return self.stdout.read(), None
def kill(self):
self.killed = True
def _fake_popen(monkeypatch, output=b"", code=0):
calls = []
def popen(argv, **kwargs):
proc = _FakeProc(output, code)
calls.append((argv, proc))
return proc
monkeypatch.setattr(upload.subprocess, "Popen", popen)
monkeypatch.setattr(upload.shutil, "which", lambda name: f"/usr/bin/{name}")
return calls
def _collect(worker, *names):
seen = {n: [] for n in names}
for n in names:
getattr(worker, n).connect(lambda *a, n=n: seen[n].append(a))
return seen
@pytest.mark.parametrize("code, outcome", [(0, True), (1, False)])
def test_server_check_reports_existence(qapp, monkeypatch, code, outcome):
calls = _fake_popen(monkeypatch, code=code)
worker = upload.ServerCheckWorker(HOST, "/srv/mixes/mix")
seen = _collect(worker, "result", "failed")
worker._run()
assert seen["result"] == [(outcome,)] and not seen["failed"]
assert calls[0][0][0] == "ssh"
def test_server_check_connection_error(qapp, monkeypatch):
_fake_popen(monkeypatch, b"ssh: Could not resolve hostname\n", 255)
worker = upload.ServerCheckWorker(HOST, "/srv/mixes/mix")
seen = _collect(worker, "result", "failed")
worker._run()
assert not seen["result"]
assert "Could not resolve hostname" in seen["failed"][0][0]
def test_missing_rsync_is_an_error_not_a_crash(qapp, monkeypatch, tmp_path):
monkeypatch.setattr(upload.shutil, "which", lambda name: None)
worker = upload.UploadWorker(HOST, tmp_path, "/srv/mixes/mix")
seen = _collect(worker, "failed", "finished")
worker._run()
assert "rsync" in seen["failed"][0][0] and not seen["finished"]
def test_upload_reports_progress_and_files(qapp, monkeypatch, tmp_path):
out = (b"sending incremental file list\n"
b" 32,768 10% 1.00MB/s 0:00:01 (xfr#1, to-chk=3/5)\r"
b" 327,680 100% 1.00MB/s 0:00:01 (xfr#3, to-chk=0/5)\n")
_fake_popen(monkeypatch, out, 0)
worker = upload.UploadWorker(HOST, tmp_path, "/srv/mixes/mix")
seen = _collect(worker, "progress", "finished", "failed")
worker._run()
assert [p[0] for p in seen["progress"]] == [10, 100]
assert seen["finished"][0][0]["files_sent"] == 3
assert not seen["failed"]
def test_upload_failure_carries_rsyncs_message(qapp, monkeypatch, tmp_path):
_fake_popen(monkeypatch,
b'rsync: [Receiver] mkdir "/srv/mixes/mix" failed: '
b"No such file or directory (2)\n", 11)
worker = upload.UploadWorker(HOST, tmp_path, "/srv/mixes/mix")
seen = _collect(worker, "failed", "finished")
worker._run()
assert "No such file or directory" in seen["failed"][0][0]
assert "exit 11" in seen["failed"][0][0]
def test_cancel_kills_the_process(qapp, monkeypatch, tmp_path):
calls = _fake_popen(monkeypatch, b"", 0)
worker = upload.UploadWorker(HOST, tmp_path, "/srv/mixes/mix")
worker._spawn(["rsync"])
worker.cancel()
assert calls[0][1].killed
# ---- the dialogs ----
class _FakeLastFm(QObject):
login_finished = pyqtSignal(bool, str)
status_message = pyqtSignal(str)
def is_logged_in(self):
return False
def test_upload_box_is_off_by_default_and_slug_follows_title(qapp):
from lintunes.gui.export_dialog import WebMixDialog
dialog = WebMixDialog("Summer Mix", None, {})
assert not dialog._upload.isChecked()
assert dialog.values()["slug"] == "summer-mix"
dialog._title.textEdited.emit("Fall Mix")
assert dialog.values()["slug"] == "fall-mix"
dialog._slug.textEdited.emit("custom")
dialog._slug.setText("custom")
dialog._title.textEdited.emit("Winter Mix")
assert dialog.values()["slug"] == "custom"
def test_ticked_without_a_server_stays_open(qapp, monkeypatch):
from lintunes.gui import export_dialog
told = []
monkeypatch.setattr(export_dialog.QMessageBox, "warning",
lambda *a, **k: told.append(a[2]))
dialog = export_dialog.WebMixDialog("Mix", None, {})
dialog._upload.setChecked(True)
dialog.accept()
assert told and "Preferences" in told[0] and "uncheck" in told[0]
assert dialog.result() != dialog.DialogCode.Accepted
def test_ticked_with_a_server_accepts(qapp, monkeypatch):
from lintunes.gui import export_dialog
monkeypatch.setattr(export_dialog.QMessageBox, "warning",
lambda *a, **k: pytest.fail("should not warn"))
dialog = export_dialog.WebMixDialog(
"Mix", None, {upload.HOST_KEY: HOST, upload.PATH_KEY: "/srv/mixes"})
dialog._upload.setChecked(True)
assert f"{HOST}:/srv/mixes/mix" in dialog._upload_note.text()
dialog.accept()
assert dialog.result() == dialog.DialogCode.Accepted
assert dialog.values()["upload"] is True
def test_preferences_save_the_server_to_config_json(qapp):
from lintunes.gui.preferences_dialog import PreferencesDialog
from lintunes.preferences import Preferences
prefs = Preferences(Path(config.config_path()).parent / "data")
dialog = PreferencesDialog(prefs, _FakeLastFm())
dialog._web_host.setText(HOST)
dialog._web_path.setText("/srv/mixes")
dialog._save_web_server()
saved = config.load_config()
assert saved[upload.HOST_KEY] == HOST
assert saved[upload.PATH_KEY] == "/srv/mixes"
# Nothing about the server reaches the synced preferences.
pref_file = Path(config.config_path()).parent / "data" / "preferences.json"
assert not pref_file.exists() or HOST not in pref_file.read_text()
# ---- the whole flow through MainWindow, with fake workers ----
class _FakeCheck:
exists = False
def __init__(self, host, rdir, parent=None):
from PyQt6.QtCore import QObject, pyqtSignal
class S(QObject):
result = pyqtSignal(bool)
failed = pyqtSignal(str)
cancelled = pyqtSignal()
self._s = S()
self.result, self.failed = self._s.result, self._s.failed
self.cancelled = self._s.cancelled
def busy(self):
return False
def cancel(self):
pass
def start(self):
self.result.emit(_FakeCheck.exists)
class _FakeUpload:
runs = []
def __init__(self, host, local, rdir, parent=None):
from PyQt6.QtCore import QObject, pyqtSignal
class S(QObject):
progress = pyqtSignal(int, str)
finished = pyqtSignal(dict)
failed = pyqtSignal(str)
cancelled = pyqtSignal()
self._s = S()
for n in ("progress", "finished", "failed", "cancelled"):
setattr(self, n, getattr(self._s, n))
self.args = (host, Path(local), rdir)
def busy(self):
return False
def cancel(self):
pass
def start(self):
host, local, rdir = self.args
_FakeUpload.runs.append(
(host, rdir, sorted(p.relative_to(local).as_posix()
for p in local.rglob("*") if p.is_file())))
self.finished.emit({"host": host, "dest": rdir, "files_sent": 2})
@pytest.fixture
def window(qapp, tmp_path, monkeypatch):
from lintunes.gui import main_window as mw
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Playlist
from lintunes.models.playlist import PlaylistType
from lintunes.preferences import Preferences
library = Library(
tracks={1: _track(1, _audio(tmp_path, "a.mp3")),
2: _track(2, _audio(tmp_path, "b.mp3"))},
playlists={"abcd1234": Playlist(
name="My Mix", persistent_id="abcd1234",
playlist_type=PlaylistType.REGULAR, track_ids=[1, 2])})
manager = LibraryManager(library, tmp_path / "data")
win = mw.MainWindow(manager, Preferences(tmp_path / "data"))
class Kind:
DialogCode = mw.ExportKindDialog.DialogCode
def __init__(self, *a):
pass
def exec(self):
return self.DialogCode.Accepted
def kind(self):
return exporter.WEB
class Web(Kind):
def values(self):
return {"title": "My Mix", "description": "", "image": None,
"accent": exporter.DEFAULT_ACCENT, "upload": True,
"slug": "my-mix"}
monkeypatch.setattr(mw, "ExportKindDialog", Kind)
monkeypatch.setattr(mw, "WebMixDialog", Web)
monkeypatch.setattr(mw.upload, "ServerCheckWorker", _FakeCheck)
monkeypatch.setattr(mw.upload, "UploadWorker", _FakeUpload)
monkeypatch.setattr(mw.QFileDialog, "getExistingDirectory",
lambda *a, **k: pytest.fail("no folder picker"))
monkeypatch.setattr(mw.sounds, "play_done", lambda *a: None)
_FakeUpload.runs = []
_FakeCheck.exists = False
yield win
if win._web_progress is not None:
win._web_progress.close()
win.close()
def _wait_for(qapp, cond, timeout=5):
end = time.monotonic() + timeout
while not cond() and time.monotonic() < end:
qapp.processEvents()
time.sleep(0.01)
assert cond()
def test_upload_flow_exports_to_staging_then_uploads(qapp, window):
config.save_config({upload.HOST_KEY: HOST, upload.PATH_KEY: "/srv/mixes"})
window._export_playlist("abcd1234")
_wait_for(qapp, lambda: _FakeUpload.runs)
host, rdir, files = _FakeUpload.runs[0]
assert (host, rdir) == (HOST, "/srv/mixes/my-mix")
assert "index.html" in files
assert "audios/Artist - Song 1.mp3" in files
assert window._web_upload is None
assert window._web_progress.ended
def test_a_removed_song_doesnt_survive_in_staging(qapp, window):
config.save_config({upload.HOST_KEY: HOST, upload.PATH_KEY: "/srv/mixes"})
window._export_playlist("abcd1234")
_wait_for(qapp, lambda: _FakeUpload.runs)
window._manager.library.playlists["abcd1234"].track_ids = [1]
window._export_playlist("abcd1234")
_wait_for(qapp, lambda: len(_FakeUpload.runs) == 2)
files = _FakeUpload.runs[1][2]
assert "audios/Artist - Song 2.mp3" not in files
def test_existing_folder_asks_and_no_stops(qapp, window, monkeypatch):
from lintunes.gui import main_window as mw
config.save_config({upload.HOST_KEY: HOST, upload.PATH_KEY: "/srv/mixes"})
_FakeCheck.exists = True
asked = []
def question(*a, **k):
asked.append(a[2])
return mw.QMessageBox.StandardButton.No
monkeypatch.setattr(mw.QMessageBox, "question", question)
window._export_playlist("abcd1234")
assert asked and "already exists" in asked[0]
assert window._web_upload is None and not _FakeUpload.runs
assert window._web_progress.status(2) == "Waiting"
def test_unticked_export_is_the_old_flow(qapp, window, monkeypatch, tmp_path):
from lintunes.gui import main_window as mw
picked = []
monkeypatch.setattr(mw.QFileDialog, "getExistingDirectory",
lambda *a, **k: picked.append(1) or "")
original = mw.WebMixDialog.values
monkeypatch.setattr(mw.WebMixDialog, "values",
lambda self: {**original(self), "upload": False})
window._export_playlist("abcd1234")
assert picked and window._web_upload is None
# ---- the web player's shuffle ----
def test_player_ships_a_shuffle_button():
js = (exporter.templates() / "player.js").read_text()
css = (exporter.templates() / "player.css").read_text()
assert 'class="shuffle"' in js and "setShuffle" in js
assert ".audiojs .shuffle.on" in css