diff --git a/CLAUDE.md b/CLAUDE.md index 936dbb2..a619996 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/ + /` (no folder picker) and mirrors it with `rsync -a --checksum + --delete` over ssh to `://`. 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 diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 56bb6de..0b23bfa 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.47.1" +__version__ = "0.48.0" diff --git a/lintunes/export/exporter.py b/lintunes/export/exporter.py index 0b975d2..c650297 100644 --- a/lintunes/export/exporter.py +++ b/lintunes/export/exporter.py @@ -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, diff --git a/lintunes/export/templates/player.css b/lintunes/export/templates/player.css index 7589d14..fbac128 100644 --- a/lintunes/export/templates/player.css +++ b/lintunes/export/templates/player.css @@ -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; diff --git a/lintunes/export/templates/player.js b/lintunes/export/templates/player.js index a745466..6de5935 100644 --- a/lintunes/export/templates/player.js +++ b/lintunes/export/templates/player.js @@ -13,6 +13,12 @@ * Expected page markup: * *
  1. Title — Artist
  2. …
+ * + * 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 @@ '' + '
' + '
00:00/00:00
' + - '
'; + '
' + + '
' + + '' + + '
'; 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(); diff --git a/lintunes/export/upload.py b/lintunes/export/upload.py new file mode 100644 index 0000000..6f5ed82 --- /dev/null +++ b/lintunes/export/upload.py @@ -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 ``://``. 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 ``//`` 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) diff --git a/lintunes/gui/export_dialog.py b/lintunes/gui/export_dialog.py index bb9a8a2..e737402 100644 --- a/lintunes/gui/export_dialog.py +++ b/lintunes/gui/export_dialog.py @@ -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(), } diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 43bc525..07abf8b 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -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 diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index 08cb287..b0488c8 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -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", ""): diff --git a/lintunes/gui/web_upload_progress.py b/lintunes/gui/web_upload_progress.py new file mode 100644 index 0000000..cc2c519 --- /dev/null +++ b/lintunes/gui/web_upload_progress.py @@ -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") diff --git a/tests/test_round81.py b/tests/test_round81.py new file mode 100644 index 0000000..0dd5710 --- /dev/null +++ b/tests/test_round81.py @@ -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 //, 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 +