diff --git a/.gitignore b/.gitignore index abef217..89a4a51 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ converted/ *.log specs.md *.AppImage +squashfs-root/ diff --git a/src/hotkeys.py b/src/hotkeys.py index b81beb4..ed13ba2 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -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"), ] diff --git a/src/main.py b/src/main.py index b69f582..814b46b 100644 --- a/src/main.py +++ b/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)) diff --git a/src/metadata_editor.py b/src/metadata_editor.py new file mode 100644 index 0000000..56efde7 --- /dev/null +++ b/src/metadata_editor.py @@ -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 diff --git a/src/metadata_handler.py b/src/metadata_handler.py index dfc0a7a..683bea3 100644 --- a/src/metadata_handler.py +++ b/src/metadata_handler.py @@ -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