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:
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.22.1"
|
||||
__version__ = "0.23.0"
|
||||
|
||||
+147
-6
@@ -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
|
||||
|
||||
@@ -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"<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._use_btn.setEnabled(False)
|
||||
if self._album_btn is not None:
|
||||
|
||||
@@ -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
|
||||
+207
-56
@@ -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)
|
||||
if not _has_image_hint(mime):
|
||||
self.hint_changed.emit("The clipboard doesn't hold an image")
|
||||
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())):
|
||||
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:
|
||||
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:
|
||||
self._stage_file(Path(file_name))
|
||||
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())):
|
||||
found = clipboard_image(event.mimeData())
|
||||
if found:
|
||||
self._stage(*found)
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
|
||||
|
||||
_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:
|
||||
# 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user