From 660740992ab17d909d3d7f9158c4dbe960a32d35 Mon Sep 17 00:00:00 2001 From: trav Date: Sat, 10 Oct 2026 23:39:06 -0700 Subject: [PATCH] v0.49.0: a web mix can carry several images, one after another under the description --- CLAUDE.md | 5 ++- lintunes/__init__.py | 2 +- lintunes/export/exporter.py | 44 ++++++++++++++------- lintunes/gui/export_dialog.py | 74 +++++++++++++++++++++++++---------- lintunes/gui/main_window.py | 4 +- tests/test_round37.py | 3 +- tests/test_round81.py | 54 ++++++++++++++++++++++++- 7 files changed, 145 insertions(+), 41 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a619996..1f7b63f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -660,7 +660,10 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal 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. + the current song and plays every song once. A mix can carry several + images (`plan.images`): the dialog always keeps one empty row to add + another, and the page shows them in order under the description, a blank + line apart; none at all ships `placeholder.png`. - **`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 e1dc59f..6ae27d0 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.48.2" +__version__ = "0.49.0" diff --git a/lintunes/export/exporter.py b/lintunes/export/exporter.py index c650297..c071fe5 100644 --- a/lintunes/export/exporter.py +++ b/lintunes/export/exporter.py @@ -107,7 +107,7 @@ class ExportPlan: m3u_name: str # "" for a web mix, which writes no m3u title: str = "" description: str = "" - image: Path | None = None # None = ship the gray placeholder + images: list = field(default_factory=list) # [] = the gray placeholder accent: str = DEFAULT_ACCENT # hover backgrounds + the progress fill items: list = field(default_factory=list) skipped: list = field(default_factory=list) # (display, reason) @@ -208,18 +208,21 @@ def build_tracklist(items: list) -> str: return "\n".join(rows) -def build_index_html(plan: ExportPlan, image_name: str | None) -> str: +def build_index_html(plan: ExportPlan, image_names) -> str: """Render index.html for `plan`. The title and track names are escaped; the description deliberately is **not**, because the hand-made mixes lean on inline links and
in exactly that spot and the dialog says so. + + `image_names` are the copied images, shown one after another with a + blank line between each (a single name still works). """ - if image_name: - image_tag = (f'
') - else: - image_tag = "" + if isinstance(image_names, str): + image_names = [image_names] + image_tag = "\n

\n".join( + f'
' + for name in image_names or []) accent = normalize_accent(plan.accent) tmpl = string.Template((templates() / "index.html.tmpl").read_text(encoding="utf-8")) return tmpl.substitute( @@ -383,16 +386,27 @@ class ExportWorker(QObject): if plan.kind == WEB: for name in _WEB_ASSETS: shutil.copyfile(templates() / name, plan.dest_dir / name) - if plan.image is not None and Path(plan.image).is_file(): - image_name = sanitize_name(Path(plan.image).stem) \ - + Path(plan.image).suffix.lower() - shutil.copyfile(plan.image, plan.dest_dir / image_name) - else: - image_name = "placeholder.png" + image_names = [] + for image in plan.images: + image = Path(image) + if not image.is_file(): + continue + name = sanitize_name(image.stem) + image.suffix.lower() + # Two pictures called cover.jpg from different folders must + # not overwrite each other. + stem, ext = os.path.splitext(name) + n = 2 + while name in image_names: + name = f"{stem} {n}{ext}" + n += 1 + shutil.copyfile(image, plan.dest_dir / name) + image_names.append(name) + if not image_names: + image_names = ["placeholder.png"] shutil.copyfile(templates() / "placeholder.png", - plan.dest_dir / image_name) + plan.dest_dir / "placeholder.png") (plan.dest_dir / "index.html").write_text( - build_index_html(plan, image_name), encoding="utf-8") + build_index_html(plan, image_names), encoding="utf-8") return entries = [(item.dest_name, item.secs, item.display) diff --git a/lintunes/gui/export_dialog.py b/lintunes/gui/export_dialog.py index e737402..dbe8f2c 100644 --- a/lintunes/gui/export_dialog.py +++ b/lintunes/gui/export_dialog.py @@ -1,7 +1,7 @@ """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, images, 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 @@ -15,7 +15,7 @@ from PyQt6.QtGui import QColor from PyQt6.QtWidgets import ( QCheckBox, QColorDialog, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, - QPushButton, QRadioButton, QVBoxLayout) + QPushButton, QRadioButton, QVBoxLayout, QWidget) from lintunes import theme from lintunes.export import exporter, upload @@ -68,7 +68,7 @@ class ExportKindDialog(QDialog): class WebMixDialog(QDialog): - """Title, description, hero image and accent color for a web mix. + """Title, description, images and accent color for a web mix. The accent isn't remembered between exports: it opens on ``exporter.DEFAULT_ACCENT`` every time, so an export nobody touched comes @@ -97,18 +97,15 @@ class WebMixDialog(QDialog): self._description.setMinimumHeight(140) form.addRow("Description:", self._description) - image_row = QHBoxLayout() - self._image = QLineEdit() - self._image.setPlaceholderText("(a gray placeholder)") - self._image.setReadOnly(True) - image_row.addWidget(self._image, stretch=1) - browse = QPushButton("Browse…") - browse.clicked.connect(self._choose_image) - image_row.addWidget(browse) - clear = QPushButton("Clear") - clear.clicked.connect(lambda: self._image.clear()) - image_row.addWidget(clear) - form.addRow("Image:", image_row) + # One row per image, always with an empty one at the end: choosing a + # file in the last row adds another below it, and clearing any other + # row removes it. + images = QWidget() + self._image_rows = QVBoxLayout(images) + self._image_rows.setContentsMargins(0, 0, 0, 0) + self._images: list[QLineEdit] = [] + self._add_image_row() + form.addRow("Images:", images) color_row = QHBoxLayout() self._accent = exporter.DEFAULT_ACCENT @@ -208,12 +205,49 @@ class WebMixDialog(QDialog): return super().accept() - def _choose_image(self): - start = str(Path(self._image.text()).parent) if self._image.text() else "" + def _add_image_row(self): + row = QWidget() + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 0, 0, 0) + field = QLineEdit() + field.setReadOnly(True) + field.setPlaceholderText("(a gray placeholder)" if not self._images + else "(add another image)") + layout.addWidget(field, stretch=1) + browse = QPushButton("Browse…") + browse.clicked.connect(lambda: self._choose_image(field)) + layout.addWidget(browse) + clear = QPushButton("Clear") + clear.clicked.connect(lambda: self._clear_image(field)) + layout.addWidget(clear) + self._images.append(field) + self._image_rows.addWidget(row) + + def _choose_image(self, field: QLineEdit): + # Start where this row's image is, or where the last one came from. + near = field.text() or next( + (f.text() for f in reversed(self._images) if f.text()), "") + start = str(Path(near).parent) if near else "" name, _filter = QFileDialog.getOpenFileName( self, "Choose Image", start, _IMAGE_FILTER) if name: - self._image.setText(name) + self._set_image(field, name) + + def _set_image(self, field: QLineEdit, path: str): + field.setText(path) + if field is self._images[-1]: + self._add_image_row() + + def _clear_image(self, field: QLineEdit): + if field is self._images[-1]: + field.clear() + return + index = self._images.index(field) + self._images.pop(index) + row = self._image_rows.takeAt(index).widget() + row.deleteLater() + # The first row's placeholder says what an export with none gets. + self._images[0].setPlaceholderText("(a gray placeholder)") def _set_accent(self, color_hex: str): self._accent = exporter.normalize_accent(color_hex) @@ -227,11 +261,11 @@ class WebMixDialog(QDialog): self._set_accent(color.name()) def values(self) -> dict: - text = self._image.text().strip() return { "title": self._title.text().strip(), "description": self._description.toPlainText().strip(), - "image": Path(text) if text else None, + "images": [Path(f.text().strip()) for f in self._images + if f.text().strip()], "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 f612760..15e4baf 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -975,7 +975,7 @@ class MainWindow(QMainWindow): if details is not None: plan.title = details["title"] or playlist.name plan.description = details["description"] - plan.image = details["image"] + plan.images = details["images"] plan.accent = details["accent"] if not self._confirm_export(plan): @@ -1022,7 +1022,7 @@ class MainWindow(QMainWindow): return plan.title = details["title"] or playlist.name plan.description = details["description"] - plan.image = details["image"] + plan.images = details["images"] plan.accent = details["accent"] if not self._confirm_export(plan, local=False): return diff --git a/tests/test_round37.py b/tests/test_round37.py index 79a9991..8065f52 100644 --- a/tests/test_round37.py +++ b/tests/test_round37.py @@ -311,7 +311,8 @@ class TestWebExport: image = tmp_path / "friend.png" image.write_bytes(b"\x89PNG\r\n\x1a\n" + b"fake") dest, _ = self._export( - tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))], image=image) + tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))], + images=[image]) assert (dest / "friend.png").read_bytes() == image.read_bytes() assert 'src="friend.png"' in (dest / "index.html").read_text() diff --git a/tests/test_round81.py b/tests/test_round81.py index 4a1539e..d1ed72e 100644 --- a/tests/test_round81.py +++ b/tests/test_round81.py @@ -379,7 +379,7 @@ def window(qapp, tmp_path, monkeypatch): class Web(Kind): def values(self): - return {"title": "My Mix", "description": "", "image": None, + return {"title": "My Mix", "description": "", "images": [], "accent": exporter.DEFAULT_ACCENT, "upload": True, "slug": "my-mix"} @@ -483,3 +483,55 @@ def test_finished_window_hides_stop(qapp): win.finish("done") assert win._stop.isHidden() and win._hide.text() == "Close" win.close() + + +# ---- several images ---- + +def test_choosing_an_image_adds_another_row(qapp, tmp_path): + from lintunes.gui.export_dialog import WebMixDialog + dialog = WebMixDialog("Mix", None, {}) + assert len(dialog._images) == 1 and dialog.values()["images"] == [] + dialog._set_image(dialog._images[0], str(tmp_path / "a.png")) + assert len(dialog._images) == 2 and not dialog._images[1].text() + dialog._set_image(dialog._images[1], str(tmp_path / "b.png")) + assert len(dialog._images) == 3 + # Re-choosing an earlier row doesn't add one. + dialog._set_image(dialog._images[0], str(tmp_path / "c.png")) + assert len(dialog._images) == 3 + assert dialog.values()["images"] == [tmp_path / "c.png", + tmp_path / "b.png"] + + +def test_clearing_a_row_removes_it_but_keeps_an_empty_one(qapp, tmp_path): + from lintunes.gui.export_dialog import WebMixDialog + dialog = WebMixDialog("Mix", None, {}) + dialog._set_image(dialog._images[0], str(tmp_path / "a.png")) + dialog._set_image(dialog._images[1], str(tmp_path / "b.png")) + dialog._clear_image(dialog._images[0]) + assert dialog.values()["images"] == [tmp_path / "b.png"] + assert len(dialog._images) == 2 and not dialog._images[-1].text() + dialog._clear_image(dialog._images[-1]) # the empty one stays + assert len(dialog._images) == 2 + + +def test_several_images_land_in_order_with_a_gap(tmp_path): + a = tmp_path / "pics" / "cover.png" + b = tmp_path / "pics2" / "cover.png" # same name, other folder + c = tmp_path / "pics" / "band.jpg" + for path, data in ((a, b"A"), (b, b"B"), (c, b"C")): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + plan = exporter.plan_export( + "Mix", [_track(1, _audio(tmp_path, "a.mp3"))], tmp_path / "out", + exporter.WEB) + plan.images = [a, b, c] + worker = exporter.ExportWorker(plan) + worker._run() + dest = tmp_path / "out" / "Mix" + page = (dest / "index.html").read_text() + names = ["cover.png", "cover 2.png", "band.jpg"] + spots = [page.index(f'src="{n}"') for n in names] + assert spots == sorted(spots) + assert page[spots[0]:spots[1]].count("

") == 1 + assert (dest / "cover 2.png").read_bytes() == b"B" + assert not (dest / "placeholder.png").exists()