feat: add metadata editor dialog with cover art and batch editing support

- Add update_tags() method to MetadataHandler for writing arbitrary tag
  changes to MP3/M4A/FLAC/OGG files (title, artist, album, album_artist,
  genre, date, track/disc number, comment, cover art)
- New MetadataEditorDialog (QDialog) with: cover art preview (drag-drop,
  click-to-change, remove), form fields for all common tags, F2 / context
  menu / toolbar button integration
- Batch editing: select multiple tracks to edit common fields (title and
  track numbers hidden), mixed values shown as placeholder
- Search now filters across Artist, Title, and Album columns
- Fix: layout warning from double setLayout in dialog
- Fix: cover cache invalidation after metadata edit
- Fix: missing QDialog import causing crash in shortcut remapping
- Add F2 hotkey default for Edit Metadata action
This commit is contained in:
Maksim Totmin 2026-06-01 00:07:42 +07:00
parent 1ace4ad98c
commit a93bc7907d
5 changed files with 1017 additions and 3 deletions

1
.gitignore vendored
View File

@ -37,3 +37,4 @@ converted/
*.log *.log
specs.md specs.md
*.AppImage *.AppImage
squashfs-root/

View File

@ -94,6 +94,7 @@ DEFAULTS = [
("ipod_refresh", "Ctrl+R", "Refresh Devices", "global"), ("ipod_refresh", "Ctrl+R", "Refresh Devices", "global"),
("download", "Ctrl+Enter", "Start Download", "global"), ("download", "Ctrl+Enter", "Start Download", "global"),
("delete_selected", "Delete", "Delete Selected", "global"), ("delete_selected", "Delete", "Delete Selected", "global"),
("edit_metadata", "F2", "Edit Metadata", "global"),
] ]

View File

