v0.49.0: a web mix can carry several images, one after another under the description
This commit is contained in:
@@ -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
|
existing folder asks before replacing it), `ExportWorker`, then
|
||||||
`UploadWorker`, all shown in `gui/web_upload_progress.py`. The web
|
`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
|
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
|
- **`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.48.2"
|
__version__ = "0.49.0"
|
||||||
|
|||||||
+29
-15
@@ -107,7 +107,7 @@ class ExportPlan:
|
|||||||
m3u_name: str # "" for a web mix, which writes no m3u
|
m3u_name: str # "" for a web mix, which writes no m3u
|
||||||
title: str = ""
|
title: str = ""
|
||||||
description: 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
|
accent: str = DEFAULT_ACCENT # hover backgrounds + the progress fill
|
||||||
items: list = field(default_factory=list)
|
items: list = field(default_factory=list)
|
||||||
skipped: list = field(default_factory=list) # (display, reason)
|
skipped: list = field(default_factory=list) # (display, reason)
|
||||||
@@ -208,18 +208,21 @@ def build_tracklist(items: list) -> str:
|
|||||||
return "\n".join(rows)
|
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`.
|
"""Render index.html for `plan`.
|
||||||
|
|
||||||
The title and track names are escaped; the description deliberately is
|
The title and track names are escaped; the description deliberately is
|
||||||
**not**, because the hand-made mixes lean on inline links and <br> in
|
**not**, because the hand-made mixes lean on inline links and <br> in
|
||||||
exactly that spot and the dialog says so.
|
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:
|
if isinstance(image_names, str):
|
||||||
image_tag = (f' <img src="{html.escape(image_name, quote=True)}"'
|
image_names = [image_names]
|
||||||
f' width="400"><br>')
|
image_tag = "\n <br><br>\n".join(
|
||||||
else:
|
f' <img src="{html.escape(name, quote=True)}" width="400"><br>'
|
||||||
image_tag = ""
|
for name in image_names or [])
|
||||||
accent = normalize_accent(plan.accent)
|
accent = normalize_accent(plan.accent)
|
||||||
tmpl = string.Template((templates() / "index.html.tmpl").read_text(encoding="utf-8"))
|
tmpl = string.Template((templates() / "index.html.tmpl").read_text(encoding="utf-8"))
|
||||||
return tmpl.substitute(
|
return tmpl.substitute(
|
||||||
@@ -383,16 +386,27 @@ class ExportWorker(QObject):
|
|||||||
if plan.kind == WEB:
|
if plan.kind == WEB:
|
||||||
for name in _WEB_ASSETS:
|
for name in _WEB_ASSETS:
|
||||||
shutil.copyfile(templates() / name, plan.dest_dir / name)
|
shutil.copyfile(templates() / name, plan.dest_dir / name)
|
||||||
if plan.image is not None and Path(plan.image).is_file():
|
image_names = []
|
||||||
image_name = sanitize_name(Path(plan.image).stem) \
|
for image in plan.images:
|
||||||
+ Path(plan.image).suffix.lower()
|
image = Path(image)
|
||||||
shutil.copyfile(plan.image, plan.dest_dir / image_name)
|
if not image.is_file():
|
||||||
else:
|
continue
|
||||||
image_name = "placeholder.png"
|
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",
|
shutil.copyfile(templates() / "placeholder.png",
|
||||||
plan.dest_dir / image_name)
|
plan.dest_dir / "placeholder.png")
|
||||||
(plan.dest_dir / "index.html").write_text(
|
(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
|
return
|
||||||
|
|
||||||
entries = [(item.dest_name, item.secs, item.display)
|
entries = [(item.dest_name, item.secs, item.display)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""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, images, accent color —
|
||||||
and whether to upload it to the web server set in Preferences (Round 81).
|
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
|
||||||
@@ -15,7 +15,7 @@ from PyQt6.QtGui import QColor
|
|||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QCheckBox, QColorDialog, QDialog, QDialogButtonBox, QFileDialog,
|
QCheckBox, QColorDialog, QDialog, QDialogButtonBox, QFileDialog,
|
||||||
QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit,
|
QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit,
|
||||||
QPushButton, QRadioButton, QVBoxLayout)
|
QPushButton, QRadioButton, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
from lintunes import theme
|
from lintunes import theme
|
||||||
from lintunes.export import exporter, upload
|
from lintunes.export import exporter, upload
|
||||||
@@ -68,7 +68,7 @@ class ExportKindDialog(QDialog):
|
|||||||
|
|
||||||
|
|
||||||
class WebMixDialog(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
|
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
|
||||||
@@ -97,18 +97,15 @@ class WebMixDialog(QDialog):
|
|||||||
self._description.setMinimumHeight(140)
|
self._description.setMinimumHeight(140)
|
||||||
form.addRow("Description:", self._description)
|
form.addRow("Description:", self._description)
|
||||||
|
|
||||||
image_row = QHBoxLayout()
|
# One row per image, always with an empty one at the end: choosing a
|
||||||
self._image = QLineEdit()
|
# file in the last row adds another below it, and clearing any other
|
||||||
self._image.setPlaceholderText("(a gray placeholder)")
|
# row removes it.
|
||||||
self._image.setReadOnly(True)
|
images = QWidget()
|
||||||
image_row.addWidget(self._image, stretch=1)
|
self._image_rows = QVBoxLayout(images)
|
||||||
browse = QPushButton("Browse…")
|
self._image_rows.setContentsMargins(0, 0, 0, 0)
|
||||||
browse.clicked.connect(self._choose_image)
|
self._images: list[QLineEdit] = []
|
||||||
image_row.addWidget(browse)
|
self._add_image_row()
|
||||||
clear = QPushButton("Clear")
|
form.addRow("Images:", images)
|
||||||
clear.clicked.connect(lambda: self._image.clear())
|
|
||||||
image_row.addWidget(clear)
|
|
||||||
form.addRow("Image:", image_row)
|
|
||||||
|
|
||||||
color_row = QHBoxLayout()
|
color_row = QHBoxLayout()
|
||||||
self._accent = exporter.DEFAULT_ACCENT
|
self._accent = exporter.DEFAULT_ACCENT
|
||||||
@@ -208,12 +205,49 @@ class WebMixDialog(QDialog):
|
|||||||
return
|
return
|
||||||
super().accept()
|
super().accept()
|
||||||
|
|
||||||
def _choose_image(self):
|
def _add_image_row(self):
|
||||||
start = str(Path(self._image.text()).parent) if self._image.text() else ""
|
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(
|
name, _filter = QFileDialog.getOpenFileName(
|
||||||
self, "Choose Image", start, _IMAGE_FILTER)
|
self, "Choose Image", start, _IMAGE_FILTER)
|
||||||
if name:
|
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):
|
def _set_accent(self, color_hex: str):
|
||||||
self._accent = exporter.normalize_accent(color_hex)
|
self._accent = exporter.normalize_accent(color_hex)
|
||||||
@@ -227,11 +261,11 @@ class WebMixDialog(QDialog):
|
|||||||
self._set_accent(color.name())
|
self._set_accent(color.name())
|
||||||
|
|
||||||
def values(self) -> dict:
|
def values(self) -> dict:
|
||||||
text = self._image.text().strip()
|
|
||||||
return {
|
return {
|
||||||
"title": self._title.text().strip(),
|
"title": self._title.text().strip(),
|
||||||
"description": self._description.toPlainText().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,
|
"accent": self._accent,
|
||||||
"upload": self._upload.isChecked(),
|
"upload": self._upload.isChecked(),
|
||||||
"slug": self._slug.text().strip(),
|
"slug": self._slug.text().strip(),
|
||||||
|
|||||||
@@ -975,7 +975,7 @@ class MainWindow(QMainWindow):
|
|||||||
if details is not None:
|
if details is not None:
|
||||||
plan.title = details["title"] or playlist.name
|
plan.title = details["title"] or playlist.name
|
||||||
plan.description = details["description"]
|
plan.description = details["description"]
|
||||||
plan.image = details["image"]
|
plan.images = details["images"]
|
||||||
plan.accent = details["accent"]
|
plan.accent = details["accent"]
|
||||||
|
|
||||||
if not self._confirm_export(plan):
|
if not self._confirm_export(plan):
|
||||||
@@ -1022,7 +1022,7 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
plan.title = details["title"] or playlist.name
|
plan.title = details["title"] or playlist.name
|
||||||
plan.description = details["description"]
|
plan.description = details["description"]
|
||||||
plan.image = details["image"]
|
plan.images = details["images"]
|
||||||
plan.accent = details["accent"]
|
plan.accent = details["accent"]
|
||||||
if not self._confirm_export(plan, local=False):
|
if not self._confirm_export(plan, local=False):
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -311,7 +311,8 @@ class TestWebExport:
|
|||||||
image = tmp_path / "friend.png"
|
image = tmp_path / "friend.png"
|
||||||
image.write_bytes(b"\x89PNG\r\n\x1a\n" + b"fake")
|
image.write_bytes(b"\x89PNG\r\n\x1a\n" + b"fake")
|
||||||
dest, _ = self._export(
|
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 (dest / "friend.png").read_bytes() == image.read_bytes()
|
||||||
assert 'src="friend.png"' in (dest / "index.html").read_text()
|
assert 'src="friend.png"' in (dest / "index.html").read_text()
|
||||||
|
|
||||||
|
|||||||
+53
-1
@@ -379,7 +379,7 @@ def window(qapp, tmp_path, monkeypatch):
|
|||||||
|
|
||||||
class Web(Kind):
|
class Web(Kind):
|
||||||
def values(self):
|
def values(self):
|
||||||
return {"title": "My Mix", "description": "", "image": None,
|
return {"title": "My Mix", "description": "", "images": [],
|
||||||
"accent": exporter.DEFAULT_ACCENT, "upload": True,
|
"accent": exporter.DEFAULT_ACCENT, "upload": True,
|
||||||
"slug": "my-mix"}
|
"slug": "my-mix"}
|
||||||
|
|
||||||
@@ -483,3 +483,55 @@ def test_finished_window_hides_stop(qapp):
|
|||||||
win.finish("done")
|
win.finish("done")
|
||||||
assert win._stop.isHidden() and win._hide.text() == "Close"
|
assert win._stop.isHidden() and win._hide.text() == "Close"
|
||||||
win.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("<br><br>") == 1
|
||||||
|
assert (dest / "cover 2.png").read_bytes() == b"B"
|
||||||
|
assert not (dest / "placeholder.png").exists()
|
||||||
|
|||||||
Reference in New Issue
Block a user