refactor: extract main.py into ui/ tabs + app.py, extract search_itunes into services/, remove MusicBrainz
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QProgressBar,
|
||||
QComboBox, QListWidget, QListWidgetItem, QMessageBox,
|
||||
QFileDialog,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
|
||||
from ipod_device import IPodDevice
|
||||
from worker import WorkerThread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class iPodTab(QWidget):
|
||||
|
||||
device_mounted = pyqtSignal(str)
|
||||
device_unmounted = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ipod_devices: List[Dict] = []
|
||||
self.current_mount_point: Optional[str] = None
|
||||
self.worker_thread: Optional[WorkerThread] = None
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
device_layout = QHBoxLayout()
|
||||
device_label = QLabel("iPod Device:")
|
||||
self.device_combo = QComboBox()
|
||||
self.device_combo.setPlaceholderText("No devices found")
|
||||
refresh_button = QPushButton("Refresh")
|
||||
refresh_button.clicked.connect(self._on_refresh_devices_clicked)
|
||||
device_layout.addWidget(device_label)
|
||||
device_layout.addWidget(self.device_combo)
|
||||
device_layout.addWidget(refresh_button)
|
||||
layout.addLayout(device_layout)
|
||||
|
||||
mount_layout = QHBoxLayout()
|
||||
self.mount_button = QPushButton("Mount")
|
||||
self.mount_button.setEnabled(False)
|
||||
self.mount_button.clicked.connect(self._on_mount_clicked)
|
||||
self.eject_button = QPushButton("Eject")
|
||||
self.eject_button.setEnabled(False)
|
||||
self.eject_button.clicked.connect(self._on_eject_clicked)
|
||||
mount_layout.addWidget(self.mount_button)
|
||||
mount_layout.addWidget(self.eject_button)
|
||||
mount_layout.addStretch()
|
||||
layout.addLayout(mount_layout)
|
||||
|
||||
self.device_info_label = QLabel("No device selected")
|
||||
layout.addWidget(self.device_info_label)
|
||||
|
||||
self.device_status_label = QLabel("Status: Not connected")
|
||||
layout.addWidget(self.device_status_label)
|
||||
|
||||
self.track_count_label = QLabel("Tracks on device: 0")
|
||||
layout.addWidget(self.track_count_label)
|
||||
|
||||
on_device_label = QLabel("On Device:")
|
||||
layout.addWidget(on_device_label)
|
||||
|
||||
self.transferred_list = QListWidget()
|
||||
self.transferred_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
||||
layout.addWidget(self.transferred_list)
|
||||
|
||||
remove_button = QPushButton("Remove Selected from iPod")
|
||||
remove_button.clicked.connect(self._on_remove_tracks_clicked)
|
||||
layout.addWidget(remove_button)
|
||||
|
||||
orphan_button = QPushButton("Scan for Orphaned Files")
|
||||
orphan_button.clicked.connect(self._on_scan_orphans_clicked)
|
||||
layout.addWidget(orphan_button)
|
||||
|
||||
dedup_button = QPushButton("Remove Duplicate Tracks")
|
||||
dedup_button.clicked.connect(self._on_remove_duplicates_clicked)
|
||||
layout.addWidget(dedup_button)
|
||||
|
||||
self.export_button = QPushButton("\u2B07 Download to Computer...")
|
||||
self.export_button.setEnabled(False)
|
||||
self.export_button.clicked.connect(self._on_export_ipod_clicked)
|
||||
layout.addWidget(self.export_button)
|
||||
|
||||
self.export_progress = QProgressBar()
|
||||
self.export_progress.setRange(0, 100)
|
||||
self.export_progress.setVisible(False)
|
||||
layout.addWidget(self.export_progress)
|
||||
|
||||
self.export_status = QLabel("")
|
||||
self.export_status.setVisible(False)
|
||||
layout.addWidget(self.export_status)
|
||||
|
||||
self.delete_progress = QProgressBar()
|
||||
self.delete_progress.setRange(0, 100)
|
||||
self.delete_progress.setVisible(False)
|
||||
layout.addWidget(self.delete_progress)
|
||||
|
||||
self.delete_status = QLabel("")
|
||||
self.delete_status.setVisible(False)
|
||||
layout.addWidget(self.delete_status)
|
||||
|
||||
def _on_refresh_devices_clicked(self):
|
||||
if self.worker_thread and self.worker_thread.isRunning():
|
||||
return
|
||||
|
||||
self.device_combo.clear()
|
||||
self.device_info_label.setText("Detecting devices...")
|
||||
|
||||
self.worker_thread = WorkerThread(task_type="detect_devices")
|
||||
self.worker_thread.progress_signal.connect(self._on_device_detection_progress)
|
||||
self.worker_thread.finished_signal.connect(self._on_device_detection_finished)
|
||||
self.worker_thread.start()
|
||||
|
||||
def _on_device_detection_progress(self, progress, status):
|
||||
self.device_info_label.setText(status)
|
||||
|
||||
def _on_device_detection_finished(self, success, message, result):
|
||||
if success:
|
||||
self.ipod_devices = result
|
||||
self.device_combo.clear()
|
||||
for device in self.ipod_devices:
|
||||
mounted = device.get("mounted", False)
|
||||
mount_text = " [mounted]" if mounted else " [not mounted]"
|
||||
self.device_combo.addItem(device.get("name", "Unknown Device") + mount_text)
|
||||
|
||||
if self.ipod_devices:
|
||||
device = self.ipod_devices[0]
|
||||
device_id = device["id"]
|
||||
detected_mount = device.get("mount_point")
|
||||
already_mounted = device.get("mounted", False)
|
||||
|
||||
if already_mounted and detected_mount:
|
||||
self._set_device_mounted(device, detected_mount)
|
||||
else:
|
||||
ipod = IPodDevice()
|
||||
mount_point = ipod.mount_device(device_id, detected_mount=detected_mount)
|
||||
if mount_point:
|
||||
device["mount_point"] = mount_point
|
||||
device["mounted"] = True
|
||||
self._set_device_mounted(device, mount_point)
|
||||
else:
|
||||
self._set_device_unmounted(device)
|
||||
else:
|
||||
self._set_device_not_found()
|
||||
else:
|
||||
self._set_device_not_found()
|
||||
|
||||
self._cleanup_worker()
|
||||
|
||||
def _set_device_mounted(self, device: dict, mount_point: str):
|
||||
self.current_mount_point = mount_point
|
||||
ipod = IPodDevice(mount_point=mount_point)
|
||||
device_info = ipod.get_device_info()
|
||||
device["info"] = device_info
|
||||
|
||||
free_space = device_info.get("free_space", 0)
|
||||
total_space = device_info.get("total_space", 0)
|
||||
|
||||
self.device_info_label.setText(
|
||||
f"Device: {device['name']}\n"
|
||||
f"Mount Point: {mount_point}\n"
|
||||
f"Free Space: {free_space / 1024**2:.1f} MB / {total_space / 1024**2:.1f} MB"
|
||||
)
|
||||
self.device_status_label.setText("Status: Mounted and ready")
|
||||
self.mount_button.setEnabled(False)
|
||||
self.eject_button.setEnabled(True)
|
||||
|
||||
self.device_mounted.emit(mount_point)
|
||||
|
||||
self._load_ipod_tracks()
|
||||
|
||||
def _set_device_unmounted(self, device: dict):
|
||||
self.current_mount_point = None
|
||||
self.device_info_label.setText(
|
||||
f"Device: {device['name']}\n"
|
||||
f"Click 'Mount' to connect"
|
||||
)
|
||||
self.device_status_label.setText("Status: Connected but not mounted")
|
||||
self.mount_button.setEnabled(True)
|
||||
self.eject_button.setEnabled(False)
|
||||
self.track_count_label.setText("Tracks on device: 0")
|
||||
self.transferred_list.clear()
|
||||
self.export_button.setEnabled(False)
|
||||
self.export_progress.setVisible(False)
|
||||
self.export_status.setVisible(False)
|
||||
|
||||
self.device_unmounted.emit()
|
||||
|
||||
def _set_device_not_found(self):
|
||||
self.current_mount_point = None
|
||||
self.device_info_label.setText("No iPod devices found")
|
||||
self.device_status_label.setText("Status: Not connected")
|
||||
self.device_combo.clear()
|
||||
self.mount_button.setEnabled(False)
|
||||
self.eject_button.setEnabled(False)
|
||||
self.track_count_label.setText("Tracks on device: 0")
|
||||
self.transferred_list.clear()
|
||||
self.export_button.setEnabled(False)
|
||||
self.export_progress.setVisible(False)
|
||||
self.export_status.setVisible(False)
|
||||
|
||||
self.device_unmounted.emit()
|
||||
|
||||
def _on_mount_clicked(self):
|
||||
if not self.ipod_devices:
|
||||
return
|
||||
|
||||
device = self.ipod_devices[0]
|
||||
device_id = device["id"]
|
||||
detected_mount = device.get("mount_point")
|
||||
|
||||
self.device_status_label.setText("Mounting...")
|
||||
self.mount_button.setEnabled(False)
|
||||
|
||||
try:
|
||||
ipod = IPodDevice()
|
||||
mount_point = ipod.mount_device(device_id, detected_mount=detected_mount)
|
||||
if mount_point:
|
||||
device["mount_point"] = mount_point
|
||||
device["mounted"] = True
|
||||
self._set_device_mounted(device, mount_point)
|
||||
else:
|
||||
self.device_status_label.setText("Status: Mount failed")
|
||||
self.mount_button.setEnabled(True)
|
||||
QMessageBox.warning(self, "Mount Error", "Failed to mount iPod. Check permissions and try again.")
|
||||
except Exception as e:
|
||||
self.device_status_label.setText("Status: Mount error")
|
||||
self.mount_button.setEnabled(True)
|
||||
QMessageBox.warning(self, "Mount Error", f"Error mounting iPod: {e}")
|
||||
|
||||
def _on_eject_clicked(self):
|
||||
reply = QMessageBox.question(
|
||||
self, "Eject iPod",
|
||||
"Eject iPod? Make sure no transfers are in progress.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
self.device_status_label.setText("Ejecting...")
|
||||
self.eject_button.setEnabled(False)
|
||||
|
||||
try:
|
||||
ipod = IPodDevice(mount_point=self.current_mount_point)
|
||||
success = ipod.unmount_device()
|
||||
if success or True:
|
||||
self.current_mount_point = None
|
||||
if self.ipod_devices:
|
||||
self.ipod_devices[0]["mounted"] = False
|
||||
self.ipod_devices[0]["mount_point"] = None
|
||||
|
||||
self.device_info_label.setText("iPod ejected. You can now disconnect it.")
|
||||
self.device_status_label.setText("Status: Ejected - safe to disconnect")
|
||||
self.mount_button.setEnabled(False)
|
||||
self.eject_button.setEnabled(False)
|
||||
self.track_count_label.setText("Tracks on device: 0")
|
||||
self.transferred_list.clear()
|
||||
self.device_unmounted.emit()
|
||||
else:
|
||||
self.device_status_label.setText("Status: Eject failed")
|
||||
self.eject_button.setEnabled(True)
|
||||
QMessageBox.warning(self, "Eject Error", "Failed to eject iPod safely.")
|
||||
except Exception as e:
|
||||
self.device_status_label.setText("Status: Eject error")
|
||||
self.eject_button.setEnabled(True)
|
||||
QMessageBox.warning(self, "Eject Error", f"Error ejecting iPod: {e}")
|
||||
|
||||
def _load_ipod_tracks(self):
|
||||
if not self.current_mount_point:
|
||||
return
|
||||
|
||||
try:
|
||||
from ipod_nano7_db import Nano7Database
|
||||
db = Nano7Database(self.current_mount_point)
|
||||
tracks = db.get_all_tracks()
|
||||
|
||||
self.transferred_list.clear()
|
||||
for track in tracks:
|
||||
display = f"{track['artist']} — {track['title']}"
|
||||
if track.get("album"):
|
||||
display += f" ({track['album']})"
|
||||
item = QListWidgetItem(display)
|
||||
item.setData(Qt.ItemDataRole.UserRole, track)
|
||||
self.transferred_list.addItem(item)
|
||||
|
||||
self.track_count_label.setText(f"Tracks on device: {len(tracks)}")
|
||||
self.export_button.setEnabled(len(tracks) > 0)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load iPod tracks: {e}")
|
||||
self.track_count_label.setText("Tracks on device: unknown")
|
||||
|
||||
def _on_remove_tracks_clicked(self):
|
||||
selected = self.transferred_list.selectedItems()
|
||||
if not selected:
|
||||
QMessageBox.information(self, "Info", "Select tracks to remove")
|
||||
return
|
||||
|
||||
count = len(selected)
|
||||
reply = QMessageBox.question(
|
||||
self, "Confirm Delete",
|
||||
f"Remove {count} track(s) from iPod?\nThis will permanently delete the files from the device.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
if self.worker_thread and self.worker_thread.isRunning():
|
||||
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
|
||||
return
|
||||
|
||||
pids = []
|
||||
for item in selected:
|
||||
track_data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if track_data and "pid" in track_data:
|
||||
pids.append(track_data["pid"])
|
||||
|
||||
if not pids:
|
||||
QMessageBox.warning(self, "Error", "No valid tracks selected")
|
||||
return
|
||||
|
||||
self._set_delete_buttons_enabled(False)
|
||||
self.delete_progress.setVisible(True)
|
||||
self.delete_progress.setValue(0)
|
||||
self.delete_status.setVisible(True)
|
||||
self.delete_status.setText(f"Removing {len(pids)} track(s)...")
|
||||
|
||||
self.worker_thread = WorkerThread(
|
||||
task_type="delete_tracks",
|
||||
pids=pids,
|
||||
mount_point=self.current_mount_point,
|
||||
)
|
||||
self.worker_thread.progress_signal.connect(self._on_delete_progress)
|
||||
self.worker_thread.finished_signal.connect(self._on_delete_finished)
|
||||
self.worker_thread.start()
|
||||
|
||||
def _set_delete_buttons_enabled(self, enabled: bool):
|
||||
for w in self.findChildren(QPushButton):
|
||||
if w.text() in ("Remove Selected from iPod", "Remove Duplicate Tracks",
|
||||
"Scan for Orphaned Files", "\u2B07 Download to Computer..."):
|
||||
w.setEnabled(enabled)
|
||||
|
||||
def _on_delete_progress(self, progress, status):
|
||||
self.delete_progress.setValue(progress)
|
||||
self.delete_status.setText(status)
|
||||
|
||||
def _on_delete_finished(self, success, message, result):
|
||||
self._set_delete_buttons_enabled(True)
|
||||
if success:
|
||||
self.delete_progress.setValue(100)
|
||||
self._load_ipod_tracks()
|
||||
QMessageBox.information(self, "Done" if success else "Error", message)
|
||||
self._cleanup_worker()
|
||||
|
||||
def _on_export_ipod_clicked(self):
|
||||
if not self.current_mount_point:
|
||||
QMessageBox.warning(self, "Error", "No iPod device mounted.")
|
||||
return
|
||||
|
||||
selected = self.transferred_list.selectedItems()
|
||||
if not selected:
|
||||
QMessageBox.information(self, "Info", "Select tracks to download")
|
||||
return
|
||||
|
||||
dest_dir = QFileDialog.getExistingDirectory(self, "Select Download Destination")
|
||||
if not dest_dir:
|
||||
return
|
||||
|
||||
tracks = []
|
||||
for item in selected:
|
||||
data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if data and data.get("file_path") and os.path.exists(data["file_path"]):
|
||||
tracks.append(data)
|
||||
|
||||
if not tracks:
|
||||
QMessageBox.warning(self, "Error", "No valid track files found on device")
|
||||
return
|
||||
|
||||
if self.worker_thread and self.worker_thread.isRunning():
|
||||
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
|
||||
return
|
||||
|
||||
self.export_button.setEnabled(False)
|
||||
self.export_progress.setVisible(True)
|
||||
self.export_progress.setValue(0)
|
||||
self.export_status.setVisible(True)
|
||||
self.export_status.setText(f"Exporting {len(tracks)} track(s)...")
|
||||
|
||||
self.worker_thread = WorkerThread(
|
||||
task_type="export_ipod",
|
||||
tracks=tracks,
|
||||
dest_dir=dest_dir,
|
||||
mount_point=self.current_mount_point,
|
||||
)
|
||||
self.worker_thread.progress_signal.connect(self._on_export_ipod_progress)
|
||||
self.worker_thread.finished_signal.connect(self._on_export_ipod_finished)
|
||||
self.worker_thread.start()
|
||||
|
||||
def _on_export_ipod_progress(self, progress, status):
|
||||
self.export_progress.setValue(progress)
|
||||
self.export_status.setText(status)
|
||||
|
||||
def _on_export_ipod_finished(self, success, message, result):
|
||||
self.export_button.setEnabled(True)
|
||||
self.export_progress.setValue(100 if success else 0)
|
||||
|
||||
if success:
|
||||
self.export_status.setText(message)
|
||||
QMessageBox.information(self, "Export Complete", message)
|
||||
else:
|
||||
self.export_status.setText(f"Error: {message}")
|
||||
QMessageBox.warning(self, "Export Error", message)
|
||||
|
||||
self._cleanup_worker()
|
||||
|
||||
def _on_scan_orphans_clicked(self):
|
||||
if not self.current_mount_point:
|
||||
QMessageBox.warning(self, "Error", "No iPod device mounted.")
|
||||
return
|
||||
|
||||
try:
|
||||
from ipod_nano7_db import Nano7Database
|
||||
db = Nano7Database(self.current_mount_point)
|
||||
orphans = db.get_orphaned_files()
|
||||
|
||||
if not orphans:
|
||||
QMessageBox.information(self, "Scan Complete", "No orphaned files found.")
|
||||
return
|
||||
|
||||
total_size = sum(o["size"] for o in orphans)
|
||||
total_mb = total_size / (1024 * 1024)
|
||||
|
||||
details = f"Found {len(orphans)} orphaned file(s) occupying {total_mb:.1f} MB:\n\n"
|
||||
for o in orphans[:20]:
|
||||
size_kb = o["size"] / 1024
|
||||
details += f" {o['artist']} — {o['title']} ({size_kb:.0f} KB)\n"
|
||||
if len(orphans) > 20:
|
||||
details += f" ... and {len(orphans) - 20} more\n"
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self, "Orphaned Files Found",
|
||||
f"{details}\nDelete these files to free up space on iPod?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
deleted = db.delete_orphaned_files()
|
||||
QMessageBox.information(self, "Cleanup Complete", f"Deleted {deleted} orphaned file(s).")
|
||||
self._load_ipod_tracks()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to scan for orphaned files: {e}")
|
||||
QMessageBox.warning(self, "Error", f"Failed to scan: {e}")
|
||||
|
||||
def _on_remove_duplicates_clicked(self):
|
||||
if not self.current_mount_point:
|
||||
QMessageBox.warning(self, "Error", "No iPod device mounted.")
|
||||
return
|
||||
|
||||
if self.worker_thread and self.worker_thread.isRunning():
|
||||
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
|
||||
return
|
||||
|
||||
self._set_delete_buttons_enabled(False)
|
||||
self.delete_progress.setVisible(True)
|
||||
self.delete_progress.setValue(0)
|
||||
self.delete_status.setVisible(True)
|
||||
self.delete_status.setText("Scanning for duplicates...")
|
||||
|
||||
self.worker_thread = WorkerThread(
|
||||
task_type="remove_duplicates",
|
||||
mount_point=self.current_mount_point,
|
||||
)
|
||||
self.worker_thread.progress_signal.connect(self._on_delete_progress)
|
||||
self.worker_thread.finished_signal.connect(self._on_delete_duplicates_finished)
|
||||
self.worker_thread.start()
|
||||
|
||||
def _on_delete_duplicates_finished(self, success, message, result):
|
||||
self._set_delete_buttons_enabled(True)
|
||||
self._load_ipod_tracks()
|
||||
if success:
|
||||
self.delete_progress.setValue(100)
|
||||
QMessageBox.information(self, "Duplicates" if success else "Error", message)
|
||||
self._cleanup_worker()
|
||||
|
||||
def _cleanup_worker(self):
|
||||
if self.worker_thread:
|
||||
self.worker_thread.wait(3000)
|
||||
self.worker_thread.deleteLater()
|
||||
self.worker_thread = None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,743 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Metadata Editor Dialog for neo-pod-desktop
|
||||
Allows editing tags and cover art of audio files (MP3, M4A, FLAC, OGG, OPUS).
|
||||
Supports single-track and batch (multi-track) editing.
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.mp4 import MP4
|
||||
from mutagen.id3 import ID3
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
from mutagen.oggopus import OggOpus
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||
QPushButton, QLabel, QLineEdit, QSpinBox, QCheckBox,
|
||||
QFileDialog, QMessageBox, QFrame, QDialogButtonBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QSize, QTimer
|
||||
from PyQt6.QtGui import QPixmap, QDragEnterEvent, QDropEvent
|
||||
|
||||
from metadata_handler import MetadataHandler
|
||||
from artwork.cache import cover_cache
|
||||
from services.itunes_search import search_itunes
|
||||
|
||||
|
||||
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._lookup_btn = QPushButton("\U0001F50D Lookup in iTunes")
|
||||
self._lookup_btn.setToolTip("Search iTunes Store for metadata by artist and title")
|
||||
self._lookup_btn.clicked.connect(self._on_lookup_clicked)
|
||||
self._lookup_btn.setVisible(not self._batch_mode)
|
||||
self._lookup_status = QLabel("")
|
||||
self._lookup_status.setStyleSheet("color: #888; font-size: 11px; font-style: italic;")
|
||||
self._lookup_status.setVisible(False)
|
||||
lookup_row = QHBoxLayout()
|
||||
lookup_row.addWidget(self._lookup_btn)
|
||||
lookup_row.addWidget(self._lookup_status)
|
||||
lookup_row.addStretch()
|
||||
form_layout.addRow(QLabel(""), lookup_row)
|
||||
|
||||
self._album_label = QLabel("Album:")
|
||||
form_layout.addRow(self._album_label, self.album_edit)
|
||||
|
||||
self._album_artist_label = QLabel("Album Artist:")
|
||||
form_layout.addRow(self._album_artist_label, self.album_artist_edit)
|
||||
|
||||
self._genre_label = QLabel("Genre:")
|
||||
form_layout.addRow(self._genre_label, self.genre_edit)
|
||||
|
||||
self._year_label = QLabel("Year:")
|
||||
form_layout.addRow(self._year_label, self.year_spin)
|
||||
|
||||
self._track_label = QLabel("Track #:")
|
||||
self._track_row_layout = QHBoxLayout()
|
||||
self._track_row_layout.addWidget(self.track_num_spin)
|
||||
self._track_row_layout.addWidget(QLabel("/"))
|
||||
self._track_row_layout.addWidget(self.track_total_spin)
|
||||
self._track_row_layout.addStretch()
|
||||
form_layout.addRow(self._track_label, self._track_row_layout)
|
||||
|
||||
self._disc_label = QLabel("Disc #:")
|
||||
self._disc_row_layout = QHBoxLayout()
|
||||
self._disc_row_layout.addWidget(self.disc_num_spin)
|
||||
self._disc_row_layout.addWidget(QLabel("/"))
|
||||
self._disc_row_layout.addWidget(self.disc_total_spin)
|
||||
self._disc_row_layout.addStretch()
|
||||
form_layout.addRow(self._disc_label, self._disc_row_layout)
|
||||
|
||||
self._comment_label = QLabel("Comment:")
|
||||
form_layout.addRow(self._comment_label, self.comment_edit)
|
||||
|
||||
body.addWidget(form_frame, stretch=1)
|
||||
|
||||
outer.addLayout(body, stretch=1)
|
||||
|
||||
self._track_count_label = QLabel("")
|
||||
self._track_count_label.setVisible(False)
|
||||
self._track_count_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
outer.addWidget(self._track_count_label)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Save |
|
||||
QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttons.accepted.connect(self._on_save)
|
||||
buttons.rejected.connect(self.reject)
|
||||
outer.addWidget(buttons)
|
||||
|
||||
def _apply_batch_mode(self):
|
||||
title_text = f"\uD83D\uDCCB Editing {len(self.file_paths)} selected tracks"
|
||||
self._track_count_label.setText(title_text)
|
||||
self._track_count_label.setVisible(True)
|
||||
|
||||
self._title_label.setText("Title: (hidden in batch)")
|
||||
self.title_edit.setVisible(False)
|
||||
|
||||
for w in (self.track_num_spin, self.track_total_spin):
|
||||
w.setVisible(False)
|
||||
self._track_label.setText("Track #: (hidden in batch)")
|
||||
|
||||
for widget in self.findChildren(QLabel):
|
||||
if widget.text() == "/" and widget.parent() in (self._track_row_layout.parent(), None):
|
||||
pass
|
||||
|
||||
self.cover_apply_check.setVisible(True)
|
||||
|
||||
self._lookup_btn.setVisible(False)
|
||||
self._lookup_status.setVisible(False)
|
||||
|
||||
for key in self._mixed_fields:
|
||||
self._set_mixed(key)
|
||||
|
||||
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 _on_lookup_clicked(self):
|
||||
artist = self.artist_edit.text().strip()
|
||||
title = self.title_edit.text().strip()
|
||||
if not artist and not title:
|
||||
self._show_lookup_status("Enter artist or title first", is_error=True)
|
||||
return
|
||||
|
||||
self._lookup_btn.setEnabled(False)
|
||||
self._show_lookup_status("Searching iTunes\u2026")
|
||||
|
||||
result = search_itunes(artist=artist, title=title)
|
||||
|
||||
self._lookup_btn.setEnabled(True)
|
||||
|
||||
if not result:
|
||||
self._show_lookup_status("No results found", is_error=True)
|
||||
return
|
||||
|
||||
self.title_edit.setText(result.get("title", ""))
|
||||
self.artist_edit.setText(result.get("artist", ""))
|
||||
self.album_edit.setText(result.get("album", ""))
|
||||
self.album_artist_edit.setText(result.get("album_artist", ""))
|
||||
self.genre_edit.setText(result.get("genre", ""))
|
||||
year = result.get("year", 0)
|
||||
if year and year > 0:
|
||||
self.year_spin.setValue(year)
|
||||
track_num = result.get("track_number", 0)
|
||||
if track_num:
|
||||
self.track_num_spin.setValue(track_num)
|
||||
track_total = result.get("track_total", 0)
|
||||
if track_total:
|
||||
self.track_total_spin.setValue(track_total)
|
||||
disc_num = result.get("disc_number", 0)
|
||||
if disc_num:
|
||||
self.disc_num_spin.setValue(disc_num)
|
||||
disc_total = result.get("disc_total", 0)
|
||||
if disc_total:
|
||||
self.disc_total_spin.setValue(disc_total)
|
||||
|
||||
cover_url = result.get("cover_url", "")
|
||||
if cover_url and not self._existing_cover_data and not self._new_cover_path:
|
||||
try:
|
||||
import tempfile
|
||||
import requests
|
||||
resp = requests.get(cover_url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".jpg")
|
||||
os.close(fd)
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
self._new_cover_path = tmp_path
|
||||
self._update_cover_display()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._show_lookup_status("Metadata loaded from iTunes")
|
||||
|
||||
def _show_lookup_status(self, text: str, is_error: bool = False):
|
||||
self._lookup_status.setText(text)
|
||||
self._lookup_status.setStyleSheet(
|
||||
"color: #c0392b; font-size: 11px; font-style: italic;"
|
||||
if is_error else
|
||||
"color: #27ae60; font-size: 11px; font-style: italic;"
|
||||
)
|
||||
self._lookup_status.setVisible(True)
|
||||
QTimer.singleShot(4000, lambda: self._lookup_status.setVisible(False))
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event: QDropEvent):
|
||||
urls = event.mimeData().urls()
|
||||
if urls:
|
||||
path = urls[0].toLocalFile()
|
||||
if os.path.isfile(path):
|
||||
ext = Path(path).suffix.lower()
|
||||
if ext in ('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tiff', '.webp'):
|
||||
self._new_cover_path = path
|
||||
self._cover_removed = False
|
||||
self._update_cover_display()
|
||||
|
||||
def _collect_current(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'title': self.title_edit.text().strip(),
|
||||
'artist': self.artist_edit.text().strip(),
|
||||
'album': self.album_edit.text().strip(),
|
||||
'album_artist': self.album_artist_edit.text().strip(),
|
||||
'genre': self.genre_edit.text().strip(),
|
||||
'date': str(self.year_spin.value()) if self.year_spin.value() > 0 else '',
|
||||
'track_number': self.track_num_spin.value(),
|
||||
'track_total': self.track_total_spin.value(),
|
||||
'disc_number': self.disc_num_spin.value(),
|
||||
'disc_total': self.disc_total_spin.value(),
|
||||
'comment': self.comment_edit.text().strip(),
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Settings Tab for neo-pod-desktop.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit,
|
||||
QComboBox, QCheckBox, QSpinBox, QGroupBox, QHeaderView,
|
||||
QTableWidget, QTableWidgetItem, QFileDialog, QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
|
||||
from hotkeys import HotkeyManager, KeyCaptureDialog
|
||||
|
||||
|
||||
class SettingsTab(QWidget):
|
||||
"""Settings tab widget."""
|
||||
|
||||
hide_library_buttons_changed = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.hotkey_manager: Optional[HotkeyManager] = None
|
||||
self._setup_ui()
|
||||
|
||||
def set_hotkey_manager(self, hm: HotkeyManager):
|
||||
self.hotkey_manager = hm
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
library_group = QGroupBox("Library")
|
||||
library_layout = QVBoxLayout(library_group)
|
||||
|
||||
output_layout = QHBoxLayout()
|
||||
output_label = QLabel("Output Directory:")
|
||||
self.output_dir_input = QLineEdit()
|
||||
self.output_dir_input.setText(os.path.join(os.path.expanduser("~"), "Music", "iPod"))
|
||||
browse_btn = QPushButton("Browse...")
|
||||
browse_btn.clicked.connect(self._on_browse_output_clicked)
|
||||
output_layout.addWidget(output_label)
|
||||
output_layout.addWidget(self.output_dir_input)
|
||||
output_layout.addWidget(browse_btn)
|
||||
library_layout.addLayout(output_layout)
|
||||
|
||||
convert_layout = QHBoxLayout()
|
||||
convert_format_label = QLabel("Convert Format:")
|
||||
self.format_combo = QComboBox()
|
||||
self.format_combo.addItems(["mp3", "m4a"])
|
||||
convert_quality_label = QLabel("Quality (kbps):")
|
||||
self.quality_spin = QSpinBox()
|
||||
self.quality_spin.setRange(128, 320)
|
||||
self.quality_spin.setValue(256)
|
||||
self.quality_spin.setSingleStep(32)
|
||||
convert_layout.addWidget(convert_format_label)
|
||||
convert_layout.addWidget(self.format_combo)
|
||||
convert_layout.addWidget(convert_quality_label)
|
||||
convert_layout.addWidget(self.quality_spin)
|
||||
convert_layout.addStretch()
|
||||
library_layout.addLayout(convert_layout)
|
||||
|
||||
layout.addWidget(library_group)
|
||||
|
||||
metadata_group = QGroupBox("Metadata Options")
|
||||
metadata_layout = QVBoxLayout(metadata_group)
|
||||
|
||||
self.embed_artwork_check = QCheckBox("Embed Album Artwork")
|
||||
self.embed_artwork_check.setChecked(True)
|
||||
metadata_layout.addWidget(self.embed_artwork_check)
|
||||
|
||||
layout.addWidget(metadata_group)
|
||||
|
||||
advanced_group = QGroupBox("Advanced Options")
|
||||
advanced_layout = QVBoxLayout(advanced_group)
|
||||
|
||||
self.clean_temp_check = QCheckBox("Clean Temporary Files After Transfer")
|
||||
self.clean_temp_check.setChecked(True)
|
||||
advanced_layout.addWidget(self.clean_temp_check)
|
||||
|
||||
self.auto_detect_check = QCheckBox("Auto-Detect iPod on Startup")
|
||||
self.auto_detect_check.setChecked(True)
|
||||
advanced_layout.addWidget(self.auto_detect_check)
|
||||
|
||||
self.hide_library_buttons_check = QCheckBox("Hide Library Toolbar Buttons")
|
||||
self.hide_library_buttons_check.setChecked(False)
|
||||
self.hide_library_buttons_check.toggled.connect(self._on_hide_buttons_toggled)
|
||||
advanced_layout.addWidget(self.hide_library_buttons_check)
|
||||
|
||||
layout.addWidget(advanced_group)
|
||||
|
||||
shortcuts_group = QGroupBox("Keyboard Shortcuts")
|
||||
shortcuts_layout = QVBoxLayout(shortcuts_group)
|
||||
|
||||
self.shortcuts_table = QTableWidget()
|
||||
self.shortcuts_table.setColumnCount(2)
|
||||
self.shortcuts_table.setHorizontalHeaderLabels(["Action", "Shortcut"])
|
||||
self.shortcuts_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self.shortcuts_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||
self.shortcuts_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self.shortcuts_table.setAlternatingRowColors(True)
|
||||
self.shortcuts_table.cellDoubleClicked.connect(self._on_shortcut_double_clicked)
|
||||
shortcuts_header = self.shortcuts_table.horizontalHeader()
|
||||
shortcuts_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
shortcuts_header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||
shortcuts_layout.addWidget(self.shortcuts_table)
|
||||
|
||||
reset_shortcuts_btn = QPushButton("Reset to Defaults")
|
||||
reset_shortcuts_btn.clicked.connect(self._on_reset_shortcuts_clicked)
|
||||
shortcuts_layout.addWidget(reset_shortcuts_btn)
|
||||
|
||||
layout.addWidget(shortcuts_group)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
about_label = QLabel(
|
||||
"neo-pod-desktop\n"
|
||||
"Version 1.0.0\n\n"
|
||||
"Desktop application for downloading, converting, managing and transferring music to iPod Nano devices."
|
||||
)
|
||||
about_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(about_label)
|
||||
|
||||
def _on_hide_buttons_toggled(self, checked: bool):
|
||||
self.hide_library_buttons_changed.emit(checked)
|
||||
|
||||
def _on_browse_output_clicked(self):
|
||||
directory = QFileDialog.getExistingDirectory(
|
||||
self, "Select Output Directory",
|
||||
self.output_dir_input.text()
|
||||
)
|
||||
if directory:
|
||||
self.output_dir_input.setText(directory)
|
||||
|
||||
def load_settings(self, config):
|
||||
self.output_dir_input.setText(
|
||||
config.get("General", "output_dir",
|
||||
fallback=os.path.join(os.path.expanduser("~"), "Music", "iPod"))
|
||||
)
|
||||
self.format_combo.setCurrentText(config.get("General", "format", fallback="m4a"))
|
||||
self.quality_spin.setValue(config.get_int("General", "quality", fallback=256))
|
||||
self.embed_artwork_check.setChecked(
|
||||
config.get_boolean("Metadata", "embed_artwork", fallback=True)
|
||||
)
|
||||
self.clean_temp_check.setChecked(
|
||||
config.get_boolean("Advanced", "clean_temp", fallback=True)
|
||||
)
|
||||
self.auto_detect_check.setChecked(
|
||||
config.get_boolean("Device", "auto_detect", fallback=True)
|
||||
)
|
||||
hide_library_buttons = config.get_boolean("Advanced", "hide_library_buttons", fallback=False)
|
||||
self.hide_library_buttons_check.setChecked(hide_library_buttons)
|
||||
|
||||
def save_settings(self, config):
|
||||
config.set("General", "output_dir", self.output_dir_input.text())
|
||||
config.set("General", "format", self.format_combo.currentText())
|
||||
config.set("General", "quality", str(self.quality_spin.value()))
|
||||
config.set("Metadata", "embed_artwork", str(self.embed_artwork_check.isChecked()).lower())
|
||||
config.set("Advanced", "clean_temp", str(self.clean_temp_check.isChecked()).lower())
|
||||
config.set("Device", "auto_detect", str(self.auto_detect_check.isChecked()).lower())
|
||||
config.set("Advanced", "hide_library_buttons", str(self.hide_library_buttons_check.isChecked()).lower())
|
||||
|
||||
def refresh_shortcuts_table(self):
|
||||
if self.hotkey_manager is None:
|
||||
return
|
||||
names = self.hotkey_manager.all_action_names()
|
||||
self.shortcuts_table.setRowCount(len(names))
|
||||
for row, name in enumerate(names):
|
||||
label = self.hotkey_manager.get_label(name)
|
||||
key = self.hotkey_manager.get_current_key_string(name)
|
||||
self.shortcuts_table.setItem(row, 0, QTableWidgetItem(label))
|
||||
item = QTableWidgetItem(key if key else "\u2014")
|
||||
self.shortcuts_table.setItem(row, 1, item)
|
||||
|
||||
def _on_shortcut_double_clicked(self, row: int, _col: int):
|
||||
if self.hotkey_manager is None:
|
||||
return
|
||||
names = self.hotkey_manager.all_action_names()
|
||||
if row < 0 or row >= len(names):
|
||||
return
|
||||
action_name = names[row]
|
||||
current = self.hotkey_manager.get_current_key_string(action_name)
|
||||
dialog = KeyCaptureDialog(self, current=current)
|
||||
if dialog.exec() == KeyCaptureDialog.DialogCode.Accepted:
|
||||
seq = dialog.captured_sequence()
|
||||
self.hotkey_manager.set_shortcut_seq(action_name, seq)
|
||||
self.refresh_shortcuts_table()
|
||||
|
||||
def _on_reset_shortcuts_clicked(self):
|
||||
if self.hotkey_manager is None:
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self, "Reset Shortcuts",
|
||||
"Reset all keyboard shortcuts to their default values?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.hotkey_manager.reset_to_defaults()
|
||||
self.refresh_shortcuts_table()
|
||||
QMessageBox.information(self, "Done", "Shortcuts reset to defaults.")
|
||||
Reference in New Issue
Block a user