- 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>
608 lines
24 KiB
Python
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()
|