neo-pod-desktop/src/ui/metadata_editor.py
Maksim Totmin bfd146a4a0 feat: add star rating to metadata editor with write support to audio tags
- ClickableStarRating widget — 5 stars with hover preview, toggle to unrate
- Rating field in MetadataEditorDialog (0–5 stars), saved to LibraryCache (0–100)
- MetadataHandler writes rating to MP3 (POPM), M4A (\xa9rt), FLAC/Ogg (RATING)
2026-06-01 19:59:56 +07:00

833 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Metadata Editor Dialog for neo-pod-desktop
Allows editing tags and cover art of audio files (MP3, M4A, FLAC, OGG, OPUS).
Supports single-track and batch (multi-track) editing.
"""
import os
import base64
import struct
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
from io import BytesIO
from PIL import Image
from mutagen import File as MutagenFile
from mutagen.mp4 import MP4
from mutagen.id3 import ID3
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.oggopus import OggOpus
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QPushButton, QLabel, QLineEdit, QSpinBox, QCheckBox,
QFileDialog, QMessageBox, QFrame, QDialogButtonBox,
)
from PyQt6.QtCore import Qt, QSize, QTimer
from PyQt6.QtGui import QPixmap, QDragEnterEvent, QDropEvent
from metadata_handler import MetadataHandler
from artwork.cache import cover_cache
from services.itunes_search import search_itunes
from library_cache import get_library_cache, save_library_cache
from .star_rating import ClickableStarRating
COVER_SIZE = 250
SENTINEL_MULTIPLE = "__MULTIPLE__"
class MetadataEditorDialog(QDialog):
"""Dialog for viewing and editing audio file metadata and cover art."""
def __init__(self, file_paths: List[str], parent=None):
super().__init__(parent)
self.file_paths = file_paths
self._batch_mode = len(file_paths) > 1
self._current_file = file_paths[0] if file_paths else ""
self._original_tags: Dict[str, Any] = {}
self._mixed_fields: set = set()
self._new_cover_path: Optional[str] = None
self._cover_removed = False
self._existing_cover_data: Optional[bytes] = None
title = f"Edit Metadata ({len(file_paths)} files)" if self._batch_mode else "Edit Metadata"
self.setWindowTitle(title)
self.setMinimumSize(620, 440)
self.setModal(True)
self._load_tags()
self._setup_ui()
self._populate_form()
if self._batch_mode:
self._apply_batch_mode()
def _load_tags(self):
"""Load metadata from file(s). In batch mode, finds common values."""
if not self.file_paths:
return
first_tags = self._load_single_file(self.file_paths[0])
self._original_tags = dict(first_tags)
self._existing_cover_data = self._extract_cover_from_path(self.file_paths[0])
self._load_ratings_from_cache()
if self._batch_mode:
all_tags = [first_tags]
all_different = False
for path in self.file_paths[1:]:
tags = self._load_single_file(path, skip_cover=True)
all_tags.append(tags)
if all_different:
continue
for key in list(self._original_tags.keys()):
if tags.get(key) != self._original_tags.get(key):
self._mixed_fields.add(key)
def _load_ratings_from_cache(self):
"""Load rating from LibraryCache for all selected files.
Stores the rating (05 stars) in ``self._original_tags['rating']``
(only when cache has a value; filetag rating takes precedence if
cache is empty). In batch mode, marks ``rating`` as mixed when
values differ.
"""
cache = get_library_cache()
ratings = []
for p in self.file_paths:
entry = cache.get(p)
r = entry.get("rating", 0) if entry else 0
ratings.append(max(0, min(5, round(r / 20))))
cache_val = ratings[0]
if cache_val > 0 or "rating" not in self._original_tags:
self._original_tags["rating"] = cache_val
if self._batch_mode and len(set(ratings)) > 1:
self._mixed_fields.add("rating")
self._ratings = {p: r for p, r in zip(self.file_paths, ratings)}
else:
self._ratings = {}
def _load_single_file(self, file_path: str, skip_cover: bool = False) -> Dict[str, Any]:
"""Load tags from a single file. Returns dict of tag_key -> value."""
result: Dict[str, Any] = {}
try:
audio = MutagenFile(file_path)
if audio is None:
return result
tags = audio.tags
ext = Path(file_path).suffix.lower()
field_map = {
'title': ('title', '\xa9nam'),
'artist': ('artist', '\xa9ART'),
'album': ('album', '\xa9alb'),
'album_artist': ('albumartist', 'aART'),
'genre': ('genre', '\xa9gen'),
'date': ('date', '\xa9day'),
'comment': ('comment', '\xa9cmt'),
}
for our_key, (easy_key, mp4_key) in field_map.items():
val = self._safe_get(tags, easy_key, mp4_key)
if val is not None:
result[our_key] = val
result.update(self._load_track_disc(tags))
result.update(self._load_rating_from_tags(tags, ext))
except Exception:
pass
return result
@staticmethod
def _load_rating_from_tags(tags, ext: str) -> dict:
"""Read rating (05 stars) from audio file tags, if present."""
if tags is None:
return {}
try:
if ext in ('.m4a', '.aac', '.mp4') and '\xa9rt' in tags:
rt = tags['\xa9rt']
if isinstance(rt, list) and rt:
val = int(rt[0]) // 20 # 0-100 → 0-5
return {"rating": max(0, min(5, val))}
elif ext == '.mp3':
for frame in tags.getall('POPM'):
if frame.rating > 0:
pops = [0, 64, 128, 192, 224, 255]
stars = max(i for i, v in enumerate(pops) if v <= frame.rating)
return {"rating": stars}
elif ext in ('.flac', '.ogg', '.opus') and 'RATING' in tags:
raw = tags['RATING']
if isinstance(raw, list):
raw = raw[0]
val = int(str(raw)) // 20
return {"rating": max(0, min(5, val))}
except Exception:
pass
return {}
def _load_track_disc(self, tags) -> Dict[str, Any]:
result = {}
if tags is None:
return result
if 'trkn' in tags:
trkn = tags['trkn']
if isinstance(trkn, list) and trkn:
t = trkn[0]
if isinstance(t, (tuple, list)) and len(t) >= 1:
result['track_number'] = int(t[0]) if t[0] else 0
if len(t) >= 2:
result['track_total'] = int(t[1]) if t[1] else 0
elif 'TRCK' in tags:
tn, tt = self._parse_slash(str(tags['TRCK']))
if tn:
result['track_number'] = tn
if tt:
result['track_total'] = tt
elif hasattr(tags, 'getall'):
try:
frames = tags.getall('TRCK')
if frames:
tn, tt = self._parse_slash(str(frames[0]))
if tn:
result['track_number'] = tn
if tt:
result['track_total'] = tt
except Exception:
pass
for mp4_key, (vorbis_key, num_key, total_key) in (
('disk', ('DISCNUMBER', 'disc_number', 'disc_total')),
):
if mp4_key in tags:
disk_val = tags[mp4_key]
if isinstance(disk_val, list) and disk_val:
d = disk_val[0]
if isinstance(d, (tuple, list)) and len(d) >= 1:
result[num_key] = int(d[0]) if d[0] else 0
if len(d) >= 2:
result[total_key] = int(d[1]) if d[1] else 0
elif vorbis_key in tags:
dn, dt = self._parse_slash(str(tags[vorbis_key]))
if dn:
result[num_key] = dn
if dt:
result[total_key] = dt
elif hasattr(tags, 'getall'):
try:
frames = tags.getall('TPOS')
if frames:
dn, dt = self._parse_slash(str(frames[0]))
if dn:
result[num_key] = dn
if dt:
result[total_key] = dt
except Exception:
pass
return result
def _extract_cover_from_path(self, file_path: str) -> Optional[bytes]:
try:
audio = MutagenFile(file_path)
if audio is None:
return None
return self._extract_cover_raw(audio, audio.tags)
except Exception:
return None
def _safe_get(self, tags, easy_key: str, mp4_key: str) -> Optional[str]:
if tags is None:
return None
try:
if mp4_key in tags:
val = tags[mp4_key]
if isinstance(val, list) and val:
return str(val[0])
return str(val)
except (ValueError, KeyError):
pass
try:
easy_lower = easy_key.lower()
for k in tags.keys():
if k.lower() == easy_lower:
val = tags[k]
if isinstance(val, list) and val:
return str(val[0])
return str(val)
except (ValueError, AttributeError):
pass
id3_map = {
'title': 'TIT2', 'artist': 'TPE1', 'album': 'TALB',
'albumartist': 'TPE2', 'genre': 'TCON', 'date': 'TDRC', 'comment': 'COMM',
}
frame_id = id3_map.get(easy_key.lower())
if frame_id and hasattr(tags, 'getall'):
try:
frames = tags.getall(frame_id)
if frames:
return str(frames[0])
except Exception:
pass
return None
def _parse_slash(self, val: str):
try:
parts = str(val).split('/')
first = int(parts[0]) if parts[0].strip().isdigit() else 0
second = int(parts[1]) if len(parts) > 1 and parts[1].strip().isdigit() else 0
return first, second
except Exception:
return 0, 0
def _extract_cover_raw(self, audio, tags) -> Optional[bytes]:
if hasattr(tags, 'getall') and tags is not None:
try:
for frame in tags.getall('APIC'):
return frame.data
except Exception:
pass
try:
if tags and 'covr' in tags:
cover_list = tags['covr']
if isinstance(cover_list, list) and cover_list:
return bytes(cover_list[0])
except Exception:
pass
try:
if tags and 'metadata_block_picture' in tags:
pic_data = tags['metadata_block_picture']
if isinstance(pic_data, list):
pic_data = pic_data[0]
pic_data = str(pic_data)
decoded = base64.b64decode(pic_data)
pos = 0
pos += 4
mime_len = struct.unpack('>I', decoded[pos:pos + 4])[0]
pos += 4 + mime_len
desc_len = struct.unpack('>I', decoded[pos:pos + 4])[0]
pos += 4 + desc_len
pos += 20
return decoded[pos:]
except Exception:
pass
if hasattr(audio, 'pictures') and audio.pictures:
return audio.pictures[0].data
return None
def _setup_ui(self):
outer = QVBoxLayout(self)
outer.setSpacing(12)
body = QHBoxLayout()
body.setSpacing(16)
cover_layout = QVBoxLayout()
cover_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.cover_label = QLabel()
self.cover_label.setFixedSize(COVER_SIZE, COVER_SIZE)
self.cover_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.cover_label.setStyleSheet(
"QLabel { border: 2px dashed #888; border-radius: 6px; background: palette(window); }"
)
self.cover_label.setAcceptDrops(True)
self.cover_label.setText("Drag cover\nhere\nor click")
self.cover_label.setToolTip("Click to choose cover image, drag & drop to replace")
self.cover_label.mousePressEvent = self._on_cover_clicked
cover_layout.addWidget(self.cover_label)
self.cover_apply_check = QCheckBox("Apply cover to all selected")
self.cover_apply_check.setVisible(False)
self.cover_apply_check.setChecked(True)
cover_layout.addWidget(self.cover_apply_check)
clear_btn = QPushButton("Remove Cover")
clear_btn.clicked.connect(self._on_clear_cover)
cover_layout.addWidget(clear_btn)
cover_layout.addStretch()
body.addLayout(cover_layout)
form_frame = QFrame()
form_layout = QFormLayout(form_frame)
form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
form_layout.setFieldGrowthPolicy(
QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow
)
self.title_edit = QLineEdit()
self.title_edit.setPlaceholderText("Track Title")
self.artist_edit = QLineEdit()
self.artist_edit.setPlaceholderText("Artist")
self.album_edit = QLineEdit()
self.album_edit.setPlaceholderText("Album")
self.album_artist_edit = QLineEdit()
self.album_artist_edit.setPlaceholderText("Album Artist")
self.genre_edit = QLineEdit()
self.genre_edit.setPlaceholderText("Genre")
self.comment_edit = QLineEdit()
self.comment_edit.setPlaceholderText("Comment")
self.year_spin = QSpinBox()
self.year_spin.setRange(0, 2100)
self.year_spin.setSpecialValueText("")
self.track_num_spin = QSpinBox()
self.track_num_spin.setRange(0, 999)
self.track_num_spin.setSpecialValueText("")
self.track_total_spin = QSpinBox()
self.track_total_spin.setRange(0, 999)
self.track_total_spin.setSpecialValueText("")
self.disc_num_spin = QSpinBox()
self.disc_num_spin.setRange(0, 99)
self.disc_num_spin.setSpecialValueText("")
self.disc_total_spin = QSpinBox()
self.disc_total_spin.setRange(0, 99)
self.disc_total_spin.setSpecialValueText("")
self._title_label = QLabel("Title:")
form_layout.addRow(self._title_label, self.title_edit)
self._artist_label = QLabel("Artist:")
form_layout.addRow(self._artist_label, self.artist_edit)
self._lookup_btn = QPushButton("\U0001F50D Lookup in iTunes")
self._lookup_btn.setToolTip("Search iTunes Store for metadata by artist and title")
self._lookup_btn.clicked.connect(self._on_lookup_clicked)
self._lookup_btn.setVisible(not self._batch_mode)
self._lookup_status = QLabel("")
self._lookup_status.setStyleSheet("color: #888; font-size: 11px; font-style: italic;")
self._lookup_status.setVisible(False)
lookup_row = QHBoxLayout()
lookup_row.addWidget(self._lookup_btn)
lookup_row.addWidget(self._lookup_status)
lookup_row.addStretch()
form_layout.addRow(QLabel(""), lookup_row)
self._album_label = QLabel("Album:")
form_layout.addRow(self._album_label, self.album_edit)
self._album_artist_label = QLabel("Album Artist:")
form_layout.addRow(self._album_artist_label, self.album_artist_edit)
self._genre_label = QLabel("Genre:")
form_layout.addRow(self._genre_label, self.genre_edit)
self.rating_widget = ClickableStarRating()
self.rating_widget.rating_changed.connect(self._on_rating_changed)
self._rating_label = QLabel("Rating:")
form_layout.addRow(self._rating_label, self.rating_widget)
self._year_label = QLabel("Year:")
form_layout.addRow(self._year_label, self.year_spin)
self._track_label = QLabel("Track #:")
self._track_row_layout = QHBoxLayout()
self._track_row_layout.addWidget(self.track_num_spin)
self._track_row_layout.addWidget(QLabel("/"))
self._track_row_layout.addWidget(self.track_total_spin)
self._track_row_layout.addStretch()
form_layout.addRow(self._track_label, self._track_row_layout)
self._disc_label = QLabel("Disc #:")
self._disc_row_layout = QHBoxLayout()
self._disc_row_layout.addWidget(self.disc_num_spin)
self._disc_row_layout.addWidget(QLabel("/"))
self._disc_row_layout.addWidget(self.disc_total_spin)
self._disc_row_layout.addStretch()
form_layout.addRow(self._disc_label, self._disc_row_layout)
self._comment_label = QLabel("Comment:")
form_layout.addRow(self._comment_label, self.comment_edit)
body.addWidget(form_frame, stretch=1)
outer.addLayout(body, stretch=1)
self._track_count_label = QLabel("")
self._track_count_label.setVisible(False)
self._track_count_label.setStyleSheet("color: #888; font-size: 11px;")
outer.addWidget(self._track_count_label)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save |
QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self._on_save)
buttons.rejected.connect(self.reject)
outer.addWidget(buttons)
def _apply_batch_mode(self):
title_text = f"\uD83D\uDCCB Editing {len(self.file_paths)} selected tracks"
self._track_count_label.setText(title_text)
self._track_count_label.setVisible(True)
self._title_label.setText("Title: (hidden in batch)")
self.title_edit.setVisible(False)
for w in (self.track_num_spin, self.track_total_spin):
w.setVisible(False)
self._track_label.setText("Track #: (hidden in batch)")
for widget in self.findChildren(QLabel):
if widget.text() == "/" and widget.parent() in (self._track_row_layout.parent(), None):
pass
self.cover_apply_check.setVisible(True)
self._lookup_btn.setVisible(False)
self._lookup_status.setVisible(False)
for key in self._mixed_fields:
self._set_mixed(key)
if "rating" in self._mixed_fields:
self.rating_widget.set_rating(0)
self._rating_label.setText("Rating: (mixed)")
def _set_mixed(self, key: str):
placeholders = {
'title': "(different values)",
'artist': "(different values)",
'album': "(different values)",
'album_artist': "(different values)",
'genre': "(different values)",
'date': "",
'comment': "(different values)",
'track_number': 0,
'track_total': 0,
'disc_number': 0,
'disc_total': 0,
}
edit_map = {
'title': self.title_edit,
'artist': self.artist_edit,
'album': self.album_edit,
'album_artist': self.album_artist_edit,
'genre': self.genre_edit,
'comment': self.comment_edit,
}
spin_map = {
'date': self.year_spin,
'track_number': self.track_num_spin,
'track_total': self.track_total_spin,
'disc_number': self.disc_num_spin,
'disc_total': self.disc_total_spin,
}
if key in edit_map:
edit_map[key].setPlaceholderText(placeholders.get(key, "(different values)"))
edit_map[key].setText("")
elif key in spin_map:
spin_map[key].setValue(0)
spin_map[key].setSpecialValueText("\u2014")
def _populate_form(self):
t = self._original_tags
self.title_edit.setText(t.get('title', ''))
self.artist_edit.setText(t.get('artist', ''))
self.album_edit.setText(t.get('album', ''))
self.album_artist_edit.setText(t.get('album_artist', ''))
self.genre_edit.setText(t.get('genre', ''))
self.comment_edit.setText(t.get('comment', ''))
date_str = t.get('date', '')
try:
year = int(str(date_str)[:4])
except (ValueError, TypeError):
year = 0
self.year_spin.setValue(year)
self.track_num_spin.setValue(t.get('track_number', 0))
self.track_total_spin.setValue(t.get('track_total', 0))
self.disc_num_spin.setValue(t.get('disc_number', 0))
self.disc_total_spin.setValue(t.get('disc_total', 0))
self.rating_widget.set_rating(t.get("rating", 0))
self._update_cover_display()
def _on_rating_changed(self, stars: int) -> None:
pass # placeholder for future live-preview
def _update_cover_display(self):
if self._cover_removed:
self.cover_label.setText("No cover")
self.cover_label.setPixmap(QPixmap())
return
if self._batch_mode and not self._new_cover_path and self._existing_cover_data is None:
self.cover_label.setText("Multiple\ncovers\n\nClick to replace")
self.cover_label.setPixmap(QPixmap())
return
pixmap = None
if self._new_cover_path and os.path.exists(self._new_cover_path):
pixmap = QPixmap(self._new_cover_path)
elif self._existing_cover_data:
pixmap = QPixmap()
pixmap.loadFromData(self._existing_cover_data)
if pixmap and not pixmap.isNull():
scaled = pixmap.scaled(
COVER_SIZE - 16, COVER_SIZE - 16,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self.cover_label.setPixmap(scaled)
else:
self.cover_label.setText("No cover")
self.cover_label.setPixmap(QPixmap())
def _on_cover_clicked(self, event):
path, _ = QFileDialog.getOpenFileName(
self, "Select Cover Image", "",
"Images (*.png *.jpg *.jpeg *.bmp *.gif *.tiff *.webp);;All Files (*)"
)
if path:
self._new_cover_path = path
self._cover_removed = False
self._update_cover_display()
def _on_clear_cover(self):
self._new_cover_path = None
self._cover_removed = True
self._update_cover_display()
def _on_lookup_clicked(self):
artist = self.artist_edit.text().strip()
title = self.title_edit.text().strip()
if not artist and not title:
self._show_lookup_status("Enter artist or title first", is_error=True)
return
self._lookup_btn.setEnabled(False)
self._show_lookup_status("Searching iTunes\u2026")
result = search_itunes(artist=artist, title=title)
self._lookup_btn.setEnabled(True)
if not result:
self._show_lookup_status("No results found", is_error=True)
return
self.title_edit.setText(result.get("title", ""))
self.artist_edit.setText(result.get("artist", ""))
self.album_edit.setText(result.get("album", ""))
self.album_artist_edit.setText(result.get("album_artist", ""))
self.genre_edit.setText(result.get("genre", ""))
year = result.get("year", 0)
if year and year > 0:
self.year_spin.setValue(year)
track_num = result.get("track_number", 0)
if track_num:
self.track_num_spin.setValue(track_num)
track_total = result.get("track_total", 0)
if track_total:
self.track_total_spin.setValue(track_total)
disc_num = result.get("disc_number", 0)
if disc_num:
self.disc_num_spin.setValue(disc_num)
disc_total = result.get("disc_total", 0)
if disc_total:
self.disc_total_spin.setValue(disc_total)
cover_url = result.get("cover_url", "")
if cover_url and not self._existing_cover_data and not self._new_cover_path:
try:
import tempfile
import requests
resp = requests.get(cover_url, timeout=10)
resp.raise_for_status()
fd, tmp_path = tempfile.mkstemp(suffix=".jpg")
os.close(fd)
with open(tmp_path, "wb") as f:
f.write(resp.content)
self._new_cover_path = tmp_path
self._update_cover_display()
except Exception:
pass
self._show_lookup_status("Metadata loaded from iTunes")
def _show_lookup_status(self, text: str, is_error: bool = False):
self._lookup_status.setText(text)
self._lookup_status.setStyleSheet(
"color: #c0392b; font-size: 11px; font-style: italic;"
if is_error else
"color: #27ae60; font-size: 11px; font-style: italic;"
)
self._lookup_status.setVisible(True)
QTimer.singleShot(4000, lambda: self._lookup_status.setVisible(False))
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event: QDropEvent):
urls = event.mimeData().urls()
if urls:
path = urls[0].toLocalFile()
if os.path.isfile(path):
ext = Path(path).suffix.lower()
if ext in ('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tiff', '.webp'):
self._new_cover_path = path
self._cover_removed = False
self._update_cover_display()
def _collect_current(self) -> Dict[str, Any]:
return {
'title': self.title_edit.text().strip(),
'artist': self.artist_edit.text().strip(),
'album': self.album_edit.text().strip(),
'album_artist': self.album_artist_edit.text().strip(),
'genre': self.genre_edit.text().strip(),
'date': str(self.year_spin.value()) if self.year_spin.value() > 0 else '',
'track_number': self.track_num_spin.value(),
'track_total': self.track_total_spin.value(),
'disc_number': self.disc_num_spin.value(),
'disc_total': self.disc_total_spin.value(),
'comment': self.comment_edit.text().strip(),
'rating': self.rating_widget.rating(),
}
def _collect_changes(self) -> Dict[str, Any]:
if self._batch_mode:
return self._collect_batch_changes()
return self._collect_single_changes()
def _collect_single_changes(self) -> Dict[str, Any]:
changes = {}
current = self._collect_current()
for key, val in current.items():
orig = self._original_tags.get(key)
if isinstance(val, int) and isinstance(orig, int):
if val != orig:
changes[key] = val
elif isinstance(val, int) and isinstance(orig, str):
try:
if int(str(orig)[:4]) != val:
changes[key] = val
except ValueError:
if val:
changes[key] = val
elif isinstance(val, int) and orig is None:
if val != 0:
changes[key] = val
elif str(val) != str(orig or ''):
changes[key] = val
return changes
def _collect_batch_changes(self) -> Dict[str, Any]:
current = self._collect_current()
exclude = {'title', 'track_number', 'track_total'}
changes = {}
for key, val in current.items():
if key in exclude:
continue
orig = self._original_tags.get(key)
if key in self._mixed_fields:
if isinstance(val, int) and val == 0:
continue
if isinstance(val, str) and not val:
continue
changes[key] = val
else:
if isinstance(val, int) and isinstance(orig, int):
if val != orig:
changes[key] = val
elif isinstance(val, str) and orig is not None:
if str(val) != str(orig):
changes[key] = val
elif val and not orig:
changes[key] = val
return changes
def _on_save(self):
changes = self._collect_changes()
cover_arg = None
if self._cover_removed:
cover_arg = ''
elif self._new_cover_path:
cover_arg = self._new_cover_path
if not changes and cover_arg is None:
self.accept()
return
paths = self.file_paths if self._batch_mode else [self.file_paths[0]]
try:
handler = MetadataHandler()
failed = 0
for path in paths:
ok = handler.update_tags(path, changes, cover_arg)
if not ok:
failed += 1
else:
self._invalidate_cover_cache(path, changes, cover_arg)
if not failed:
self._persist_rating(paths, changes)
if failed == len(paths):
QMessageBox.warning(self, "Error", "Failed to save metadata changes.")
elif failed:
QMessageBox.warning(
self, "Partial Success",
f"Saved {len(paths) - failed}/{len(paths)} files.\n{failed} file(s) failed."
)
self.accept()
else:
self.accept()
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save metadata:\n{e}")
def _persist_rating(self, paths: list[str], changes: dict[str, Any]) -> None:
if "rating" not in changes:
return
try:
cache = get_library_cache()
for p in paths:
entry = cache.get(p)
if entry is None:
continue
entry["rating"] = changes["rating"] * 20
cache.put(p, entry)
save_library_cache()
except Exception:
logger.exception("Failed to persist rating to LibraryCache")
def _invalidate_cover_cache(self, file_path: str, changes: Dict[str, Any],
cover_arg: Optional[str]) -> None:
"""Update cover cache after successful save."""
try:
audio = MutagenFile(file_path)
new_cover = self._extract_cover_raw(audio, audio.tags) if audio else None
artist = changes.get('artist') or self._original_tags.get('artist', 'Unknown Artist')
album = changes.get('album') or self._original_tags.get('album', '')
if new_cover:
cover_cache.put(artist, album, new_cover)
elif cover_arg == '':
key = cover_cache._key(artist, album)
cache_path = os.path.join(cover_cache.cache_dir, f"{key}.jpg")
if os.path.exists(cache_path):
os.remove(cache_path)
except Exception:
pass