diff --git a/src/main.py b/src/main.py index 1a7d832..58cb044 100644 --- a/src/main.py +++ b/src/main.py @@ -7,8 +7,6 @@ Provides a GUI for the YouTube Music to iPod Nano transfer tool import os import sys import logging -import threading -import time from typing import List, Dict, Optional, Tuple from pathlib import Path @@ -16,300 +14,243 @@ from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog, QComboBox, QCheckBox, QTabWidget, QListWidget, QListWidgetItem, - QMessageBox, QSpinBox, QGroupBox, QRadioButton, QSplitter + QMessageBox, QSpinBox, QGroupBox, QHeaderView ) -from PyQt6.QtCore import Qt, QThread, pyqtSignal, pyqtSlot, QSize -from PyQt6.QtGui import QIcon, QPixmap +from PyQt6.QtCore import Qt, QThread, pyqtSignal from youtube_downloader import YouTubeDownloader, TrackInfo from audio_converter import AudioConverter from metadata_handler import MetadataHandler from ipod_device import IPodDevice -# Configure logging 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'} + + 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): - """ - Initialize worker thread - - Args: - task_type: Type of task to perform - **kwargs: Additional arguments for the task - """ super().__init__() self.task_type = task_type self.kwargs = kwargs self.is_running = True - + def run(self): - """Run the worker thread""" try: - if self.task_type == "download": - self._run_download() - elif self.task_type == "convert": - self._run_convert() - elif self.task_type == "transfer": - self._run_transfer() - elif self.task_type == "detect_devices": - self._run_detect_devices() + handlers = { + "download": self._run_download, + "convert": self._run_convert, + "transfer": self._run_transfer, + "detect_devices": self._run_detect_devices, + } + 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_download(self): - """Run download task""" url = self.kwargs.get("url") output_dir = self.kwargs.get("output_dir", "downloads") - format = self.kwargs.get("format", "m4a") + fmt = self.kwargs.get("format", "m4a") quality = self.kwargs.get("quality", 256) - + self.progress_signal.emit(0, f"Initializing download from {url}") - - # Initialize downloader with progress callback + downloader = YouTubeDownloader( output_dir=output_dir, progress_callback=self._download_progress_callback ) - - # Process URL - tracks = downloader.process_url(url, format, quality) - + tracks = downloader.process_url(url, fmt, quality) + if not tracks: self.finished_signal.emit(False, "No tracks were successfully downloaded", []) return - - # Report success + self.finished_signal.emit(True, f"Downloaded {len(tracks)} tracks", tracks) - + def _download_progress_callback(self, progress: int, status: str): - """Callback for download progress updates""" self.progress_signal.emit(progress, status) - + def _run_convert(self): - """Run conversion task""" tracks = self.kwargs.get("tracks", []) local_files = self.kwargs.get("local_files", []) output_dir = self.kwargs.get("output_dir", "converted") - format = self.kwargs.get("format", "m4a") + fmt = self.kwargs.get("format", "m4a") quality = self.kwargs.get("quality", 256) - - # Filter out tracks without download paths - valid_tracks = [track for track in tracks if hasattr(track, 'download_path') and track.download_path and os.path.exists(track.download_path)] - - # Process local files: extract real metadata from tags + + 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): - # Try to extract metadata from file tags first track = metadata_handler.extract_tags(f) if track: - # Ensure download_path is set 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") - - # Initialize converter and metadata handler + converter = AudioConverter(output_dir=output_dir) metadata_handler = MetadataHandler() - - # Convert each track + 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: - # Convert to iPod format output_path = converter.convert_to_ipod_format( - track, track.download_path, format, quality + track, track.download_path, fmt, quality ) - - # Embed metadata 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 - - # Report success - self.finished_signal.emit( - True, - f"Converted {len(converted_tracks)} tracks", - converted_tracks - ) - + + self.finished_signal.emit(True, f"Converted {len(converted_tracks)} tracks", converted_tracks) + def _run_transfer(self): - """Run transfer task""" 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") - - # Initialize iPod device + ipod = IPodDevice(mount_point=mount_point) - - # Get device info device_info = ipod.get_device_info() free_space = device_info.get("free_space", 0) - - # Calculate total size of tracks - total_size = 0 - for track, path in tracks: - if os.path.exists(path): - total_size += os.path.getsize(path) - - # Check if there's enough space + + 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", + 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 - - # Transfer each track + 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: - # Transfer file 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 - - # Report success + self.finished_signal.emit( - True, - f"Transferred {len(transferred_tracks)} tracks to iPod", - transferred_tracks + True, f"Transferred {len(transferred_tracks)} tracks to iPod", transferred_tracks ) - + def _run_detect_devices(self): - """Run device detection task""" self.progress_signal.emit(0, "Detecting iPod devices...") - - # Initialize iPod device + ipod = IPodDevice() - - # Detect devices devices = ipod.detect_devices() - + if not devices: self.finished_signal.emit(False, "No iPod devices found", []) return - - # Report success + self.finished_signal.emit(True, f"Found {len(devices)} iPod devices", devices) - + def stop(self): - """Stop the worker thread""" self.is_running = False class MainWindow(QMainWindow): """Main application window""" - + def __init__(self): - """Initialize the main window""" super().__init__() - + self.setWindowTitle("YouTube to iPod Nano") self.setMinimumSize(800, 600) - - # Initialize variables + self.worker_thread = None - self.downloaded_tracks = [] - self.converted_tracks = [] - self.ipod_devices = [] - self.current_mount_point = None - - # Set up UI + self.downloaded_tracks: List[TrackInfo] = [] + self.library_ready: List[Dict] = [] + self.ipod_devices: List[Dict] = [] + self.current_mount_point: Optional[str] = None + self._setup_ui() - + self._scan_library() + self._on_refresh_devices_clicked() + def _setup_ui(self): - """Set up the user interface""" - # Main widget and layout main_widget = QWidget() main_layout = QVBoxLayout(main_widget) - - # Tab widget + tab_widget = QTabWidget() main_layout.addWidget(tab_widget) - - # Download tab + download_tab = QWidget() tab_widget.addTab(download_tab, "Download") self._setup_download_tab(download_tab) - - # Convert tab - convert_tab = QWidget() - tab_widget.addTab(convert_tab, "Convert") - self._setup_convert_tab(convert_tab) - - # Transfer tab - transfer_tab = QWidget() - tab_widget.addTab(transfer_tab, "Transfer") - self._setup_transfer_tab(transfer_tab) - - # Settings tab + + library_tab = QWidget() + tab_widget.addTab(library_tab, "Library") + self._setup_library_tab(library_tab) + + ipod_tab = QWidget() + tab_widget.addTab(ipod_tab, "iPod") + self._setup_ipod_tab(ipod_tab) + settings_tab = QWidget() tab_widget.addTab(settings_tab, "Settings") self._setup_settings_tab(settings_tab) - - # Status bar + self.statusBar().showMessage("Ready") - - # Set central widget self.setCentralWidget(main_widget) - + def _setup_download_tab(self, tab): - """Set up the download tab""" layout = QVBoxLayout(tab) - - # URL input + url_layout = QHBoxLayout() url_label = QLabel("YouTube URL:") self.url_input = QLineEdit() @@ -317,8 +258,7 @@ class MainWindow(QMainWindow): url_layout.addWidget(url_label) url_layout.addWidget(self.url_input) layout.addLayout(url_layout) - - # Format selection + format_layout = QHBoxLayout() format_label = QLabel("Format:") self.format_combo = QComboBox() @@ -334,89 +274,83 @@ class MainWindow(QMainWindow): format_layout.addWidget(self.quality_spin) format_layout.addStretch() layout.addLayout(format_layout) - - # Download button + self.download_button = QPushButton("Download") self.download_button.clicked.connect(self._on_download_clicked) layout.addWidget(self.download_button) - - # Progress bar + self.download_progress = QProgressBar() self.download_progress.setRange(0, 100) - self.download_progress.setValue(0) layout.addWidget(self.download_progress) - - # Status label + self.download_status = QLabel("Ready to download") layout.addWidget(self.download_status) - - # Track list + track_list_label = QLabel("Downloaded Tracks:") layout.addWidget(track_list_label) - + self.track_list = QListWidget() layout.addWidget(self.track_list) - - def _setup_convert_tab(self, tab): - """Set up the convert tab""" + + def _setup_library_tab(self, tab): layout = QVBoxLayout(tab) - - # Output directory + 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_button = QPushButton("Browse...") - browse_button.clicked.connect(self._on_browse_output_clicked) + browse_btn = QPushButton("Browse...") + browse_btn.clicked.connect(self._on_browse_output_clicked) + refresh_btn = QPushButton("Refresh") + refresh_btn.clicked.connect(self._on_refresh_library_clicked) output_layout.addWidget(output_label) output_layout.addWidget(self.output_dir_input) - output_layout.addWidget(browse_button) + output_layout.addWidget(browse_btn) + output_layout.addWidget(refresh_btn) layout.addLayout(output_layout) - - # Local file selection - file_layout = QHBoxLayout() - select_files_btn = QPushButton("Select Local Files...") - select_files_btn.clicked.connect(self._on_select_files_clicked) - remove_files_btn = QPushButton("Remove Selected") - remove_files_btn.clicked.connect(self._on_remove_files_clicked) - file_layout.addWidget(select_files_btn) - file_layout.addWidget(remove_files_btn) - layout.addLayout(file_layout) - - # Source files list - source_label = QLabel("Files to Convert (FLAC, WAV, OGG, etc.):") + + ready_label = QLabel("Ready to Transfer:") + layout.addWidget(ready_label) + + self.library_ready_list = QListWidget() + self.library_ready_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) + layout.addWidget(self.library_ready_list, stretch=2) + + source_label = QLabel("Source Files — Need Conversion:") layout.addWidget(source_label) - self.source_files_list = QListWidget() - self.source_files_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) - layout.addWidget(self.source_files_list) - - # Convert button - self.convert_button = QPushButton("Convert") - self.convert_button.clicked.connect(self._on_convert_clicked) - layout.addWidget(self.convert_button) - - # Progress bar - self.convert_progress = QProgressBar() - self.convert_progress.setRange(0, 100) - self.convert_progress.setValue(0) - layout.addWidget(self.convert_progress) - - # Status label - self.convert_status = QLabel("Ready to convert") - layout.addWidget(self.convert_status) - - # Converted track list - converted_list_label = QLabel("Converted Tracks:") - layout.addWidget(converted_list_label) - - self.converted_list = QListWidget() - layout.addWidget(self.converted_list) - - def _setup_transfer_tab(self, tab): - """Set up the transfer tab""" + + self.library_source_list = QListWidget() + self.library_source_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) + layout.addWidget(self.library_source_list, stretch=1) + + btn_row = QHBoxLayout() + self.library_add_btn = QPushButton("Add Files...") + self.library_add_btn.clicked.connect(self._on_library_add_files) + self.library_remove_btn = QPushButton("Remove Selected") + self.library_remove_btn.clicked.connect(self._on_library_remove_selected) + btn_row.addWidget(self.library_add_btn) + btn_row.addWidget(self.library_remove_btn) + btn_row.addStretch() + layout.addLayout(btn_row) + + self.library_convert_btn = QPushButton("Convert Selected") + self.library_convert_btn.clicked.connect(self._on_library_convert_clicked) + layout.addWidget(self.library_convert_btn) + + self.library_transfer_btn = QPushButton("Transfer Selected to iPod ▸") + self.library_transfer_btn.clicked.connect(self._on_library_transfer_clicked) + layout.addWidget(self.library_transfer_btn) + + self.library_progress = QProgressBar() + self.library_progress.setRange(0, 100) + layout.addWidget(self.library_progress) + + self.library_status = QLabel("Ready") + layout.addWidget(self.library_status) + + def _setup_ipod_tab(self, tab): layout = QVBoxLayout(tab) - - # Device selection + device_layout = QHBoxLayout() device_label = QLabel("iPod Device:") self.device_combo = QComboBox() @@ -427,8 +361,7 @@ class MainWindow(QMainWindow): device_layout.addWidget(self.device_combo) device_layout.addWidget(refresh_button) layout.addLayout(device_layout) - - # Mount/Eject buttons + mount_layout = QHBoxLayout() self.mount_button = QPushButton("Mount") self.mount_button.setEnabled(False) @@ -440,109 +373,81 @@ class MainWindow(QMainWindow): mount_layout.addWidget(self.eject_button) mount_layout.addStretch() layout.addLayout(mount_layout) - - # Device info + self.device_info_label = QLabel("No device selected") layout.addWidget(self.device_info_label) - - # Device status + self.device_status_label = QLabel("Status: Not connected") layout.addWidget(self.device_status_label) - - # Track count + self.track_count_label = QLabel("Tracks on device: 0") layout.addWidget(self.track_count_label) - - # Transfer button - self.transfer_button = QPushButton("Transfer to iPod") - self.transfer_button.setEnabled(False) - self.transfer_button.clicked.connect(self._on_transfer_clicked) - layout.addWidget(self.transfer_button) - - # Progress bar - self.transfer_progress = QProgressBar() - self.transfer_progress.setRange(0, 100) - self.transfer_progress.setValue(0) - layout.addWidget(self.transfer_progress) - - # Status label - self.transfer_status = QLabel("Ready to transfer") - layout.addWidget(self.transfer_status) - - # Transferred track list - transferred_list_label = QLabel("Transferred Tracks:") - layout.addWidget(transferred_list_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 selected button + remove_button = QPushButton("Remove Selected from iPod") remove_button.clicked.connect(self._on_remove_tracks_clicked) layout.addWidget(remove_button) - - # Scan for orphaned files button + orphan_button = QPushButton("Scan for Orphaned Files") orphan_button.clicked.connect(self._on_scan_orphans_clicked) layout.addWidget(orphan_button) - + def _setup_settings_tab(self, tab): - """Set up the settings tab""" layout = QVBoxLayout(tab) - - # Video support + video_group = QGroupBox("Video Support") video_layout = QVBoxLayout(video_group) - + self.enable_video_check = QCheckBox("Enable Video Download (iPod Nano 5th-7th gen)") video_layout.addWidget(self.enable_video_check) - + video_resolution_layout = QHBoxLayout() video_resolution_label = QLabel("Video Resolution:") self.video_resolution_combo = QComboBox() self.video_resolution_combo.addItems(["640x480", "480x360", "320x240"]) self.video_resolution_combo.setEnabled(False) self.enable_video_check.toggled.connect(self.video_resolution_combo.setEnabled) - + video_resolution_layout.addWidget(video_resolution_label) video_resolution_layout.addWidget(self.video_resolution_combo) video_resolution_layout.addStretch() video_layout.addLayout(video_resolution_layout) - + layout.addWidget(video_group) - - # Metadata options + 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 options + 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) - + layout.addWidget(advanced_group) - - # Spacer + layout.addStretch() - - # About section + about_label = QLabel( "YouTube to iPod Nano Transfer Tool\n" "Version 1.0.0\n\n" @@ -550,214 +455,344 @@ class MainWindow(QMainWindow): ) about_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(about_label) - + + def _format_duration(self, seconds: int) -> str: + if seconds <= 0: + return "??:??" + m, s = divmod(int(seconds), 60) + return f"{m}:{s:02d}" + + def _format_size(self, size_bytes: int) -> str: + if size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.0f} KB" + return f"{size_bytes / (1024 * 1024):.1f} MB" + + def _scan_library(self): + """Scan Output Directory for ready-to-transfer and source files.""" + output_dir = self.output_dir_input.text().strip() + if not output_dir or not os.path.isdir(output_dir): + self.library_ready.clear() + self._refresh_library_ui() + return + + ready = [] + sources = [] + + for root, _dirs, files in os.walk(output_dir): + for fname in sorted(files): + fpath = os.path.join(root, fname) + ext = os.path.splitext(fname)[1].lower() + if not os.path.isfile(fpath): + continue + + if ext in AUDIO_EXTS: + size = os.path.getsize(fpath) + try: + handler = MetadataHandler() + info = handler.extract_tags(fpath) + duration = info.duration if info else 0 + artist = info.artist if info else "Unknown" + title = info.title if info else fname + except Exception: + duration = 0 + artist = "Unknown" + title = fname + + ready.append({ + "path": fpath, + "title": title, + "artist": artist, + "duration_s": duration, + "size": size, + }) + elif ext in SOURCE_EXTS: + size = os.path.getsize(fpath) + try: + handler = MetadataHandler() + info = handler.extract_tags(fpath) + title = info.title if info else fname + artist = info.artist if info else "Unknown" + except Exception: + title = fname + artist = "Unknown" + sources.append({ + "path": fpath, + "title": title, + "artist": artist, + "size": size, + }) + + self.library_ready = ready + self._refresh_library_ui() + self._refresh_source_ui(sources) + + def _refresh_library_ui(self): + self.library_ready_list.clear() + for track in sorted(self.library_ready, key=lambda t: (t["artist"], t["title"])): + display = ( + f"{track['artist']} — {track['title']}" + f" {self._format_duration(track['duration_s'])}" + f" {self._format_size(track['size'])}" + ) + item = QListWidgetItem(display) + item.setData(Qt.ItemDataRole.UserRole, track) + self.library_ready_list.addItem(item) + + count = len(self.library_ready) + ready_label = self.findChild(QLabel, "ready_label") + if count: + self.library_status.setText(f"{count} track(s) ready to transfer") + self.library_transfer_btn.setEnabled(True) + else: + self.library_status.setText("No tracks ready — convert source files or download") + self.library_transfer_btn.setEnabled(False) + + def _refresh_source_ui(self, sources): + self.library_source_list.clear() + for src in sorted(sources, key=lambda s: (s["artist"], s["title"])): + display = f"{src['artist']} — {src['title']} {self._format_size(src['size'])}" + item = QListWidgetItem(display) + item.setData(Qt.ItemDataRole.UserRole, src) + self.library_source_list.addItem(item) + + count = len(sources) + if count: + self.library_convert_btn.setEnabled(True) + else: + self.library_convert_btn.setEnabled(False) + + def _get_selected_ready_tracks(self) -> List[Tuple[TrackInfo, str]]: + selected = self.library_ready_list.selectedItems() + if not selected: + selected = [self.library_ready_list.item(i) for i in range(self.library_ready_list.count())] + + tracks = [] + for item in selected: + data = item.data(Qt.ItemDataRole.UserRole) + if data: + track_info = TrackInfo( + video_id=f"local_{hash(data['path'])}", + title=data['title'], + artist=data['artist'], + album="", + thumbnail_url="", + duration=data.get('duration_s', 0), + download_path=data['path'], + ) + tracks.append((track_info, data['path'])) + return tracks + + def _get_selected_source_files(self) -> List[str]: + selected = self.library_source_list.selectedItems() + if not selected: + selected = [self.library_source_list.item(i) for i in range(self.library_source_list.count())] + + paths = [] + for item in selected: + data = item.data(Qt.ItemDataRole.UserRole) + if data and os.path.exists(data.get("path", "")): + paths.append(data["path"]) + return paths + def _on_browse_output_clicked(self): - """Handle browse output directory button click""" directory = QFileDialog.getExistingDirectory( self, "Select Output Directory", self.output_dir_input.text() ) - if directory: self.output_dir_input.setText(directory) - - def _on_select_files_clicked(self): - """Handle select local files button click""" + self._scan_library() + + def _on_refresh_library_clicked(self): + self.library_status.setText("Scanning library...") + self._scan_library() + + def _on_library_add_files(self): audio_filter = "Audio Files (*.flac *.wav *.ogg *.opus *.wma *.ape *.aiff *.alac)" files, _ = QFileDialog.getOpenFileNames( self, "Select Audio Files", "", audio_filter ) - for f in files: - # Avoid duplicates - items = self.source_files_list.findItems(f, Qt.MatchFlag.MatchExactly) - if not items: - self.source_files_list.addItem(f) - - def _on_remove_files_clicked(self): - """Handle remove selected files button click""" - for item in self.source_files_list.selectedItems(): - self.source_files_list.takeItem(self.source_files_list.row(item)) - + existing = self.library_source_list.findItems(f, Qt.MatchFlag.MatchExactly) + if not existing: + self.library_source_list.addItem(f) + self._scan_library() + + def _on_library_remove_selected(self): + for item in self.library_ready_list.selectedItems(): + self.library_ready_list.takeItem(self.library_ready_list.row(item)) + for item in self.library_source_list.selectedItems(): + self.library_source_list.takeItem(self.library_source_list.row(item)) + def _on_download_clicked(self): - """Handle download button click""" url = self.url_input.text().strip() - if not url: QMessageBox.warning(self, "Error", "Please enter a YouTube URL") return - if not (url.startswith("http://") or url.startswith("https://")): QMessageBox.warning(self, "Error", "Please enter a valid URL") return - - # Disable download button + self.download_button.setEnabled(False) self.download_status.setText("Downloading...") self.download_progress.setValue(0) - - # Clear track list self.track_list.clear() - - # Start worker thread + self.worker_thread = WorkerThread( task_type="download", url=url, format=self.format_combo.currentText(), quality=self.quality_spin.value() ) - self.worker_thread.progress_signal.connect(self._on_download_progress) self.worker_thread.finished_signal.connect(self._on_download_finished) self.worker_thread.start() - + def _on_download_progress(self, progress, status): - """Handle download progress updates""" self.download_progress.setValue(progress) self.download_status.setText(status) - + def _on_download_finished(self, success, message, result): - """Handle download completion""" - # Enable download button self.download_button.setEnabled(True) - + if success and result: self.download_status.setText(message) self.downloaded_tracks = result - - # Update track list self.track_list.clear() for track in self.downloaded_tracks: if hasattr(track, 'download_path') and track.download_path: item = QListWidgetItem(f"{track.artist} - {track.title}") self.track_list.addItem(item) - - # Enable convert button if we have tracks - if self.track_list.count() > 0: - self.convert_button.setEnabled(True) - else: - self.convert_button.setEnabled(False) - self.download_status.setText("No valid tracks were downloaded") + self._scan_library() else: self.download_status.setText(f"Error: {message}") QMessageBox.warning(self, "Download Error", message) self.downloaded_tracks = [] self.track_list.clear() - - # Clean up worker thread + self.worker_thread = None - - def _on_convert_clicked(self): - """Handle convert button click""" - local_files = [self.source_files_list.item(i).text() for i in range(self.source_files_list.count())] + + def _on_library_convert_clicked(self): + source_files = self._get_selected_source_files() has_downloaded = bool(self.downloaded_tracks) - has_local = bool(local_files) - - if not has_downloaded and not has_local: - QMessageBox.warning(self, "Error", "No tracks to convert. Download tracks first or select local audio files.") + + if not source_files and not has_downloaded: + QMessageBox.warning( + self, "Error", + "No tracks to convert. Add local source files or download tracks first." + ) return - + output_dir = self.output_dir_input.text().strip() - if not output_dir: - QMessageBox.warning(self, "Error", "Please enter an output directory") + QMessageBox.warning(self, "Error", "Please set an output directory") return - - task_type = "convert" + + self.library_convert_btn.setEnabled(False) + self.library_status.setText("Converting...") + self.library_progress.setValue(0) + kwargs = { "tracks": self.downloaded_tracks if has_downloaded else [], - "local_files": local_files, + "local_files": source_files, "output_dir": output_dir, "format": self.format_combo.currentText(), "quality": self.quality_spin.value() } - - # Disable convert button - self.convert_button.setEnabled(False) - self.convert_status.setText("Converting...") - self.convert_progress.setValue(0) - - # Clear converted list - self.converted_list.clear() - - # Start worker thread - self.worker_thread = WorkerThread( - task_type=task_type, - **kwargs - ) - - self.worker_thread.progress_signal.connect(self._on_convert_progress) - self.worker_thread.finished_signal.connect(self._on_convert_finished) + + self.worker_thread = WorkerThread(task_type="convert", **kwargs) + self.worker_thread.progress_signal.connect(self._on_library_progress) + self.worker_thread.finished_signal.connect(self._on_library_convert_finished) self.worker_thread.start() - - def _on_convert_progress(self, progress, status): - """Handle convert progress updates""" - self.convert_progress.setValue(progress) - self.convert_status.setText(status) - - def _on_convert_finished(self, success, message, result): - """Handle convert completion""" - # Enable convert button - self.convert_button.setEnabled(True) - + + def _on_library_progress(self, progress, status): + self.library_progress.setValue(progress) + self.library_status.setText(status) + + def _on_library_convert_finished(self, success, message, result): + self.library_convert_btn.setEnabled(True) + if success: - self.convert_status.setText(message) - self.converted_tracks = result - - # Update converted list - for track, path in self.converted_tracks: - item = QListWidgetItem(f"{track.artist} - {track.title}") - self.converted_list.addItem(item) + self.library_status.setText(message) + self._scan_library() else: - self.convert_status.setText(f"Error: {message}") + self.library_status.setText(f"Error: {message}") QMessageBox.warning(self, "Conversion Error", message) - - # Clean up worker thread + self.worker_thread = None - + + def _on_library_transfer_clicked(self): + selected_tracks = self._get_selected_ready_tracks() + if not selected_tracks: + QMessageBox.warning(self, "Error", "No tracks selected. Select tracks to transfer.") + return + + if not self.current_mount_point: + QMessageBox.warning(self, "Error", "No iPod device mounted. Go to iPod tab and mount first.") + return + + self.library_transfer_btn.setEnabled(False) + self.library_status.setText("Transferring...") + self.library_progress.setValue(0) + + self.worker_thread = WorkerThread( + task_type="transfer", + tracks=selected_tracks, + 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) + self.worker_thread.start() + + def _on_library_transfer_progress(self, progress, status): + self.library_progress.setValue(progress) + self.library_status.setText(status) + + def _on_library_transfer_finished(self, success, message, result): + self.library_transfer_btn.setEnabled(True) + + if success: + self.library_status.setText(message) + self._scan_library() + self._load_ipod_tracks() + QMessageBox.information(self, "Transfer Complete", message) + else: + self.library_status.setText(f"Error: {message}") + QMessageBox.warning(self, "Transfer Error", message) + + self.worker_thread = None + def _on_refresh_devices_clicked(self): - """Handle refresh devices button click""" - # Disable refresh button self.device_combo.clear() self.device_info_label.setText("Detecting devices...") - - # Start worker thread + 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): - """Handle device detection progress updates""" self.device_info_label.setText(status) - + def _on_device_detection_finished(self, success, message, result): - """Handle device detection completion""" if success: self.ipod_devices = result - - # Update device combo box 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) - - # Try to mount the first device + 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: - # Device already mounted self._set_device_mounted(device, detected_mount) else: - # Device not mounted - auto-mount ipod = IPodDevice() mount_point = ipod.mount_device(device_id, detected_mount=detected_mount) - if mount_point: device["mount_point"] = mount_point device["mounted"] = True @@ -765,38 +800,24 @@ class MainWindow(QMainWindow): else: self._set_device_unmounted(device) else: - self.device_info_label.setText("No iPod devices found") - self.device_status_label.setText("Status: Not connected") - self.mount_button.setEnabled(False) - self.eject_button.setEnabled(False) - self.transfer_button.setEnabled(False) - self.track_count_label.setText("Tracks on device: 0") - + self._set_device_not_found() + self.statusBar().showMessage(message) else: - 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.transfer_button.setEnabled(False) - self.track_count_label.setText("Tracks on device: 0") + self._set_device_not_found() self.statusBar().showMessage(message) - - # Clean up worker thread + self.worker_thread = None - + def _set_device_mounted(self, device: dict, mount_point: str): - """Update UI for a mounted device""" 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" @@ -805,13 +826,10 @@ class MainWindow(QMainWindow): self.device_status_label.setText("Status: Mounted and ready") self.mount_button.setEnabled(False) self.eject_button.setEnabled(True) - self.transfer_button.setEnabled(True) - - # Load existing tracks from iPod + self._load_ipod_tracks() - + def _set_device_unmounted(self, device: dict): - """Update UI for an unmounted device""" self.device_info_label.setText( f"Device: {device['name']}\n" f"Click 'Mount' to connect" @@ -819,25 +837,32 @@ class MainWindow(QMainWindow): self.device_status_label.setText("Status: Connected but not mounted") self.mount_button.setEnabled(True) self.eject_button.setEnabled(False) - self.transfer_button.setEnabled(False) self.track_count_label.setText("Tracks on device: 0") - + self.transferred_list.clear() + + def _set_device_not_found(self): + 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() + def _on_mount_clicked(self): - """Mount the selected iPod device""" 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 @@ -851,9 +876,8 @@ class MainWindow(QMainWindow): 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): - """Unmount and eject the iPod device""" reply = QMessageBox.question( self, "Eject iPod", "Eject iPod? Make sure no transfers are in progress.", @@ -861,23 +885,19 @@ class MainWindow(QMainWindow): ) if reply != QMessageBox.StandardButton.Yes: return - + self.device_status_label.setText("Ejecting...") self.eject_button.setEnabled(False) - self.transfer_button.setEnabled(False) - + try: ipod = IPodDevice(mount_point=self.current_mount_point) success = ipod.unmount_device() - - if success or True: # power-off may return False but device is still ejected + if success or True: self.current_mount_point = None - - # Update device info 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) @@ -893,91 +913,36 @@ class MainWindow(QMainWindow): self.device_status_label.setText("Status: Eject error") self.eject_button.setEnabled(True) QMessageBox.warning(self, "Eject Error", f"Error ejecting iPod: {e}") - - def _on_transfer_clicked(self): - """Handle transfer button click""" - if not self.converted_tracks: - QMessageBox.warning(self, "Error", "No tracks to transfer. Convert tracks first.") - return - - if not self.current_mount_point: - QMessageBox.warning(self, "Error", "No iPod device mounted. Refresh devices first.") - return - - # Disable transfer button - self.transfer_button.setEnabled(False) - self.transfer_status.setText("Transferring...") - self.transfer_progress.setValue(0) - - # Clear transferred list - self.transferred_list.clear() - - # Start worker thread - self.worker_thread = WorkerThread( - task_type="transfer", - tracks=self.converted_tracks, - mount_point=self.current_mount_point - ) - - self.worker_thread.progress_signal.connect(self._on_transfer_progress) - self.worker_thread.finished_signal.connect(self._on_transfer_finished) - self.worker_thread.start() - - def _on_transfer_progress(self, progress, status): - """Handle transfer progress updates""" - self.transfer_progress.setValue(progress) - self.transfer_status.setText(status) - - def _on_transfer_finished(self, success, message, result): - """Handle transfer completion""" - # Enable transfer button - self.transfer_button.setEnabled(True) - - if success: - self.transfer_status.setText(message) - - # Reload track list from iPod - self._load_ipod_tracks() - - QMessageBox.information(self, "Transfer Complete", message) - else: - self.transfer_status.setText(f"Error: {message}") - QMessageBox.warning(self, "Transfer Error", message) - - # Clean up worker thread - self.worker_thread = None - + def _load_ipod_tracks(self): - """Load existing tracks from iPod database into transferred_list""" 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["album"]: - display += f" ({track['album']})" + 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)}") 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): - """Remove selected tracks from iPod with confirmation""" 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", @@ -986,11 +951,11 @@ class MainWindow(QMainWindow): ) if reply != QMessageBox.StandardButton.Yes: return - + try: from ipod_nano7_db import Nano7Database db = Nano7Database(self.current_mount_point) - + removed = 0 for item in selected: track_data = item.data(Qt.ItemDataRole.UserRole) @@ -998,43 +963,37 @@ class MainWindow(QMainWindow): success = db.delete_track(track_data["pid"]) if success: removed += 1 - - # Reload list + self._load_ipod_tracks() QMessageBox.information(self, "Done", f"Successfully removed {removed} track(s) from iPod") except Exception as e: logger.error(f"Failed to remove tracks: {e}") QMessageBox.warning(self, "Error", f"Failed to remove tracks: {e}") - + def _on_scan_orphans_clicked(self): - """Scan for orphaned audio files on iPod that have no DB entry.""" 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 - - # Calculate total size + total_size = sum(o["size"] for o in orphans) total_mb = total_size / (1024 * 1024) - - # Build details text + details = f"Found {len(orphans)} orphaned file(s) occupying {total_mb:.1f} MB:\n\n" - for o in orphans[:20]: # Show first 20 + for o in orphans[:20]: size_kb = o["size"] / 1024 - artist = o["artist"] - title = o["title"] - details += f" {artist} - {title} ({size_kb:.0f} KB)\n" + 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?", @@ -1043,21 +1002,15 @@ class MainWindow(QMainWindow): if reply == QMessageBox.StandardButton.Yes: deleted = db.delete_orphaned_files() QMessageBox.information(self, "Cleanup Complete", f"Deleted {deleted} orphaned file(s).") - - # Reload track list 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 closeEvent(self, event): - """Handle window close event""" - # Stop worker thread if running if self.worker_thread and self.worker_thread.isRunning(): self.worker_thread.stop() self.worker_thread.wait() - - # Accept close event event.accept()