diff --git a/requirements.txt b/requirements.txt index f7b1303..0aaae1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ eyeD3>=0.9.7 pyusb>=1.2.1 pillow>=9.5.0 requests>=2.28.2 -musicbrainzngs>=0.7.1 PyQt6>=6.5.0 numpy>=1.24.0 iopenpod>=1.0.53 diff --git a/run.py b/run.py index 26b7eb6..277e5fa 100755 --- a/run.py +++ b/run.py @@ -12,12 +12,9 @@ def main(): src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src") sys.path.insert(0, src_dir) - from src.main import QApplication, MainWindow - app = QApplication(sys.argv) - window = MainWindow() - window.show() - return app.exec() + from src.app import main as app_main + app_main() if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh index b257abf..7bb414e 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -98,7 +98,7 @@ build_appdir() { --exclude-module PyQt6.QtQuick3D \ --exclude-module PyQt6.QtShaderTools \ --exclude-module PyQt6.QtSpatialAudio \ - src/main.py + src/app.py } create_desktop_file() { diff --git a/setup.py b/setup.py index a792706..50a89cf 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ setup( install_requires=requirements, entry_points={ 'console_scripts': [ - 'neo-pod-desktop=src.main:main', + 'neo-pod-desktop=src.app:main', ], }, classifiers=[ diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..bb904c6 --- /dev/null +++ b/src/app.py @@ -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() diff --git a/src/config_loader.py b/src/config_loader.py index b2a216a..9975976 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -116,8 +116,6 @@ class ConfigLoader: if not self.config.has_option('Metadata', 'embed_artwork'): 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 if not self.config.has_section('Device'): diff --git a/src/metadata_handler.py b/src/metadata_handler.py index 54d1330..8e940ab 100644 --- a/src/metadata_handler.py +++ b/src/metadata_handler.py @@ -541,20 +541,6 @@ class MetadataHandler: duration=0, 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], new_cover_path: Optional[str] = None) -> bool: diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/itunes_search.py b/src/services/itunes_search.py new file mode 100644 index 0000000..d9bdcab --- /dev/null +++ b/src/services/itunes_search.py @@ -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 diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/ipod_tab.py b/src/ui/ipod_tab.py new file mode 100644 index 0000000..11fda8a --- /dev/null +++ b/src/ui/ipod_tab.py @@ -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 diff --git a/src/main.py b/src/ui/library_tab.py similarity index 50% rename from src/main.py rename to src/ui/library_tab.py index f328e65..8e6469a 100644 --- a/src/main.py +++ b/src/ui/library_tab.py @@ -1,267 +1,46 @@ #!/usr/bin/env python3 """ -Main Application Module for neo-pod-desktop -Provides a GUI for downloading, converting, managing and transferring music to iPod Nano devices +Library Tab for neo-pod-desktop. +Provides the library UI, playback controls, and transfer/conversion workflows. """ import os import sys import logging -from typing import List, Dict, Optional, Tuple +from typing import List, Dict, Optional, Tuple, Any from pathlib import Path from PyQt6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog, - QComboBox, QCheckBox, QTabWidget, QListWidget, QListWidgetItem, - QMessageBox, QSpinBox, QGroupBox, QHeaderView, - QTableWidget, QTableWidgetItem, QSlider, QMenu, QDialog, QStyle, - QProxyStyle + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit, + QProgressBar, QFileDialog, QMessageBox, QGroupBox, QHeaderView, + QTableWidget, QTableWidgetItem, QSlider, QMenu, QStyle, QProxyStyle ) -from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer +from PyQt6.QtCore import Qt, QUrl, QTimer, pyqtSignal from PyQt6.QtGui import QPixmap, QColor from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from track_info import TrackInfo -from audio_converter import AudioConverter from metadata_handler import MetadataHandler -from metadata_editor import MetadataEditorDialog -from ipod_device import IPodDevice -from library_cache import get_library_cache, save_library_cache, invalidate_cache_for_path +from ui.metadata_editor import MetadataEditorDialog +from library_cache import get_library_cache, invalidate_cache_for_path from artwork.cache import cover_cache from config_loader import ConfigLoader -from hotkeys import HotkeyManager, KeyCaptureDialog +from worker import WorkerThread -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) - AUDIO_EXTS = {'.m4a', '.mp3', '.aac'} SOURCE_EXTS = {'.flac', '.wav', '.ogg', '.opus', '.wma', '.ape', '.aiff', '.alac'} EQ_FRAMES = [ - "\u2581\u2583\u2585", - "\u2582\u2585\u2587", - "\u2583\u2587\u2586", - "\u2585\u2586\u2584", - "\u2586\u2584\u2583", - "\u2587\u2583\u2582", - "\u2586\u2582\u2581", - "\u2585\u2581\u2583", - "\u2583\u2582\u2585", - "\u2581\u2583\u2587", - "\u2582\u2585\u2586", - "\u2583\u2587\u2584", - "\u2585\u2586\u2583", - "\u2586\u2584\u2581", + "\u2581\u2583\u2585", "\u2582\u2585\u2587", "\u2583\u2587\u2586", + "\u2585\u2586\u2584", "\u2586\u2584\u2583", "\u2587\u2583\u2582", + "\u2586\u2582\u2581", "\u2585\u2581\u2583", "\u2583\u2582\u2585", + "\u2581\u2583\u2587", "\u2582\u2585\u2586", "\u2583\u2587\u2584", + "\u2585\u2586\u2583", "\u2586\u2584\u2581", ] -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() - - class JumpStyle(QProxyStyle): def styleHint(self, hint, opt=None, widget=None, returnData=None): if hint == QStyle.StyleHint.SH_Slider_AbsoluteSetButtons: @@ -269,27 +48,19 @@ class JumpStyle(QProxyStyle): return super().styleHint(hint, opt, widget, returnData) -class MainWindow(QMainWindow): - """Main application window""" +class LibraryTab(QWidget): + """Library tab widget with playback, conversion and transfer.""" - def __init__(self): - super().__init__() + transfer_finished = pyqtSignal() + tab_switch_requested = pyqtSignal(int) - self.setWindowTitle("neo-pod-desktop") - self.setMinimumSize(800, 600) + def __init__(self, config_loader: ConfigLoader, parent=None): + super().__init__(parent) + self.config_loader = config_loader - self.worker_thread = None self.library_ready: List[Dict] = [] self.library_source_files: List[str] = [] self.library_scanned_sources: List[Dict] = [] - self.ipod_devices: List[Dict] = [] - self.current_mount_point: Optional[str] = None - self.hide_library_buttons = False - self._library_toolbar_buttons: list = [] - self.ipod_tab_index = 1 - - self.config_loader = ConfigLoader() - self.hotkey_manager: Optional[HotkeyManager] = None self.player: Optional[QMediaPlayer] = None self.audio_output: Optional[QAudioOutput] = None @@ -304,145 +75,25 @@ class MainWindow(QMainWindow): self._saved_playing: bool = False self._pending_seek_ms: int = 0 + self._library_toolbar_buttons: list = [] + + self.worker_thread: Optional[WorkerThread] = None + + self._current_mount_point: Optional[str] = None + self._setup_ui() - self._load_settings() self._setup_player() - self._setup_hotkeys() - self._scan_library() - self._resume_playback_if_saved() - self._on_refresh_devices_clicked() - def _cleanup_worker(self): - """Safely clean up the worker thread after it finishes. - - Calls wait() to ensure the C++ thread is fully cleaned up before - dropping the Python reference. This prevents the Qt crash: - 'QThread: Destroyed while thread is still running' - """ - if self.worker_thread: - self.worker_thread.wait(3000) - self.worker_thread.deleteLater() - self.worker_thread = None - - def _load_settings(self): - config = self.config_loader - 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.use_musicbrainz_check.setChecked( - config.get_boolean("Metadata", "use_musicbrainz", fallback=False) - ) - 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) - self._on_hide_library_buttons_toggled(hide_library_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 - 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("Metadata", "use_musicbrainz", str(self.use_musicbrainz_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()) - - config.set("Playback", "last_volume", str(self.volume_slider.value())) - if self.current_playback_index >= 0 and self.player.source().isValid(): - track_path = self.player.source().toLocalFile() - if track_path and os.path.exists(track_path): - config.set("Playback", "last_track_path", track_path) - position = self.player.position() - duration = self.player.duration() - if duration > 0 and position > duration - 2000: - position = 0 - config.set("Playback", "last_position_ms", str(position)) - config.set("Playback", "last_playing", str(self._is_playing).lower()) - else: - config.set("Playback", "last_track_path", "") - config.set("Playback", "last_position_ms", "0") - else: - config.set("Playback", "last_track_path", "") - config.set("Playback", "last_position_ms", "0") - config.save() - - def _setup_hotkeys(self): - self.hotkey_manager = HotkeyManager(self, self.config_loader) - self.hotkey_manager.register_all({ - "play_pause": self._on_play_pause_clicked, - "prev_track": self._on_prev_clicked, - "next_track": self._on_next_clicked, - "volume_up": lambda: self._adjust_volume(5), - "volume_down": lambda: self._adjust_volume(-5), - "seek_forward": self._seek_forward, - "seek_backward": self._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._focus_search, - "select_all": self._select_all_current, - "library_refresh": self._on_refresh_library_clicked, - "ipod_refresh": self._on_refresh_devices_clicked, - "delete_selected": self._delete_selected_current, - "edit_metadata": self._on_edit_metadata, - "library_add_files": self._on_library_add_files, - }) - self._refresh_shortcuts_table() - - def _adjust_volume(self, delta: int): - new_val = max(0, min(100, self.volume_slider.value() + delta)) - self.volume_slider.setValue(new_val) + def set_mount_point(self, mount_point: Optional[str]): + self._current_mount_point = mount_point def _setup_ui(self): - main_widget = QWidget() - main_layout = QVBoxLayout(main_widget) + layout = QVBoxLayout(self) - self.tab_widget = QTabWidget() - main_layout.addWidget(self.tab_widget) - - library_tab = QWidget() - self.tab_widget.addTab(library_tab, "Library") - self._setup_library_tab(library_tab) - - ipod_tab = QWidget() - self.tab_widget.addTab(ipod_tab, "iPod") - self._setup_ipod_tab(ipod_tab) - - settings_tab = QWidget() - self.tab_widget.addTab(settings_tab, "Settings") - self._setup_settings_tab(settings_tab) - - self.tab_widget.setTabVisible(self.ipod_tab_index, False) - - self.statusBar().showMessage("Ready") - self.setCentralWidget(main_widget) - - def _setup_library_tab(self, tab): - layout = QVBoxLayout(tab) - - # === Playback controls bar === playback_group = QGroupBox() playback_layout = QVBoxLayout(playback_group) playback_layout.setContentsMargins(8, 6, 8, 6) - # Top row: transport buttons + progress + time + volume controls_row = QHBoxLayout() controls_row.setSpacing(8) @@ -494,7 +145,6 @@ class MainWindow(QMainWindow): controls_row.addWidget(self.volume_slider) playback_layout.addLayout(controls_row) - # Bottom row: now playing info with album art now_playing_layout = QHBoxLayout() self.cover_label = QLabel() self.cover_label.setFixedSize(60, 60) @@ -598,187 +248,7 @@ class MainWindow(QMainWindow): layout.addLayout(toolbar) - def _setup_ipod_tab(self, tab): - layout = QVBoxLayout(tab) - - 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 _setup_settings_tab(self, tab): - layout = QVBoxLayout(tab) - - 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) - - self.use_musicbrainz_check = QCheckBox("Use MusicBrainz for Enhanced Metadata (Experimental)") - metadata_layout.addWidget(self.use_musicbrainz_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_library_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_library_buttons_toggled(self, checked: bool): - self.hide_library_buttons = checked - for btn in self._library_toolbar_buttons: - btn.setVisible(not checked) - def _setup_player(self): - """Initialize QMediaPlayer and QAudioOutput for playback.""" self.player = QMediaPlayer() self.audio_output = QAudioOutput() self.audio_output.setVolume(self._saved_volume / 100.0) @@ -793,7 +263,6 @@ class MainWindow(QMainWindow): self._eq_timer.timeout.connect(self._tick_eq_animation) def _play_track_from_index(self, index: int, start_position_ms: int = 0, auto_play: bool = True): - """Start playing a track from the library table by row index.""" if index < 0 or index >= self.library_table.rowCount(): return data_item = self.library_table.item(index, 0) @@ -860,7 +329,6 @@ class MainWindow(QMainWindow): self._stop_eq_animation() else: if self.player.playbackState() == QMediaPlayer.PlaybackState.StoppedState: - # Nothing playing yet — play first selected row or row 0 selected_rows = {item.row() for item in self.library_table.selectedItems()} start = min(selected_rows) if selected_rows else 0 self._play_track_from_index(start) @@ -897,24 +365,19 @@ class MainWindow(QMainWindow): new_pos = max(0, self.player.position() - 5000) self.player.setPosition(new_pos) + def _adjust_volume(self, delta: int): + new_val = max(0, min(100, self.volume_slider.value() + delta)) + self.volume_slider.setValue(new_val) + def _focus_search(self): - self.tab_widget.setCurrentIndex(1) self.library_search_input.setFocus() self.library_search_input.selectAll() def _select_all_current(self): - idx = self.tab_widget.currentIndex() - if idx == 1: - self.library_table.selectAll() - elif idx == 2: - self.transferred_list.selectAll() + self.library_table.selectAll() def _delete_selected_current(self): - idx = self.tab_widget.currentIndex() - if idx == 1: - self._on_library_remove_selected() - elif idx == 2: - self._on_remove_tracks_clicked() + self._on_library_remove_selected() def _on_slider_pressed(self): if self.player: @@ -1046,13 +509,7 @@ class MainWindow(QMainWindow): return f"{size_bytes / (1024 * 1024):.1f} MB" def _scan_library(self, force: bool = False): - """Scan Output Directory for ready-to-transfer and source files. - - Uses a JSON cache to skip metadata extraction for files whose mtime - hasn't changed since they were last cached. Pass ``force=True`` to - bypass the cache and re-read every file (refresh / F5). - """ - output_dir = self.output_dir_input.text().strip() + output_dir = self.get_output_dir() if not output_dir or not os.path.isdir(output_dir): self.library_ready.clear() self.library_scanned_sources = [] @@ -1101,7 +558,7 @@ class MainWindow(QMainWindow): if cache_misses: self.library_status.setText( - f"Scanned {cache_misses} file(s) — {cache_hits} from cache") + f"Scanned {cache_misses} file(s) \u2014 {cache_hits} from cache") seen_paths = set() deduped_ready = [] @@ -1177,21 +634,18 @@ class MainWindow(QMainWindow): seen_paths = set() all_tracks = [] - # Add ready tracks for t in self.library_ready: path = t.get("path", "") if path and path not in seen_paths and os.path.exists(path): seen_paths.add(path) all_tracks.append({**t, "_status": "ready"}) - # Add scanned source files for s in self.library_scanned_sources: path = s.get("path", "") if path and path not in seen_paths and os.path.exists(path): seen_paths.add(path) all_tracks.append({**s, "duration_s": 0, "genre": "", "_status": "source"}) - # Add manually added source files for fpath in self.library_source_files: if fpath in seen_paths or not os.path.exists(fpath): continue @@ -1243,13 +697,11 @@ class MainWindow(QMainWindow): QTableWidgetItem(self._format_size(track["size"])), ] - # Dim source tracks (gray text) like iTunes if not is_ready: gray = self.palette().color(self.palette().ColorRole.PlaceholderText) for item in cells: item.setForeground(gray) - # Store full track data in column 0 data_key = track.copy() data_key.pop("_status", None) cells[0].setData(Qt.ItemDataRole.UserRole, (is_ready, data_key)) @@ -1265,7 +717,7 @@ class MainWindow(QMainWindow): has_ready = ready_count > 0 has_source = source_count > 0 - self.library_transfer_btn.setEnabled(has_ready and self.current_mount_point is not None) + self.library_transfer_btn.setEnabled(has_ready and self._current_mount_point is not None) self.library_convert_btn.setEnabled(has_source) self.library_remove_btn.setEnabled(len(all_tracks) > 0) self.library_edit_btn.setEnabled(len(all_tracks) > 0) @@ -1362,14 +814,6 @@ class MainWindow(QMainWindow): paths.append(data["path"]) return paths - 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) - self._scan_library() - def _on_refresh_library_clicked(self): self.library_status.setText("Scanning library...") self._scan_library(force=True) @@ -1390,8 +834,6 @@ class MainWindow(QMainWindow): self._scan_library() def _incremental_update_after_edit(self, file_paths: list): - """Re-extract metadata for edited files, update cache, refresh UI - without walking the entire library directory.""" cache = get_library_cache() handler = MetadataHandler() @@ -1422,7 +864,6 @@ class MainWindow(QMainWindow): self._refresh_library_ui() def _on_edit_metadata(self): - """Open metadata editor for selected track(s).""" selected_rows = set() for item in self.library_table.selectedItems(): selected_rows.add(item.row()) @@ -1455,7 +896,6 @@ class MainWindow(QMainWindow): return None def _on_library_remove_selected(self): - """Delete selected files from disk and clean up empty directories.""" files_to_delete = [] selected_rows = set() @@ -1521,7 +961,7 @@ class MainWindow(QMainWindow): self.library_table.setRowHidden(row, False) continue match = False - for col in (1, 2, 3): # Artist, Title, Album + for col in (1, 2, 3): item = self.library_table.item(row, col) if item and query in item.text().lower(): match = True @@ -1543,7 +983,7 @@ class MainWindow(QMainWindow): play_action = menu.addAction("\u25B6 Play") play_action.triggered.connect(lambda: self._play_track_from_index(row)) - if is_ready and self.current_mount_point is not None: + if is_ready and self._current_mount_point is not None: transfer_action = menu.addAction("\uD83D\uDCE4 Transfer to iPod") transfer_action.triggered.connect(self._on_library_transfer_clicked) else: @@ -1590,7 +1030,7 @@ class MainWindow(QMainWindow): QMessageBox.information(self, "Info", "Please wait for the current task to finish") return - output_dir = self.output_dir_input.text().strip() + output_dir = self.get_output_dir() if not output_dir: QMessageBox.warning(self, "Error", "Please set an output directory") return @@ -1603,8 +1043,8 @@ class MainWindow(QMainWindow): "tracks": [], "local_files": source_files, "output_dir": output_dir, - "format": self.format_combo.currentText(), - "quality": self.quality_spin.value() + "format": self.get_convert_format(), + "quality": self.get_convert_quality() } self.worker_thread = WorkerThread(task_type="convert", **kwargs) @@ -1650,7 +1090,7 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "Error", "No tracks selected. Select tracks to transfer.") return - if not self.current_mount_point: + if not self._current_mount_point: QMessageBox.warning(self, "Error", "No iPod device mounted. Go to iPod tab and mount first.") return @@ -1665,7 +1105,7 @@ class MainWindow(QMainWindow): self.worker_thread = WorkerThread( task_type="transfer", tracks=selected_tracks, - mount_point=self.current_mount_point + mount_point=self._current_mount_point ) self.worker_thread.progress_signal.connect(self._on_library_transfer_progress) self.worker_thread.finished_signal.connect(self._on_library_transfer_finished) @@ -1681,7 +1121,7 @@ class MainWindow(QMainWindow): if success: self.library_status.setText(message) self._scan_library() - self._load_ipod_tracks() + self.transfer_finished.emit() QMessageBox.information(self, "Transfer Complete", message) else: self.library_status.setText(f"Error: {message}") @@ -1689,504 +1129,57 @@ class MainWindow(QMainWindow): self._cleanup_worker() - def _on_refresh_devices_clicked(self): - if self.worker_thread and self.worker_thread.isRunning(): - return + def _cleanup_worker(self): + if self.worker_thread: + self.worker_thread.wait(3000) + self.worker_thread.deleteLater() + self.worker_thread = None - self.device_combo.clear() - self.device_info_label.setText("Detecting devices...") + def load_player_settings(self, config): + 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) - 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) + def save_player_settings(self, config): + config.set("Playback", "last_volume", str(self.volume_slider.value())) + if self.current_playback_index >= 0 and self.player.source().isValid(): + track_path = self.player.source().toLocalFile() + if track_path and os.path.exists(track_path): + config.set("Playback", "last_track_path", track_path) + position = self.player.position() + duration = self.player.duration() + if duration > 0 and position > duration - 2000: + position = 0 + config.set("Playback", "last_position_ms", str(position)) + config.set("Playback", "last_playing", str(self._is_playing).lower()) else: - self._set_device_not_found() - - self.statusBar().showMessage(message) + config.set("Playback", "last_track_path", "") + config.set("Playback", "last_position_ms", "0") else: - self._set_device_not_found() - self.statusBar().showMessage(message) - - 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.tab_widget.setTabVisible(self.ipod_tab_index, True) + config.set("Playback", "last_track_path", "") + config.set("Playback", "last_position_ms", "0") + def on_device_mounted(self, mount_point: str): + self._current_mount_point = mount_point self.library_progress.setVisible(True) self.library_status.setVisible(True) - 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) + def on_device_unmounted(self): + self._current_mount_point = None self.library_transfer_btn.setEnabled(False) - self.library_progress.setVisible(False) self.library_status.setVisible(False) - self.tab_widget.setTabVisible(self.ipod_tab_index, True) + def set_buttons_visible(self, visible: bool): + for btn in self._library_toolbar_buttons: + btn.setVisible(visible) - 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.library_transfer_btn.setEnabled(False) + def get_output_dir(self): + return self.config_loader.get("General", "output_dir", fallback=os.path.join(os.path.expanduser("~"), "Music", "iPod")) - self.library_progress.setVisible(False) - self.library_status.setVisible(False) + def get_convert_format(self): + return self.config_loader.get("General", "format", fallback="m4a") - self.tab_widget.setTabVisible(self.ipod_tab_index, False) - - 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) - self.statusBar().showMessage(f"iPod mounted at {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 - self.library_transfer_btn.setEnabled(False) - self.library_progress.setVisible(False) - self.library_status.setVisible(False) - 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.statusBar().showMessage("iPod ejected safely") - 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 _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() == QDialog.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.") - - def closeEvent(self, event): - if self.worker_thread and self.worker_thread.isRunning(): - self.worker_thread.stop() - self.worker_thread.wait(5000) - self.worker_thread.deleteLater() - 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 # User already set it - - # Check for libqgtk3.so (official Qt GTK integration) - 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 - - # Check for qt6ct (Qt configuration tool) - 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() + def get_convert_quality(self): + return self.config_loader.get_int("General", "quality", fallback=256) diff --git a/src/metadata_editor.py b/src/ui/metadata_editor.py similarity index 87% rename from src/metadata_editor.py rename to src/ui/metadata_editor.py index 56efde7..1a375c0 100644 --- a/src/metadata_editor.py +++ b/src/ui/metadata_editor.py @@ -25,11 +25,12 @@ from PyQt6.QtWidgets import ( QPushButton, QLabel, QLineEdit, QSpinBox, QCheckBox, 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 metadata_handler import MetadataHandler from artwork.cache import cover_cache +from services.itunes_search import search_itunes COVER_SIZE = 250 @@ -343,6 +344,19 @@ class MetadataEditorDialog(QDialog): 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) @@ -409,6 +423,9 @@ class MetadataEditorDialog(QDialog): 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) @@ -517,6 +534,73 @@ class MetadataEditorDialog(QDialog): 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() diff --git a/src/ui/settings_tab.py b/src/ui/settings_tab.py new file mode 100644 index 0000000..e109866 --- /dev/null +++ b/src/ui/settings_tab.py @@ -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.") diff --git a/src/worker.py b/src/worker.py new file mode 100644 index 0000000..e52ed30 --- /dev/null +++ b/src/worker.py @@ -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()