refactor: extract main.py into ui/ tabs + app.py, extract search_itunes into services/, remove MusicBrainz

This commit is contained in:
Maksim Totmin
2026-06-01 09:59:00 +07:00
parent e569f8c731
commit 71d162520c
16 changed files with 1346 additions and 1120 deletions
View File
+493
View File
@@ -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
+743
View File
@@ -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
+202
View File
@@ -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.")