refactor: extract main.py into ui/ tabs + app.py, extract search_itunes into services/, remove MusicBrainz
This commit is contained in:
parent
e569f8c731
commit
71d162520c
@ -4,7 +4,6 @@ eyeD3>=0.9.7
|
|||||||
pyusb>=1.2.1
|
pyusb>=1.2.1
|
||||||
pillow>=9.5.0
|
pillow>=9.5.0
|
||||||
requests>=2.28.2
|
requests>=2.28.2
|
||||||
musicbrainzngs>=0.7.1
|
|
||||||
PyQt6>=6.5.0
|
PyQt6>=6.5.0
|
||||||
numpy>=1.24.0
|
numpy>=1.24.0
|
||||||
iopenpod>=1.0.53
|
iopenpod>=1.0.53
|
||||||
|
|||||||
9
run.py
9
run.py
@ -12,12 +12,9 @@ def main():
|
|||||||
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
|
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
|
||||||
sys.path.insert(0, src_dir)
|
sys.path.insert(0, src_dir)
|
||||||
|
|
||||||
from src.main import QApplication, MainWindow
|
from src.app import main as app_main
|
||||||
app = QApplication(sys.argv)
|
app_main()
|
||||||
window = MainWindow()
|
|
||||||
window.show()
|
|
||||||
return app.exec()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
main()
|
||||||
|
|||||||
@ -98,7 +98,7 @@ build_appdir() {
|
|||||||
--exclude-module PyQt6.QtQuick3D \
|
--exclude-module PyQt6.QtQuick3D \
|
||||||
--exclude-module PyQt6.QtShaderTools \
|
--exclude-module PyQt6.QtShaderTools \
|
||||||
--exclude-module PyQt6.QtSpatialAudio \
|
--exclude-module PyQt6.QtSpatialAudio \
|
||||||
src/main.py
|
src/app.py
|
||||||
}
|
}
|
||||||
|
|
||||||
create_desktop_file() {
|
create_desktop_file() {
|
||||||
|
|||||||
2
setup.py
2
setup.py
@ -27,7 +27,7 @@ setup(
|
|||||||
install_requires=requirements,
|
install_requires=requirements,
|
||||||
entry_points={
|
entry_points={
|
||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
'neo-pod-desktop=src.main:main',
|
'neo-pod-desktop=src.app:main',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
classifiers=[
|
classifiers=[
|
||||||
|
|||||||
185
src/app.py
Normal file
185
src/app.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Main Application Module for neo-pod-desktop
|
||||||
|
Provides a GUI for converting, managing and transferring music to iPod Nano devices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QApplication, QMainWindow, QWidget, QVBoxLayout,
|
||||||
|
QTabWidget,
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
from config_loader import ConfigLoader
|
||||||
|
from hotkeys import HotkeyManager
|
||||||
|
from ui.library_tab import LibraryTab
|
||||||
|
from ui.ipod_tab import iPodTab
|
||||||
|
from ui.settings_tab import SettingsTab
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
"""Main application window"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.setWindowTitle("neo-pod-desktop")
|
||||||
|
self.setMinimumSize(800, 600)
|
||||||
|
|
||||||
|
self.config_loader = ConfigLoader()
|
||||||
|
self.hotkey_manager: Optional[HotkeyManager] = None
|
||||||
|
self.ipod_tab_index = 1
|
||||||
|
|
||||||
|
self._setup_ui()
|
||||||
|
self._load_settings()
|
||||||
|
self._setup_hotkeys()
|
||||||
|
self._wire_signals()
|
||||||
|
|
||||||
|
self.library_tab._scan_library()
|
||||||
|
self.library_tab._resume_playback_if_saved()
|
||||||
|
self.ipod_tab._on_refresh_devices_clicked()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
main_widget = QWidget()
|
||||||
|
main_layout = QVBoxLayout(main_widget)
|
||||||
|
|
||||||
|
self.tab_widget = QTabWidget()
|
||||||
|
main_layout.addWidget(self.tab_widget)
|
||||||
|
|
||||||
|
self.library_tab = LibraryTab(self.config_loader)
|
||||||
|
self.tab_widget.addTab(self.library_tab, "Library")
|
||||||
|
|
||||||
|
self.ipod_tab = iPodTab()
|
||||||
|
self.tab_widget.addTab(self.ipod_tab, "iPod")
|
||||||
|
|
||||||
|
self.settings_tab = SettingsTab()
|
||||||
|
self.tab_widget.addTab(self.settings_tab, "Settings")
|
||||||
|
|
||||||
|
self.tab_widget.setTabVisible(self.ipod_tab_index, False)
|
||||||
|
|
||||||
|
self.statusBar().showMessage("Ready")
|
||||||
|
self.setCentralWidget(main_widget)
|
||||||
|
|
||||||
|
def _wire_signals(self):
|
||||||
|
self.ipod_tab.device_mounted.connect(self._on_device_mounted)
|
||||||
|
self.ipod_tab.device_unmounted.connect(self._on_device_unmounted)
|
||||||
|
self.settings_tab.hide_library_buttons_changed.connect(
|
||||||
|
self.library_tab.set_buttons_visible
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_device_mounted(self, mount_point: str):
|
||||||
|
self.library_tab.on_device_mounted(mount_point)
|
||||||
|
self.tab_widget.setTabVisible(self.ipod_tab_index, True)
|
||||||
|
self.statusBar().showMessage(f"iPod mounted at {mount_point}")
|
||||||
|
|
||||||
|
def _on_device_unmounted(self):
|
||||||
|
self.library_tab.on_device_unmounted()
|
||||||
|
self.tab_widget.setTabVisible(self.ipod_tab_index, False)
|
||||||
|
self.statusBar().showMessage("iPod disconnected")
|
||||||
|
|
||||||
|
def _load_settings(self):
|
||||||
|
config = self.config_loader
|
||||||
|
self.library_tab.load_player_settings(config)
|
||||||
|
self.settings_tab.load_settings(config)
|
||||||
|
hide_buttons = config.get_boolean("Advanced", "hide_library_buttons", fallback=False)
|
||||||
|
self.library_tab.set_buttons_visible(not hide_buttons)
|
||||||
|
|
||||||
|
self._saved_volume = config.get_int("Playback", "last_volume", fallback=80)
|
||||||
|
self._saved_track_path = config.get("Playback", "last_track_path", fallback="")
|
||||||
|
self._saved_position_ms = config.get_int("Playback", "last_position_ms", fallback=0)
|
||||||
|
self._saved_playing = config.get_boolean("Playback", "last_playing", fallback=False)
|
||||||
|
|
||||||
|
def _save_settings(self):
|
||||||
|
config = self.config_loader
|
||||||
|
self.settings_tab.save_settings(config)
|
||||||
|
self.library_tab.save_player_settings(config)
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
def _setup_hotkeys(self):
|
||||||
|
self.hotkey_manager = HotkeyManager(self, self.config_loader)
|
||||||
|
self.hotkey_manager.register_all({
|
||||||
|
"play_pause": self.library_tab._on_play_pause_clicked,
|
||||||
|
"prev_track": self.library_tab._on_prev_clicked,
|
||||||
|
"next_track": self.library_tab._on_next_clicked,
|
||||||
|
"volume_up": lambda: self.library_tab._adjust_volume(5),
|
||||||
|
"volume_down": lambda: self.library_tab._adjust_volume(-5),
|
||||||
|
"seek_forward": self.library_tab._seek_forward,
|
||||||
|
"seek_backward": self.library_tab._seek_backward,
|
||||||
|
"tab_library": lambda: self.tab_widget.setCurrentIndex(0),
|
||||||
|
"tab_ipod": lambda: self.tab_widget.setCurrentIndex(self.ipod_tab_index),
|
||||||
|
"tab_settings": lambda: self.tab_widget.setCurrentIndex(2),
|
||||||
|
"search_focus": self.library_tab._focus_search,
|
||||||
|
"select_all": self.library_tab._select_all_current,
|
||||||
|
"library_refresh": self.library_tab._on_refresh_library_clicked,
|
||||||
|
"ipod_refresh": self.ipod_tab._on_refresh_devices_clicked,
|
||||||
|
"delete_selected": self.library_tab._delete_selected_current,
|
||||||
|
"edit_metadata": self.library_tab._on_edit_metadata,
|
||||||
|
"library_add_files": self.library_tab._on_library_add_files,
|
||||||
|
})
|
||||||
|
self.settings_tab.set_hotkey_manager(self.hotkey_manager)
|
||||||
|
self.settings_tab.refresh_shortcuts_table()
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
self._save_settings()
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
|
# GTK3 platform theme paths for Linux desktop integration
|
||||||
|
_GTK3_PLUGIN_PATHS = [
|
||||||
|
"/usr/lib/qt6/plugins/platformthemes/libqgtk3.so",
|
||||||
|
"/usr/lib/x86_64-linux-gnu/qt6/plugins/platformthemes/libqgtk3.so",
|
||||||
|
"/usr/lib64/qt6/plugins/platformthemes/libqgtk3.so",
|
||||||
|
"/usr/lib/aarch64-linux-gnu/qt6/plugins/platformthemes/libqgtk3.so",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_linux_theming():
|
||||||
|
"""Configure Qt to follow the system GTK theme on Linux.
|
||||||
|
|
||||||
|
Must be called BEFORE QApplication is created.
|
||||||
|
Respects user-set QT_QPA_PLATFORMTHEME (doesn't override it).
|
||||||
|
Falls back through available platform themes: gtk3 → qt6ct → none.
|
||||||
|
"""
|
||||||
|
if sys.platform != "linux":
|
||||||
|
return
|
||||||
|
|
||||||
|
if "QT_QPA_PLATFORMTHEME" in os.environ:
|
||||||
|
return
|
||||||
|
|
||||||
|
for path in _GTK3_PLUGIN_PATHS:
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.environ["QT_QPA_PLATFORMTHEME"] = "gtk3"
|
||||||
|
logger.debug(f"Using GTK3 platform theme: {path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.path.exists("/usr/bin/qt6ct"):
|
||||||
|
os.environ["QT_QPA_PLATFORMTHEME"] = "qt6ct"
|
||||||
|
logger.debug("Using qt6ct platform theme")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug("No GTK platform theme plugin found, using Qt default style")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
_setup_linux_theming()
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
|
if not os.environ.get("QT_QPA_PLATFORMTHEME"):
|
||||||
|
app.setStyle("Fusion")
|
||||||
|
|
||||||
|
window = MainWindow()
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -116,8 +116,6 @@ class ConfigLoader:
|
|||||||
|
|
||||||
if not self.config.has_option('Metadata', 'embed_artwork'):
|
if not self.config.has_option('Metadata', 'embed_artwork'):
|
||||||
self.config.set('Metadata', 'embed_artwork', 'true')
|
self.config.set('Metadata', 'embed_artwork', 'true')
|
||||||
if not self.config.has_option('Metadata', 'use_musicbrainz'):
|
|
||||||
self.config.set('Metadata', 'use_musicbrainz', 'false')
|
|
||||||
|
|
||||||
# Device section
|
# Device section
|
||||||
if not self.config.has_section('Device'):
|
if not self.config.has_section('Device'):
|
||||||
|
|||||||
@ -541,20 +541,6 @@ class MetadataHandler:
|
|||||||
duration=0,
|
duration=0,
|
||||||
download_path=file_path,
|
download_path=file_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
def enhance_metadata_with_musicbrainz(self, track_info: TrackInfo) -> TrackInfo:
|
|
||||||
"""
|
|
||||||
Enhance track metadata using MusicBrainz API
|
|
||||||
|
|
||||||
Args:
|
|
||||||
track_info: TrackInfo object with track metadata
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Enhanced TrackInfo object
|
|
||||||
"""
|
|
||||||
# This is a placeholder for future implementation
|
|
||||||
# MusicBrainz integration would go here
|
|
||||||
return track_info
|
|
||||||
|
|
||||||
def update_tags(self, file_path: str, tags: Dict[str, Any],
|
def update_tags(self, file_path: str, tags: Dict[str, Any],
|
||||||
new_cover_path: Optional[str] = None) -> bool:
|
new_cover_path: Optional[str] = None) -> bool:
|
||||||
|
|||||||
0
src/models/__init__.py
Normal file
0
src/models/__init__.py
Normal file
0
src/services/__init__.py
Normal file
0
src/services/__init__.py
Normal file
63
src/services/itunes_search.py
Normal file
63
src/services/itunes_search.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
iTunes Search API service for neo-pod-desktop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
def search_itunes(artist: str, title: str, limit: int = 5) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Search iTunes Store for track metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artist: Artist name
|
||||||
|
title: Track title
|
||||||
|
limit: Max results to fetch (default 5)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: title, artist, album, album_artist, genre, year,
|
||||||
|
track_number, track_total, disc_number, disc_total, cover_url.
|
||||||
|
Returns None on failure or no results.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
term = " ".join(p for p in (artist, title) if p)
|
||||||
|
if not term:
|
||||||
|
return None
|
||||||
|
quoted = urllib.parse.quote(term)
|
||||||
|
url = f"https://itunes.apple.com/search?term={quoted}&entity=song&limit={limit}&country=US"
|
||||||
|
resp = requests.get(url, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
results = data.get("results", [])
|
||||||
|
if not results:
|
||||||
|
return None
|
||||||
|
r = results[0]
|
||||||
|
cover_url = r.get("artworkUrl100", "")
|
||||||
|
if cover_url:
|
||||||
|
cover_url = cover_url.replace("100x100", "600x600")
|
||||||
|
year = 0
|
||||||
|
release_date = r.get("releaseDate", "")
|
||||||
|
if release_date and len(release_date) >= 4:
|
||||||
|
try:
|
||||||
|
year = int(release_date[:4])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"title": r.get("trackName", ""),
|
||||||
|
"artist": r.get("artistName", ""),
|
||||||
|
"album": r.get("collectionName", ""),
|
||||||
|
"album_artist": r.get("collectionArtistName", ""),
|
||||||
|
"genre": r.get("primaryGenreName", ""),
|
||||||
|
"year": year,
|
||||||
|
"track_number": r.get("trackNumber", 0) or 0,
|
||||||
|
"track_total": r.get("trackCount", 0) or 0,
|
||||||
|
"disc_number": r.get("discNumber", 0) or 0,
|
||||||
|
"disc_total": r.get("discCount", 0) or 0,
|
||||||
|
"cover_url": cover_url,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
0
src/ui/__init__.py
Normal file
0
src/ui/__init__.py
Normal file
493
src/ui/ipod_tab.py
Normal file
493
src/ui/ipod_tab.py
Normal 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
@ -25,11 +25,12 @@ from PyQt6.QtWidgets import (
|
|||||||
QPushButton, QLabel, QLineEdit, QSpinBox, QCheckBox,
|
QPushButton, QLabel, QLineEdit, QSpinBox, QCheckBox,
|
||||||
QFileDialog, QMessageBox, QFrame, QDialogButtonBox,
|
QFileDialog, QMessageBox, QFrame, QDialogButtonBox,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QSize
|
from PyQt6.QtCore import Qt, QSize, QTimer
|
||||||
from PyQt6.QtGui import QPixmap, QDragEnterEvent, QDropEvent
|
from PyQt6.QtGui import QPixmap, QDragEnterEvent, QDropEvent
|
||||||
|
|
||||||
from metadata_handler import MetadataHandler
|
from metadata_handler import MetadataHandler
|
||||||
from artwork.cache import cover_cache
|
from artwork.cache import cover_cache
|
||||||
|
from services.itunes_search import search_itunes
|
||||||
|
|
||||||
|
|
||||||
COVER_SIZE = 250
|
COVER_SIZE = 250
|
||||||
@ -343,6 +344,19 @@ class MetadataEditorDialog(QDialog):
|
|||||||
self._artist_label = QLabel("Artist:")
|
self._artist_label = QLabel("Artist:")
|
||||||
form_layout.addRow(self._artist_label, self.artist_edit)
|
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:")
|
self._album_label = QLabel("Album:")
|
||||||
form_layout.addRow(self._album_label, self.album_edit)
|
form_layout.addRow(self._album_label, self.album_edit)
|
||||||
|
|
||||||
@ -409,6 +423,9 @@ class MetadataEditorDialog(QDialog):
|
|||||||
|
|
||||||
self.cover_apply_check.setVisible(True)
|
self.cover_apply_check.setVisible(True)
|
||||||
|
|
||||||
|
self._lookup_btn.setVisible(False)
|
||||||
|
self._lookup_status.setVisible(False)
|
||||||
|
|
||||||
for key in self._mixed_fields:
|
for key in self._mixed_fields:
|
||||||
self._set_mixed(key)
|
self._set_mixed(key)
|
||||||
|
|
||||||
@ -517,6 +534,73 @@ class MetadataEditorDialog(QDialog):
|
|||||||
self._cover_removed = True
|
self._cover_removed = True
|
||||||
self._update_cover_display()
|
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):
|
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||||
if event.mimeData().hasUrls():
|
if event.mimeData().hasUrls():
|
||||||
event.acceptProposedAction()
|
event.acceptProposedAction()
|
||||||
202
src/ui/settings_tab.py
Normal file
202
src/ui/settings_tab.py
Normal 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.")
|
||||||
226
src/worker.py
Normal file
226
src/worker.py
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Worker thread for background tasks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
|
from track_info import TrackInfo
|
||||||
|
from audio_converter import AudioConverter
|
||||||
|
from metadata_handler import MetadataHandler
|
||||||
|
from ipod_device import IPodDevice
|
||||||
|
from library_cache import invalidate_cache_for_path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
AUDIO_EXTS = {'.m4a', '.mp3', '.aac'}
|
||||||
|
SOURCE_EXTS = {'.flac', '.wav', '.ogg', '.opus', '.wma', '.ape', '.aiff', '.alac'}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerThread(QThread):
|
||||||
|
"""Worker thread for background tasks"""
|
||||||
|
|
||||||
|
progress_signal = pyqtSignal(int, str)
|
||||||
|
finished_signal = pyqtSignal(bool, str, object)
|
||||||
|
|
||||||
|
def __init__(self, task_type: str, **kwargs):
|
||||||
|
super().__init__()
|
||||||
|
self.task_type = task_type
|
||||||
|
self.kwargs = kwargs
|
||||||
|
self.is_running = True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
handlers = {
|
||||||
|
"convert": self._run_convert,
|
||||||
|
"transfer": self._run_transfer,
|
||||||
|
"detect_devices": self._run_detect_devices,
|
||||||
|
"export_ipod": self._run_export_ipod,
|
||||||
|
"delete_tracks": self._run_delete_tracks,
|
||||||
|
"remove_duplicates": self._run_remove_duplicates,
|
||||||
|
}
|
||||||
|
handler = handlers.get(self.task_type)
|
||||||
|
if handler:
|
||||||
|
handler()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown task type: {self.task_type}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error in worker thread: {e}")
|
||||||
|
self.finished_signal.emit(False, str(e), None)
|
||||||
|
|
||||||
|
def _run_convert(self):
|
||||||
|
tracks = self.kwargs.get("tracks", [])
|
||||||
|
local_files = self.kwargs.get("local_files", [])
|
||||||
|
output_dir = self.kwargs.get("output_dir", "converted")
|
||||||
|
fmt = self.kwargs.get("format", "mp3")
|
||||||
|
quality = self.kwargs.get("quality", 256)
|
||||||
|
|
||||||
|
valid_tracks = [
|
||||||
|
t for t in tracks
|
||||||
|
if hasattr(t, 'download_path') and t.download_path and os.path.exists(t.download_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
metadata_handler = MetadataHandler()
|
||||||
|
for f in local_files:
|
||||||
|
if os.path.exists(f):
|
||||||
|
track = metadata_handler.extract_tags(f)
|
||||||
|
if track:
|
||||||
|
track.download_path = f
|
||||||
|
valid_tracks.append(track)
|
||||||
|
|
||||||
|
if not valid_tracks:
|
||||||
|
self.finished_signal.emit(False, "No valid tracks to convert", None)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.progress_signal.emit(0, f"Initializing conversion of {len(valid_tracks)} tracks")
|
||||||
|
|
||||||
|
converter = AudioConverter(output_dir=output_dir)
|
||||||
|
metadata_handler = MetadataHandler()
|
||||||
|
|
||||||
|
converted_tracks = []
|
||||||
|
for i, track in enumerate(valid_tracks):
|
||||||
|
if not self.is_running:
|
||||||
|
break
|
||||||
|
|
||||||
|
progress = int(((i + 1) / len(valid_tracks)) * 100)
|
||||||
|
self.progress_signal.emit(progress, f"Converting {track.title}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
output_path = converter.convert_to_ipod_format(
|
||||||
|
track, track.download_path, fmt, quality
|
||||||
|
)
|
||||||
|
if output_path:
|
||||||
|
metadata_handler.process_file(output_path, track)
|
||||||
|
converted_tracks.append((track, output_path))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error converting track {track.title}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not converted_tracks:
|
||||||
|
self.finished_signal.emit(False, "Failed to convert any tracks", None)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.finished_signal.emit(True, f"Converted {len(converted_tracks)} tracks", converted_tracks)
|
||||||
|
|
||||||
|
def _run_transfer(self):
|
||||||
|
tracks = self.kwargs.get("tracks", [])
|
||||||
|
mount_point = self.kwargs.get("mount_point")
|
||||||
|
|
||||||
|
if not tracks:
|
||||||
|
self.finished_signal.emit(False, "No tracks to transfer", None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not mount_point:
|
||||||
|
self.finished_signal.emit(False, "No iPod device mounted", None)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.progress_signal.emit(0, f"Initializing transfer of {len(tracks)} tracks")
|
||||||
|
|
||||||
|
ipod = IPodDevice(mount_point=mount_point)
|
||||||
|
device_info = ipod.get_device_info()
|
||||||
|
free_space = device_info.get("free_space", 0)
|
||||||
|
|
||||||
|
total_size = sum(os.path.getsize(path) for _, path in tracks if os.path.exists(path))
|
||||||
|
|
||||||
|
if total_size > free_space:
|
||||||
|
self.finished_signal.emit(
|
||||||
|
False,
|
||||||
|
f"Not enough space on iPod. Need {total_size / 1024**2:.1f} MB, "
|
||||||
|
f"but only {free_space / 1024**2:.1f} MB available",
|
||||||
|
None
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
transferred_tracks = []
|
||||||
|
for i, (track, path) in enumerate(tracks):
|
||||||
|
if not self.is_running:
|
||||||
|
break
|
||||||
|
|
||||||
|
progress = int(((i + 1) / len(tracks)) * 100)
|
||||||
|
self.progress_signal.emit(progress, f"Transferring {track.title}...")
|
||||||
|
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
success = ipod.transfer_file(path)
|
||||||
|
if success:
|
||||||
|
transferred_tracks.append((track, path))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error transferring track {track.title}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.finished_signal.emit(
|
||||||
|
True, f"Transferred {len(transferred_tracks)} tracks to iPod", transferred_tracks
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_detect_devices(self):
|
||||||
|
self.progress_signal.emit(0, "Detecting iPod devices...")
|
||||||
|
|
||||||
|
ipod = IPodDevice()
|
||||||
|
devices = ipod.detect_devices()
|
||||||
|
|
||||||
|
if not devices:
|
||||||
|
self.finished_signal.emit(False, "No iPod devices found", [])
|
||||||
|
return
|
||||||
|
|
||||||
|
self.finished_signal.emit(True, f"Found {len(devices)} iPod devices", devices)
|
||||||
|
|
||||||
|
def _run_export_ipod(self):
|
||||||
|
tracks = self.kwargs.get("tracks", [])
|
||||||
|
dest_dir = self.kwargs.get("dest_dir", "")
|
||||||
|
mount_point = self.kwargs.get("mount_point")
|
||||||
|
|
||||||
|
if not tracks or not dest_dir or not mount_point:
|
||||||
|
self.finished_signal.emit(False, "Missing parameters for export", None)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.progress_signal.emit(0, f"Exporting {len(tracks)} track(s)...")
|
||||||
|
|
||||||
|
ipod = IPodDevice(mount_point=mount_point)
|
||||||
|
|
||||||
|
def progress(p, s):
|
||||||
|
self.progress_signal.emit(p, s)
|
||||||
|
|
||||||
|
exported = ipod.export_tracks(tracks, dest_dir, progress_callback=progress)
|
||||||
|
self.finished_signal.emit(True, f"Exported {len(exported)} track(s)", exported)
|
||||||
|
|
||||||
|
def _run_delete_tracks(self):
|
||||||
|
pids = self.kwargs.get("pids", [])
|
||||||
|
mount_point = self.kwargs.get("mount_point")
|
||||||
|
|
||||||
|
if not pids or not mount_point:
|
||||||
|
self.finished_signal.emit(False, "No tracks to delete or device not mounted", None)
|
||||||
|
return
|
||||||
|
|
||||||
|
from ipod_nano7_db import Nano7Database
|
||||||
|
db = Nano7Database(mount_point)
|
||||||
|
|
||||||
|
def progress(p, s):
|
||||||
|
self.progress_signal.emit(p, s)
|
||||||
|
|
||||||
|
removed = db.delete_tracks(pids, progress_callback=progress)
|
||||||
|
self.finished_signal.emit(True, f"Removed {removed} track(s)", removed)
|
||||||
|
|
||||||
|
def _run_remove_duplicates(self):
|
||||||
|
mount_point = self.kwargs.get("mount_point")
|
||||||
|
|
||||||
|
if not mount_point:
|
||||||
|
self.finished_signal.emit(False, "No iPod device mounted", None)
|
||||||
|
return
|
||||||
|
|
||||||
|
from ipod_nano7_db import Nano7Database
|
||||||
|
db = Nano7Database(mount_point)
|
||||||
|
|
||||||
|
def progress(p, s):
|
||||||
|
self.progress_signal.emit(p, s)
|
||||||
|
|
||||||
|
self.progress_signal.emit(0, "Scanning for duplicates...")
|
||||||
|
removed = db.remove_duplicates(progress_callback=progress)
|
||||||
|
self.finished_signal.emit(True, f"Removed {len(removed)} duplicate(s)", removed)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.is_running = False
|
||||||
|
self.wait()
|
||||||
Loading…
x
Reference in New Issue
Block a user