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 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 09:38:39 -07:00
co-authored by Claude Opus 5
parent d448f64c2b
commit 3150df0f89
9 changed files with 723 additions and 93 deletions
+14
View File
@@ -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 Stdlib only, cached, and every failure path returns `None` — a machine with no
Syncthing must still merge, just without naming anyone. 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` - **`lintunes/fingerprint.py`** — "Identify Track…": Chromaprint's `fpcalc`
CLI (detected with `shutil.which`, the `ffmpeg_available()` pattern — a CLI (detected with `shutil.which`, the `ffmpeg_available()` pattern — a
runtime tool, never a pip dep) fingerprints the file, and AcoustID's web API runtime tool, never a pip dep) fingerprints the file, and AcoustID's web API
+1 -1
View File
@@ -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.22.1" __version__ = "0.23.0"
+147 -6
View File
@@ -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 Two catalogs because neither is complete: iTunes has no copy at all of some
tested offline; ``AlbumArtFetcher`` runs the whole search+download on a 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 daemon thread (the lastfm pattern) and reports back over a Qt signal, which
is delivered queued on the GUI thread. is delivered queued on the GUI thread.
""" """
import re
import threading import threading
import unicodedata
from dataclasses import dataclass from dataclasses import dataclass
from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtCore import QObject, pyqtSignal
ITUNES_SEARCH_URL = "https://itunes.apple.com/search" 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 ART_SIZE = 600 # px; the API hands out 100x100 URLs that scale on request
TIMEOUT_S = 15 TIMEOUT_S = 15
@@ -21,6 +30,7 @@ class ArtCandidate:
artist: str artist: str
album: str album: str
art_url: str # already upgraded to ART_SIZE art_url: str # already upgraded to ART_SIZE
source: str = "iTunes"
def upgrade_artwork_url(url: str, size: int = ART_SIZE) -> str: 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", ""), artist=item.get("artistName", ""),
album=item.get("collectionName", ""), album=item.get("collectionName", ""),
art_url=upgrade_artwork_url(url), art_url=upgrade_artwork_url(url),
source="iTunes",
)) ))
return candidates 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 import requests
response = requests.get( response = requests.get(
ITUNES_SEARCH_URL, ITUNES_SEARCH_URL,
params={"term": f"{artist} {album}".strip(), "entity": "album", params={"term": term, "entity": "album", "media": "music",
"media": "music", "limit": limit}, "limit": limit},
timeout=TIMEOUT_S, timeout=TIMEOUT_S,
) )
response.raise_for_status() response.raise_for_status()
return parse_results(response.json()) 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]: def fetch_image(url: str) -> tuple[bytes, str]:
"""Download an image; returns (bytes, mime type).""" """Download an image; returns (bytes, mime type)."""
import requests import requests
+3 -2
View File
@@ -1,6 +1,6 @@
"""Confirmation dialog for downloaded album art. """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 with Next Result cycling through the other matches. Nothing is written until
the user accepts; the caller then embeds ``selected_image``/``selected_mime``. the user accepts; the caller then embeds ``selected_image``/``selected_mime``.
""" """
@@ -80,7 +80,8 @@ class AlbumArtDialog(QDialog):
n = len(self._candidates) n = len(self._candidates)
counter = f" ({self._index + 1}/{n})" if n > 1 else "" counter = f" ({self._index + 1}/{n})" if n > 1 else ""
self._title.setText( self._title.setText(
f"<b>{candidate.artist} — {candidate.album}</b>{counter}") f"<b>{candidate.artist} — {candidate.album}</b>{counter}"
f"<br><small>via {candidate.source}</small>")
self._image = None self._image = None
self._use_btn.setEnabled(False) self._use_btn.setEnabled(False)
if self._album_btn is not None: if self._album_btn is not None:
+33
View File
@@ -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
+214 -63
View File
@@ -1,3 +1,4 @@
import logging
from pathlib import Path from pathlib import Path
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
@@ -5,32 +6,127 @@ from PyQt6.QtWidgets import (
QPlainTextEdit, QHBoxLayout, QLabel, QVBoxLayout, QPushButton, QMessageBox, QPlainTextEdit, QHBoxLayout, QLabel, QVBoxLayout, QPushButton, QMessageBox,
QApplication, QMenu, QFileDialog, QWidget, QApplication, QMenu, QFileDialog, QWidget,
) )
from PyQt6.QtCore import Qt, QBuffer, QIODevice, pyqtSignal from PyQt6.QtCore import Qt, QBuffer, QIODevice, QTimer, pyqtSignal
from PyQt6.QtGui import QPixmap, QKeySequence from PyQt6.QtGui import QPixmap, QImage, QKeySequence, QShortcut
from lintunes import tagging from lintunes import tagging
from lintunes.gui.art_ops import embed_artwork
log = logging.getLogger(__name__)
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"} _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): class ArtSquare(QLabel):
"""Clickable cover-art square: click to focus, then paste (Ctrl+V) or """Cover-art square: paste (Ctrl+V, the Paste button, or right-click) or
drop an image file to stage new artwork.""" drop an image file to stage new artwork. Nothing is written until the
dialog's OK."""
art_staged = pyqtSignal(bytes, str) # image bytes, mime art_staged = pyqtSignal(bytes, str) # image bytes, mime
hint_changed = pyqtSignal(str)
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setFixedSize(120, 120) self.setFixedSize(120, 120)
self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setWordWrap(True)
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
self.setAcceptDrops(True) self.setAcceptDrops(True)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_menu) 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) 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: if image_bytes:
pixmap = QPixmap() pixmap = QPixmap()
if pixmap.loadFromData(image_bytes): if pixmap.loadFromData(image_bytes):
@@ -39,13 +135,13 @@ class ArtSquare(QLabel):
Qt.TransformationMode.SmoothTransformation)) Qt.TransformationMode.SmoothTransformation))
self.setStyleSheet( self.setStyleSheet(
"ArtSquare { border: 1px solid palette(mid); }" "ArtSquare { border: 1px solid palette(mid); }"
"ArtSquare:focus { border: 2px solid palette(highlight); }") "ArtSquare:focus { border: 3px solid palette(highlight); }")
return return
self.setPixmap(QPixmap()) self.setPixmap(QPixmap())
self.setText("no artwork\n\nclick, then paste\nan image") self.setText(placeholder)
self.setStyleSheet( self.setStyleSheet(
"ArtSquare { border: 2px dashed palette(mid); color: palette(mid); }" "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 ---- # ---- staging from clipboard / files ----
@@ -56,47 +152,44 @@ class ArtSquare(QLabel):
super().keyPressEvent(event) super().keyPressEvent(event)
def paste_from_clipboard(self): 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() 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 return
# Prefer raw image file bytes (keeps original compression) if not _has_image_hint(mime):
for fmt in ("image/jpeg", "image/png"): self.hint_changed.emit("The clipboard doesn't hold an image")
if mime.hasFormat(fmt): return
self._stage(bytes(mime.data(fmt)), fmt) if self._retries_left > 0:
return self._retries_left -= 1
if mime.hasImage(): self.hint_changed.emit("Reading the clipboard…")
image = QApplication.clipboard().image() self._retry_timer.start()
if not image.isNull(): return
buffer = QBuffer() self.hint_changed.emit("Couldn't read an image from the clipboard")
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
def _stage_file(self, path: Path) -> bool: 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 return False
mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg" self._stage(*found)
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)
return True return True
def _stage(self, image_bytes: bytes, mime: str): 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.set_artwork(image_bytes)
self.art_staged.emit(image_bytes, mime) self.art_staged.emit(image_bytes, mime)
self.hint_changed.emit(HINT_STAGED)
def _show_menu(self, pos): def _show_menu(self, pos):
menu = QMenu(self) menu = QMenu(self)
@@ -106,21 +199,24 @@ class ArtSquare(QLabel):
if chosen is paste: if chosen is paste:
self.paste_from_clipboard() self.paste_from_clipboard()
elif chosen is choose: elif chosen is choose:
file_name, _filter = QFileDialog.getOpenFileName( self.choose_file()
self, "Choose Artwork", "",
"Images (*.jpg *.jpeg *.png *.gif *.bmp *.webp)") def choose_file(self):
if file_name: file_name, _filter = QFileDialog.getOpenFileName(
self._stage_file(Path(file_name)) 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): def dragEnterEvent(self, event):
if event.mimeData().hasUrls() or event.mimeData().hasImage(): if event.mimeData().hasUrls() or event.mimeData().hasImage():
event.acceptProposedAction() event.acceptProposedAction()
def dropEvent(self, event): def dropEvent(self, event):
for url in event.mimeData().urls(): found = clipboard_image(event.mimeData())
if url.isLocalFile() and self._stage_file(Path(url.toLocalFile())): if found:
event.acceptProposedAction() self._stage(*found)
return event.acceptProposedAction()
_TEXT_FIELDS = [ _TEXT_FIELDS = [
@@ -220,7 +316,24 @@ class InfoDialog(QDialog):
self._art_square = ArtSquare() self._art_square = ArtSquare()
self._art_square.art_staged.connect(self._on_art_staged) self._art_square.art_staged.connect(self._on_art_staged)
art_col.addWidget(self._art_square) 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() 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) top.addLayout(art_col)
self._staged_art: tuple[bytes, str] | None = None self._staged_art: tuple[bytes, str] | None = None
@@ -281,8 +394,8 @@ class InfoDialog(QDialog):
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
if self._multi: if self._multi:
# Batch editing: no single file, no per-track art/times navigation. # Batch editing: no single file, no per-track times navigation.
self._art_square.setVisible(False) # Artwork stays: a pasted cover goes into every selected track.
self._start_row.setVisible(False) self._start_row.setVisible(False)
self._stop_row.setVisible(False) self._stop_row.setVisible(False)
self._file_label.setVisible(False) self._file_label.setVisible(False)
@@ -319,6 +432,24 @@ class InfoDialog(QDialog):
first = values[0] first = values[0]
return first if all(v == first for v in values) else _MIXED 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): def _on_art_staged(self, image_bytes: bytes, mime: str):
self._staged_art = (bytes(image_bytes), mime) self._staged_art = (bytes(image_bytes), mime)
@@ -330,6 +461,7 @@ class InfoDialog(QDialog):
existing_art = (tagging.read_embedded_artwork(track.location) existing_art = (tagging.read_embedded_artwork(track.location)
if track.location else None) if track.location else None)
self._art_square.set_artwork(existing_art) self._art_square.set_artwork(existing_art)
self._art_hint.setText(HINT_IDLE)
for field, _label in _TEXT_FIELDS: for field, _label in _TEXT_FIELDS:
self._editors[field].setText(getattr(track, field) or "") self._editors[field].setText(getattr(track, field) or "")
for field, _label, _max in _NUMBER_FIELDS: for field, _label, _max in _NUMBER_FIELDS:
@@ -350,6 +482,9 @@ class InfoDialog(QDialog):
def _load_multi(self): def _load_multi(self):
self._loading = True self._loading = True
self.setWindowTitle(f"{len(self._track_ids)} Items — Info") 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: for field, _label in _TEXT_FIELDS:
common = self._common_value(field) common = self._common_value(field)
editor = self._editors[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()) fields_changed = any(getattr(track, k) != v for k, v in fields.items())
if not fields_changed and self._staged_art is None: if not fields_changed and self._staged_art is None:
return True return True
if track.location: if self._staged_art is not None:
try: # Embedding also refreshes the library's size (which triggers
if self._staged_art is not None: # track_updated, so the transport art repaints).
tagging.write_artwork(track.location, *self._staged_art) _done, failures = embed_artwork(self._manager, [track],
except Exception as e: *self._staged_art)
if failures:
QMessageBox.warning(self, "Tag write failed", 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 return False
if self._staged_art is not None: # Field edits go through the manager, which writes the file tags and
# Embedding art changed the file; keep the library's size honest # records the change for undo. Artwork itself is written above and is
# (and trigger track_updated so the transport art refreshes) # intentionally not undoable.
fields["size"] = Path(track.location).stat().st_size fields.pop("size", None)
# 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.
self._manager.edit_track_fields(track.track_id, fields) self._manager.edit_track_fields(track.track_id, fields)
self._staged_art = None self._staged_art = None
return True return True
@@ -440,6 +574,23 @@ class InfoDialog(QDialog):
fields["compilation"] = self._compilation.isChecked() fields["compilation"] = self._compilation.isChecked()
if "comments" in self._touched: if "comments" in self._touched:
fields["comments"] = self._comments.toPlainText().strip() 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: if fields:
self._manager.edit_tracks_fields(self._track_ids, fields) self._manager.edit_tracks_fields(self._track_ids, fields)
return True return True
+5 -21
View File
@@ -9,7 +9,7 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QEvent, QTimer from PyQt6.QtCore import Qt, QEvent, QTimer
from PyQt6.QtGui import QAction, QKeySequence 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 layout as andtunes_layout
from lintunes.andtunes import install as andtunes_install from lintunes.andtunes import install as andtunes_install
from lintunes.andtunes import sync as andtunes_sync 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.player import Player
from lintunes.importers import file_importer from lintunes.importers import file_importer
from lintunes.gui.album_art_dialog import AlbumArtDialog 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.cast_dialog import ChromecastDialog
from lintunes.gui.device_sync_dialog import DeviceSyncSettingsDialog from lintunes.gui.device_sync_dialog import DeviceSyncSettingsDialog
from lintunes.gui.export_dialog import ExportKindDialog, WebMixDialog from lintunes.gui.export_dialog import ExportKindDialog, WebMixDialog
@@ -1390,26 +1391,9 @@ class MainWindow(QMainWindow):
self._art_next_group() self._art_next_group()
def _embed_album_art(self, tracks, image: bytes, mime: str): def _embed_album_art(self, tracks, image: bytes, mime: str):
"""Embed confirmed artwork into each track's file. Like the Info """Embed confirmed artwork into each track's file (not undoable —
dialog, the artwork write itself is not undoable; the size refresh is see ``art_ops``)."""
an in-memory update (size isn't a tag field).""" done, failures = embed_artwork(self._manager, tracks, image, mime)
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})
if failures: if failures:
QMessageBox.warning( QMessageBox.warning(
self, "Download Album Art", self, "Download Album Art",
+27
View File
@@ -1,5 +1,32 @@
## Done ## 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) ### 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 - [x] **"lookup on youtube" in a song's right-click menu.** Opens the default
+279
View File
@@ -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]