diff options
Diffstat (limited to 'tidal_dl_ng/gui')
| -rw-r--r-- | tidal_dl_ng/gui/activate.py | 5 | ||||
| -rw-r--r-- | tidal_dl_ng/gui/dialog_playlist_manager.py | 327 | ||||
| -rw-r--r-- | tidal_dl_ng/gui/initialization.py | 1 | ||||
| -rw-r--r-- | tidal_dl_ng/gui/main_window.py | 10 | ||||
| -rw-r--r-- | tidal_dl_ng/gui/playlist_membership.py | 725 | ||||
| -rw-r--r-- | tidal_dl_ng/gui/playlist_membership_mixin.py | 280 | ||||
| -rw-r--r-- | tidal_dl_ng/gui/tidal_session.py | 2 | ||||
| -rw-r--r-- | tidal_dl_ng/gui/trees_results.py | 5 |
8 files changed, 1351 insertions, 4 deletions
diff --git a/tidal_dl_ng/gui/activate.py b/tidal_dl_ng/gui/activate.py index 4de3160..c04ab56 100644 --- a/tidal_dl_ng/gui/activate.py +++ b/tidal_dl_ng/gui/activate.py @@ -54,10 +54,9 @@ def gui_activate(tidal: Tidal | None = None): window = MainWindow(tidal=tidal) window.show() - # Check for updates - window.s_update_check.emit(True) - sys.exit(app.exec()) + exit_code = app.exec() + sys.exit(exit_code) if __name__ == "__main__": diff --git a/tidal_dl_ng/gui/dialog_playlist_manager.py b/tidal_dl_ng/gui/dialog_playlist_manager.py new file mode 100644 index 0000000..7d61306 --- /dev/null +++ b/tidal_dl_ng/gui/dialog_playlist_manager.py @@ -0,0 +1,327 @@ +"""Playlist Manager Dialog - UI for managing track membership in playlists. + +This module provides a modal dialog for users to quickly add/remove tracks +from their playlists without leaving the application. + +Design: +- Displays all user playlists as checkboxes +- Pre-populated with current membership state from cache +- Handles add/remove transactions with rollback on error +- Provides visual feedback (loading spinner, success/error notifications) +""" + +from PySide6 import QtCore, QtGui, QtWidgets +from requests.exceptions import RequestException +from tidalapi import Session, Track + +from tidal_dl_ng.gui.playlist_membership import ThreadSafePlaylistCache +from tidal_dl_ng.helper.playlist_api import add_track_to_playlist, remove_track_from_playlist +from tidal_dl_ng.logger import logger_gui +from tidal_dl_ng.ui.dialog_playlist_manager import Ui_DialogPlaylistManager +from tidal_dl_ng.worker import Worker + + +class PlaylistManagerDialog(QtWidgets.QDialog): + """Modal dialog for managing track membership in playlists. + + Displays current user playlists with checkboxes indicating whether + the track is currently in each playlist. Users can check/uncheck + to add/remove tracks with immediate visual feedback. + + Features: + - Thread-safe API calls (no main thread blocking) + - Optimistic UI updates with rollback on error + - Toast notifications for user feedback + - Alphabetical playlist sorting + + Example: + dialog = PlaylistManagerDialog( + track=track, + cache=cache, + session=tidal.session, + threadpool=main_window.threadpool, + parent=main_window + ) + dialog.playlist_changed.connect(on_playlist_changed) + dialog.exec() + """ + + # Signals + playlist_added: QtCore.Signal = QtCore.Signal(str, str) # track_id, playlist_id + playlist_removed: QtCore.Signal = QtCore.Signal(str, str) + + def __init__( + self, + track: Track, + cache: ThreadSafePlaylistCache, + session: Session, + threadpool: QtCore.QThreadPool, + parent: QtWidgets.QWidget | None = None, + ) -> None: + """Initialize the playlist manager dialog. + + Args: + track: The track to manage playlists for + cache: Thread-safe playlist cache with pre-loaded data + session: Authenticated Tidal session for API calls + threadpool: QThreadPool for background tasks + parent: Parent widget + """ + super().__init__(parent) + self.track: Track = track + self.cache: ThreadSafePlaylistCache = cache + self.session: Session = session + self.threadpool: QtCore.QThreadPool = threadpool + + # Store current states for rollback + self._original_states: dict[str, bool] = {} + self._pending_tasks: dict[str, Worker] = {} + + # Use compiled .ui + self.ui = Ui_DialogPlaylistManager() + self.ui.setupUi(self) + + # Set dynamic title with track name + track_title: str = getattr(self.track, "name", "Unknown Track") + self.ui.labelTitle.setText( + f'Gérer les playlists pour : <b><span style="color:#1e88e5;">{track_title}</span></b>' + ) + + # Populate playlists list into verticalLayoutList + self._populate_playlists_ui() + + def _populate_playlists_ui(self) -> None: + """Populate dialog with user playlists from cache. + + Fetches all playlists from cache, sorts alphabetically, + and creates checkbox items with current membership state. + """ + # Get all playlist IDs from cache + all_playlist_ids: set[str] = self.cache.get_all_playlists() + + # Hide empty label if we have playlists + self.ui.labelEmpty.setVisible(len(all_playlist_ids) == 0) + if not all_playlist_ids: + return + + # Sort playlists alphabetically by name/ID + sorted_playlist_ids: list[str] = sorted( + all_playlist_ids, + key=lambda pid: ( + self.cache.get_playlist_metadata(pid).get("name", pid).lower() + if self.cache.get_playlist_metadata(pid) + else pid.lower() + ), + ) + + # Create checkbox for each playlist + track_id: str = str(self.track.id) + + for playlist_id in sorted_playlist_ids: + # Get playlist info + metadata: dict | None = self.cache.get_playlist_metadata(playlist_id) + + if metadata is None: + # Try to fetch metadata from Tidal session as a fallback + try: + playlist_obj = self.session.playlist(playlist_id) + if playlist_obj: + fetched_name = getattr(playlist_obj, "name", None) or f"Playlist {playlist_id}" + fetched_count = getattr(playlist_obj, "num_tracks", None) + item_count_val = int(fetched_count) if isinstance(fetched_count, int) else 0 + # Store back into cache for future lookups + self.cache.set_playlist_metadata(playlist_id, fetched_name, item_count_val) + metadata = {"name": fetched_name, "item_count": item_count_val, "id": str(playlist_id)} + logger_gui.debug( + f"(i) Fetched missing metadata for playlist '{playlist_id}': name='{fetched_name}', count={item_count_val}" + ) + else: + logger_gui.warning(f"⚠️ No metadata found for playlist {playlist_id} (session returned None)") + except Exception as e: + logger_gui.warning(f"⚠️ Failed to fetch metadata for playlist {playlist_id}: {e}") + + playlist_name: str = ( + metadata.get("name", f"Playlist {playlist_id}") if metadata else f"Playlist {playlist_id}" + ) + item_count: int = metadata.get("item_count", 0) if metadata else 0 + + # Check if track is in this playlist + is_in_playlist: bool = self.cache.is_track_in_playlist(track_id, playlist_id) + self._original_states[playlist_id] = is_in_playlist + + # Row widget + row_widget = QtWidgets.QWidget() + row_layout = QtWidgets.QHBoxLayout(row_widget) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(8) + + # Checkbox + checkbox: QtWidgets.QCheckBox = QtWidgets.QCheckBox() + checkbox.setChecked(is_in_playlist) + checkbox.setProperty("playlist_id", playlist_id) + checkbox.stateChanged.connect( + lambda state, cbox=checkbox, pid=playlist_id: (self._on_playlist_checkbox_changed(cbox, pid, state)) + ) + row_layout.addWidget(checkbox) + + # Playlist name + name_label: QtWidgets.QLabel = QtWidgets.QLabel(playlist_name) + row_layout.addWidget(name_label) + + # Item count + count_label: QtWidgets.QLabel = QtWidgets.QLabel(f"({item_count})") + count_label.setStyleSheet("color: gray; font-size: 0.85em;") + row_layout.addWidget(count_label) + + # Stretch + row_layout.addStretch() + + self.ui.verticalLayoutList.insertWidget(self.ui.verticalLayoutList.count() - 1, row_widget) # before spacer + + def _on_playlist_checkbox_changed(self, checkbox: QtWidgets.QCheckBox, playlist_id: str, state: int) -> None: + """Handle checkbox state change for a playlist. + + Implements transactional logic: + 1. Disable checkbox and show spinner + 2. Make API call (POST/DELETE) + 3. On success: update cache, re-enable + 4. On error: rollback state, show toast + + Args: + checkbox: The checkbox widget + playlist_id: ID of the playlist + state: Qt CheckState (2=checked, 0=unchecked) + """ + is_checked: bool = state == QtCore.Qt.CheckState.Checked.value + + # Save previous state for rollback + previous_state: bool = not is_checked + + # Disable UI during transaction + checkbox.setEnabled(False) + + # Start transaction + track_id: str = str(self.track.id) + + if is_checked: + # Add track to playlist + worker: Worker = Worker( + self._api_add_track_to_playlist, + track_id, + playlist_id, + checkbox, + previous_state, + ) + else: + # Remove track from playlist + worker: Worker = Worker( + self._api_remove_track_from_playlist, + track_id, + playlist_id, + checkbox, + previous_state, + ) + + # Store worker reference for potential cancellation + self._pending_tasks[f"{playlist_id}"] = worker + self.threadpool.start(worker) + + def _api_add_track_to_playlist( + self, track_id: str, playlist_id: str, checkbox: QtWidgets.QCheckBox, previous_state: bool + ) -> None: + """API call to add track to playlist (runs in worker thread). + + Args: + track_id: Track UUID + playlist_id: Playlist UUID + checkbox: Checkbox widget to update on completion + previous_state: Previous checkbox state for rollback + """ + try: + # Use centralized API helper + add_track_to_playlist(self.session, playlist_id, track_id) + + # Success: update cache and UI + self.cache.add_track_to_playlist(track_id, playlist_id) + self.playlist_added.emit(track_id, playlist_id) # Notify listeners + + # Re-enable checkbox + checkbox.setEnabled(True) + + except RequestException: + # Rollback: restore previous state + checkbox.blockSignals(True) + checkbox.setChecked(previous_state) + checkbox.blockSignals(False) + checkbox.setEnabled(True) + + # Show error notification (via statusbar or toast) + self._show_error_notification("Impossible d'ajouter à la playlist") + + except Exception: + checkbox.blockSignals(True) + checkbox.setChecked(previous_state) + checkbox.blockSignals(False) + checkbox.setEnabled(True) + self._show_error_notification("Erreur lors de la modification") + + def _api_remove_track_from_playlist( + self, track_id: str, playlist_id: str, checkbox: QtWidgets.QCheckBox, previous_state: bool + ) -> None: + """API call to remove track from playlist (runs in worker thread). + + Args: + track_id: Track UUID + playlist_id: Playlist UUID + checkbox: Checkbox widget to update on completion + previous_state: Previous checkbox state for rollback + """ + try: + # Use centralized API helper + remove_track_from_playlist(self.session, playlist_id, track_id) + + # Success: update cache and UI + self.cache.remove_track_from_playlist(track_id, playlist_id) + self.playlist_removed.emit(track_id, playlist_id) + + # Re-enable checkbox + checkbox.setEnabled(True) + + except RequestException: + # Rollback: restore previous state + checkbox.blockSignals(True) + checkbox.setChecked(previous_state) + checkbox.blockSignals(False) + checkbox.setEnabled(True) + + self._show_error_notification("Impossible de retirer de la playlist") + + except Exception: + checkbox.blockSignals(True) + checkbox.setChecked(previous_state) + checkbox.blockSignals(False) + checkbox.setEnabled(True) + self._show_error_notification("Erreur lors de la modification") + + def _show_error_notification(self, message: str) -> None: + """Show a non-intrusive error notification. + + Args: + message: Error message to display + + TODO: Integrate with app's notification system (Toast/Snackbar) + """ + # Silent - errors are shown via UI notifications + pass + + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + """Handle dialog close event. + + Cancels any pending operations. + + Args: + event: Close event + """ + # Cancel pending tasks (Worker doesn't have built-in abort, but we can clean up references) + self._pending_tasks.clear() + super().closeEvent(event) diff --git a/tidal_dl_ng/gui/initialization.py b/tidal_dl_ng/gui/initialization.py index 7c721a3..c65901b 100644 --- a/tidal_dl_ng/gui/initialization.py +++ b/tidal_dl_ng/gui/initialization.py @@ -127,6 +127,7 @@ class InitializationMixin: "Quality", "Date", "Downloaded?", + "Playlists", ] model.setColumnCount(len(labels_column)) model.setRowCount(0) diff --git a/tidal_dl_ng/gui/main_window.py b/tidal_dl_ng/gui/main_window.py index 33806dc..1ec4fa0 100644 --- a/tidal_dl_ng/gui/main_window.py +++ b/tidal_dl_ng/gui/main_window.py @@ -19,6 +19,7 @@ from tidal_dl_ng.gui.downloads import DownloadsMixin from tidal_dl_ng.gui.history import HistoryMixin from tidal_dl_ng.gui.initialization import InitializationMixin from tidal_dl_ng.gui.playlist import GuiPlaylistManager +from tidal_dl_ng.gui.playlist_membership_mixin import PlaylistMembershipMixin from tidal_dl_ng.gui.progress import ProgressMixin from tidal_dl_ng.gui.queue import GuiQueueManager from tidal_dl_ng.gui.search import GuiSearchManager @@ -52,6 +53,7 @@ class MainWindow( TreesResultsMixin, ContextMenusMixin, HistoryMixin, + PlaylistMembershipMixin, ): """Main application window for TIDAL Downloader Next Generation. @@ -169,7 +171,7 @@ class MainWindow( self.init_tidal(tidal) - logger_gui.debug("All setup.") + logger_gui.info("All setup.") def closeEvent(self, event: QtGui.QCloseEvent) -> None: """Handle the close event of the main window. @@ -177,6 +179,12 @@ class MainWindow( Args: event (QtGui.QCloseEvent): The close event. """ + logger_gui.warning("⚠️ CLOSE EVENT TRIGGERED!") + import traceback + + logger_gui.debug("Close event traceback:") + for line in traceback.format_stack(): + logger_gui.debug(line.strip()) # Save the main window size and position self.settings.data.window_x = self.x() self.settings.data.window_y = self.y() diff --git a/tidal_dl_ng/gui/playlist_membership.py b/tidal_dl_ng/gui/playlist_membership.py new file mode 100644 index 0000000..9066095 --- /dev/null +++ b/tidal_dl_ng/gui/playlist_membership.py @@ -0,0 +1,725 @@ +"""Playlist membership manager - Handles loading and caching playlist context. + +This module provides background loading of user playlists and track memberships +to enable instant interaction with the "Playlists" column in result views. + +Architecture: +- ThreadSafePlaylistCache: Thread-safe cache for track→playlist membership +- PlaylistContextLoader: QRunnable worker for fetching playlist data +- PlaylistColumnDelegate: Custom delegate for rendering the "Playlists" column +""" + +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from enum import StrEnum + +from PySide6 import QtCore, QtGui, QtWidgets +from requests.exceptions import RequestException +from tidalapi import Session + +from tidal_dl_ng.helper.playlist_api import ( + get_playlist_items, + get_playlist_metadata, + get_user_playlists, +) +from tidal_dl_ng.logger import logger_gui +from tidal_dl_ng.ui.spinner import QtWaitingSpinner + + +class PlaylistCellState(StrEnum): + """Enumeration of possible visual states for the playlist column cell.""" + + PENDING = "pending" # Loading spinner + READY = "ready" # Interactive button + ERROR = "error" # Warning icon + + +class ThreadSafePlaylistCache: + """Thread-safe cache for track membership in playlists. + + Stores mapping: track_id → Set[playlist_id] + Uses RLock for thread-safety and Set for O(1) lookups. + + Example: + cache = ThreadSafePlaylistCache() + cache.add_track_to_playlist("track_uuid_1", "playlist_uuid_2") + playlist_ids = cache.get_playlists_for_track("track_uuid_1") + # → {"playlist_uuid_2"} + """ + + def __init__(self) -> None: + """Initialize the cache with empty data and lock.""" + self._lock: threading.RLock = threading.RLock() + self._data: dict[str, set[str]] = {} + self._playlist_metadata: dict[str, dict[str, str | int]] = {} + + def add_track_to_playlist(self, track_id: str, playlist_id: str) -> None: + """Add a track to a playlist in the cache. + + Args: + track_id: The unique identifier of the track + playlist_id: The unique identifier of the playlist + + Thread-safe: Uses lock for atomic update. + """ + # Normalize IDs to strings + track_id = str(track_id) + playlist_id = str(playlist_id) + + with self._lock: + if track_id not in self._data: + self._data[track_id] = set() + self._data[track_id].add(playlist_id) + + def remove_track_from_playlist(self, track_id: str, playlist_id: str) -> None: + """Remove a track from a playlist in the cache. + + Args: + track_id: The unique identifier of the track + playlist_id: The unique identifier of the playlist + + Thread-safe: Uses lock for atomic update. + """ + # Normalize IDs to strings + track_id = str(track_id) + playlist_id = str(playlist_id) + + with self._lock: + if track_id in self._data: + self._data[track_id].discard(playlist_id) + if not self._data[track_id]: + del self._data[track_id] + + def get_playlists_for_track(self, track_id: str) -> set[str]: + """Get all playlists containing a specific track. + + Args: + track_id: The unique identifier of the track + + Returns: + Set of playlist IDs containing this track (empty set if not found) + + Complexity: O(1) + Thread-safe: Returns a copy to prevent external mutation. + """ + # Normalize ID to string + track_id = str(track_id) + + with self._lock: + return self._data.get(track_id, set()).copy() + + # === Adjouts pour gestion complète du cycle de vie du cache === + def clear(self) -> None: + """Clear all cached data (tracks + playlist metadata). + Utilisé pour désactiver le cache au premier chargement et repartir sur une base saine. + """ + with self._lock: + self._data.clear() + self._playlist_metadata.clear() + + def update_from_dict(self, cache: dict[str, set[str]]) -> None: + """Merge incoming cache mapping into the current cache. + + Args: + cache: Dict[track_id, Set[playlist_id]] + """ + if not isinstance(cache, dict): + return + with self._lock: + for tid, playlists in cache.items(): + tid_str = str(tid) + if tid_str not in self._data: + self._data[tid_str] = set() + # Normaliser les IDs playlist en str + for pid in playlists: + self._data[tid_str].add(str(pid)) + + def set_playlist_metadata(self, playlist_id: str, name: str, item_count: int) -> None: + """Store metadata for a playlist (name and item count).""" + with self._lock: + self._playlist_metadata[str(playlist_id)] = { + "name": str(name), + "item_count": int(item_count), + } + + def get_playlist_metadata(self, playlist_id: str) -> dict[str, str | int]: + """Retrieve stored metadata for a playlist (or defaults).""" + with self._lock: + return self._playlist_metadata.get(str(playlist_id), {"name": str(playlist_id), "item_count": 0}).copy() + + def get_playlist_name(self, playlist_id: str) -> str: + """Convenience accessor for playlist name.""" + return str(self.get_playlist_metadata(playlist_id).get("name", playlist_id)) + + def get_playlist_count(self, playlist_id: str) -> int: + """Convenience accessor for playlist item_count.""" + try: + return int(self.get_playlist_metadata(playlist_id).get("item_count", 0)) + except Exception: + return 0 + + def is_track_in_playlist(self, track_id: str, playlist_id: str) -> bool: + """Check if a track is in a specific playlist. + + Args: + track_id: The unique identifier of the track + playlist_id: The unique identifier of the playlist + + Returns: + True if track is in playlist, False otherwise + + Complexity: O(1) + """ + # Normalize IDs to strings + track_id = str(track_id) + playlist_id = str(playlist_id) + + with self._lock: + return playlist_id in self._data.get(track_id, set()) + + def clear_track_data(self) -> None: + """Clear only track→playlist mapping data, preserving metadata. + + Thread-safe: Uses lock for atomic clear. + Useful for refreshing track data without losing playlist names. + """ + with self._lock: + self._data.clear() + + def get_all_playlists(self) -> set[str]: + """Get all playlist IDs currently tracked in cache. + + Returns all playlists from metadata, not just those with tracks, + ensuring empty playlists are also included. + + Returns: + Set of all unique playlist IDs + """ + with self._lock: + # Return all playlist IDs from metadata (includes empty playlists) + # Fall back to track-based IDs if metadata not yet loaded + if self._playlist_metadata: + return set(self._playlist_metadata.keys()) + else: + # Fallback: return playlist IDs that have at least one track + all_ids: set[str] = set() + for playlist_ids in self._data.values(): + all_ids.update(playlist_ids) + return all_ids + + +class PlaylistLoaderSignals(QtCore.QObject): + """Signal emitter for PlaylistContextLoader. + + Separated because QRunnable doesn't inherit from QObject, + so we need a separate QObject to emit signals. + """ + + started = QtCore.Signal() + progress = QtCore.Signal(int, int) # current, total + cache_ready = QtCore.Signal(dict) # Track→Playlist cache + error = QtCore.Signal(str) # error message + finished = QtCore.Signal() + # New: playlist metadata (id -> {name, item_count}) + metadata_ready = QtCore.Signal(dict) + + +class PlaylistContextLoader(QtCore.QRunnable): + """Background worker for loading user playlist context. + + Fetches all user playlists and their contents, building a cache of + track→playlist memberships. Runs in background thread pool. + + Signals (via signals attribute): + started: Emitted when loading begins + progress: Emitted with (current, total) during loading + cache_ready: Emitted when cache is complete, provides cache dict + error: Emitted if critical error occurs + finished: Emitted when loading completes (success or error) + + Example: + loader = PlaylistContextLoader(session, user_id="123456") + loader.signals.cache_ready.connect(self.on_cache_ready) + threadpool.start(loader) + """ + + # Constants + DEFAULT_PLAYLIST_LIMIT: int = 50 + DEFAULT_ITEMS_LIMIT: int = 300 + MAX_WORKERS: int = 5 + REQUEST_TIMEOUT: int = 30 + + def __init__(self, session: Session, user_id: str, max_workers: int = MAX_WORKERS) -> None: + """Initialize the playlist context loader. + + Args: + session: Authenticated Tidal API session + user_id: ID of current user + max_workers: Maximum concurrent API requests + """ + super().__init__() + self.session: Session = session + self.user_id: str = user_id + self.max_workers: int = max(1, min(max_workers, 5)) # Clamp to 1-5 + self._abort_requested: threading.Event = threading.Event() + + # Create signal emitter + self.signals = PlaylistLoaderSignals() + + @QtCore.Slot() + def run(self) -> None: + """Main worker thread entry point. + + Sequence: + 1. Fetch all user playlists (with pagination) + 2. For each playlist, fetch all track IDs (parallel with executor) + 3. Build cache Dict[track_id, Set[playlist_id]] + 4. Emit cache_ready signal + """ + try: + self.signals.started.emit() + + # Step 1: Fetch all user playlists + playlists: list[dict[str, str | int]] = self._fetch_user_playlists() + if not playlists: + # Emit empty metadata so UI can hide dialog empty state properly + self.signals.metadata_ready.emit({}) + self.signals.cache_ready.emit({}) + self.signals.finished.emit() + return + + # Emit playlist metadata for UI (names & counts) + metadata_payload: dict[str, dict[str, str | int]] = { + p["uuid"]: {"name": p["title"], "item_count": p["numberOfItems"]} for p in playlists + } + + # Debug: log all playlist names (gated) + playlist_names = [p["title"] for p in playlists] + logger_gui.debug(f"📝 Loading {len(playlists)} playlists: {', '.join(sorted(playlist_names)[:10])}...") + + self.signals.metadata_ready.emit(metadata_payload) + + # Step 2: Fetch contents for all playlists (parallel) + cache: dict[str, set[str]] = self._fetch_all_playlist_contents(playlists) + + # Log cache statistics (keep info minimal or gate) + total_unique_tracks = len(cache) + total_memberships = sum(len(pls) for pls in cache.values()) + logger_gui.debug(f" → Sample cache track IDs: {list(cache.keys())[:10] if cache else []}") + # Keep the success info, but you asked to suppress logs; gate it as well + logger_gui.debug( + f"✅ Cache built: {total_unique_tracks} unique tracks, {total_memberships} total playlist memberships" + ) + + # Step 3: Emit ready signal with cache data + self.signals.cache_ready.emit(cache) + + self.signals.finished.emit() + + except RequestException as e: + self.signals.error.emit(f"Network error loading playlists: {e!s}") + self.signals.finished.emit() + except Exception as e: + self.signals.error.emit(f"Unexpected error loading playlists: {e!s}") + self.signals.finished.emit() + + def _fetch_user_playlists(self) -> list[dict[str, str | int]]: + """Fetch all editable playlists for the current user using tidalapi helpers. + + Returns: + List of playlist dicts with 'uuid', 'title', 'numberOfItems' + """ + try: + # Use centralized API helper + tidal_playlists = get_user_playlists(self.session) + + # Extract metadata from each playlist + playlists: list[dict[str, str | int]] = [] + for pl in tidal_playlists: + metadata = get_playlist_metadata(pl) + playlists.append( + { + "uuid": metadata["id"], + "title": metadata["name"], + "numberOfItems": metadata["item_count"], + } + ) + + except Exception as e: + raise RequestException(f"Failed to fetch user playlists: {e}") from e # noqa: TRY003 + else: + return playlists + + def _fetch_all_playlist_contents(self, playlists: list[dict[str, str | int]]) -> dict[str, set[str]]: + """Fetch contents of all playlists in parallel. + + Uses ThreadPoolExecutor to parallelize API requests. + + Args: + playlists: List of playlist dicts from _fetch_user_playlists + + Returns: + Cache dict mapping track_id → Set[playlist_id] + """ + cache: dict[str, set[str]] = {} + + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + # Submit all playlist fetch tasks + futures: dict = { + executor.submit(self._fetch_playlist_items, playlist["uuid"], playlist["title"]): playlist["uuid"] + for playlist in playlists + if not self._abort_requested.is_set() + } + + # Collect results as they complete + for i, future in enumerate(as_completed(futures)): + if self._abort_requested.is_set(): + executor.shutdown(wait=False) + break + + playlist_uuid: str = futures[future] + + try: + track_ids: set[str] = future.result() + + # Add this playlist to cache for each track + for track_id in track_ids: + if track_id not in cache: + cache[track_id] = set() + cache[track_id].add(playlist_uuid) + + except RequestException as e: + logger_gui.debug(f"Request error for playlist {playlist_uuid}: {e}") + continue + except Exception as e: + logger_gui.debug(f"Unexpected error for playlist {playlist_uuid}: {e}") + continue + else: + # Emit progress + self.signals.progress.emit(i + 1, len(futures)) + + return cache + + def _fetch_playlist_items(self, playlist_uuid: str, playlist_name: str = "") -> set[str]: + """Fetch all track IDs from a single playlist using tidalapi helpers. + + Args: + playlist_uuid: UUID of the playlist + playlist_name: Name of the playlist (for logging) + + Returns: + Set of track UUIDs in this playlist + """ + try: + # Get playlist object + playlist = self.session.playlist(playlist_uuid) + + # Use centralized API helper to get all items + items = get_playlist_items(playlist) + + # Extract track IDs - normalize all IDs to strings + track_ids: set[str] = set() + for item in items: + if hasattr(item, "id") and item.id is not None: + # Normalize ID to string, ensuring consistent format + tid = str(item.id) + track_ids.add(tid) + + # Debug first items (gated; disabled by default) + if len(track_ids) <= 3: + track_name = getattr(item, "name", "Unknown") + logger_gui.debug( + f" [{playlist_name}...] Track '{track_name}' ID: {tid} (type: {type(item.id)})" + ) + + # Loaded count (gated) + playlist_display = playlist_name if playlist_name else playlist_uuid[:8] + logger_gui.debug(f"📋 Loaded {len(track_ids)} tracks from playlist '{playlist_display}'") + + except Exception as e: + raise RequestException(f"Failed to fetch items for playlist {playlist_uuid}: {e}") from e # noqa: TRY003 + else: + return track_ids + + def request_abort(self) -> None: + """Request graceful abortion of the loader. + + Sets abort flag; loader will finish current request then stop. + Safe to call from any thread. + """ + self._abort_requested.set() + + +class PlaylistColumnDelegate(QtWidgets.QStyledItemDelegate): + """Custom delegate for rendering the "Playlists" column. + + Displays different states: + - PENDING: QtWaitingSpinner widget (loading) + - READY: Clickable button (ready to open dialog) + - ERROR: Warning icon (error occurred) + + Emits: button_clicked signal when cell is clicked in READY state. + + Example: + delegate = PlaylistColumnDelegate(parent=table) + table.setItemDelegateForColumn(9, delegate) + delegate.button_clicked.connect(self.on_playlist_button_clicked) + """ + + # Signals + button_clicked: QtCore.Signal = QtCore.Signal(QtCore.QModelIndex) + + def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: + """Initialize the delegate. + + Args: + parent: Parent widget (table view) + """ + super().__init__(parent) + self._cache_ready: bool = False + self._cell_states: dict[str, PlaylistCellState] = {} + self._spinner: QtWaitingSpinner | None = None + # Reference to cache to compute counts + self._cache: ThreadSafePlaylistCache | None = None + # Column index of the hidden obj storing Track (default 1 as per model) + self._obj_column_index: int = 1 + + # Create spinner widget for PENDING state + if parent: + self._spinner = QtWaitingSpinner(parent, centerOnParent=False, disableParentWhenSpinning=False) + self._spinner.setNumberOfLines(12) + self._spinner.setLineLength(4) + self._spinner.setLineWidth(2) + self._spinner.setInnerRadius(4) + self._spinner.setColor(QtGui.QColor(100, 150, 255)) + self._spinner.hide() # Hidden by default + + def set_cache(self, cache: ThreadSafePlaylistCache) -> None: + """Attach cache reference for count rendering.""" + self._cache = cache + + def set_obj_column_index(self, col: int) -> None: + self._obj_column_index = col + + def paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> None: + """Paint the cell contents based on current state. + + Args: + painter: QPainter for drawing + option: Style options (selection, hover, etc.) + index: Model index of the cell + """ + # Determine state + row_key: str = str(index.row()) + state: PlaylistCellState = self._cell_states.get(row_key, PlaylistCellState.PENDING) + + # Auto-initialize to READY if cache is ready and state is unknown (scroll scenario) + if state == PlaylistCellState.PENDING and self._cache_ready: + state = PlaylistCellState.READY + self._cell_states[row_key] = PlaylistCellState.READY + + # Draw background + super().paint(painter, option, index) + + # Calculate content rect (inside cell) + content_rect: QtCore.QRect = option.rect.adjusted(4, 4, -4, -4) + + if state == PlaylistCellState.PENDING: + # Show spinner for this cell + if self._spinner: + # Position spinner in cell + spinner_size = self._spinner.width() + center_x = content_rect.center().x() - spinner_size // 2 + center_y = content_rect.center().y() - spinner_size // 2 + + self._spinner.move(center_x, center_y) + self._spinner.show() + + # Ensure spinner is running + if not self._spinner.isSpinning(): + self._spinner.start() + elif state == PlaylistCellState.READY: + self._paint_button(painter, content_rect, option, index) + elif state == PlaylistCellState.ERROR: + self._paint_error(painter, content_rect) + + def _paint_button( + self, + painter: QtGui.QPainter, + rect: QtCore.QRect, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> None: + """Paint clickable button for READY state. + + Args: + painter: QPainter for drawing + rect: Area to paint in + option: Style options (for hover effects) + index: Model index to get track data from + """ + # Draw button background + is_hovered: bool = bool(option.state & QtWidgets.QStyle.StateFlag.State_MouseOver) + bg_color: QtGui.QColor = QtGui.QColor(220, 240, 255) if is_hovered else QtGui.QColor(240, 240, 240) + + painter.fillRect(rect, bg_color) + + # Draw button border + painter.setPen(QtGui.QPen(QtGui.QColor(100, 150, 255), 1)) + painter.drawRect(rect) + + # Draw text/icon + text_color: QtGui.QColor = QtGui.QColor(50, 100, 200) if is_hovered else QtGui.QColor(100, 120, 150) + painter.setPen(text_color) + + # Compute playlists count for this track + count_text = "0" + parent = self.parent() + track_id = None + + if isinstance(parent, QtWidgets.QTreeView) and self._cache is not None: + model = parent.model() + try: + # Try to resolve track_id if index is valid + if index.isValid(): + # Resolve to source index if proxy model is used + if isinstance(model, QtCore.QSortFilterProxyModel): + source_index = model.mapToSource(index) + source_model = model.sourceModel() + else: + source_index = index + source_model = model + + # Only proceed if source index is valid + source_row = source_index.row() + if source_row >= 0: + # For QStandardItemModel, use item() directly + if hasattr(source_model, "item"): + obj_item = source_model.item(source_row, self._obj_column_index) + obj_data = obj_item.data(QtCore.Qt.ItemDataRole.UserRole) if obj_item else None + else: + # Fallback for other model types + obj_idx = source_model.index(source_row, self._obj_column_index) + obj_data = obj_idx.data(QtCore.Qt.ItemDataRole.UserRole) + + if obj_data is not None: + tid = getattr(obj_data, "id", None) + if tid is not None: + track_id = str(tid) + playlists_for_track = self._cache.get_playlists_for_track(track_id) + count = len(playlists_for_track) + count_text = str(count) + + # Suppress noisy zero-playlist debug logs + # (kept silent unless VERBOSE_DEBUG is enabled) + track_name = getattr(obj_data, "name", "Unknown") + sample_keys = list(self._cache._data.keys())[:5] + is_in_cache = track_id in self._cache._data + logger_gui.debug(f"⚠️ Track '{track_name}' (ID: {track_id}) shows 0 playlists") + logger_gui.debug(f" Is track ID '{track_id}' in cache? {is_in_cache}") + logger_gui.debug(f" Sample cache keys: {sample_keys}") + + except Exception as e: + logger_gui.error(f"Error getting track playlist count: {e}", exc_info=True) + + # Always draw text with count (fallback to "0" if track_id not found) + # Pluralize "Playlist" → "Playlists" if count > 1 + count_int = int(count_text) if count_text.isdigit() else 0 + text = f"({count_text}) Playlist" if count_int <= 1 else f"({count_text}) Playlists" + painter.drawText(rect, QtCore.Qt.AlignmentFlag.AlignCenter, text) + + def _paint_error(self, painter: QtGui.QPainter, rect: QtCore.QRect) -> None: + """Paint error icon for ERROR state. + + Args: + painter: QPainter for drawing + rect: Area to paint in + """ + # Draw warning icon + text: str = "⚠️" + font: QtGui.QFont = QtGui.QFont() + font.setPointSize(10) + painter.setFont(font) + + painter.setPen(QtGui.QColor(200, 150, 50)) + painter.drawText(rect, QtCore.Qt.AlignmentFlag.AlignCenter, text) + + def set_cell_state(self, row: int, state: PlaylistCellState) -> None: + """Update the state of a cell. + + Args: + row: Row index + state: New state (PENDING, READY, ERROR) + """ + row_key: str = str(row) + self._cell_states[row_key] = state + + def set_cache_ready(self, is_ready: bool) -> None: + """Notify delegate that cache loading is complete. + + Args: + is_ready: True if cache is ready, False if loading started + """ + self._cache_ready = is_ready + + if not is_ready: + # Cache loading started - reset to PENDING + if self._spinner and not self._spinner.isSpinning(): + self._spinner.start() + return + + # Cache is ready - transition ALL rows to READY state + parent = self.parent() + if isinstance(parent, QtWidgets.QTreeView): + model = parent.model() + if model is not None: + rows = model.rowCount() + for r in range(rows): + self._cell_states[str(r)] = PlaylistCellState.READY + + # Stop spinner + if self._spinner and self._spinner.isSpinning(): + self._spinner.stop() + self._spinner.hide() + + # Force repaint + if isinstance(parent, QtWidgets.QWidget): + try: + if hasattr(parent, "viewport"): + parent.viewport().update() + parent.viewport().repaint() + except RuntimeError: + pass + + def editorEvent( + self, + event: QtCore.QEvent, + model: QtCore.QAbstractItemModel, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> bool: + """Handle mouse events on the cell. + + Args: + event: The event + model: The model + option: Style options + index: Model index + + Returns: + True if event was handled + """ + if event.type() == QtCore.QEvent.Type.MouseButtonRelease: + row_key: str = str(index.row()) + state: PlaylistCellState = self._cell_states.get(row_key, PlaylistCellState.PENDING) + + if state == PlaylistCellState.READY: + self.button_clicked.emit(index) + return True + + return super().editorEvent(event, model, option, index) diff --git a/tidal_dl_ng/gui/playlist_membership_mixin.py b/tidal_dl_ng/gui/playlist_membership_mixin.py new file mode 100644 index 0000000..95754ca --- /dev/null +++ b/tidal_dl_ng/gui/playlist_membership_mixin.py @@ -0,0 +1,280 @@ +"""Playlist Membership Mixin for MainWindow. + +Integrates the playlist membership management system into the main window, +handling initialization, signal connections, and UI updates. +""" + +from PySide6 import QtCore +from tidalapi import Track + +from tidal_dl_ng.gui.dialog_playlist_manager import PlaylistManagerDialog +from tidal_dl_ng.gui.playlist_membership import ( + PlaylistCellState, + PlaylistColumnDelegate, + PlaylistContextLoader, + ThreadSafePlaylistCache, +) +from tidal_dl_ng.logger import logger_gui +from tidal_dl_ng.model.gui_data import StatusbarMessage + + +class PlaylistMembershipMixin: + """Mixin for playlist membership management integration into MainWindow.""" + + # Type hints + playlist_cache: ThreadSafePlaylistCache + playlist_loader: "PlaylistContextLoader | None" + playlist_column_delegate: PlaylistColumnDelegate + + # Signals for playlist events + s_playlist_cache_ready: QtCore.Signal = QtCore.Signal(dict) + s_playlist_loader_error: QtCore.Signal = QtCore.Signal(str) + s_playlist_loader_progress: QtCore.Signal = QtCore.Signal(int, int) + + def init_playlist_membership_manager(self) -> None: + """Initialize the playlist membership management system. + + Sets up: + - Thread-safe cache for track→playlist mappings + - Background worker for loading playlists + - Custom delegate for the playlists column + - Signal connections + """ + + # 1. Create thread-safe cache + self.playlist_cache = ThreadSafePlaylistCache() + + # 2. Store worker reference (will be recreated on each results change) + self.playlist_loader: PlaylistContextLoader | None = None + + # 3. Create custom delegate for playlists column + self.playlist_column_delegate = PlaylistColumnDelegate(parent=self.tr_results) + self.playlist_column_delegate.set_cache(self.playlist_cache) + self.playlist_column_delegate.set_obj_column_index(1) # hidden obj column + self.playlist_column_delegate.button_clicked.connect(self.on_playlist_column_button_clicked) + + # 5. Attach delegate to the results table (column 9 = playlists) + # Note: Column indices: 0=index, 1=obj, 2=artist, 3=title, 4=album, + # 5=duration, 6=quality, 7=date, 8=downloaded, 9=playlists + + # Diagnostic: check if column 9 exists + if hasattr(self, "model_tr_results") and self.model_tr_results: + col_count = self.model_tr_results.columnCount() + if col_count <= 9: + logger_gui.error(f"❌ Column 9 does NOT exist! Model only has {col_count} columns") + + self.tr_results.setItemDelegateForColumn(9, self.playlist_column_delegate) + + # Configure column width and appearance + self.tr_results.setColumnWidth(9, 60) + + # Make sure column is visible + if self.tr_results.isColumnHidden(9): + self.tr_results.setColumnHidden(9, False) + + self.tr_results.setColumnHidden(1, True) # Hide obj column + + # 6. Connect model signals to trigger preloading + if hasattr(self, "model_tr_results") and self.model_tr_results: + self.model_tr_results.modelReset.connect(self.on_results_layout_changed) + self.model_tr_results.layoutChanged.connect(self.on_results_layout_changed) + + # Initialize states for any existing rows + existing_rows = self.model_tr_results.rowCount() + if existing_rows > 0: + for r in range(existing_rows): + self.playlist_column_delegate.set_cell_state(r, PlaylistCellState.READY) + else: + logger_gui.error("❌ model_tr_results not found - signal not connected!") + + # 7. Launch initial playlist loading immediately + self._load_playlists() + + def connect_playlist_signals(self) -> None: + """Connect playlist-related signals (called from main signal setup).""" + # Signal connections already done in init_playlist_membership_manager + pass + + def _load_playlists(self) -> None: + """Load user playlists in background. + + Creates a new worker and launches it in the threadpool. + """ + # Purge ENTIRE cache (tracks + metadata) to ensure first-load starts clean + # et éviter que des métadonnées obsolètes n'influencent l'affichage. + if hasattr(self, "playlist_cache") and self.playlist_cache: + self.playlist_cache.clear() + + # Indiquer explicitement au délégué que le cache n'est pas prêt + # afin d'afficher le spinner au lieu des compteurs. + if hasattr(self, "playlist_column_delegate") and self.playlist_column_delegate: + self.playlist_column_delegate.set_cache_ready(False) + + self.playlist_loader = PlaylistContextLoader( + session=self.tidal.session, + user_id=str(self.tidal.session.user.id), + max_workers=5, + ) + + # Connect signals for this worker instance + self.playlist_loader.signals.started.connect(self.on_playlist_loader_started) + self.playlist_loader.signals.cache_ready.connect(self.on_playlist_cache_ready) + self.playlist_loader.signals.metadata_ready.connect(self.on_playlist_metadata_ready) + self.playlist_loader.signals.error.connect(self.on_playlist_loader_error) + self.playlist_loader.signals.progress.connect(self.on_playlist_loader_progress) + self.playlist_loader.signals.finished.connect(self.on_playlist_loader_finished) + + # Start loading playlists in background + self.threadpool.start(self.playlist_loader) + + def on_results_layout_changed(self) -> None: + """Handle when new results are displayed in the main table.""" + # Initialize READY states for all visible rows + try: + rows = self.model_tr_results.rowCount() + for r in range(rows): + self.playlist_column_delegate.set_cell_state(r, PlaylistCellState.READY) + except Exception as e: + logger_gui.warning(f"Failed to initialize cell states: {e}") + + # Force repaint to show buttons (if cache is already ready) + if self.playlist_column_delegate._cache_ready: + try: + self.tr_results.viewport().update() + self.tr_results.viewport().repaint() + except RuntimeError: + pass + + def on_playlist_loader_started(self) -> None: + """Called when playlist loader starts.""" + pass # Silent + + def on_playlist_cache_ready(self, cache: dict) -> None: + """Called when playlist cache is ready. + + Args: + cache: Dict[track_id, Set[playlist_id]] from worker + """ + + # Update cache WITHOUT clearing (metadata was already loaded) + # NOTE: We don't call clear() because metadata_ready signal was already + # emitted and stored. We only update track→playlist mapping. + self.playlist_cache.update_from_dict(cache) + + # Initialize cell states for all current rows as READY + try: + rows = self.model_tr_results.rowCount() + for r in range(rows): + self.playlist_column_delegate.set_cell_state(r, PlaylistCellState.READY) + except Exception as e: + logger_gui.warning(f"Failed to initialize cell states: {e}") + + # Notify delegate that cache is ready (stops animation timer) + self.playlist_column_delegate.set_cache_ready(True) + + # Force complete repaint of the table to show buttons instead of spinners + self.tr_results.viewport().update() + self.tr_results.viewport().repaint() + + def on_playlist_metadata_ready(self, metadata: dict) -> None: + """Receive playlist metadata and store it in the cache. + + Args: + metadata: Dict[playlist_id, {name: str, item_count: int}] + """ + try: + for pid, info in metadata.items(): + name = str(info.get("name", pid)) + count = int(info.get("item_count", 0)) + self.playlist_cache.set_playlist_metadata(pid, name, count) + logger_gui.debug(f"📝 Stored metadata for {len(metadata)} playlists") + except Exception as e: + logger_gui.warning(f"Failed to store playlist metadata: {e}") + + def on_playlist_loader_error(self, error_msg: str) -> None: + """Handle playlist loader error. + + Args: + error_msg: Error description + """ + logger_gui.warning(f"Playlist loader error: {error_msg}") + + # Show user-friendly notification + self.s_statusbar_message.emit( + StatusbarMessage( + message=f"Erreur: {error_msg}", + timeout=5000, + ) + ) + + def on_playlist_loader_progress(self, current: int, total: int) -> None: + """Handle playlist loader progress. + + Args: + current: Number of playlists processed + total: Total number of playlists + """ + pass # Silent - no need to spam logs + + def on_playlist_loader_finished(self) -> None: + """Handle playlist loader finished (success or error).""" + pass # Silent + + def on_playlist_column_button_clicked(self, index: QtCore.QModelIndex) -> None: + """Handle click on playlist column button. + + Opens the playlist manager dialog for the clicked track. + + Args: + index: QModelIndex of the cell clicked + """ + # Map proxy index to source index if using proxy model + if isinstance(self.tr_results.model(), type(self.proxy_tr_results)): + source_index = self.proxy_tr_results.mapToSource(index) + else: + source_index = index + + # Get the track object from the model + obj_item = self.model_tr_results.item(source_index.row(), 1) # Column 1 = obj + if not obj_item: + logger_gui.warning("Failed to get track object from model") + return + + track = obj_item.data(QtCore.Qt.ItemDataRole.UserRole) + if not isinstance(track, Track): + logger_gui.warning(f"Cell does not contain Track object: {type(track)}") + return + + # Create and show the dialog + dialog = PlaylistManagerDialog( + track=track, + cache=self.playlist_cache, + session=self.tidal.session, + threadpool=self.threadpool, + parent=self, + ) + + # Connect signals for tracking changes + dialog.playlist_added.connect(self.on_track_added_to_playlist) + dialog.playlist_removed.connect(self.on_track_removed_from_playlist) + + # Show dialog modally + dialog.exec() + + def on_track_added_to_playlist(self, track_id: str, playlist_id: str) -> None: + """Handle track added to playlist. + + Args: + track_id: Track UUID + playlist_id: Playlist UUID + """ + pass # Silent - update handled by dialog + + def on_track_removed_from_playlist(self, track_id: str, playlist_id: str) -> None: + """Handle track removed from playlist. + + Args: + track_id: Track UUID + playlist_id: Playlist UUID + """ + pass # Silent - update handled by dialog diff --git a/tidal_dl_ng/gui/tidal_session.py b/tidal_dl_ng/gui/tidal_session.py index 349414f..b886548 100644 --- a/tidal_dl_ng/gui/tidal_session.py +++ b/tidal_dl_ng/gui/tidal_session.py @@ -56,6 +56,8 @@ class TidalSessionMixin: if result: self._init_dl() self.thread_it(self.playlist_manager.tidal_user_lists) + # Initialize playlist membership manager + self.init_playlist_membership_manager() def on_logout(self) -> None: """Log out from TIDAL and close the application.""" diff --git a/tidal_dl_ng/gui/trees_results.py b/tidal_dl_ng/gui/trees_results.py index 0658078..e9f6c12 100644 --- a/tidal_dl_ng/gui/trees_results.py +++ b/tidal_dl_ng/gui/trees_results.py @@ -76,11 +76,15 @@ class TreesResultsMixin: ) child_downloaded: QtGui.QStandardItem = QtGui.QStandardItem() + child_playlists: QtGui.QStandardItem = QtGui.QStandardItem() + if isinstance(item.obj, Track): track_id = str(item.obj.id) if self.history_service.is_downloaded(track_id): child_downloaded.setText("✅") child_downloaded.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + # Store track_id in playlists column for lookup + child_playlists.setData(track_id, QtCore.Qt.ItemDataRole.UserRole) if isinstance(item.obj, Mix | Playlist | Album | Artist): child_dummy: QtGui.QStandardItem = QtGui.QStandardItem() @@ -97,6 +101,7 @@ class TreesResultsMixin: child_quality, child_date, child_downloaded, + child_playlists, ) def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]): |
