From 3150df0f8933fdb833c8ccf5b09690a952690b3c Mon Sep 17 00:00:00 2001 From: trav Date: Tue, 22 Sep 2026 09:38:39 -0700 Subject: [PATCH] v0.23.0: covers for the whole album - Multi-select Get Info keeps the art square; a pasted cover goes into every selected track (shared cover shown, else "mixed artwork"). - Paste Artwork button + Ctrl+V anywhere outside a text field; a caption says what happened. Clipboard reads that don't decode are retried and never staged; every attempt is logged at INFO. - Album art search queries Deezer alongside iTunes and ranks by album match (iTunes has no copy of Digable Planets' Reachin' at all). - embed_artwork shared by Get Info and Download Album Art; single-track Get Info now invalidates the MPRIS art cache. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 14 ++ lintunes/__init__.py | 2 +- lintunes/art_search.py | 153 ++++++++++++++++- lintunes/gui/album_art_dialog.py | 5 +- lintunes/gui/art_ops.py | 33 ++++ lintunes/gui/info_dialog.py | 277 +++++++++++++++++++++++------- lintunes/gui/main_window.py | 26 +-- tasks-done.md | 27 +++ tests/test_round60.py | 279 +++++++++++++++++++++++++++++++ 9 files changed, 723 insertions(+), 93 deletions(-) create mode 100644 lintunes/gui/art_ops.py create mode 100644 tests/test_round60.py diff --git a/CLAUDE.md b/CLAUDE.md index 2d9ba08..5bce986 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,6 +195,20 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal Stdlib only, cached, and every failure path returns `None` — a machine with no Syncthing must still merge, just without naming anyone. +- **`lintunes/art_search.py`** — Download Album Art. Queries **both** the + iTunes Search API and Deezer's public album search (neither needs a key) + and merges them through `rank_candidates`: album match beats artist match, + so a right-artist, wrong-album hit never wins. Two sources because neither + catalog is complete: iTunes has **no copy at all** of Digable Planets' + *Reachin'*, which Deezer finds on the first query (Round 60). One source + failing is not an error while the other answers. Embedding, whether from + there or from Get Info, goes through `gui/art_ops.embed_artwork`: write, + `mpris.invalidate_artwork`, size refresh. Get Info's `ArtSquare` **never + stages bytes that don't decode** (`clipboard_image` checks every candidate + with `QImage.loadFromData`), because a clipboard can advertise `image/png` + and hand over nothing. Every paste attempt is logged at INFO + (`lintunes.gui.info_dialog`), so it shows up in the journal. + - **`lintunes/fingerprint.py`** — "Identify Track…": Chromaprint's `fpcalc` CLI (detected with `shutil.which`, the `ffmpeg_available()` pattern — a runtime tool, never a pip dep) fingerprints the file, and AcoustID's web API diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 5613d7c..73ad5dc 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.22.1" +__version__ = "0.23.0" diff --git a/lintunes/art_search.py b/lintunes/art_search.py index d455d5f..e81b2fa 100644 --- a/lintunes/art_search.py +++ b/lintunes/art_search.py @@ -1,17 +1,26 @@ -"""Album-art lookup via the iTunes Search API (no API key required). +"""Album-art lookup via the iTunes Search API and Deezer (neither needs a key). -Pure parsing/URL helpers are separated from the network calls so they can be -tested offline; ``AlbumArtFetcher`` runs the whole search+download on a +Two catalogs because neither is complete: iTunes has no copy at all of some +well-known albums (Digable Planets' *Reachin'* returns nothing however it's +spelled) that Deezer finds on the first try, and vice versa. Results from both +are merged and ranked by how well they match what was asked for, so a stray +hit from the artist's other album never outranks the real one. + +Pure parsing/ranking helpers are separated from the network calls so they can +be tested offline; ``AlbumArtFetcher`` runs the whole search+download on a daemon thread (the lastfm pattern) and reports back over a Qt signal, which is delivered queued on the GUI thread. """ +import re import threading +import unicodedata from dataclasses import dataclass from PyQt6.QtCore import QObject, pyqtSignal ITUNES_SEARCH_URL = "https://itunes.apple.com/search" +DEEZER_SEARCH_URL = "https://api.deezer.com/search/album" ART_SIZE = 600 # px; the API hands out 100x100 URLs that scale on request TIMEOUT_S = 15 @@ -21,6 +30,7 @@ class ArtCandidate: artist: str album: str art_url: str # already upgraded to ART_SIZE + source: str = "iTunes" def upgrade_artwork_url(url: str, size: int = ART_SIZE) -> str: @@ -39,22 +49,153 @@ def parse_results(payload: dict) -> list[ArtCandidate]: artist=item.get("artistName", ""), album=item.get("collectionName", ""), art_url=upgrade_artwork_url(url), + source="iTunes", )) return candidates -def search_album_art(artist: str, album: str, limit: int = 5) -> list[ArtCandidate]: +def parse_deezer_results(payload: dict) -> list[ArtCandidate]: + candidates = [] + for item in payload.get("data", []) or []: + url = (item.get("cover_xl") or item.get("cover_big") + or item.get("cover_medium")) + if not url: + continue + candidates.append(ArtCandidate( + artist=(item.get("artist") or {}).get("name", ""), + album=item.get("title", ""), + art_url=url, + source="Deezer", + )) + return candidates + + +_EDITION_NOISE = re.compile( + r"\b(deluxe|remaster(ed)?|expanded|anniversary|special|bonus track" + r"|collector'?s)\b.*$", re.IGNORECASE) +_TRAILING_GROUP = re.compile(r"\s*[\(\[][^\(\)\[\]]*[\)\]]\s*$") + + +def _plain_quotes(text: str) -> str: + return (text.replace("’", "'").replace("‘", "'") + .replace("“", '"').replace("”", '"')) + + +def _simplify_album(album: str) -> str: + """The title without its trailing ``(...)``/``[...]`` groups or edition + noise: "Reachin' (A New Refutation of Time and Space)" -> "Reachin'".""" + text = _plain_quotes(album).strip() + while True: + stripped = _TRAILING_GROUP.sub("", text) + if stripped == text or not stripped: + break + text = stripped + text = _EDITION_NOISE.sub("", text).strip(" -–—:,") + return text or _plain_quotes(album).strip() + + +def _normalize(text: str) -> str: + """Case-, accent- and punctuation-blind form used to compare titles.""" + text = unicodedata.normalize("NFKD", _plain_quotes(text)) + text = "".join(c for c in text if not unicodedata.combining(c)) + return " ".join(re.sub(r"[^\w\s]", " ", text.casefold()).split()) + + +def rank_candidates(candidates: list[ArtCandidate], artist: str, + album: str) -> list[ArtCandidate]: + """Best match first; duplicates (same source, same normalized artist and + album) dropped. The album decides more than the artist: a right-artist + wrong-album hit is exactly the result that must not win.""" + want_album = _normalize(album) + want_simple = _normalize(_simplify_album(album)) + want_artist = _normalize(artist) + sources = {"iTunes": 0, "Deezer": 1} + + def key(indexed): + index, c = indexed + got = _normalize(c.album) + if got == want_album: + album_score = 0 + elif _normalize(_simplify_album(c.album)) == want_simple: + album_score = 1 + elif want_simple and want_simple in got: + album_score = 2 + else: + album_score = 3 + got_artist = _normalize(c.artist) + artist_score = 0 if got_artist == want_artist else ( + 1 if want_artist and want_artist in got_artist else 2) + return (album_score, artist_score, sources.get(c.source, 9), index) + + seen = set() + unique = [] + for c in candidates: + ident = (c.source, _normalize(c.artist), _normalize(c.album)) + if ident in seen: + continue + seen.add(ident) + unique.append(c) + return [c for _i, c in sorted(enumerate(unique), key=key)] + + +def _itunes_query(term: str, limit: int) -> list[ArtCandidate]: import requests response = requests.get( ITUNES_SEARCH_URL, - params={"term": f"{artist} {album}".strip(), "entity": "album", - "media": "music", "limit": limit}, + params={"term": term, "entity": "album", "media": "music", + "limit": limit}, timeout=TIMEOUT_S, ) response.raise_for_status() return parse_results(response.json()) +def search_itunes(artist: str, album: str, limit: int = 5) -> list[ArtCandidate]: + found = _itunes_query(f"{artist} {album}".strip(), limit) + simple = _simplify_album(album) + if not found and simple != album.strip(): + found = _itunes_query(f"{artist} {simple}".strip(), limit) + return found + + +def _deezer_query(query: str, limit: int) -> list[ArtCandidate]: + import requests + response = requests.get(DEEZER_SEARCH_URL, + params={"q": query, "limit": limit}, + timeout=TIMEOUT_S) + response.raise_for_status() + payload = response.json() + if "error" in payload: + raise RuntimeError(payload["error"].get("message", "Deezer error")) + return parse_deezer_results(payload) + + +def search_deezer(artist: str, album: str, limit: int = 5) -> list[ArtCandidate]: + simple = _simplify_album(album).replace('"', "") + strict = f'album:"{simple}"' + if artist: + strict = f'artist:"{artist.replace(chr(34), "")}" ' + strict + found = _deezer_query(strict, limit) + if not found: + found = _deezer_query(f"{artist} {simple}".strip(), limit) + return found + + +def search_album_art(artist: str, album: str, limit: int = 5) -> list[ArtCandidate]: + """Both catalogs, merged and ranked. One source failing is not an error + while the other answers; both failing raises the first failure.""" + found: list[ArtCandidate] = [] + errors: list[Exception] = [] + for search in (search_itunes, search_deezer): + try: + found.extend(search(artist, album, limit)) + except Exception as e: # network, HTTP, JSON — the other may still work + errors.append(e) + if errors and len(errors) == 2: + raise errors[0] + return rank_candidates(found, artist, album) + + def fetch_image(url: str) -> tuple[bytes, str]: """Download an image; returns (bytes, mime type).""" import requests diff --git a/lintunes/gui/album_art_dialog.py b/lintunes/gui/album_art_dialog.py index 594a244..2996876 100644 --- a/lintunes/gui/album_art_dialog.py +++ b/lintunes/gui/album_art_dialog.py @@ -1,6 +1,6 @@ """Confirmation dialog for downloaded album art. -Shows one iTunes Search result at a time (300px preview + "Artist — Album"), +Shows one search result (iTunes or Deezer) at a time (300px preview + "Artist — Album"), with Next Result cycling through the other matches. Nothing is written until the user accepts; the caller then embeds ``selected_image``/``selected_mime``. """ @@ -80,7 +80,8 @@ class AlbumArtDialog(QDialog): n = len(self._candidates) counter = f" ({self._index + 1}/{n})" if n > 1 else "" self._title.setText( - f"{candidate.artist} — {candidate.album}{counter}") + f"{candidate.artist} — {candidate.album}{counter}" + f"
via {candidate.source}") self._image = None self._use_btn.setEnabled(False) if self._album_btn is not None: diff --git a/lintunes/gui/art_ops.py b/lintunes/gui/art_ops.py new file mode 100644 index 0000000..db926d1 --- /dev/null +++ b/lintunes/gui/art_ops.py @@ -0,0 +1,33 @@ +"""Embedding cover art into files — shared by Get Info (one track or a whole +selection) and Download Album Art. + +The artwork write itself is not undoable; the size refresh is an in-memory +update (size isn't a tag field) that also fires ``track_updated``, which is +what repaints the transport art. +""" +from pathlib import Path + +from lintunes import mpris, tagging + + +def embed_artwork(manager, tracks, image: bytes, mime: str): + """Write ``image`` into every track's file. Returns ``(done, failures)``, + failures as ``"Name: error"`` strings; a track with no file is skipped.""" + failures = [] + done = 0 + for track in tracks: + if not track.location: + continue + try: + tagging.write_artwork(track.location, image, mime) + except Exception as e: + failures.append(f"{track.name}: {e}") + continue + done += 1 + mpris.invalidate_artwork(track) + try: + size = Path(track.location).stat().st_size + except OSError: + continue + manager.update_track_fields(track.track_id, {"size": size}) + return done, failures diff --git a/lintunes/gui/info_dialog.py b/lintunes/gui/info_dialog.py index cdef972..2675105 100644 --- a/lintunes/gui/info_dialog.py +++ b/lintunes/gui/info_dialog.py @@ -1,3 +1,4 @@ +import logging from pathlib import Path from PyQt6.QtWidgets import ( @@ -5,32 +6,127 @@ from PyQt6.QtWidgets import ( QPlainTextEdit, QHBoxLayout, QLabel, QVBoxLayout, QPushButton, QMessageBox, QApplication, QMenu, QFileDialog, QWidget, ) -from PyQt6.QtCore import Qt, QBuffer, QIODevice, pyqtSignal -from PyQt6.QtGui import QPixmap, QKeySequence +from PyQt6.QtCore import Qt, QBuffer, QIODevice, QTimer, pyqtSignal +from PyQt6.QtGui import QPixmap, QImage, QKeySequence, QShortcut from lintunes import tagging +from lintunes.gui.art_ops import embed_artwork +log = logging.getLogger(__name__) _IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"} +# A read that comes back empty or undecodable is retried this many times, +# PASTE_RETRY_MS apart, before the square says so. Retries re-ask the +# clipboard from scratch and also try Qt's decoded-image path, which is a +# separate read on Wayland (the per-type result is cached per offer). +PASTE_RETRIES = 3 +PASTE_RETRY_MS = 300 + +# Past this many tracks the multi-track dialog doesn't open every file to +# compare covers; it just offers the paste. +MULTI_ART_READ_LIMIT = 100 + +HINT_IDLE = "Ctrl+V or Paste Artwork to add a cover" +HINT_STAGED = "New artwork — saved when you click OK" + + +def _decodes(data: bytes) -> bool: + return bool(data) and QImage().loadFromData(data) + + +def _jpeg_bytes(image: QImage) -> bytes: + buffer = QBuffer() + buffer.open(QIODevice.OpenModeFlag.WriteOnly) + image.save(buffer, "JPEG", quality=92) + return bytes(buffer.data()) + + +def image_file_bytes(path: Path) -> tuple[bytes, str] | None: + """(bytes, mime) for an image file every tag format accepts, recoding + anything but JPEG/PNG; None if it isn't a readable image.""" + suffix = path.suffix.lower() + if suffix not in _IMAGE_EXTENSIONS or not path.is_file(): + return None + if suffix in (".jpg", ".jpeg", ".png"): + data = path.read_bytes() + if not _decodes(data): + return None + return data, "image/png" if suffix == ".png" else "image/jpeg" + image = QImage(str(path)) + if image.isNull(): + return None + return _jpeg_bytes(image), "image/jpeg" + + +def clipboard_image(mime) -> tuple[bytes, str] | None: + """The first image in ``mime`` that actually decodes, as (bytes, mime). + + Never trusts a format's presence alone: a clipboard can advertise + image/png and hand over nothing (or half a PNG), and staging those bytes + would embed a broken cover on OK.""" + if mime is None: + return None + # Prefer raw image file bytes (keeps original compression) + for fmt in ("image/jpeg", "image/png"): + if mime.hasFormat(fmt): + data = bytes(mime.data(fmt)) + ok = _decodes(data) + log.info("art paste: %s %d bytes, decodes=%s", fmt, len(data), ok) + if ok: + return data, fmt + if mime.hasImage(): + image = mime.imageData() + ok = isinstance(image, QImage) and not image.isNull() + log.info("art paste: decoded image, ok=%s", ok) + if ok: + return _jpeg_bytes(image), "image/jpeg" + if mime.hasUrls(): + for url in mime.urls(): + if url.isLocalFile(): + found = image_file_bytes(Path(url.toLocalFile())) + log.info("art paste: file %s, ok=%s", url.toLocalFile(), + found is not None) + if found: + return found + return None + + +def _has_image_hint(mime) -> bool: + """Whether the clipboard claims to hold something image-like at all — + only then is a failed read worth retrying.""" + if mime is None: + return False + return (mime.hasImage() or mime.hasUrls() + or any(f.startswith("image/") for f in mime.formats())) + class ArtSquare(QLabel): - """Clickable cover-art square: click to focus, then paste (Ctrl+V) or - drop an image file to stage new artwork.""" + """Cover-art square: paste (Ctrl+V, the Paste button, or right-click) or + drop an image file to stage new artwork. Nothing is written until the + dialog's OK.""" art_staged = pyqtSignal(bytes, str) # image bytes, mime + hint_changed = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) self.setFixedSize(120, 120) self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setWordWrap(True) self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) self.setAcceptDrops(True) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._show_menu) + self._retries_left = 0 + self._retry_timer = QTimer(self) + self._retry_timer.setSingleShot(True) + self._retry_timer.setInterval(PASTE_RETRY_MS) + self._retry_timer.timeout.connect(self._try_paste) self.set_artwork(None) - def set_artwork(self, image_bytes: bytes | None): + def set_artwork(self, image_bytes: bytes | None, + placeholder: str = "no artwork"): if image_bytes: pixmap = QPixmap() if pixmap.loadFromData(image_bytes): @@ -39,13 +135,13 @@ class ArtSquare(QLabel): Qt.TransformationMode.SmoothTransformation)) self.setStyleSheet( "ArtSquare { border: 1px solid palette(mid); }" - "ArtSquare:focus { border: 2px solid palette(highlight); }") + "ArtSquare:focus { border: 3px solid palette(highlight); }") return self.setPixmap(QPixmap()) - self.setText("no artwork\n\nclick, then paste\nan image") + self.setText(placeholder) self.setStyleSheet( "ArtSquare { border: 2px dashed palette(mid); color: palette(mid); }" - "ArtSquare:focus { border: 2px dashed palette(highlight); }") + "ArtSquare:focus { border: 3px dashed palette(highlight); }") # ---- staging from clipboard / files ---- @@ -56,47 +152,44 @@ class ArtSquare(QLabel): super().keyPressEvent(event) def paste_from_clipboard(self): + self._retries_left = PASTE_RETRIES + self._retry_timer.stop() + self._try_paste() + + def _try_paste(self): mime = QApplication.clipboard().mimeData() - if mime is None: + if mime is not None: + log.info("art paste: clipboard offers %s", mime.formats()) + found = clipboard_image(mime) + if found: + self._stage(*found) return - # Prefer raw image file bytes (keeps original compression) - for fmt in ("image/jpeg", "image/png"): - if mime.hasFormat(fmt): - self._stage(bytes(mime.data(fmt)), fmt) - return - if mime.hasImage(): - image = QApplication.clipboard().image() - if not image.isNull(): - buffer = QBuffer() - buffer.open(QIODevice.OpenModeFlag.WriteOnly) - image.save(buffer, "JPEG", quality=92) - self._stage(bytes(buffer.data()), "image/jpeg") - return - if mime.hasUrls(): - for url in mime.urls(): - if url.isLocalFile() and self._stage_file(Path(url.toLocalFile())): - return + if not _has_image_hint(mime): + self.hint_changed.emit("The clipboard doesn't hold an image") + return + if self._retries_left > 0: + self._retries_left -= 1 + self.hint_changed.emit("Reading the clipboard…") + self._retry_timer.start() + return + self.hint_changed.emit("Couldn't read an image from the clipboard") def _stage_file(self, path: Path) -> bool: - if path.suffix.lower() not in _IMAGE_EXTENSIONS or not path.is_file(): + found = image_file_bytes(path) + if found is None: return False - mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg" - if path.suffix.lower() not in (".jpg", ".jpeg", ".png"): - # Recode exotic formats to JPEG so all tag formats accept them - pixmap = QPixmap(str(path)) - if pixmap.isNull(): - return False - buffer = QBuffer() - buffer.open(QIODevice.OpenModeFlag.WriteOnly) - pixmap.save(buffer, "JPEG", quality=92) - self._stage(bytes(buffer.data()), "image/jpeg") - return True - self._stage(path.read_bytes(), mime) + self._stage(*found) return True def _stage(self, image_bytes: bytes, mime: str): + if not _decodes(image_bytes): + # Backstop for every path in: a cover that won't decode here + # won't show anywhere else either. + self.hint_changed.emit("That image couldn't be read") + return self.set_artwork(image_bytes) self.art_staged.emit(image_bytes, mime) + self.hint_changed.emit(HINT_STAGED) def _show_menu(self, pos): menu = QMenu(self) @@ -106,21 +199,24 @@ class ArtSquare(QLabel): if chosen is paste: self.paste_from_clipboard() elif chosen is choose: - file_name, _filter = QFileDialog.getOpenFileName( - self, "Choose Artwork", "", - "Images (*.jpg *.jpeg *.png *.gif *.bmp *.webp)") - if file_name: - self._stage_file(Path(file_name)) + self.choose_file() + + def choose_file(self): + file_name, _filter = QFileDialog.getOpenFileName( + self, "Choose Artwork", "", + "Images (*.jpg *.jpeg *.png *.gif *.bmp *.webp)") + if file_name and not self._stage_file(Path(file_name)): + self.hint_changed.emit("That file isn't a readable image") def dragEnterEvent(self, event): if event.mimeData().hasUrls() or event.mimeData().hasImage(): event.acceptProposedAction() def dropEvent(self, event): - for url in event.mimeData().urls(): - if url.isLocalFile() and self._stage_file(Path(url.toLocalFile())): - event.acceptProposedAction() - return + found = clipboard_image(event.mimeData()) + if found: + self._stage(*found) + event.acceptProposedAction() _TEXT_FIELDS = [ @@ -220,7 +316,24 @@ class InfoDialog(QDialog): self._art_square = ArtSquare() self._art_square.art_staged.connect(self._on_art_staged) art_col.addWidget(self._art_square) + self._art_hint = QLabel(HINT_IDLE) + self._art_hint.setWordWrap(True) + self._art_hint.setFixedWidth(self._art_square.width()) + self._art_hint.setStyleSheet("color: gray; font-size: 10px;") + self._art_square.hint_changed.connect(self._art_hint.setText) + art_col.addWidget(self._art_hint) + paste_art = QPushButton("Paste Artwork") + paste_art.setAutoDefault(False) + paste_art.setFixedWidth(self._art_square.width()) + paste_art.clicked.connect(self._art_square.paste_from_clipboard) + art_col.addWidget(paste_art) art_col.addStretch() + # Ctrl+V anywhere in the dialog that isn't a text field pastes the + # cover — no need to land a click on the square first. Text fields + # claim Paste through ShortcutOverride, so they keep their own. + QShortcut(QKeySequence.StandardKey.Paste, self, + context=Qt.ShortcutContext.WindowShortcut, + activated=self._art_square.paste_from_clipboard) top.addLayout(art_col) self._staged_art: tuple[bytes, str] | None = None @@ -281,8 +394,8 @@ class InfoDialog(QDialog): buttons.rejected.connect(self.reject) if self._multi: - # Batch editing: no single file, no per-track art/times navigation. - self._art_square.setVisible(False) + # Batch editing: no single file, no per-track times navigation. + # Artwork stays: a pasted cover goes into every selected track. self._start_row.setVisible(False) self._stop_row.setVisible(False) self._file_label.setVisible(False) @@ -319,6 +432,24 @@ class InfoDialog(QDialog): first = values[0] return first if all(v == first for v in values) else _MIXED + def _common_artwork(self) -> bytes | None: + """The cover every selected track shares, else None (also None past + MULTI_ART_READ_LIMIT, where opening every file isn't worth it).""" + if len(self._track_ids) > MULTI_ART_READ_LIMIT: + return None + tracks = self._manager.library.tracks + first = None + for index, tid in enumerate(self._track_ids): + location = tracks[tid].location + art = tagging.read_embedded_artwork(location) if location else None + if art is None: + return None + if index == 0: + first = art + elif art != first: + return None + return first + def _on_art_staged(self, image_bytes: bytes, mime: str): self._staged_art = (bytes(image_bytes), mime) @@ -330,6 +461,7 @@ class InfoDialog(QDialog): existing_art = (tagging.read_embedded_artwork(track.location) if track.location else None) self._art_square.set_artwork(existing_art) + self._art_hint.setText(HINT_IDLE) for field, _label in _TEXT_FIELDS: self._editors[field].setText(getattr(track, field) or "") for field, _label, _max in _NUMBER_FIELDS: @@ -350,6 +482,9 @@ class InfoDialog(QDialog): def _load_multi(self): self._loading = True self.setWindowTitle(f"{len(self._track_ids)} Items — Info") + self._art_square.set_artwork( + self._common_artwork(), + placeholder=f"mixed artwork\n\npaste to set\nall {len(self._track_ids)}") for field, _label in _TEXT_FIELDS: common = self._common_value(field) editor = self._editors[field] @@ -409,21 +544,20 @@ class InfoDialog(QDialog): fields_changed = any(getattr(track, k) != v for k, v in fields.items()) if not fields_changed and self._staged_art is None: return True - if track.location: - try: - if self._staged_art is not None: - tagging.write_artwork(track.location, *self._staged_art) - except Exception as e: + if self._staged_art is not None: + # Embedding also refreshes the library's size (which triggers + # track_updated, so the transport art repaints). + _done, failures = embed_artwork(self._manager, [track], + *self._staged_art) + if failures: QMessageBox.warning(self, "Tag write failed", - f"Could not write tags to file:\n{e}") + "Could not write artwork to file:\n" + + failures[0]) return False - if self._staged_art is not None: - # Embedding art changed the file; keep the library's size honest - # (and trigger track_updated so the transport art refreshes) - fields["size"] = Path(track.location).stat().st_size - # Field edits (incl. the post-art size bump) go through the manager, - # which writes the file tags and records the change for undo. Artwork - # itself is written above and is intentionally not undoable. + # Field edits go through the manager, which writes the file tags and + # records the change for undo. Artwork itself is written above and is + # intentionally not undoable. + fields.pop("size", None) self._manager.edit_track_fields(track.track_id, fields) self._staged_art = None return True @@ -440,6 +574,23 @@ class InfoDialog(QDialog): fields["compilation"] = self._compilation.isChecked() if "comments" in self._touched: fields["comments"] = self._comments.toPlainText().strip() + if self._staged_art is not None: + tracks = [self._manager.library.tracks[tid] + for tid in self._track_ids] + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + try: + _done, failures = embed_artwork(self._manager, tracks, + *self._staged_art) + finally: + QApplication.restoreOverrideCursor() + if failures: + # The rest landed; say which didn't, then carry on with the + # field edits rather than leaving the dialog half-applied. + QMessageBox.warning( + self, "Tag write failed", + "Couldn't embed artwork in some files:\n\n" + + "\n".join(failures[:10])) + self._staged_art = None if fields: self._manager.edit_tracks_fields(self._track_ids, fields) return True diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 7ceac06..9af45f3 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -9,7 +9,7 @@ from PyQt6.QtWidgets import ( from PyQt6.QtCore import Qt, QEvent, QTimer from PyQt6.QtGui import QAction, QKeySequence -from lintunes import device_sync, mpris, music_folder, tagging, theme, url_import +from lintunes import device_sync, music_folder, theme, url_import from lintunes.andtunes import layout as andtunes_layout from lintunes.andtunes import install as andtunes_install from lintunes.andtunes import sync as andtunes_sync @@ -23,6 +23,7 @@ from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor from lintunes.player import Player from lintunes.importers import file_importer from lintunes.gui.album_art_dialog import AlbumArtDialog +from lintunes.gui.art_ops import embed_artwork from lintunes.gui.cast_dialog import ChromecastDialog from lintunes.gui.device_sync_dialog import DeviceSyncSettingsDialog from lintunes.gui.export_dialog import ExportKindDialog, WebMixDialog @@ -1390,26 +1391,9 @@ class MainWindow(QMainWindow): self._art_next_group() def _embed_album_art(self, tracks, image: bytes, mime: str): - """Embed confirmed artwork into each track's file. Like the Info - dialog, the artwork write itself is not undoable; the size refresh is - an in-memory update (size isn't a tag field).""" - failures = [] - done = 0 - for track in tracks: - if not track.location: - continue - try: - tagging.write_artwork(track.location, image, mime) - except Exception as e: - failures.append(f"{track.name}: {e}") - continue - done += 1 - mpris.invalidate_artwork(track) - try: - size = Path(track.location).stat().st_size - except OSError: - continue - self._manager.update_track_fields(track.track_id, {"size": size}) + """Embed confirmed artwork into each track's file (not undoable — + see ``art_ops``).""" + done, failures = embed_artwork(self._manager, tracks, image, mime) if failures: QMessageBox.warning( self, "Download Album Art", diff --git a/tasks-done.md b/tasks-done.md index 7c45929..852edb8 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -1,5 +1,32 @@ ## Done +### Round 60 (2026-09-22) — covers for the whole album (v0.23.0) + +- [x] **Paste artwork onto a multi-selection.** Multi-track Get Info keeps + the art square. It shows the shared cover if every track (≤100) has + the same one, otherwise "mixed artwork". A pasted cover goes into + every selected track on OK; untouched, nothing is written. +- [x] **Paste that takes the first time.** There's now a Paste Artwork + button, and Ctrl+V anywhere in the dialog outside a text field pastes + the cover, so no click has to land on the square first. A caption + says what happened ("New artwork — saved when you click OK", "The + clipboard doesn't hold an image", …). A read that doesn't decode is + retried 3× at 300 ms and is **never staged**; before this, a + half-read PNG would blank the square and could have been embedded on + OK. The root cause of the flaky paste is unconfirmed: the journal + shows no Qt Wayland clipboard timeouts. So each attempt now logs its + formats, byte counts and decode result at INFO, where the journal + will show the next failure. +- [x] **Deezer as a second art source.** iTunes has no *Reachin'* at all; + Deezer has it at 1000 px. Results are merged and ranked by album + match; the dialog says "via iTunes/Deezer". iTunes retries with the + title minus its parenthetical ("Reachin'") when the full one finds + nothing. +- [x] `embed_artwork` shared by Get Info and Download Album Art. Single-track + Get Info now invalidates the MPRIS art cache too; it never did. +- [x] Drag an image straight from a browser onto the square (image data, + not only local files). + ### Round 58 (2026-09-18) — lookup on youtube (v0.22.0) - [x] **"lookup on youtube" in a song's right-click menu.** Opens the default diff --git a/tests/test_round60.py b/tests/test_round60.py new file mode 100644 index 0000000..ddbce93 --- /dev/null +++ b/tests/test_round60.py @@ -0,0 +1,279 @@ +"""Round 60: album art for a whole selection, a paste that never stages a +broken image, and Deezer as a second art-search source.""" +import shutil + +import pytest +from PyQt6.QtCore import QMimeData, QUrl + +from lintunes import art_search, tagging +from lintunes.art_search import ( + ArtCandidate, _simplify_album, parse_deezer_results, rank_candidates, + search_album_art, +) +from lintunes.library_manager import LibraryManager +from lintunes.models import Track +from lintunes.models.library import Library + + +def _manager(tmp_path, tracks=()): + library = Library() + for track in tracks: + library.tracks[track.track_id] = track + return LibraryManager(library, tmp_path) + + +def _copies(mp3_file, tmp_path, n): + tracks = [] + for i in range(1, n + 1): + path = tmp_path / f"song{i}.mp3" + shutil.copyfile(mp3_file, path) + tracks.append(Track(track_id=i, name=f"Song {i}", artist="A", + album="B", location=str(path), size=1)) + return tracks + + +# ---- search: Deezer + ranking ---- + +REACHIN = "Reachin' (A New Refutation of Time and Space)" + + +class TestDeezer: + def test_parse_prefers_xl_cover(self): + payload = {"data": [ + {"title": REACHIN, "artist": {"name": "Digable Planets"}, + "cover_xl": "https://x/1000.jpg", "cover_big": "https://x/500.jpg"}, + {"title": "No Cover", "artist": {"name": "Z"}}, + ]} + [c] = parse_deezer_results(payload) + assert c.source == "Deezer" + assert c.art_url == "https://x/1000.jpg" + assert c.artist == "Digable Planets" + + def test_simplify_album(self): + assert _simplify_album(REACHIN) == "Reachin'" + assert _simplify_album("Reachin’ (A New Refutation)") == "Reachin'" + assert _simplify_album("Rumours (Deluxe Edition) [Remastered]") == "Rumours" + assert _simplify_album("Abbey Road - Remastered 2019") == "Abbey Road" + assert _simplify_album("(What's the Story) Morning Glory?") \ + == "(What's the Story) Morning Glory?" + + def test_right_album_outranks_right_artist(self): + stray = ArtCandidate("Digable Planets", "Blowout Comb", "u1", "iTunes") + real = ArtCandidate("Digable Planets", + "Reachin’ (A New Refutation Of Time And Space)", + "u2", "Deezer") + ranked = rank_candidates([stray, real], "Digable Planets", REACHIN) + assert ranked[0] is real + + def test_ranking_drops_same_source_duplicates_keeps_both_sources(self): + a = ArtCandidate("X", "Album", "u1", "iTunes") + b = ArtCandidate("x", "album", "u2", "iTunes") + c = ArtCandidate("X", "Album", "u3", "Deezer") + ranked = rank_candidates([a, b, c], "X", "Album") + assert [r.art_url for r in ranked] == ["u1", "u3"] + + +class _Response: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + pass + + def json(self): + return self._payload + + +def _fake_get(itunes=None, deezer=None): + """requests.get stand-in: a payload dict, or an exception to raise.""" + def get(url, params=None, timeout=None): + answer = itunes if "itunes" in url else deezer + if isinstance(answer, Exception): + raise answer + return _Response(answer if answer is not None else {}) + return get + + +DEEZER_REACHIN = {"data": [{"title": REACHIN, + "artist": {"name": "Digable Planets"}, + "cover_xl": "https://dz/1000.jpg"}]} +ITUNES_STRAY = {"results": [{"artistName": "Digable Planets", + "collectionName": "Blowout Comb", + "artworkUrl100": "https://it/100x100bb.jpg"}]} + + +class TestSearchAlbumArt: + def test_deezer_only_hit_is_found_and_first(self, monkeypatch): + import requests + monkeypatch.setattr(requests, "get", + _fake_get(ITUNES_STRAY, DEEZER_REACHIN)) + found = search_album_art("Digable Planets", REACHIN) + assert found[0].source == "Deezer" + assert len(found) == 2 + + def test_one_source_failing_is_not_an_error(self, monkeypatch): + import requests + monkeypatch.setattr(requests, "get", + _fake_get(OSError("offline"), DEEZER_REACHIN)) + assert [c.source for c in search_album_art("Digable Planets", REACHIN)] \ + == ["Deezer"] + + def test_both_failing_raises(self, monkeypatch): + import requests + monkeypatch.setattr(requests, "get", + _fake_get(OSError("a"), OSError("b"))) + with pytest.raises(OSError): + search_album_art("A", "B") + + def test_both_empty_is_empty(self, monkeypatch): + import requests + monkeypatch.setattr(requests, "get", _fake_get({}, {})) + assert search_album_art("A", "B") == [] + + def test_itunes_retries_with_simplified_title(self, monkeypatch): + terms = [] + + def query(term, limit): + terms.append(term) + return [] + monkeypatch.setattr(art_search, "_itunes_query", query) + art_search.search_itunes("Digable Planets", REACHIN) + assert terms == [f"Digable Planets {REACHIN}", + "Digable Planets Reachin'"] + + +# ---- paste: never stage what doesn't decode ---- + +def _png_bytes(): + from PyQt6.QtCore import QBuffer, QIODevice + from PyQt6.QtGui import QImage, QColor + image = QImage(8, 8, QImage.Format.Format_RGB32) + image.fill(QColor("red")) + buffer = QBuffer() + buffer.open(QIODevice.OpenModeFlag.WriteOnly) + image.save(buffer, "PNG") + return bytes(buffer.data()) + + +class TestPaste: + def test_truncated_png_is_rejected(self, qapp): + from lintunes.gui.info_dialog import clipboard_image + mime = QMimeData() + mime.setData("image/png", _png_bytes()[:20]) + assert clipboard_image(mime) is None + + def test_good_png_is_accepted_raw(self, qapp): + from lintunes.gui.info_dialog import clipboard_image + mime = QMimeData() + png = _png_bytes() + mime.setData("image/png", png) + assert clipboard_image(mime) == (png, "image/png") + + def test_local_image_file_url(self, qapp, tmp_path): + from lintunes.gui.info_dialog import clipboard_image + path = tmp_path / "cover.png" + path.write_bytes(_png_bytes()) + mime = QMimeData() + mime.setUrls([QUrl.fromLocalFile(str(path))]) + assert clipboard_image(mime)[1] == "image/png" + + def test_bad_read_retries_then_stages_good(self, qapp, monkeypatch): + from lintunes.gui import info_dialog + square = info_dialog.ArtSquare() + staged, hints = [], [] + square.art_staged.connect(lambda b, m: staged.append(b)) + square.hint_changed.connect(hints.append) + bad = QMimeData() + bad.setData("image/png", b"\x89PNG\r\n") + good = QMimeData() + good.setData("image/png", _png_bytes()) + reads = iter([bad, good]) + + class Clipboard: + def mimeData(self): + return next(reads) + monkeypatch.setattr(info_dialog.QApplication, "clipboard", + staticmethod(lambda: Clipboard())) + square.paste_from_clipboard() + assert staged == [] + assert hints[-1] == "Reading the clipboard…" + square._retry_timer.stop() + square._try_paste() # what the timer would do + assert staged == [_png_bytes()] + assert hints[-1] == info_dialog.HINT_STAGED + + def test_text_clipboard_says_so_without_retrying(self, qapp, monkeypatch): + from lintunes.gui import info_dialog + square = info_dialog.ArtSquare() + hints = [] + square.hint_changed.connect(hints.append) + text = QMimeData() + text.setText("hello") + monkeypatch.setattr(info_dialog.QApplication, "clipboard", + staticmethod(lambda: type( + "C", (), {"mimeData": lambda self: text})())) + square.paste_from_clipboard() + assert hints == ["The clipboard doesn't hold an image"] + assert not square._retry_timer.isActive() + + def test_stage_backstop_rejects_garbage(self, qapp): + from lintunes.gui.info_dialog import ArtSquare + square = ArtSquare() + staged = [] + square.art_staged.connect(lambda b, m: staged.append(b)) + square._stage(b"not an image", "image/jpeg") + assert staged == [] + + +# ---- multi-track Get Info carries artwork ---- + +class TestMultiArt: + def test_square_visible_and_paste_embeds_everywhere( + self, qapp, tmp_path, mp3_file, jpeg_bytes): + from lintunes.gui.info_dialog import InfoDialog + tracks = _copies(mp3_file, tmp_path, 3) + manager = _manager(tmp_path, tracks) + dialog = InfoDialog(manager, [1, 2, 3]) + assert not dialog._art_square.isHidden() + assert "mixed" in dialog._art_square.text() + dialog._art_square._stage(jpeg_bytes, "image/jpeg") + dialog._on_ok() + for track in tracks: + assert tagging.read_embedded_artwork(track.location) == jpeg_bytes + assert track.size > 1 + + def test_shared_cover_is_shown(self, qapp, tmp_path, mp3_file, jpeg_bytes): + from lintunes.gui.info_dialog import InfoDialog + tracks = _copies(mp3_file, tmp_path, 2) + for track in tracks: + tagging.write_artwork(track.location, jpeg_bytes, "image/jpeg") + dialog = InfoDialog(_manager(tmp_path, tracks), [1, 2]) + assert dialog._art_square.pixmap() is not None + assert not dialog._art_square.pixmap().isNull() + + def test_untouched_art_writes_nothing(self, qapp, tmp_path, mp3_file): + from lintunes.gui.info_dialog import InfoDialog + tracks = _copies(mp3_file, tmp_path, 2) + before = [open(t.location, "rb").read() for t in tracks] + InfoDialog(_manager(tmp_path, tracks), [1, 2])._on_ok() + assert [open(t.location, "rb").read() for t in tracks] == before + assert all(t.size == 1 for t in tracks) + + +class TestEmbedHelper: + def test_reports_failures_and_invalidates_mpris( + self, qapp, tmp_path, mp3_file, jpeg_bytes, monkeypatch): + from lintunes.gui import art_ops + invalidated = [] + monkeypatch.setattr(art_ops.mpris, "invalidate_artwork", + invalidated.append) + [good] = _copies(mp3_file, tmp_path, 1) + broken = Track(track_id=2, name="Broken", + location=str(tmp_path / "missing.mp3")) + nofile = Track(track_id=3, name="No File", location="") + manager = _manager(tmp_path, [good, broken, nofile]) + done, failures = art_ops.embed_artwork( + manager, [good, broken, nofile], jpeg_bytes, "image/jpeg") + assert done == 1 + assert len(failures) == 1 and failures[0].startswith("Broken:") + assert invalidated == [good]