diff options
Diffstat (limited to 'tidal_dl_ng/gui/dialog_playlist_manager.py')
| -rw-r--r-- | tidal_dl_ng/gui/dialog_playlist_manager.py | 327 |
1 files changed, 327 insertions, 0 deletions
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) |