@ -15,7 +15,7 @@ from PyQt6.QtWidgets import (
QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog, QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog,
QComboBox, QCheckBox, QTabWidget, QListWidget, QListWidgetItem, QComboBox, QCheckBox, QTabWidget, QListWidget, QListWidgetItem,
QMessageBox, QSpinBox, QGroupBox, QHeaderView, QMessageBox, QSpinBox, QGroupBox, QHeaderView,
QTableWidget, QTableWidgetItem, QSlider, QMenu QTableWidget, QTableWidgetItem, QSlider, QMenu, QDialog
) )
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl
from PyQt6.QtGui import QPixmap from PyQt6.QtGui import QPixmap
@ -24,6 +24,7 @@ from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from youtube_downloader import YouTubeDownloader, TrackInfo from youtube_downloader import YouTubeDownloader, TrackInfo
from audio_converter import AudioConverter from audio_converter import AudioConverter
from metadata_handler import MetadataHandler from metadata_handler import MetadataHandler
from metadata_editor import MetadataEditorDialog
from ipod_device import IPodDevice from ipod_device import IPodDevice
from artwork.cache import cover_cache from artwork.cache import cover_cache
from config_loader import ConfigLoader from config_loader import ConfigLoader
@ -352,6 +353,7 @@ class MainWindow(QMainWindow):
"ipod_refresh": self._on_refresh_devices_clicked, "ipod_refresh": self._on_refresh_devices_clicked,
"download": self._on_download_clicked, "download": self._on_download_clicked,
"delete_selected": self._delete_selected_current, "delete_selected": self._delete_selected_current,
"edit_metadata": self._on_edit_metadata,
}) })
self._refresh_shortcuts_table() self._refresh_shortcuts_table()
@ -563,6 +565,11 @@ class MainWindow(QMainWindow):
self.library_remove_btn.clicked.connect(self._on_library_remove_selected) self.library_remove_btn.clicked.connect(self._on_library_remove_selected)
toolbar.addWidget(self.library_remove_btn) toolbar.addWidget(self.library_remove_btn)
self.library_edit_btn = QPushButton("\u270F\uFE0F Edit")
self.library_edit_btn.setEnabled(False)
self.library_edit_btn.clicked.connect(self._on_edit_metadata)
toolbar.addWidget(self.library_edit_btn)
refresh_btn = QPushButton("\uD83D\uDD04") refresh_btn = QPushButton("\uD83D\uDD04")
refresh_btn.setToolTip("Refresh Library") refresh_btn.setToolTip("Refresh Library")
refresh_btn.clicked.connect(self._on_refresh_library_clicked) refresh_btn.clicked.connect(self._on_refresh_library_clicked)
@ -1116,6 +1123,7 @@ class MainWindow(QMainWindow):
self.library_transfer_btn.setEnabled(has_ready) self.library_transfer_btn.setEnabled(has_ready)
self.library_convert_btn.setEnabled(has_source) self.library_convert_btn.setEnabled(has_source)
self.library_remove_btn.setEnabled(len(all_tracks) > 0) self.library_remove_btn.setEnabled(len(all_tracks) > 0)
self.library_edit_btn.setEnabled(len(all_tracks) > 0)
parts = [] parts = []
if ready_count: if ready_count:
@ -1202,6 +1210,39 @@ class MainWindow(QMainWindow):
if new_files: if new_files:
self._scan_library() self._scan_library()
def _on_edit_metadata(self):
"""Open metadata editor for selected track(s)."""
selected_rows = set()
for item in self.library_table.selectedItems():
selected_rows.add(item.row())
if not selected_rows:
QMessageBox.information(self, "Info", "No track selected")
return
file_paths = []
for row in sorted(selected_rows):
data_item = self.library_table.item(row, 0)
if data_item is None:
continue
is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole)
file_path = track_data.get("path", "")
if file_path and os.path.exists(file_path):
file_paths.append(file_path)
if not file_paths:
QMessageBox.warning(self, "Error", "No valid files found on disk")
return
dialog = MetadataEditorDialog(file_paths, self)
if dialog.exec() == MetadataEditorDialog.DialogCode.Accepted:
self._scan_library()
def _get_first_selected_row(self):
for item in self.library_table.selectedItems():
return item.row()
return None
def _on_library_remove_selected(self): def _on_library_remove_selected(self):
"""Delete selected files from disk and clean up empty directories.""" """Delete selected files from disk and clean up empty directories."""
files_to_delete = [] files_to_delete = []
@ -1260,9 +1301,17 @@ class MainWindow(QMainWindow):
QMessageBox.information(self, "Done", f"Deleted {deleted} file(s)") QMessageBox.information(self, "Done", f"Deleted {deleted} file(s)")
def _on_search_text_changed(self, text: str): def _on_search_text_changed(self, text: str):
query = text.lower()
for row in range(self.library_table.rowCount()): for row in range(self.library_table.rowCount()):
item = self.library_table.item(row, 3) if not query:
match = not text or text.lower() in item.text().lower() if item else True self.library_table.setRowHidden(row, False)
continue
match = False
for col in (1, 2, 3): # Artist, Title, Album
item = self.library_table.item(row, col)
if item and query in item.text().lower():
match = True
break
self.library_table.setRowHidden(row, not match) self.library_table.setRowHidden(row, not match)
def _on_library_table_context_menu(self, pos): def _on_library_table_context_menu(self, pos):
@ -1289,6 +1338,9 @@ class MainWindow(QMainWindow):
menu.addSeparator() menu.addSeparator()
edit_action = menu.addAction("\u270F\uFE0F Edit Metadata")
edit_action.triggered.connect(lambda: self._on_edit_metadata())
show_action = menu.addAction("\uD83D\uDCC2 Show in File Manager") show_action = menu.addAction("\uD83D\uDCC2 Show in File Manager")
show_action.triggered.connect(lambda: self._on_show_in_file_manager(track_data)) show_action.triggered.connect(lambda: self._on_show_in_file_manager(track_data))

659
src/metadata_editor.py Normal file
View File

@ -0,0 +1,659 @@
#!/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
from PyQt6.QtGui import QPixmap, QDragEnterEvent, QDropEvent
from metadata_handler import MetadataHandler
from artwork.cache import cover_cache
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])
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_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))
except Exception:
pass
return result
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._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._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)
for key in self._mixed_fields:
self._set_mixed(key)
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._update_cover_display()
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 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(),
}
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 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 _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

View File

