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]