- New src/artwork/ module with iPod Nano 7G cover art support - codecs: RGB565/RGB555/UYVY/JPEG encode/decode via numpy - presets: 50+ iPod artwork format definitions - chunks: ArtworkDB binary parser/writer (MHFD→MHSD→MHLI→MHII→MHNI) - writer: ithmb file generation + ArtworkDB assembly - extractor: cover extraction from audio files (APIC/covr/FLAC pictures) - cache: local JPEG cache for player UI - iTunes-style playback bar with 60x60 cover art display - Scan library now caches cover art by artist+album - iPod Nano7 transfer writes artwork to ArtworkDB + ithmb - FFmpeg conversion preserves embedded cover art (-map 0:t?) - One-time reembed_covers.py script in /tmp - MetadataHandler process_file() fallback to cover_path - Bug fixes: missing read_existing_artwork import, signed PID in Q format
1711 lines
66 KiB
Python
1711 lines
66 KiB
Python
#!/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
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import logging
|
|
from typing import List, Dict, Optional, Tuple
|
|
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
|
|
)
|
|
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl
|
|
from PyQt6.QtGui import QPixmap
|
|
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
|
|
|
from youtube_downloader import YouTubeDownloader, TrackInfo
|
|
from audio_converter import AudioConverter
|
|
from metadata_handler import MetadataHandler
|
|
from ipod_device import IPodDevice
|
|
from artwork.cache import cover_cache
|
|
|
|
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):
|
|
super().__init__()
|
|
self.task_type = task_type
|
|
self.kwargs = kwargs
|
|
self.is_running = True
|
|
|
|
def run(self):
|
|
try:
|
|
handlers = {
|
|
"download": self._run_download,
|
|
"convert": self._run_convert,
|
|
"transfer": self._run_transfer,
|
|
"detect_devices": self._run_detect_devices,
|
|
"export_ipod": self._run_export_ipod,
|
|
}
|
|
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):
|
|
url = self.kwargs.get("url")
|
|
output_dir = self.kwargs.get("output_dir", "downloads")
|
|
fmt = self.kwargs.get("format", "m4a")
|
|
quality = self.kwargs.get("quality", 256)
|
|
|
|
self.progress_signal.emit(0, f"Initializing download from {url}")
|
|
|
|
downloader = YouTubeDownloader(
|
|
output_dir=output_dir,
|
|
progress_callback=self._download_progress_callback
|
|
)
|
|
tracks = downloader.process_url(url, fmt, quality)
|
|
|
|
if not tracks:
|
|
self.finished_signal.emit(False, "No tracks were successfully downloaded", [])
|
|
return
|
|
|
|
self.finished_signal.emit(True, f"Downloaded {len(tracks)} tracks", tracks)
|
|
|
|
def _download_progress_callback(self, progress: int, status: str):
|
|
self.progress_signal.emit(progress, status)
|
|
|
|
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", "m4a")
|
|
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 stop(self):
|
|
self.is_running = False
|
|
self.wait()
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
"""Main application window"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
self.setWindowTitle("neo-pod-desktop")
|
|
self.setMinimumSize(800, 600)
|
|
|
|
self.worker_thread = None
|
|
self.downloaded_tracks: List[TrackInfo] = []
|
|
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.show_download_tab = False
|
|
|
|
self.player: Optional[QMediaPlayer] = None
|
|
self.audio_output: Optional[QAudioOutput] = None
|
|
self.current_playback_index: int = -1
|
|
self._is_playing = False
|
|
|
|
self._setup_ui()
|
|
self._setup_player()
|
|
self._scan_library()
|
|
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 _setup_ui(self):
|
|
main_widget = QWidget()
|
|
main_layout = QVBoxLayout(main_widget)
|
|
|
|
self.tab_widget = QTabWidget()
|
|
main_layout.addWidget(self.tab_widget)
|
|
|
|
download_tab = QWidget()
|
|
self.tab_widget.addTab(download_tab, "Download")
|
|
self._setup_download_tab(download_tab)
|
|
self.download_tab_index = 0
|
|
|
|
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.download_tab_index, self.show_download_tab)
|
|
|
|
self.statusBar().showMessage("Ready")
|
|
self.setCentralWidget(main_widget)
|
|
|
|
def _setup_download_tab(self, tab):
|
|
layout = QVBoxLayout(tab)
|
|
|
|
url_layout = QHBoxLayout()
|
|
url_label = QLabel("YouTube URL:")
|
|
self.url_input = QLineEdit()
|
|
self.url_input.setPlaceholderText("https://www.youtube.com/watch?v=...")
|
|
url_layout.addWidget(url_label)
|
|
url_layout.addWidget(self.url_input)
|
|
layout.addLayout(url_layout)
|
|
|
|
format_layout = QHBoxLayout()
|
|
format_label = QLabel("Format:")
|
|
self.format_combo = QComboBox()
|
|
self.format_combo.addItems(["m4a", "mp3"])
|
|
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)
|
|
format_layout.addWidget(format_label)
|
|
format_layout.addWidget(self.format_combo)
|
|
format_layout.addWidget(quality_label)
|
|
format_layout.addWidget(self.quality_spin)
|
|
format_layout.addStretch()
|
|
layout.addLayout(format_layout)
|
|
|
|
self.download_button = QPushButton("Download")
|
|
self.download_button.clicked.connect(self._on_download_clicked)
|
|
layout.addWidget(self.download_button)
|
|
|
|
self.download_progress = QProgressBar()
|
|
self.download_progress.setRange(0, 100)
|
|
layout.addWidget(self.download_progress)
|
|
|
|
self.download_status = QLabel("Ready to download")
|
|
layout.addWidget(self.download_status)
|
|
|
|
track_list_label = QLabel("Downloaded Tracks:")
|
|
layout.addWidget(track_list_label)
|
|
|
|
self.track_list = QListWidget()
|
|
layout.addWidget(self.track_list)
|
|
|
|
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)
|
|
|
|
self.prev_btn = QPushButton("\u23EE")
|
|
self.prev_btn.setFixedWidth(36)
|
|
self.prev_btn.clicked.connect(self._on_prev_clicked)
|
|
|
|
self.play_btn = QPushButton("\u25B6")
|
|
self.play_btn.setFixedWidth(44)
|
|
self.play_btn.clicked.connect(self._on_play_pause_clicked)
|
|
|
|
self.next_btn = QPushButton("\u23ED")
|
|
self.next_btn.setFixedWidth(36)
|
|
self.next_btn.clicked.connect(self._on_next_clicked)
|
|
|
|
controls_row.addWidget(self.prev_btn)
|
|
controls_row.addWidget(self.play_btn)
|
|
controls_row.addWidget(self.next_btn)
|
|
|
|
self.time_label_start = QLabel("0:00")
|
|
self.time_label_start.setFixedWidth(40)
|
|
self.time_label_start.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
|
|
self.position_slider = QSlider(Qt.Orientation.Horizontal)
|
|
self.position_slider.setMinimum(0)
|
|
self.position_slider.setMaximum(1000)
|
|
self.position_slider.setValue(0)
|
|
self.position_slider.sliderPressed.connect(self._on_slider_pressed)
|
|
self.position_slider.sliderReleased.connect(self._on_slider_released)
|
|
|
|
self.time_label_end = QLabel("0:00")
|
|
self.time_label_end.setFixedWidth(40)
|
|
self.time_label_end.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
|
|
|
controls_row.addWidget(self.time_label_start)
|
|
controls_row.addWidget(self.position_slider, stretch=1)
|
|
controls_row.addWidget(self.time_label_end)
|
|
|
|
vol_icon = QLabel("\U0001F50A")
|
|
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
|
|
self.volume_slider.setFixedWidth(80)
|
|
self.volume_slider.setRange(0, 100)
|
|
self.volume_slider.setValue(80)
|
|
self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
|
|
|
controls_row.addWidget(vol_icon)
|
|
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)
|
|
self.cover_label.setScaledContents(True)
|
|
self.cover_label.setStyleSheet("background: palette(window); border-radius: 4px;")
|
|
now_playing_layout.addWidget(self.cover_label)
|
|
|
|
self.now_playing_label = QLabel("Select a track to play")
|
|
self.now_playing_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
|
self.now_playing_label.setStyleSheet("color: palette(mid); font-size: 12px;")
|
|
now_playing_layout.addWidget(self.now_playing_label, stretch=1)
|
|
playback_layout.addLayout(now_playing_layout)
|
|
|
|
layout.addWidget(playback_group)
|
|
|
|
self._setup_library_toolbar(layout)
|
|
|
|
self.library_table = QTableWidget()
|
|
self.library_table.setColumnCount(7)
|
|
self.library_table.setHorizontalHeaderLabels(
|
|
["#", "Artist", "Title", "Album", "Duration", "Genre", "Size"]
|
|
)
|
|
self.library_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
self.library_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
|
|
self.library_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
self.library_table.setAlternatingRowColors(True)
|
|
self.library_table.setSortingEnabled(True)
|
|
self.library_table.cellDoubleClicked.connect(self._on_table_double_clicked)
|
|
self.library_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.library_table.customContextMenuRequested.connect(self._on_library_table_context_menu)
|
|
|
|
header = self.library_table.horizontalHeader()
|
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
|
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
|
header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
|
|
header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
|
|
|
|
layout.addWidget(self.library_table, stretch=1)
|
|
|
|
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_library_toolbar(self, layout):
|
|
toolbar = QHBoxLayout()
|
|
toolbar.setSpacing(4)
|
|
|
|
self.library_add_btn = QPushButton("\u2795 Add Files...")
|
|
self.library_add_btn.clicked.connect(self._on_library_add_files)
|
|
toolbar.addWidget(self.library_add_btn)
|
|
|
|
self.library_convert_btn = QPushButton("\uD83D\uDD04 Convert")
|
|
self.library_convert_btn.setEnabled(False)
|
|
self.library_convert_btn.clicked.connect(self._on_library_convert_clicked)
|
|
toolbar.addWidget(self.library_convert_btn)
|
|
|
|
self.library_transfer_btn = QPushButton("\uD83D\uDCE4 Transfer")
|
|
self.library_transfer_btn.setEnabled(False)
|
|
self.library_transfer_btn.clicked.connect(self._on_library_transfer_clicked)
|
|
toolbar.addWidget(self.library_transfer_btn)
|
|
|
|
self.library_remove_btn = QPushButton("\uD83D\uDDD1\uFE0F Remove")
|
|
self.library_remove_btn.setEnabled(False)
|
|
self.library_remove_btn.clicked.connect(self._on_library_remove_selected)
|
|
toolbar.addWidget(self.library_remove_btn)
|
|
|
|
refresh_btn = QPushButton("\uD83D\uDD04")
|
|
refresh_btn.setToolTip("Refresh Library")
|
|
refresh_btn.clicked.connect(self._on_refresh_library_clicked)
|
|
toolbar.addWidget(refresh_btn)
|
|
|
|
toolbar.addStretch()
|
|
|
|
self.library_search_input = QLineEdit()
|
|
self.library_search_input.setPlaceholderText("\uD83D\uDD0D Search Library...")
|
|
self.library_search_input.setFixedWidth(220)
|
|
self.library_search_input.textChanged.connect(self._on_search_text_changed)
|
|
toolbar.addWidget(self.library_search_input)
|
|
|
|
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)
|
|
|
|
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)
|
|
layout.addWidget(library_group)
|
|
|
|
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_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.show_download_check = QCheckBox("Show YouTube Download Tab")
|
|
self.show_download_check.setChecked(False)
|
|
self.show_download_check.toggled.connect(self._on_show_download_toggled)
|
|
advanced_layout.addWidget(self.show_download_check)
|
|
|
|
layout.addWidget(advanced_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_show_download_toggled(self, checked: bool):
|
|
self.show_download_tab = checked
|
|
self.tab_widget.setTabVisible(self.download_tab_index, checked)
|
|
|
|
def _setup_player(self):
|
|
"""Initialize QMediaPlayer and QAudioOutput for playback."""
|
|
self.player = QMediaPlayer()
|
|
self.audio_output = QAudioOutput()
|
|
self.audio_output.setVolume(0.8)
|
|
self.player.setAudioOutput(self.audio_output)
|
|
|
|
self.player.positionChanged.connect(self._on_media_position_changed)
|
|
self.player.durationChanged.connect(self._on_media_duration_changed)
|
|
self.player.mediaStatusChanged.connect(self._on_media_status_changed)
|
|
|
|
def _play_track_from_index(self, index: int):
|
|
"""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)
|
|
if data_item is None:
|
|
return
|
|
is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole)
|
|
if not track_data or not os.path.exists(track_data.get("path", "")):
|
|
return
|
|
|
|
self.current_playback_index = index
|
|
self.player.setSource(QUrl.fromLocalFile(track_data["path"]))
|
|
self.player.play()
|
|
self._is_playing = True
|
|
self.play_btn.setText("\u23F8")
|
|
|
|
self.now_playing_label.setText(f"{track_data['artist']} \u2014 {track_data['title']}")
|
|
self.now_playing_label.setStyleSheet(
|
|
"color: palette(text); font-weight: bold;"
|
|
)
|
|
|
|
cover_path = track_data.get("cover_path")
|
|
if cover_path and os.path.exists(cover_path):
|
|
pixmap = QPixmap(cover_path)
|
|
self.cover_label.setPixmap(pixmap)
|
|
self.cover_label.setToolTip(track_data.get("album", ""))
|
|
else:
|
|
# Fallback: try looking up by artist/album in cover cache
|
|
artist = track_data.get("artist", "")
|
|
album = track_data.get("album", "")
|
|
cached = cover_cache.get(artist, album) if artist else None
|
|
if cached and os.path.exists(cached):
|
|
pixmap = QPixmap(cached)
|
|
self.cover_label.setPixmap(pixmap)
|
|
self.cover_label.setToolTip(album)
|
|
else:
|
|
self.cover_label.clear()
|
|
self.cover_label.setToolTip("")
|
|
|
|
# Highlight the current row
|
|
self.library_table.clearSelection()
|
|
self.library_table.selectRow(index)
|
|
|
|
def _on_play_pause_clicked(self):
|
|
if self.player is None:
|
|
return
|
|
if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
|
|
self.player.pause()
|
|
self._is_playing = False
|
|
self.play_btn.setText("\u25B6")
|
|
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)
|
|
else:
|
|
self.player.play()
|
|
self._is_playing = True
|
|
self.play_btn.setText("\u23F8")
|
|
|
|
def _on_prev_clicked(self):
|
|
if self.player is None:
|
|
return
|
|
if self.player.position() > 3000:
|
|
self.player.setPosition(0)
|
|
return
|
|
idx = max(0, self.current_playback_index - 1)
|
|
self._play_track_from_index(idx)
|
|
|
|
def _on_next_clicked(self):
|
|
if self.player is None:
|
|
return
|
|
idx = min(self.library_table.rowCount() - 1, self.current_playback_index + 1)
|
|
self._play_track_from_index(idx)
|
|
|
|
def _on_slider_pressed(self):
|
|
if self.player:
|
|
self.player.setPosition(
|
|
int(self.position_slider.value() / 1000.0 * self.player.duration())
|
|
)
|
|
|
|
def _on_slider_released(self):
|
|
pass
|
|
|
|
def _on_volume_changed(self, value):
|
|
if self.audio_output:
|
|
self.audio_output.setVolume(value / 100.0)
|
|
|
|
def _on_media_position_changed(self, position: int):
|
|
if self.position_slider is None or self.player is None:
|
|
return
|
|
duration = self.player.duration()
|
|
if duration > 0 and not self.position_slider.isSliderDown():
|
|
self.position_slider.setValue(int(position / duration * 1000))
|
|
self.time_label_start.setText(self._format_time_ms(position))
|
|
|
|
def _on_media_duration_changed(self, duration: int):
|
|
self.time_label_end.setText(self._format_time_ms(duration))
|
|
if self.position_slider and not self.position_slider.isSliderDown():
|
|
self.position_slider.setValue(0)
|
|
|
|
def _on_media_status_changed(self, status):
|
|
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
|
self._on_next_clicked()
|
|
elif status == QMediaPlayer.MediaStatus.NoMedia:
|
|
self.play_btn.setText("\u25B6")
|
|
self._is_playing = False
|
|
|
|
def _on_table_double_clicked(self, row: int):
|
|
self._play_track_from_index(row)
|
|
|
|
@staticmethod
|
|
def _format_time_ms(ms: int) -> str:
|
|
total_seconds = ms // 1000
|
|
m, s = divmod(total_seconds, 60)
|
|
return f"{m}:{s:02d}"
|
|
|
|
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.library_scanned_sources = []
|
|
self._refresh_library_ui()
|
|
return
|
|
|
|
ready = []
|
|
sources = []
|
|
|
|
handler = MetadataHandler()
|
|
|
|
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)
|
|
cover_path = None
|
|
try:
|
|
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
|
|
album = info.album if info else ""
|
|
genre = info.genre if info else ""
|
|
track_num = info.track_number if info else 0
|
|
cover_path = info.cover_path if info else None
|
|
except Exception:
|
|
duration = 0
|
|
artist = "Unknown"
|
|
title = fname
|
|
album = ""
|
|
genre = ""
|
|
track_num = 0
|
|
|
|
ready.append({
|
|
"path": fpath,
|
|
"title": title,
|
|
"artist": artist,
|
|
"album": album,
|
|
"duration_s": duration,
|
|
"size": size,
|
|
"genre": genre,
|
|
"track_num": track_num,
|
|
"cover_path": cover_path,
|
|
})
|
|
elif ext in SOURCE_EXTS:
|
|
size = os.path.getsize(fpath)
|
|
try:
|
|
info = handler.extract_tags(fpath)
|
|
title = info.title if info else fname
|
|
artist = info.artist if info else "Unknown"
|
|
track_num = info.track_number if info else 0
|
|
cover_path = info.cover_path if info else None
|
|
except Exception:
|
|
title = fname
|
|
artist = "Unknown"
|
|
track_num = 0
|
|
cover_path = None
|
|
sources.append({
|
|
"path": fpath,
|
|
"title": title,
|
|
"artist": artist,
|
|
"size": size,
|
|
"track_num": track_num,
|
|
"cover_path": cover_path,
|
|
})
|
|
|
|
# Deduplicate by file path (defensive — os.walk shouldn't produce dupes)
|
|
seen_paths = set()
|
|
deduped_ready = []
|
|
for track in ready:
|
|
if track["path"] not in seen_paths:
|
|
seen_paths.add(track["path"])
|
|
deduped_ready.append(track)
|
|
ready = deduped_ready
|
|
|
|
self.library_ready = ready
|
|
self.library_scanned_sources = sources
|
|
self._refresh_library_ui()
|
|
|
|
def _refresh_library_ui(self):
|
|
self.library_table.setSortingEnabled(False)
|
|
self.library_table.setRowCount(0)
|
|
|
|
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
|
|
fname = os.path.basename(fpath)
|
|
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"
|
|
album = info.album if info else ""
|
|
cover_path = info.cover_path if info else None
|
|
except Exception:
|
|
title = fname
|
|
artist = "Unknown"
|
|
album = ""
|
|
cover_path = None
|
|
seen_paths.add(fpath)
|
|
all_tracks.append({
|
|
"path": fpath, "title": title, "artist": artist,
|
|
"size": size, "duration_s": 0, "genre": "", "album": album,
|
|
"cover_path": cover_path,
|
|
"_status": "source",
|
|
})
|
|
|
|
all_tracks.sort(key=lambda t: (
|
|
(t.get("album") or "").lower(),
|
|
t.get("track_num", 0) or 0,
|
|
t.get("artist", "").lower(),
|
|
t.get("title", "").lower(),
|
|
))
|
|
|
|
for idx, track in enumerate(all_tracks):
|
|
row = self.library_table.rowCount()
|
|
self.library_table.insertRow(row)
|
|
|
|
is_ready = track["_status"] == "ready"
|
|
|
|
track_num_str = str(track.get("track_num") or "") if is_ready else ""
|
|
duration_str = self._format_duration(track.get("duration_s", 0)) if is_ready else "\u2014"
|
|
|
|
cells = [
|
|
QTableWidgetItem(track_num_str),
|
|
QTableWidgetItem(track["artist"]),
|
|
QTableWidgetItem(track["title"]),
|
|
QTableWidgetItem(track.get("album", "") or ""),
|
|
QTableWidgetItem(duration_str),
|
|
QTableWidgetItem(track.get("genre", "") or ""),
|
|
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))
|
|
|
|
for col, item in enumerate(cells):
|
|
self.library_table.setItem(row, col, item)
|
|
|
|
self.library_table.setSortingEnabled(True)
|
|
|
|
ready_count = sum(1 for t in all_tracks if t["_status"] == "ready")
|
|
source_count = len(all_tracks) - ready_count
|
|
|
|
has_ready = ready_count > 0
|
|
has_source = source_count > 0
|
|
|
|
self.library_transfer_btn.setEnabled(has_ready)
|
|
self.library_convert_btn.setEnabled(has_source)
|
|
self.library_remove_btn.setEnabled(len(all_tracks) > 0)
|
|
|
|
parts = []
|
|
if ready_count:
|
|
parts.append(f"{ready_count} ready")
|
|
if source_count:
|
|
parts.append(f"{source_count} need conversion")
|
|
status = f"{len(all_tracks)} track(s) \u2014 {', '.join(parts)}" if parts else "No tracks in library"
|
|
self.library_status.setText(status)
|
|
|
|
def _get_selected_ready_tracks(self) -> List[Tuple[TrackInfo, str]]:
|
|
selected_rows = set()
|
|
for item in self.library_table.selectedItems():
|
|
selected_rows.add(item.row())
|
|
|
|
if not selected_rows:
|
|
selected_rows = set(range(self.library_table.rowCount()))
|
|
|
|
tracks = []
|
|
for row in sorted(selected_rows):
|
|
data_item = self.library_table.item(row, 0)
|
|
if data_item is None:
|
|
continue
|
|
is_ready, data = data_item.data(Qt.ItemDataRole.UserRole)
|
|
if not is_ready:
|
|
continue
|
|
track_info = TrackInfo(
|
|
video_id=f"local_{hash(data['path'])}",
|
|
title=data['title'],
|
|
artist=data['artist'],
|
|
album=data.get("album", ""),
|
|
thumbnail_url="",
|
|
duration=data.get('duration_s', 0),
|
|
track_number=data.get('track_num'),
|
|
genre=data.get('genre'),
|
|
download_path=data['path'],
|
|
)
|
|
tracks.append((track_info, data['path']))
|
|
return tracks
|
|
|
|
def _get_selected_source_files(self) -> List[str]:
|
|
selected_rows = set()
|
|
for item in self.library_table.selectedItems():
|
|
selected_rows.add(item.row())
|
|
|
|
if not selected_rows:
|
|
selected_rows = set(range(self.library_table.rowCount()))
|
|
|
|
paths = []
|
|
for row in sorted(selected_rows):
|
|
data_item = self.library_table.item(row, 0)
|
|
if data_item is None:
|
|
continue
|
|
is_ready, data = data_item.data(Qt.ItemDataRole.UserRole)
|
|
if is_ready:
|
|
continue
|
|
if os.path.exists(data.get("path", "")):
|
|
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()
|
|
|
|
def _on_library_add_files(self):
|
|
source_filter = "Audio Files (*.flac *.wav *.ogg *.opus *.wma *.ape *.aiff *.alac)"
|
|
files, _ = QFileDialog.getOpenFileNames(
|
|
self, "Select Audio Files", "", source_filter
|
|
)
|
|
new_files = []
|
|
for f in files:
|
|
ext = os.path.splitext(f)[1].lower()
|
|
if ext in SOURCE_EXTS and os.path.exists(f) and f not in self.library_source_files:
|
|
self.library_source_files.append(f)
|
|
new_files.append(f)
|
|
|
|
if new_files:
|
|
self._scan_library()
|
|
|
|
def _on_library_remove_selected(self):
|
|
"""Delete selected files from disk and clean up empty directories."""
|
|
files_to_delete = []
|
|
|
|
selected_rows = set()
|
|
for item in self.library_table.selectedItems():
|
|
selected_rows.add(item.row())
|
|
|
|
for row in selected_rows:
|
|
data_item = self.library_table.item(row, 0)
|
|
if data_item is None:
|
|
continue
|
|
is_ready, data = data_item.data(Qt.ItemDataRole.UserRole)
|
|
if data and os.path.exists(data.get("path", "")):
|
|
files_to_delete.append(data)
|
|
|
|
if not files_to_delete:
|
|
QMessageBox.information(self, "Info", "No files selected")
|
|
return
|
|
|
|
total_size = sum(f.get("size", 0) for f in files_to_delete)
|
|
details = ""
|
|
for f in files_to_delete[:15]:
|
|
details += f" {f.get('artist', 'Unknown')} {f['title']} ({self._format_size(f.get('size', 0))})\n"
|
|
if len(files_to_delete) > 15:
|
|
details += f" ... and {len(files_to_delete) - 15} more\n"
|
|
|
|
reply = QMessageBox.question(
|
|
self, "Delete Files",
|
|
f"Delete {len(files_to_delete)} file(s) from disk?\n\n{details}\n"
|
|
f"Total: {self._format_size(total_size)}\n\n"
|
|
f"This action cannot be undone.",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
)
|
|
if reply != QMessageBox.StandardButton.Yes:
|
|
return
|
|
|
|
deleted = 0
|
|
for f in files_to_delete:
|
|
path = f["path"]
|
|
try:
|
|
os.remove(path)
|
|
deleted += 1
|
|
if path in self.library_source_files:
|
|
self.library_source_files.remove(path)
|
|
parent = os.path.dirname(path)
|
|
while parent != os.path.dirname(parent):
|
|
if os.listdir(parent):
|
|
break
|
|
os.rmdir(parent)
|
|
parent = os.path.dirname(parent)
|
|
except OSError as e:
|
|
logger.error(f"Failed to delete {path}: {e}")
|
|
|
|
self._scan_library()
|
|
QMessageBox.information(self, "Done", f"Deleted {deleted} file(s)")
|
|
|
|
def _on_search_text_changed(self, text: str):
|
|
for row in range(self.library_table.rowCount()):
|
|
item = self.library_table.item(row, 3)
|
|
match = not text or text.lower() in item.text().lower() if item else True
|
|
self.library_table.setRowHidden(row, not match)
|
|
|
|
def _on_library_table_context_menu(self, pos):
|
|
item = self.library_table.itemAt(pos)
|
|
if item is None:
|
|
return
|
|
row = item.row()
|
|
data_item = self.library_table.item(row, 0)
|
|
if data_item is None:
|
|
return
|
|
is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole)
|
|
|
|
menu = QMenu(self)
|
|
|
|
play_action = menu.addAction("\u25B6 Play")
|
|
play_action.triggered.connect(lambda: self._play_track_from_index(row))
|
|
|
|
if is_ready:
|
|
transfer_action = menu.addAction("\uD83D\uDCE4 Transfer to iPod")
|
|
transfer_action.triggered.connect(self._on_library_transfer_clicked)
|
|
else:
|
|
convert_action = menu.addAction("\uD83D\uDD04 Convert to iPod Format")
|
|
convert_action.triggered.connect(self._on_library_convert_clicked)
|
|
|
|
menu.addSeparator()
|
|
|
|
show_action = menu.addAction("\uD83D\uDCC2 Show in File Manager")
|
|
show_action.triggered.connect(lambda: self._on_show_in_file_manager(track_data))
|
|
|
|
remove_action = menu.addAction("\uD83D\uDDD1\uFE0F Remove from Library")
|
|
remove_action.triggered.connect(self._on_library_remove_selected)
|
|
|
|
menu.exec(self.library_table.viewport().mapToGlobal(pos))
|
|
|
|
def _on_show_in_file_manager(self, track_data):
|
|
path = track_data.get("path", "")
|
|
if not path:
|
|
return
|
|
import subprocess
|
|
platform = sys.platform
|
|
if platform == "win32":
|
|
subprocess.Popen(["explorer", "/select,", os.path.normpath(path)])
|
|
elif platform == "darwin":
|
|
subprocess.Popen(["open", "-R", path])
|
|
else:
|
|
subprocess.Popen(["xdg-open", os.path.dirname(path)])
|
|
|
|
def _on_download_clicked(self):
|
|
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
|
|
|
|
if self.worker_thread and self.worker_thread.isRunning():
|
|
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
|
|
return
|
|
|
|
self.download_button.setEnabled(False)
|
|
self.download_status.setText("Downloading...")
|
|
self.download_progress.setValue(0)
|
|
self.track_list.clear()
|
|
|
|
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):
|
|
self.download_progress.setValue(progress)
|
|
self.download_status.setText(status)
|
|
|
|
def _on_download_finished(self, success, message, result):
|
|
self.download_button.setEnabled(True)
|
|
|
|
if success and result:
|
|
self.download_status.setText(message)
|
|
self.downloaded_tracks = result
|
|
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)
|
|
self._scan_library()
|
|
else:
|
|
self.download_status.setText(f"Error: {message}")
|
|
QMessageBox.warning(self, "Download Error", message)
|
|
self.downloaded_tracks = []
|
|
self.track_list.clear()
|
|
|
|
self._cleanup_worker()
|
|
|
|
def _on_library_convert_clicked(self):
|
|
source_files = self._get_selected_source_files()
|
|
has_downloaded = bool(self.downloaded_tracks)
|
|
|
|
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
|
|
|
|
if self.worker_thread and self.worker_thread.isRunning():
|
|
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
|
|
return
|
|
|
|
output_dir = self.output_dir_input.text().strip()
|
|
if not output_dir:
|
|
QMessageBox.warning(self, "Error", "Please set an output directory")
|
|
return
|
|
|
|
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": source_files,
|
|
"output_dir": output_dir,
|
|
"format": self.format_combo.currentText(),
|
|
"quality": self.quality_spin.value()
|
|
}
|
|
|
|
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_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.library_status.setText(message)
|
|
self._scan_library()
|
|
else:
|
|
self.library_status.setText(f"Error: {message}")
|
|
QMessageBox.warning(self, "Conversion Error", message)
|
|
|
|
self._cleanup_worker()
|
|
|
|
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
|
|
|
|
if self.worker_thread and self.worker_thread.isRunning():
|
|
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
|
|
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._cleanup_worker()
|
|
|
|
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()
|
|
|
|
self.statusBar().showMessage(message)
|
|
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._load_ipod_tracks()
|
|
|
|
def _set_device_unmounted(self, device: dict):
|
|
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 _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()
|
|
self.export_button.setEnabled(False)
|
|
self.export_progress.setVisible(False)
|
|
self.export_status.setVisible(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
|
|
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
|
|
|
|
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)
|
|
if track_data and "pid" in track_data:
|
|
success = db.delete_track(track_data["pid"])
|
|
if success:
|
|
removed += 1
|
|
|
|
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_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
|
|
|
|
try:
|
|
from ipod_nano7_db import Nano7Database
|
|
db = Nano7Database(self.current_mount_point)
|
|
removed = db.remove_duplicates()
|
|
|
|
if not removed:
|
|
QMessageBox.information(self, "Scan Complete", "No duplicate tracks found.")
|
|
return
|
|
|
|
details = f"Found and removed {len(removed)} duplicate entry(ies):\n\n"
|
|
for r in removed[:15]:
|
|
details += f" {r['artist']} \u2014 {r['title']} ({r['album']})\n"
|
|
if len(removed) > 15:
|
|
details += f" ... and {len(removed) - 15} more\n"
|
|
|
|
QMessageBox.information(self, "Duplicates Removed", details)
|
|
self._load_ipod_tracks()
|
|
except Exception as e:
|
|
logger.error(f"Failed to remove duplicates: {e}")
|
|
QMessageBox.warning(self, "Error", f"Failed to remove duplicates: {e}")
|
|
|
|
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()
|
|
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")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_setup_linux_theming()
|
|
|
|
app = QApplication(sys.argv)
|
|
|
|
# Apply Fusion as fallback style if no platform theme was applied
|
|
if not os.environ.get("QT_QPA_PLATFORMTHEME"):
|
|
app.setStyle("Fusion")
|
|
|
|
window = MainWindow()
|
|
window.show()
|
|
sys.exit(app.exec())
|