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.
This commit is contained in:
@@ -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
|
||||
+214
-63
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user