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:
@@ -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
|
||||
registered in `setup.py` via `package_data`; loaded with
|
||||
`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
|
||||
`Cassette friend library.md`). LinTunes never talks to a friend: it drives
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.47.1"
|
||||
__version__ = "0.48.0"
|
||||
|
||||
@@ -123,13 +123,14 @@ class ExportPlan:
|
||||
|
||||
|
||||
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.
|
||||
|
||||
`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
|
||||
plan = ExportPlan(
|
||||
kind=kind,
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
.audiojs audio { position: absolute; left: -1px; }
|
||||
|
||||
.audiojs {
|
||||
width: 250px;
|
||||
width: 286px;
|
||||
height: 36px;
|
||||
background: #eee;
|
||||
color: #000;
|
||||
@@ -88,6 +88,27 @@
|
||||
.audiojs .time em { padding: 0px 2px 0px 0px; color: #000; font-style: 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 {
|
||||
float: left;
|
||||
display: none;
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
* Expected page markup:
|
||||
* <audio preload></audio>
|
||||
* <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 () {
|
||||
"use strict";
|
||||
@@ -24,7 +30,14 @@
|
||||
'</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>';
|
||||
'<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) {
|
||||
if (!isFinite(seconds) || seconds < 0) seconds = 0;
|
||||
@@ -48,7 +61,8 @@
|
||||
scrubber: wrap.querySelector(".scrubber"),
|
||||
played: wrap.querySelector(".played"),
|
||||
duration: wrap.querySelector(".duration"),
|
||||
error: wrap.querySelector(".error-message")
|
||||
error: wrap.querySelector(".error-message"),
|
||||
shuffle: wrap.querySelector(".shuffle")
|
||||
};
|
||||
|
||||
function state(name) {
|
||||
@@ -112,6 +126,7 @@
|
||||
|
||||
return {
|
||||
element: audio,
|
||||
shuffleButton: ui.shuffle,
|
||||
load: function (src) {
|
||||
ui.progress.style.width = "0%";
|
||||
ui.loaded.style.width = "0%";
|
||||
@@ -133,6 +148,44 @@
|
||||
if (!audio || !items.length) return;
|
||||
|
||||
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) {
|
||||
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
|
||||
// page had the wrap-around commented out, so this matches it.
|
||||
// Advance to the next track, stopping at the end of the mix (or of the
|
||||
// shuffled walk) — 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;
|
||||
var next = current && step(current, 1);
|
||||
if (next) select(next, true);
|
||||
});
|
||||
|
||||
@@ -164,10 +218,13 @@
|
||||
var current = document.querySelector("ol li.playing");
|
||||
if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
select((current && current.nextElementSibling) || items[0], true);
|
||||
select((current && step(current, 1)) || first(), true);
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
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") {
|
||||
e.preventDefault();
|
||||
player.playPause();
|
||||
|
||||
@@ -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)
|
||||
@@ -1,7 +1,8 @@
|
||||
"""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, 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, 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.
|
||||
@@ -12,12 +13,12 @@ from pathlib import Path
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QColor
|
||||
from PyQt6.QtWidgets import (
|
||||
QColorDialog, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
|
||||
QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QRadioButton,
|
||||
QVBoxLayout)
|
||||
QCheckBox, QColorDialog, QDialog, QDialogButtonBox, QFileDialog,
|
||||
QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit,
|
||||
QPushButton, QRadioButton, QVBoxLayout)
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.export import exporter
|
||||
from lintunes.export import exporter, upload
|
||||
|
||||
_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
|
||||
``exporter.DEFAULT_ACCENT`` every time, so an export nobody touched comes
|
||||
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)
|
||||
self._server = upload.server_settings(config or {})
|
||||
self.setWindowTitle("Export Web Mix")
|
||||
self.setMinimumWidth(520)
|
||||
layout = QVBoxLayout(self)
|
||||
@@ -129,6 +135,29 @@ class WebMixDialog(QDialog):
|
||||
note.setEnabled(False)
|
||||
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
|
||||
| QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Export")
|
||||
@@ -136,6 +165,49 @@ class WebMixDialog(QDialog):
|
||||
buttons.rejected.connect(self.reject)
|
||||
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):
|
||||
start = str(Path(self._image.text()).parent) if self._image.text() else ""
|
||||
name, _filter = QFileDialog.getOpenFileName(
|
||||
@@ -161,4 +233,6 @@ class WebMixDialog(QDialog):
|
||||
"description": self._description.toPlainText().strip(),
|
||||
"image": Path(text) if text else None,
|
||||
"accent": self._accent,
|
||||
"upload": self._upload.isChecked(),
|
||||
"slug": self._slug.text().strip(),
|
||||
}
|
||||
|
||||
+229
-6
@@ -18,7 +18,7 @@ from lintunes.art_search import AlbumArtFetcher
|
||||
from lintunes.cast.controller import CastController
|
||||
from lintunes.eventlog import log_control
|
||||
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.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor
|
||||
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.url_import_dialog import UrlImportDialog
|
||||
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.music_folder_dialog import MusicFolderDialog
|
||||
from lintunes.gui.preferences_dialog import PreferencesDialog
|
||||
@@ -217,6 +218,7 @@ class MainWindow(QMainWindow):
|
||||
self._sync_label.hide()
|
||||
# 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_web_progress)
|
||||
self.statusBar().addPermanentWidget(self._sync_label)
|
||||
self._sync_progress = QProgressBar()
|
||||
self._sync_progress.setFixedWidth(160)
|
||||
@@ -234,6 +236,13 @@ class MainWindow(QMainWindow):
|
||||
# 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
|
||||
# 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
|
||||
# behind its own review dialog.
|
||||
self._identify_queue = []
|
||||
@@ -586,7 +595,8 @@ class MainWindow(QMainWindow):
|
||||
"""The transfer currently owning the status-bar progress widgets."""
|
||||
for worker in (self._andtunes_planner, self._andtunes_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():
|
||||
return worker
|
||||
return None
|
||||
@@ -599,6 +609,9 @@ class MainWindow(QMainWindow):
|
||||
if self._url_worker is not None and self._url_worker.busy():
|
||||
self._confirm_cancel_url_import()
|
||||
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():
|
||||
self._confirm_cancel_export()
|
||||
return
|
||||
@@ -937,10 +950,13 @@ class MainWindow(QMainWindow):
|
||||
|
||||
details = None
|
||||
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:
|
||||
return
|
||||
details = web_dialog.values()
|
||||
if details["upload"]:
|
||||
self._export_and_upload(playlist, tracks, details)
|
||||
return
|
||||
|
||||
start_dir = self._music_import_dir()
|
||||
parent_dir = QFileDialog.getExistingDirectory(
|
||||
@@ -964,7 +980,9 @@ class MainWindow(QMainWindow):
|
||||
|
||||
if not self._confirm_export(plan):
|
||||
return
|
||||
self._start_export(plan)
|
||||
|
||||
def _start_export(self, plan):
|
||||
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)
|
||||
@@ -979,7 +997,194 @@ class MainWindow(QMainWindow):
|
||||
self._export_inhibitor.inhibit()
|
||||
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."""
|
||||
if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available():
|
||||
names = "\n".join(f" • {i.display}" for i in plan.conversions[:8])
|
||||
@@ -1014,7 +1219,7 @@ class MainWindow(QMainWindow):
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return False
|
||||
|
||||
if plan.dest_dir.exists():
|
||||
if local and plan.dest_dir.exists():
|
||||
answer = QMessageBox.question(
|
||||
self, "Folder exists",
|
||||
f"“{plan.dest_dir.name}” already exists in that folder. "
|
||||
@@ -1043,6 +1248,10 @@ class MainWindow(QMainWindow):
|
||||
self._export_worker.cancel()
|
||||
|
||||
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.setValue(done_kib)
|
||||
self._sync_label.setText(
|
||||
@@ -1050,6 +1259,9 @@ class MainWindow(QMainWindow):
|
||||
f"{device_sync.format_bytes(total_kib * 1024)}")
|
||||
|
||||
def _on_export_finished(self, summary):
|
||||
if self._web_upload is not None:
|
||||
self._start_upload(summary)
|
||||
return
|
||||
self._export_inhibitor.release()
|
||||
self._hide_sync_widgets()
|
||||
what = "web mix" if summary["kind"] == exporter.WEB else "folder"
|
||||
@@ -1066,6 +1278,9 @@ class MainWindow(QMainWindow):
|
||||
sounds.play_done(self._prefs)
|
||||
|
||||
def _on_export_cancelled(self, summary):
|
||||
if self._web_upload is not None:
|
||||
self._web_upload_stopped()
|
||||
return
|
||||
self._export_inhibitor.release()
|
||||
self._hide_sync_widgets()
|
||||
self.statusBar().showMessage(
|
||||
@@ -1073,6 +1288,9 @@ class MainWindow(QMainWindow):
|
||||
f"tracks written to {summary['dest']}.", 8000)
|
||||
|
||||
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._hide_sync_widgets()
|
||||
QMessageBox.warning(self, "Export failed", message)
|
||||
@@ -2106,7 +2324,9 @@ class MainWindow(QMainWindow):
|
||||
# it is simply stopped below.
|
||||
busy = self._busy_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.setWindowTitle("Sync in progress")
|
||||
box.setText(
|
||||
@@ -2125,6 +2345,9 @@ class MainWindow(QMainWindow):
|
||||
if self._url_worker is not None:
|
||||
self._url_worker.cancel()
|
||||
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._sync_inhibitor.release()
|
||||
# Before the player: this stops the device and kills the HTTP server it
|
||||
|
||||
@@ -13,6 +13,7 @@ from PyQt6.QtGui import QFont, QColor, QFontInfo
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.config import load_config, save_config
|
||||
from lintunes.export import upload
|
||||
from lintunes.gui import visualizer_styles
|
||||
from lintunes.gui.range_slider import RangeSlider
|
||||
from lintunes.gui.laser_diagram import LaserDiagram
|
||||
@@ -122,7 +123,8 @@ class PreferencesDialog(QDialog):
|
||||
self._build_visualizer_group(),
|
||||
self._build_colors_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.pages_list.currentRowChanged.connect(self._show_page)
|
||||
self._style_headings()
|
||||
@@ -932,6 +934,48 @@ class PreferencesDialog(QDialog):
|
||||
form.addRow("AcoustID key:", self._acoustid_key)
|
||||
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):
|
||||
key = self._acoustid_key.text().strip()
|
||||
if key != self._prefs.acoustid.get("api_key", ""):
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user