- 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)
833 lines
31 KiB
Python
833 lines
31 KiB
Python
#!/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 (0–5 stars) in ``self._original_tags['rating']``
|
||
(only when cache has a value; file‑tag 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 (0–5 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
|