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:
2026-09-22 09:38:39 -07:00
parent 93399a1706
commit 6b91552a5e
9 changed files with 723 additions and 93 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""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
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
+3 -2
View File
@@ -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:
+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 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
+5 -21
View File
@@ -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",