- New: sidebar_widget.py — iTunes-like sidebar with library/playlist/nav sections, GTK palette - New: player_header.py — global player header (cover, now playing, controls) above splitter - New: playlist_manager.py — playlist CRUD with JSON persistence - Refactor: moved QMediaPlayer from LibraryTab to MainWindow for clean architecture - Refactor: LibraryTab — removed all player code, added play_requested signal - Feat: album grid (IconMode) — grouped by album name, rounded covers 8px, Spotify-like - Feat: NumericTableItem — numeric sort for track # column (1,2,3...10,11 not 1,10,2) - Feat: track_num animation fixed — finds row by path, not index (no freeze on sort) - Feat: album_artist tag extracted and stored in TrackInfo - Feat: state persistence — view_mode/filtered_album saved to config.ini on close - Feat: Ctrl+B toggle sidebar - Fix: sidebar splitter handle colour from GTK palette
1327 lines
51 KiB
Python
1327 lines
51 KiB
Python
#!/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,
|
|
}
|