Files
tidal-dl/tidal_dl_ng/gui/dialog_playlist_manager.py
T
Warry 7b60f92a48 feat: Refactor playlist management, error handling, and testing infrastructure
- Improved playlist item handling with pagination, error logging, and dedicated request hooks.
- Consolidated reusable helpers for track ID normalization, playlist validation, and removal logic.
- Enhanced error handling by introducing `PlaylistNotFound` and `UserNotAuthenticated` exceptions.
- Refined Pytest `qapp` fixture for headless CI compatibility and session-scoped reuse.
- Replaced redundant fixtures in multiple test files for consistency.
- Improved playlist management GUI with detailed debug logging for notifications and operations.
- Added `deptry` configuration to handle transitive dependencies and ignored exclusions.
- Updated Pytest and Tox configurations for consistent environment setup during CI/CD runs.
2025-12-29 20:54:26 +01:00

348 lines
13 KiB
Python

"""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.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] = {}
# Import the generated UI here to avoid circular dependency and ensure availability
from tidal_dl_ng.ui.dialog_playlist_manager import Ui_DialogPlaylistManager
# 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()
# Expose container layout for tests
self.container_layout = self.ui.verticalLayoutList
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)
"""
logger_gui.debug(f"PlaylistManagerDialog notification: {message}")
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)
# Attach implementation class to the generated UI module for import compatibility
try:
import sys
import tidal_dl_ng.ui.dialog_playlist_manager as _ui_mod
_ui_mod.PlaylistManagerDialog = PlaylistManagerDialog
# Ensure module is in sys.modules for proper imports
sys.modules["tidal_dl_ng.ui.dialog_playlist_manager"] = _ui_mod
except Exception as e:
# If UI module isn't importable in some contexts, log and continue
from tidal_dl_ng.logger import logger_gui
logger_gui.debug(f"Could not attach PlaylistManagerDialog to UI module: {e}")