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
|
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,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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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…
|
"""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
@@ -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
|
||||||
|
|||||||
@@ -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", ""):
|
||||||
|
|||||||
@@ -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