Files
lintunes/lintunes/gui/info_dialog.py
T
travandClaude Opus 5 3150df0f89 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>
2026-09-22 09:38:39 -07:00

608 lines
24 KiB
Python

import logging
from pathlib import Path
from PyQt6.QtWidgets import (
QDialog, QDialogButtonBox, QFormLayout, QLineEdit, QSpinBox, QCheckBox,
QPlainTextEdit, QHBoxLayout, QLabel, QVBoxLayout, QPushButton, QMessageBox,
QApplication, QMenu, QFileDialog, QWidget,
)
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):
"""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,
placeholder: str = "no artwork"):
if image_bytes:
pixmap = QPixmap()
if pixmap.loadFromData(image_bytes):
self.setPixmap(pixmap.scaled(
self.size(), Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation))
self.setStyleSheet(
"ArtSquare { border: 1px solid palette(mid); }"
"ArtSquare:focus { border: 3px solid palette(highlight); }")
return
self.setPixmap(QPixmap())
self.setText(placeholder)
self.setStyleSheet(
"ArtSquare { border: 2px dashed palette(mid); color: palette(mid); }"
"ArtSquare:focus { border: 3px dashed palette(highlight); }")
# ---- staging from clipboard / files ----
def keyPressEvent(self, event):
if event.matches(QKeySequence.StandardKey.Paste):
self.paste_from_clipboard()
else:
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 not None:
log.info("art paste: clipboard offers %s", mime.formats())
found = clipboard_image(mime)
if found:
self._stage(*found)
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:
found = image_file_bytes(path)
if found is None:
return False
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)
paste = menu.addAction("Paste Image\tCtrl+V")
choose = menu.addAction("Choose File…")
chosen = menu.exec(self.mapToGlobal(pos))
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 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):
found = clipboard_image(event.mimeData())
if found:
self._stage(*found)
event.acceptProposedAction()
_TEXT_FIELDS = [
("name", "Name"),
("artist", "Artist"),
("album_artist", "Album Artist"),
("album", "Album"),
("genre", "Genre"),
("composer", "Composer"),
("grouping", "Grouping"),
]
_NUMBER_FIELDS = [
("year", "Year", 9999),
("track_number", "Track Number", 999),
("track_count", "of (tracks)", 999),
("disc_number", "Disc Number", 99),
("disc_count", "of (discs)", 99),
("bpm", "BPM", 999),
]
# Distinguishes "every selected track shares this value" from "the tracks
# disagree" when editing several at once.
_MIXED = object()
def _format_ms(ms: int) -> str:
"""Render milliseconds as iTunes-style m:ss(.mmm) (millis only when set)."""
if ms <= 0:
return ""
mins, rem = divmod(ms, 60_000)
secs = rem / 1000
if rem % 1000 == 0:
return f"{mins}:{int(secs):02d}"
return f"{mins}:{secs:06.3f}"
def _parse_time_to_ms(text: str):
"""Parse m:ss(.mmm) / h:mm:ss / plain seconds to ms; None if unparseable."""
text = text.strip()
if not text:
return 0
try:
parts = [float(p) for p in text.split(":")]
except ValueError:
return None
seconds = 0.0
for part in parts:
seconds = seconds * 60 + part
return int(round(seconds * 1000))
class _TimeField(QLineEdit):
"""Small text field that edits a duration in milliseconds as m:ss.mmm."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedWidth(90)
self.setPlaceholderText("0:00")
def set_ms(self, ms: int):
self.setText(_format_ms(ms))
def ms(self) -> int:
value = _parse_time_to_ms(self.text())
return value if value is not None else 0
class InfoDialog(QDialog):
"""iTunes-style Get Info: edits library fields and the file's real tags.
Single track: Previous/Next walk a multi-selection one at a time, and the
Options block exposes custom start/stop times. Multiple tracks: a combined
editor where fields shared by every selection are editable and applied to
all, while fields that differ show "Mixed" and are only written if touched.
"""
def __init__(self, manager, track_ids: list[int], parent=None):
super().__init__(parent)
self._manager = manager
self._track_ids = [tid for tid in track_ids
if tid in manager.library.tracks]
self._index = 0
self._multi = len(self._track_ids) > 1
# In multi mode, only fields the user actually edits get written to
# every track (so an untouched "Mixed" field never overwrites anything).
self._touched: set[str] = set()
self._loading = False
layout = QVBoxLayout(self)
top = QHBoxLayout()
layout.addLayout(top)
form = QFormLayout()
top.addLayout(form, stretch=1)
art_col = QVBoxLayout()
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
self._editors = {}
for field, label in _TEXT_FIELDS:
editor = QLineEdit()
# textEdited fires only on user input, so loading values never
# marks a field touched.
editor.textEdited.connect(lambda _t, f=field: self._mark_touched(f))
self._editors[field] = editor
form.addRow(label + ":", editor)
for field, label, maximum in _NUMBER_FIELDS:
editor = QSpinBox()
editor.setRange(0, maximum)
editor.setSpecialValueText(" ")
editor.valueChanged.connect(lambda _v, f=field: self._mark_touched(f))
self._editors[field] = editor
form.addRow(label + ":", editor)
self._compilation = QCheckBox("Album is a compilation of songs by various artists")
self._compilation.clicked.connect(lambda: self._mark_touched("compilation"))
form.addRow("", self._compilation)
self._comments = QPlainTextEdit()
self._comments.setFixedHeight(60)
self._comments.textChanged.connect(lambda: self._mark_touched("comments"))
form.addRow("Comments:", self._comments)
# Custom start/stop times (single-track only; one absolute ms value
# across different-length tracks isn't meaningful).
self._start_row, self._start_check, self._start_time = \
self._make_time_row("Start time")
self._stop_row, self._stop_check, self._stop_time = \
self._make_time_row("Stop time")
form.addRow("", self._start_row)
form.addRow("", self._stop_row)
self._file_label = QLabel()
self._file_label.setStyleSheet("color: gray; font-size: 10px;")
self._file_label.setWordWrap(True)
layout.addWidget(self._file_label)
nav_row = QHBoxLayout()
self._prev_btn = QPushButton("Previous")
self._next_btn = QPushButton("Next")
nav_row.addWidget(self._prev_btn)
nav_row.addWidget(self._next_btn)
nav_row.addStretch()
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel)
nav_row.addWidget(buttons)
layout.addLayout(nav_row)
self._prev_btn.clicked.connect(lambda: self._navigate(-1))
self._next_btn.clicked.connect(lambda: self._navigate(1))
buttons.accepted.connect(self._on_ok)
buttons.rejected.connect(self.reject)
if self._multi:
# 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)
self._prev_btn.setVisible(False)
self._next_btn.setVisible(False)
self._load_multi()
else:
self._load_current()
def _make_time_row(self, title: str):
row = QWidget()
h = QHBoxLayout(row)
h.setContentsMargins(0, 0, 0, 0)
check = QCheckBox(title)
field = _TimeField()
field.setEnabled(False)
check.toggled.connect(field.setEnabled)
h.addWidget(check)
h.addWidget(field)
h.addStretch()
return row, check, field
def _mark_touched(self, field: str):
if not self._loading:
self._touched.add(field)
def _current_track(self):
return self._manager.library.tracks[self._track_ids[self._index]]
def _common_value(self, field: str):
"""The value shared by every selected track, else _MIXED."""
tracks = self._manager.library.tracks
values = [getattr(tracks[tid], field) for tid in self._track_ids]
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)
def _load_current(self):
track = self._current_track()
self._loading = True
self.setWindowTitle(f"{track.name} — Info")
self._staged_art = None
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:
self._editors[field].setValue(getattr(track, field) or 0)
self._compilation.setChecked(track.compilation)
self._comments.setPlainText(track.comments or "")
self._start_check.setChecked(track.start_time > 0)
self._start_time.setEnabled(track.start_time > 0)
self._start_time.set_ms(track.start_time)
self._stop_check.setChecked(track.stop_time > 0)
self._stop_time.setEnabled(track.stop_time > 0)
self._stop_time.set_ms(track.stop_time)
self._file_label.setText(track.location or "")
self._prev_btn.setEnabled(self._index > 0)
self._next_btn.setEnabled(self._index < len(self._track_ids) - 1)
self._loading = False
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]
if common is _MIXED:
editor.clear()
editor.setPlaceholderText("Mixed")
else:
editor.setText(common or "")
for field, _label, _max in _NUMBER_FIELDS:
common = self._common_value(field)
editor = self._editors[field]
if common is _MIXED:
editor.setSpecialValueText("Mixed")
editor.setValue(0)
else:
editor.setValue(common or 0)
comp = self._common_value("compilation")
self._compilation.setChecked(False if comp is _MIXED else bool(comp))
comments = self._common_value("comments")
if comments is _MIXED:
self._comments.setPlaceholderText("Mixed")
self._comments.setPlainText("")
else:
self._comments.setPlainText(comments or "")
self._loading = False
def _collect_fields(self) -> dict:
fields = {}
for field, _label in _TEXT_FIELDS:
fields[field] = self._editors[field].text().strip()
for field, _label, _max in _NUMBER_FIELDS:
fields[field] = self._editors[field].value()
fields["compilation"] = self._compilation.isChecked()
fields["comments"] = self._comments.toPlainText().strip()
fields["start_time"] = (self._start_time.ms()
if self._start_check.isChecked() else 0)
fields["stop_time"] = (self._stop_time.ms()
if self._stop_check.isChecked() else 0)
return fields
def _validate_times(self, track) -> str | None:
start = self._start_time.ms() if self._start_check.isChecked() else 0
stop = self._stop_time.ms() if self._stop_check.isChecked() else 0
if start and track.total_time and start >= track.total_time:
return "The start time is past the end of the track."
if start and stop and stop <= start:
return "The stop time must be after the start time."
return None
def _save_current(self) -> bool:
track = self._current_track()
error = self._validate_times(track)
if error:
QMessageBox.warning(self, "Invalid times", error)
return False
fields = self._collect_fields()
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 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",
"Could not write artwork to file:\n"
+ failures[0])
return False
# 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
def _save_multi(self) -> bool:
fields = {}
for field, _label in _TEXT_FIELDS:
if field in self._touched:
fields[field] = self._editors[field].text().strip()
for field, _label, _max in _NUMBER_FIELDS:
if field in self._touched:
fields[field] = self._editors[field].value()
if "compilation" in self._touched:
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
def _navigate(self, delta: int):
if not self._save_current():
return
self._index = max(0, min(self._index + delta, len(self._track_ids) - 1))
self._load_current()
def _on_ok(self):
saved = self._save_multi() if self._multi else self._save_current()
if saved:
self.accept()