@ -556,6 +556,307 @@ class MetadataHandler:
# MusicBrainz integration would go here # MusicBrainz integration would go here
return track_info return track_info
def update_tags(self, file_path: str, tags: Dict[str, Any],
new_cover_path: Optional[str] = None) -> bool:
"""
Update metadata tags in an audio file.
Args:
file_path: Path to audio file
tags: Dict of tag_name -> value
Supported keys: title, artist, album, album_artist, genre,
date, track_number, track_total, disc_number, disc_total, comment
new_cover_path: Path to new cover image, None to keep existing,
empty string '' to remove cover art
Returns:
True if successful, False otherwise
"""
from mutagen.flac import FLAC, Picture as FLACPicture
from mutagen.oggvorbis import OggVorbis
from mutagen.oggopus import OggOpus
file_ext = Path(file_path).suffix.lower()
try:
if file_ext in ('.m4a', '.aac', '.mp4'):
return self._update_mp4_tags(file_path, tags, new_cover_path)
elif file_ext == '.mp3':
return self._update_mp3_tags(file_path, tags, new_cover_path)
elif file_ext == '.flac':
return self._update_vorbis_tags(file_path, tags, new_cover_path, use_picture_block=True)
elif file_ext in ('.ogg', '.opus'):
return self._update_vorbis_tags(file_path, tags, new_cover_path, use_picture_block=False)
else:
logger.warning(f"Unsupported format for tag update: {file_ext}")
return False
except Exception as e:
logger.error(f"Failed to update tags in {file_path}: {e}")
return False
def _load_cover_data(self, cover_path: str) -> Optional[bytes]:
"""Load cover image from path, convert to JPEG if needed."""
try:
img = Image.open(cover_path)
if img.mode != 'RGB':
img = img.convert('RGB')
buf = BytesIO()
img.save(buf, 'JPEG', quality=90)
return buf.getvalue()
except Exception as e:
logger.error(f"Failed to load cover image {cover_path}: {e}")
return None
def _update_mp4_tags(self, file_path: str, tags: Dict[str, Any],
new_cover_path: Optional[str]) -> bool:
audio = MP4(file_path)
text_fields = {
'title': '\xa9nam',
'artist': '\xa9ART',
'album': '\xa9alb',
'album_artist': 'aART',
'genre': '\xa9gen',
'date': '\xa9day',
'comment': '\xa9cmt',
}
for key, mp4_key in text_fields.items():
if key in tags:
val = tags[key]
if val:
audio[mp4_key] = [str(val)]
elif mp4_key in audio:
del audio[mp4_key]
if 'track_number' in tags or 'track_total' in tags:
trkn_val = list(audio.get('trkn', [(0, 0)])[0]) if 'trkn' in audio else [0, 0]
if 'track_number' in tags:
trkn_val[0] = tags['track_number'] or 0
if 'track_total' in tags:
trkn_val[1] = tags['track_total'] or 0
if trkn_val[0] or trkn_val[1]:
audio['trkn'] = [tuple(trkn_val)]
elif 'trkn' in audio:
del audio['trkn']
if 'disc_number' in tags or 'disc_total' in tags:
disk_val = list(audio.get('disk', [(0, 0)])[0]) if 'disk' in audio else [0, 0]
if 'disc_number' in tags:
disk_val[0] = tags['disc_number'] or 0
if 'disc_total' in tags:
disk_val[1] = tags['disc_total'] or 0
if disk_val[0] or disk_val[1]:
audio['disk'] = [tuple(disk_val)]
elif 'disk' in audio:
del audio['disk']
if new_cover_path == '':
if 'covr' in audio:
del audio['covr']
elif new_cover_path is not None:
data = self._load_cover_data(new_cover_path)
if data:
cover = MP4Cover(data, imageformat=MP4Cover.FORMAT_JPEG)
audio['covr'] = [cover]
audio.save()
return True
def _update_mp3_tags(self, file_path: str, tags: Dict[str, Any],
new_cover_path: Optional[str]) -> bool:
try:
audio = EasyID3(file_path)
except Exception:
audio = ID3()
audio.save(file_path)
audio = EasyID3(file_path)
easy_fields = {
'title': 'title',
'artist': 'artist',
'album': 'album',
'album_artist': 'albumartist',
'genre': 'genre',
'date': 'date',
}
for key, easy_key in easy_fields.items():
if key in tags:
val = tags[key]
if val:
audio[easy_key] = str(val)
elif easy_key in audio:
del audio[easy_key]
if 'comment' in tags:
try:
audio['comment'] = str(tags['comment']) if tags['comment'] else ''
except Exception:
pass
if 'track_number' in tags or 'track_total' in tags:
current = audio.get('tracknumber', [''])[0] if 'tracknumber' in audio else ''
tn, tt = self._parse_slash_value(current)
if 'track_number' in tags:
tn = tags['track_number'] or 0
if 'track_total' in tags:
tt = tags['track_total'] or 0
if tn or tt:
audio['tracknumber'] = [f"{tn}/{tt}" if tt else str(tn)]
elif 'tracknumber' in audio:
del audio['tracknumber']
if 'disc_number' in tags or 'disc_total' in tags:
current = audio.get('discnumber', [''])[0] if 'discnumber' in audio else ''
dn, dt = self._parse_slash_value(current)
if 'disc_number' in tags:
dn = tags['disc_number'] or 0
if 'disc_total' in tags:
dt = tags['disc_total'] or 0
if dn or dt:
audio['discnumber'] = [f"{dn}/{dt}" if dt else str(dn)]
elif 'discnumber' in audio:
del audio['discnumber']
audio.save()
if new_cover_path is not None:
audio = ID3(file_path)
if new_cover_path == '':
audio.delall('APIC')
else:
data = self._load_cover_data(new_cover_path)
if data:
audio.delall('APIC')
audio.add(APIC(encoding=3, mime='image/jpeg', type=3,
desc='Cover', data=data))
audio.save()
return True
def _parse_slash_value(self, current: str) -> Tuple[int, int]:
"""Parse '5/12' format into (5, 12)."""
try:
parts = str(current).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 _update_vorbis_tags(self, file_path: str, tags: Dict[str, Any],
new_cover_path: Optional[str],
use_picture_block: bool = False) -> bool:
from mutagen.flac import FLAC, Picture as FLACPicture
from mutagen.oggvorbis import OggVorbis
from mutagen.oggopus import OggOpus
ext = Path(file_path).suffix.lower()
if ext == '.flac':
audio = FLAC(file_path)
elif ext == '.opus':
audio = OggOpus(file_path)
else:
audio = OggVorbis(file_path)
if audio.tags is None:
audio.add_tags()
vorbis_fields = {
'title': 'TITLE',
'artist': 'ARTIST',
'album': 'ALBUM',
'album_artist': 'ALBUMARTIST',
'genre': 'GENRE',
'date': 'DATE',
'comment': 'COMMENT',
}
for key, vorbis_key in vorbis_fields.items():
if key in tags:
val = tags[key]
if val:
audio.tags[vorbis_key] = str(val)
elif vorbis_key in audio.tags:
try:
del audio.tags[vorbis_key]
except (KeyError, TypeError):
pass
if 'track_number' in tags or 'track_total' in tags:
current = audio.tags.get('TRACKNUMBER', [''])[0] if 'TRACKNUMBER' in audio.tags else ''
tn, tt = self._parse_slash_value(current)
if 'track_number' in tags:
tn = tags['track_number'] or 0
if 'track_total' in tags:
tt = tags['track_total'] or 0
if tn or tt:
audio.tags['TRACKNUMBER'] = f"{tn}/{tt}" if tt else str(tn)
elif 'TRACKNUMBER' in audio.tags:
try:
del audio.tags['TRACKNUMBER']
except (KeyError, TypeError):
pass
if 'disc_number' in tags or 'disc_total' in tags:
current = audio.tags.get('DISCNUMBER', [''])[0] if 'DISCNUMBER' in audio.tags else ''
dn, dt = self._parse_slash_value(current)
if 'disc_number' in tags:
dn = tags['disc_number'] or 0
if 'disc_total' in tags:
dt = tags['disc_total'] or 0
if dn or dt:
audio.tags['DISCNUMBER'] = f"{dn}/{dt}" if dt else str(dn)
elif 'DISCNUMBER' in audio.tags:
try:
del audio.tags['DISCNUMBER']
except (KeyError, TypeError):
pass
if new_cover_path is not None:
self._clear_cover_vorbis(audio)
if new_cover_path != '':
data = self._load_cover_data(new_cover_path)
if data:
self._embed_cover_vorbis(audio, data)
elif use_picture_block:
pass
else:
pass
audio.save()
return True
def _clear_cover_vorbis(self, audio) -> None:
"""Remove all cover art from Vorbis/FLAC file."""
if audio.tags is None:
return
if 'METADATA_BLOCK_PICTURE' in audio.tags:
del audio.tags['METADATA_BLOCK_PICTURE']
if 'COVERART' in audio.tags:
del audio.tags['COVERART']
if hasattr(audio, 'clear_pictures'):
audio.clear_pictures()
def _embed_cover_vorbis(self, audio, data: bytes) -> None:
"""Embed JPEG cover art into Vorbis/FLAC file."""
from mutagen.flac import Picture as FLACPicture
pic = FLACPicture()
pic.type = 3 # Cover (front)
pic.mime = 'image/jpeg'
pic.desc = 'Cover'
pic.width = 0
pic.height = 0
pic.depth = 24
pic.data = data
pic_data = pic.write()
if hasattr(audio, 'add_picture'):
audio.clear_pictures()
audio.add_picture(pic)
else:
encoded = base64.b64encode(pic_data).decode('ascii')
audio.tags['METADATA_BLOCK_PICTURE'] = encoded
if __name__ == "__main__": if __name__ == "__main__":
# Example usage # Example usage