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:
parent
1ace4ad98c
commit
a93bc7907d
1
.gitignore
vendored
1
.gitignore
vendored
@ -37,3 +37,4 @@ converted/
|
||||
*.log
|
||||
specs.md
|
||||
*.AppImage
|
||||
squashfs-root/
|
||||
|
||||
@ -94,6 +94,7 @@ DEFAULTS = [
|
||||
("ipod_refresh", "Ctrl+R", "Refresh Devices", "global"),
|
||||
("download", "Ctrl+Enter", "Start Download", "global"),
|
||||
("delete_selected", "Delete", "Delete Selected", "global"),
|
||||
("edit_metadata", "F2", "Edit Metadata", "global"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
58
src/main.py
58
src/main.py
@ -15,7 +15,7 @@ from PyQt6.QtWidgets import (
|
||||
QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog,
|
||||
QComboBox, QCheckBox, QTabWidget, QListWidget, QListWidgetItem,
|
||||
QMessageBox, QSpinBox, QGroupBox, QHeaderView,
|
||||
QTableWidget, QTableWidgetItem, QSlider, QMenu
|
||||
QTableWidget, QTableWidgetItem, QSlider, QMenu, QDialog
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl
|
||||
from PyQt6.QtGui import QPixmap
|
||||
@ -24,6 +24,7 @@ from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
||||
from youtube_downloader import YouTubeDownloader, TrackInfo
|
||||
from audio_converter import AudioConverter
|
||||
from metadata_handler import MetadataHandler
|
||||
from metadata_editor import MetadataEditorDialog
|
||||
from ipod_device import IPodDevice
|
||||
from artwork.cache import cover_cache
|
||||
from config_loader import ConfigLoader
|
||||
@ -352,6 +353,7 @@ class MainWindow(QMainWindow):
|
||||
"ipod_refresh": self._on_refresh_devices_clicked,
|
||||
"download": self._on_download_clicked,
|
||||
"delete_selected": self._delete_selected_current,
|
||||
"edit_metadata": self._on_edit_metadata,
|
||||
})
|
||||
self._refresh_shortcuts_table()
|
||||
|
||||
@ -563,6 +565,11 @@ class MainWindow(QMainWindow):
|
||||
self.library_remove_btn.clicked.connect(self._on_library_remove_selected)
|
||||
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.setToolTip("Refresh Library")
|
||||
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_convert_btn.setEnabled(has_source)
|
||||
self.library_remove_btn.setEnabled(len(all_tracks) > 0)
|
||||
self.library_edit_btn.setEnabled(len(all_tracks) > 0)
|
||||
|
||||
parts = []
|
||||
if ready_count:
|
||||
@ -1202,6 +1210,39 @@ class MainWindow(QMainWindow):
|
||||
if new_files:
|
||||
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):
|
||||
"""Delete selected files from disk and clean up empty directories."""
|
||||
files_to_delete = []
|
||||
@ -1260,9 +1301,17 @@ class MainWindow(QMainWindow):
|
||||
QMessageBox.information(self, "Done", f"Deleted {deleted} file(s)")
|
||||
|
||||
def _on_search_text_changed(self, text: str):
|
||||
query = text.lower()
|
||||
for row in range(self.library_table.rowCount()):
|
||||
item = self.library_table.item(row, 3)
|
||||
match = not text or text.lower() in item.text().lower() if item else True
|
||||
if not query:
|
||||
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)
|
||||
|
||||
def _on_library_table_context_menu(self, pos):
|
||||
@ -1289,6 +1338,9 @@ class MainWindow(QMainWindow):
|
||||
|
||||
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.triggered.connect(lambda: self._on_show_in_file_manager(track_data))
|
||||
|
||||
|
||||
659
src/metadata_editor.py
Normal file
659
src/metadata_editor.py
Normal 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
|
||||
@ -556,6 +556,307 @@ class MetadataHandler:
|
||||
# MusicBrainz integration would go here
|
||||
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__":
|
||||
# Example usage
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user