#!/usr/bin/env python3 """ Library Tab for neo-pod-desktop. Provides the library UI, playback controls, and transfer/conversion workflows. """ import os import sys import time import logging from typing import List, Dict, Optional, Tuple, Any from pathlib import Path from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog, QMessageBox, QGroupBox, QHeaderView, QTableWidget, QTableWidgetItem, QSlider, QMenu, QInputDialog, QStackedWidget, QListWidget, QListWidgetItem, QListView, ) from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSize from PyQt6.QtGui import QPixmap, QColor, QIcon, QPainter, QPainterPath from track_info import TrackInfo from metadata_handler import MetadataHandler from ui.metadata_editor import MetadataEditorDialog from library_cache import get_library_cache, invalidate_cache_for_path, save_library_cache from artwork.cache import cover_cache from config_loader import ConfigLoader from worker import WorkerThread logger = logging.getLogger(__name__) AUDIO_EXTS = {'.m4a', '.mp3', '.aac'} SOURCE_EXTS = {'.flac', '.wav', '.ogg', '.opus', '.wma', '.ape', '.aiff', '.alac'} EQ_FRAMES = [ "\u2581\u2583\u2585", "\u2582\u2585\u2587", "\u2583\u2587\u2586", "\u2585\u2586\u2584", "\u2586\u2584\u2583", "\u2587\u2583\u2582", "\u2586\u2582\u2581", "\u2585\u2581\u2583", "\u2583\u2582\u2585", "\u2581\u2583\u2587", "\u2582\u2585\u2586", "\u2583\u2587\u2584", "\u2585\u2586\u2583", "\u2586\u2584\u2581", ] class NumericTableItem(QTableWidgetItem): def __lt__(self, other): try: return int(self.text()) < int(other.text()) except ValueError: return super().__lt__(other) class LibraryTab(QWidget): """Library tab widget with library table, filtering, and transfer workflows.""" transfer_finished = pyqtSignal() tab_switch_requested = pyqtSignal(int) play_requested = pyqtSignal(dict, int, bool) def __init__(self, config_loader: ConfigLoader, parent=None): super().__init__(parent) self.config_loader = config_loader self.library_ready: List[Dict] = [] self.library_source_files: List[str] = [] self.library_scanned_sources: List[Dict] = [] self.current_playback_index: int = -1 self._current_track_path: str = "" self._eq_active: bool = False self._eq_timer: Optional[QTimer] = None self._eq_frame: int = 0 self._eq_running: bool = False self._halfway_passed: set[int] = set() self._current_view_mode: str = "table" self._current_filtered_album: str = "" self._current_playlist_id: str = "" self._library_toolbar_buttons: list = [] self.worker_thread: Optional[WorkerThread] = None self._current_mount_point: Optional[str] = None self._setup_ui() self._eq_timer = QTimer() self._eq_timer.setInterval(80) self._eq_timer.timeout.connect(self._tick_eq_animation) def set_mount_point(self, mount_point: Optional[str]): self._current_mount_point = mount_point def _setup_ui(self): layout = QVBoxLayout(self) self._setup_library_toolbar(layout) self.library_table = QTableWidget() self.library_table.setColumnCount(8) self.library_table.setHorizontalHeaderLabels( ["#", "Artist", "Title", "Album", "Duration", "Genre", "Size", "Plays"] ) 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) self.library_table.horizontalHeader().sortIndicatorChanged.connect(self._on_sort_changed) 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) header.setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents) self.view_stack = QStackedWidget() self.library_table.setParent(self.view_stack) self.view_stack.addWidget(self.library_table) self.album_grid = QListWidget() self.album_grid.setViewMode(QListView.ViewMode.IconMode) self.album_grid.setIconSize(QSize(140, 140)) self.album_grid.setGridSize(QSize(170, 210)) self.album_grid.setWordWrap(True) self.album_grid.setResizeMode(QListWidget.ResizeMode.Adjust) self.album_grid.setSpacing(8) self.album_grid.setMovement(QListWidget.Movement.Static) self.album_grid.setStyleSheet("QListWidget { padding: 24px 12px 12px 12px; }") self.album_grid.setSelectionMode(QListWidget.SelectionMode.SingleSelection) self.album_grid.setFlow(QListWidget.Flow.LeftToRight) self.album_grid.setWrapping(True) self.album_grid.itemClicked.connect(self._on_album_clicked) self.view_stack.addWidget(self.album_grid) self.view_stack.setCurrentIndex(0) layout.addWidget(self.view_stack, stretch=1) self.library_progress = QProgressBar() self.library_progress.setRange(0, 100) self.library_progress.setVisible(False) layout.addWidget(self.library_progress) self.library_status = QLabel("Ready") self.library_status.setVisible(False) 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_sync_btn = QPushButton("\uD83D\uDD04 Sync Metadata") self.library_sync_btn.setEnabled(False) self.library_sync_btn.setToolTip("Sync metadata and artwork for all tracks on iPod") self.library_sync_btn.clicked.connect(self._on_sync_metadata_clicked) toolbar.addWidget(self.library_sync_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) self.library_edit_btn = QPushButton("\u270F\uFE0F Edit") self.library_edit_btn.setEnabled(False) self.library_edit_btn.clicked.connect(self._on_edit_metadata) toolbar.addWidget(self.library_edit_btn) refresh_btn = QPushButton("\uD83D\uDD04") refresh_btn.setToolTip("Refresh Library") refresh_btn.clicked.connect(self._on_refresh_library_clicked) toolbar.addWidget(refresh_btn) self._library_toolbar_buttons = [ self.library_add_btn, self.library_convert_btn, self.library_transfer_btn, self.library_sync_btn, self.library_remove_btn, self.library_edit_btn, 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 play_track_at_index(self, index: int, start_position_ms: int = 0, auto_play: bool = True): 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 old_index = self.current_playback_index self._clear_playing_row(old_index) self._halfway_passed.discard(old_index) self.current_playback_index = index self._current_track_path = track_data["path"] self.library_table.clearSelection() self.library_table.selectRow(index) self._set_playing_row_bg(index) if auto_play: self._start_eq_animation() self.play_requested.emit(track_data, start_position_ms, auto_play) def on_position_changed(self, position_ms: int, duration_ms: int): if duration_ms > 0 and position_ms >= duration_ms * 0.5: self._halfway_passed.add(self.current_playback_index) def on_track_ended(self): self._increment_play_count() self._stop_eq_animation() def on_track_paused(self): self._stop_eq_animation() def on_playing_changed(self, is_playing: bool): self._eq_active = is_playing if is_playing: self._start_eq_animation() else: self._stop_eq_animation() def _focus_search(self): self.library_search_input.setFocus() self.library_search_input.selectAll() def _select_all_current(self): self.library_table.selectAll() def _delete_selected_current(self): self._on_library_remove_selected() def _increment_play_count(self): idx = self.current_playback_index if idx not in self._halfway_passed: return if idx < 0 or idx >= self.library_table.rowCount(): return data_item = self.library_table.item(idx, 0) if data_item is None: return _is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) if not track_data: return path = track_data.get("path", "") if not path: return cache = get_library_cache() entry = cache.get(path) if entry is None: entry = track_data.copy() entry["play_count"] = entry.get("play_count", 0) + 1 entry["last_played"] = int(time.time()) cache.put(path, entry) save_library_cache() self._halfway_passed.discard(idx) plays_item = self.library_table.item(idx, 7) if plays_item: plays_item.setText(str(entry["play_count"])) def _start_eq_animation(self): if not self._eq_timer or self._eq_running: return self._eq_running = True self._eq_frame = 0 self._tick_eq_animation() self._eq_timer.start() def _stop_eq_animation(self): if not self._eq_timer: return self._eq_timer.stop() self._eq_running = False idx = self._find_row_by_path(self._current_track_path) if self._current_track_path else -1 if idx >= 0: item = self.library_table.item(idx, 0) if item: is_ready, data = item.data(Qt.ItemDataRole.UserRole) track_num = data.get("track_num", 0) if data else 0 item.setText(str(track_num) if (is_ready and track_num) else "") item.setData(Qt.ItemDataRole.ForegroundRole, None) def _tick_eq_animation(self): if not self._current_track_path: return idx = self._find_row_by_path(self._current_track_path) if idx < 0: return item = self.library_table.item(idx, 0) if item is None: return glyph = EQ_FRAMES[self._eq_frame % len(EQ_FRAMES)] self._eq_frame += 1 item.setText(glyph) accent = self.palette().color(self.palette().ColorRole.Highlight) item.setForeground(accent) def _set_playing_row_bg(self, row: int): if row < 0 or row >= self.library_table.rowCount(): return bg = QColor(self.palette().color(self.palette().ColorRole.Highlight)) bg.setAlpha(50) for col in range(self.library_table.columnCount()): item = self.library_table.item(row, col) if item: item.setBackground(bg) def _clear_playing_row_bg(self, row: int): if row < 0 or row >= self.library_table.rowCount(): return for col in range(self.library_table.columnCount()): item = self.library_table.item(row, col) if item: item.setData(Qt.ItemDataRole.BackgroundRole, None) def _clear_playing_row(self, row: int): if row < 0 or row >= self.library_table.rowCount(): return for col in range(self.library_table.columnCount()): item = self.library_table.item(row, col) if item: item.setData(Qt.ItemDataRole.BackgroundRole, None) self._eq_timer.stop() if self._eq_timer else None self._eq_running = False item0 = self.library_table.item(row, 0) if item0: is_ready, data = item0.data(Qt.ItemDataRole.UserRole) track_num = data.get("track_num", 0) if data else 0 item0.setText(str(track_num) if (is_ready and track_num) else "") item0.setData(Qt.ItemDataRole.ForegroundRole, None) def _find_row_by_path(self, path: str) -> int: if not path: return -1 for row in range(self.library_table.rowCount()): item = self.library_table.item(row, 0) if item is None: continue _, data = item.data(Qt.ItemDataRole.UserRole) if data and data.get("path", "") == path: return row return -1 def _on_sort_changed(self, _column: int, _order: int): if self._current_track_path: idx = self._find_row_by_path(self._current_track_path) if idx >= 0: self.current_playback_index = idx self._set_playing_row_bg(idx) def _on_table_double_clicked(self, row: int): self.play_track_at_index(row) 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, force: bool = False): output_dir = self.get_output_dir() if not output_dir or not os.path.isdir(output_dir): self.library_ready.clear() self.library_scanned_sources = [] self._refresh_library_ui() return ready = [] sources = [] cache_hits = 0 cache_misses = 0 cache = get_library_cache() 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 cached = None if force else cache.get(fpath) if cached is not None: cache_hits += 1 if ext in AUDIO_EXTS: cached.pop("_mtime", None) ready.append(cached) elif ext in SOURCE_EXTS: cached.pop("_mtime", None) sources.append(cached) continue cache_misses += 1 if ext in AUDIO_EXTS: track_data = self._extract_ready_track(fpath, fname, handler) ready.append(track_data) cache.put(fpath, track_data) elif ext in SOURCE_EXTS: track_data = self._extract_source_track(fpath, fname, handler) sources.append(track_data) cache.put(fpath, track_data) cache.save() if cache_misses: self.library_status.setText( f"Scanned {cache_misses} file(s) \u2014 {cache_hits} from cache") 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 _extract_ready_track(self, fpath: str, fname: str, handler) -> Dict[str, Any]: 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 album_artist = info.album_artist if info else "" except Exception: duration = 0 artist = "Unknown" title = fname album = "" genre = "" track_num = 0 album_artist = "" return { "path": fpath, "title": title, "artist": artist, "album": album, "duration_s": duration, "size": size, "genre": genre, "track_num": track_num, "cover_path": cover_path, "album_artist": album_artist, } def _extract_source_track(self, fpath: str, fname: str, handler) -> Dict[str, Any]: 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 return { "path": fpath, "title": title, "artist": artist, "size": size, "track_num": track_num, "cover_path": cover_path, } def _refresh_library_ui(self): self.library_table.setSortingEnabled(False) self.library_table.setRowCount(0) seen_paths = set() all_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"}) 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"}) 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 = [ NumericTableItem(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"])), QTableWidgetItem(str(track.get("play_count", "0") or "0") if is_ready else ""), ] if not is_ready: gray = self.palette().color(self.palette().ColorRole.PlaceholderText) for item in cells: item.setForeground(gray) 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 has_mount = self._current_mount_point is not None self.library_transfer_btn.setEnabled(has_ready and has_mount) self.library_sync_btn.setEnabled(has_ready and has_mount) self.library_convert_btn.setEnabled(has_source) self.library_remove_btn.setEnabled(len(all_tracks) > 0) self.library_edit_btn.setEnabled(len(all_tracks) > 0) 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) self._restore_playing_row() self._build_album_grid() def _restore_playing_row(self): idx = self.current_playback_index if idx < 0: return track_path = self._current_track_path if not track_path and idx < self.library_table.rowCount(): item = self.library_table.item(idx, 0) if item: _, data = item.data(Qt.ItemDataRole.UserRole) if data: track_path = data.get("path", "") if not track_path: self.current_playback_index = -1 return for row in range(self.library_table.rowCount()): item = self.library_table.item(row, 0) if item is None: continue _, data = item.data(Qt.ItemDataRole.UserRole) if data and data.get("path") == track_path: self.current_playback_index = row self._set_playing_row_bg(row) if self._eq_active: self._start_eq_animation() return self.current_playback_index = -1 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( source_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_refresh_library_clicked(self): self.library_status.setText("Scanning library...") self._scan_library(force=True) 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 _incremental_update_after_edit(self, file_paths: list): cache = get_library_cache() handler = MetadataHandler() paths_set = set(file_paths) for fpath in file_paths: ext = os.path.splitext(fpath)[1].lower() if ext in AUDIO_EXTS: data = self._extract_ready_track(fpath, os.path.basename(fpath), handler) cache.put(fpath, data) for item in self.library_ready: if item["path"] == fpath: item.update(data) break else: self.library_ready.append(data) elif ext in SOURCE_EXTS: data = self._extract_source_track(fpath, os.path.basename(fpath), handler) cache.put(fpath, data) for item in self.library_scanned_sources: if item["path"] == fpath: item.update(data) break else: self.library_scanned_sources.append(data) cache.save() self._refresh_library_ui() def _on_edit_metadata(self): selected_rows = set() for item in self.library_table.selectedItems(): selected_rows.add(item.row()) if not selected_rows: QMessageBox.information(self, "Info", "No track selected") return file_paths = [] for row in sorted(selected_rows): data_item = self.library_table.item(row, 0) if data_item is None: continue is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) file_path = track_data.get("path", "") if file_path and os.path.exists(file_path): file_paths.append(file_path) if not file_paths: QMessageBox.warning(self, "Error", "No valid files found on disk") return dialog = MetadataEditorDialog(file_paths, self) if dialog.exec() == MetadataEditorDialog.DialogCode.Accepted: self._incremental_update_after_edit(file_paths) def _get_first_selected_row(self): for item in self.library_table.selectedItems(): return item.row() return None def _on_library_remove_selected(self): 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}") for f in files_to_delete: invalidate_cache_for_path(f["path"]) self._scan_library() QMessageBox.information(self, "Done", f"Deleted {deleted} file(s)") def _on_search_text_changed(self, text: str): query = text.lower() for row in range(self.library_table.rowCount()): if not query: self.library_table.setRowHidden(row, False) continue match = False for col in (1, 2, 3): item = self.library_table.item(row, col) if item and query in item.text().lower(): match = True break 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_at_index(row)) if is_ready and self._current_mount_point is not None: sync_action = menu.addAction("\uD83D\uDD04 Sync Metadata to iPod") sync_action.triggered.connect(self._on_sync_metadata_clicked) 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) add_pl_menu = menu.addMenu("Add to Playlist") playlist_manager = getattr(self, "_playlist_manager", None) if playlist_manager is not None: for pl in playlist_manager.get_all(): pl_action = add_pl_menu.addAction(f"\U0001F4CB {pl.name}") pl_action.triggered.connect( lambda checked, pid=pl.id: self._add_selected_to_playlist(pid) ) add_pl_menu.addSeparator() new_pl_action = add_pl_menu.addAction("\u2795 New Playlist...") new_pl_action.triggered.connect(self._add_selected_to_new_playlist) else: add_pl_menu.setEnabled(False) menu.addSeparator() edit_action = menu.addAction("\u270F\uFE0F Edit Metadata") edit_action.triggered.connect(lambda: self._on_edit_metadata()) 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 _add_selected_to_playlist(self, playlist_id: str): paths = self._get_selected_track_paths() if not paths: return playlist_manager = getattr(self, "_playlist_manager", None) if playlist_manager: playlist_manager.add_tracks(playlist_id, paths) def _add_selected_to_new_playlist(self): from PyQt6.QtWidgets import QInputDialog name, ok = QInputDialog.getText(self, "New Playlist", "Playlist name:") if not (ok and name.strip()): return name = name.strip() playlist_manager = getattr(self, "_playlist_manager", None) if playlist_manager is None: return paths = self._get_selected_track_paths() playlist_id = playlist_manager.create(name) if paths: playlist_manager.add_tracks(playlist_id, paths) self._notify_sidebar_playlist_created(playlist_id, name) def _notify_sidebar_playlist_created(self, playlist_id: str, name: str): parent = self.parent() while parent is not None: sidebar = getattr(parent, "sidebar", None) if sidebar is not None: sidebar.add_playlist_item(playlist_id, name) break parent = parent.parent() 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_library_convert_clicked(self): source_files = self._get_selected_source_files() if not source_files: QMessageBox.warning( self, "Error", "No tracks to convert. Add local source files 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.get_output_dir() 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": [], "local_files": source_files, "output_dir": output_dir, "format": self.get_convert_format(), "quality": self.get_convert_quality() } 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() if result: converted_source_paths = set() for track, _output_path in result: src = getattr(track, "download_path", None) if src: converted_source_paths.add(os.path.normpath(src)) if converted_source_paths: self.library_scanned_sources = [ s for s in self.library_scanned_sources if os.path.normpath(s.get("path", "")) not in converted_source_paths ] self.library_source_files = [ p for p in self.library_source_files if os.path.normpath(p) not in converted_source_paths ] self._refresh_library_ui() 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.transfer_finished.emit() 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_sync_metadata_clicked(self): selected_tracks = self._get_selected_ready_tracks() if not selected_tracks: QMessageBox.warning(self, "Error", "No tracks selected.") return if not self._current_mount_point: QMessageBox.warning(self, "Error", "No iPod device mounted.") return if self.worker_thread and self.worker_thread.isRunning(): QMessageBox.information(self, "Info", "Please wait for the current task to finish") return self.library_sync_btn.setEnabled(False) self.library_status.setText("Matching tracks on iPod...") self.library_progress.setValue(0) from ipod_nano7_db import Nano7Database, content_hash as compute_content_hash db = Nano7Database(self._current_mount_point) ipod_tracks = db.get_all_tracks() ipod_by_hash = {t.get("content_hash", ""): t for t in ipod_tracks if t.get("content_hash")} sync_batch = [] errors = [] for track_info, local_path in selected_tracks: ch = compute_content_hash(local_path) ipod_track = ipod_by_hash.get(ch) if ipod_track is None: ipod_track = db.find_track_by_metadata( track_info.title, track_info.artist, track_info.album or "", ) if ipod_track is None: errors.append(f"'{track_info.title}' — not found on iPod") continue sync_batch.append({ "ipod_pid": ipod_track["pid"], "local_path": local_path, "new_metadata": { "title": track_info.title, "artist": track_info.artist, "album": track_info.album or "", "track_number": track_info.track_number or 0, "genre": track_info.genre or "", }, }) if not sync_batch: msg = "No matching tracks found on iPod" if errors: msg += ":\n" + "\n".join(errors[:5]) QMessageBox.warning(self, "Sync Error", msg) return if errors: logger.warning("Sync metadata skipped for %d unmatched track(s)", len(errors)) self.worker_thread = WorkerThread( task_type="sync_metadata", sync_batch=sync_batch, mount_point=self._current_mount_point, ) self.worker_thread.progress_signal.connect(self._on_library_progress) self.worker_thread.finished_signal.connect(self._on_sync_metadata_finished) self.worker_thread.start() def _on_sync_metadata_finished(self, success, message, result): self.library_sync_btn.setEnabled(True) if success: self.library_status.setText(message) QMessageBox.information(self, "Sync Complete", message) else: self.library_status.setText(f"Error: {message}") QMessageBox.warning(self, "Sync Error", message) self._cleanup_worker() def _cleanup_worker(self): if self.worker_thread: self.worker_thread.wait(3000) self.worker_thread.deleteLater() self.worker_thread = None def on_device_mounted(self, mount_point: str): self._current_mount_point = mount_point self.library_progress.setVisible(True) self.library_status.setVisible(True) def on_device_unmounted(self): self._current_mount_point = None self.library_transfer_btn.setEnabled(False) self.library_sync_btn.setEnabled(False) self.library_progress.setVisible(False) self.library_status.setVisible(False) def set_playlist_manager(self, playlist_manager): self._playlist_manager = playlist_manager def _get_selected_track_paths(self) -> list: paths = [] for item in self.library_table.selectedItems(): data_item = self.library_table.item(item.row(), 0) if data_item is None: continue _is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) if track_data and track_data.get("path"): paths.append(track_data["path"]) return list(set(paths)) def apply_filter(self, kind: str, value): if kind == "playlist": self._apply_playlist_filter(value) def set_view_mode(self, mode: str): """mode: 'table', 'recent', 'artists', 'albums', 'songs'""" self._current_view_mode = mode self._current_filtered_album = "" self._current_playlist_id = "" self.view_stack.setCurrentIndex(0) self._unhide_all_rows() if mode == "albums": self.view_stack.setCurrentIndex(1) self.album_grid.clearSelection() elif mode == "artists": self.library_table.sortItems(1, Qt.SortOrder.AscendingOrder) elif mode == "songs": self.library_table.sortItems(2, Qt.SortOrder.AscendingOrder) elif mode == "recent": cutoff = int(time.time()) - (30 * 86400) cache = get_library_cache() for row in range(self.library_table.rowCount()): data_item = self.library_table.item(row, 0) if data_item is None: continue _is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) path = track_data.get("path", "") if track_data else "" entry = cache.get(path) if path else None visible = False if entry and isinstance(entry, dict): added = entry.get("_mtime", 0) or entry.get("added_at", 0) or 0 visible = added >= cutoff self.library_table.setRowHidden(row, not visible) def clear_filter(self): self._unhide_all_rows() def _unhide_all_rows(self): for row in range(self.library_table.rowCount()): self.library_table.setRowHidden(row, False) def _round_pixmap(self, source: QPixmap, radius: int) -> QPixmap: size = min(source.width(), source.height()) result = QPixmap(size, size) result.fill(Qt.GlobalColor.transparent) p = QPainter(result) p.setRenderHint(QPainter.RenderHint.Antialiasing) path = QPainterPath() path.addRoundedRect(0, 0, size, size, radius, radius) p.setClipPath(path) p.drawPixmap(0, 0, source.scaled(size, size, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation)) p.end() return result def _build_album_grid(self): self.album_grid.clear() albums = {} for track in self.library_ready: album = track.get("album", "") or "Unknown Album" key = album.lower() if key not in albums: albums[key] = (album, track.get("cover_path", "")) for track in self.library_ready: album = track.get("album", "") or "Unknown Album" key = album.lower() if key in albums and not albums[key][1]: albums[key] = (album, track.get("cover_path", "")) for album, cover_path in albums.values(): pixmap = None if cover_path and os.path.exists(cover_path): pixmap = QPixmap(cover_path) if pixmap is None or pixmap.isNull(): for track in self.library_ready: t_album = track.get("album", "") or "Unknown Album" if t_album.lower() == album.lower() and track.get("cover_path"): cached = cover_cache.get( track.get("album_artist") or track.get("artist", ""), album, ) if cached and os.path.exists(cached): pixmap = QPixmap(cached) break if pixmap is None or pixmap.isNull(): pixmap = QPixmap(140, 140) clr = self.palette().color(self.palette().ColorRole.Midlight) pixmap.fill(clr) scaled = pixmap.scaled(140, 140, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation) rounded = self._round_pixmap(scaled, 8) icon = QIcon(rounded) item = QListWidgetItem(icon, album) item.setData(Qt.ItemDataRole.UserRole, album) self.album_grid.addItem(item) def _on_album_clicked(self, item: QListWidgetItem): album = item.data(Qt.ItemDataRole.UserRole) self.album_grid.clearSelection() self.filter_by_album(album) def filter_by_album(self, album: str): if not album: return self._current_filtered_album = album self._current_view_mode = "albums" self.view_stack.setCurrentIndex(0) album_lower = album.lower() for row in range(self.library_table.rowCount()): t_item = self.library_table.item(row, 0) if t_item is None: self.library_table.setRowHidden(row, False) continue _is_ready, track_data = t_item.data(Qt.ItemDataRole.UserRole) if not track_data: self.library_table.setRowHidden(row, True) continue t_album = (track_data.get("album") or "").lower() self.library_table.setRowHidden(row, t_album != album_lower) def _apply_playlist_filter(self, track_paths): path_set = set(os.path.normpath(p) for p in track_paths) for row in range(self.library_table.rowCount()): data_item = self.library_table.item(row, 0) if data_item is None: self.library_table.setRowHidden(row, False) continue _is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) row_path = os.path.normpath(track_data.get("path", "")) if track_data else "" self.library_table.setRowHidden(row, row_path not in path_set) def set_buttons_visible(self, visible: bool): for btn in self._library_toolbar_buttons: btn.setVisible(visible) def get_output_dir(self): return self.config_loader.get("General", "output_dir", fallback=os.path.join(os.path.expanduser("~"), "Music", "iPod")) def get_convert_format(self): return self.config_loader.get("General", "format", fallback="m4a") def get_convert_quality(self): return self.config_loader.get_int("General", "quality", fallback=256) def set_current_playlist_id(self, playlist_id: str): self._current_playlist_id = playlist_id self._current_view_mode = "playlist" def get_state(self) -> dict: return { "view_mode": self._current_view_mode, "filtered_album": self._current_filtered_album, "filtered_playlist_id": self._current_playlist_id, }