feat: Add playlist management functionality and compliance checks

- Introduced a new "Playlists" column in results view with visual indicators for membership.
- Added `playlist_api.py` to handle playlist-related operations (fetch, add, remove tracks).
- Implemented `PlaylistManagerDialog` for seamless playlist addition/removal with caching.
- Integrated compliance check script (`check_agents_compliance.py`) for ensuring coding standards.
- Added extensive documentation (`playlist_membership_manager.md`) covering architecture and flow.
- Updated GUI logic and QStandardItemModel setup to include playlist data.
- Included unit tests for playlist management components and API.
This commit is contained in:
Warry
2025-12-29 17:37:39 +01:00
parent 75868b32d0
commit 296c6c7d6b
17 changed files with 3433 additions and 4 deletions
+2 -3
View File
@@ -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__":
+327
View File
@@ -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)
+1
View File
@@ -127,6 +127,7 @@ class InitializationMixin:
"Quality",
"Date",
"Downloaded?",
"Playlists",
]
model.setColumnCount(len(labels_column))
model.setRowCount(0)
+9 -1
View File
@@ -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()
+725
View File
@@ -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)
@@ -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
+2
View File
@@ -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."""
+5
View File
@@ -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]):
+191
View File
@@ -0,0 +1,191 @@
"""Playlist API helper - Centralized API calls for playlist operations.
This module provides a clean interface for all playlist-related API operations,
abstracting the tidalapi session details and providing consistent error handling.
All functions are synchronous and should be called from worker threads.
"""
from requests.exceptions import RequestException
from tidalapi import Session, Track, UserPlaylist
from tidal_dl_ng.logger import logger_gui
def get_user_playlists(session: Session) -> list[UserPlaylist]:
"""Fetch all user playlists from Tidal API.
Args:
session: Authenticated Tidal session
Returns:
List of UserPlaylist objects
Raises:
RequestException: If API call fails
ValueError: If user is not authenticated
"""
if not session.user:
raise ValueError("User not authenticated") # noqa: TRY003
try:
playlists = session.user.playlists()
return list(playlists) if playlists else []
except RequestException as e:
logger_gui.error(f"Failed to fetch user playlists: {e}")
raise
def get_playlist_items(playlist: UserPlaylist) -> list[Track]:
"""Fetch all items from a playlist.
Args:
playlist: UserPlaylist object
Returns:
List of Track objects in the playlist (excludes videos and other media types)
Raises:
RequestException: If API call fails
"""
try:
# Force refresh to get latest items
playlist._items = None
# Replace single-call fetching by robust pagination to retrieve ALL items
# Some tidalapi backends return only the first N items (e.g., 100) by default.
# We iterate with an offset/limit until exhaustion.
all_items: list[Track] = []
offset: int = 0
limit: int = 100 # Use API-supported page size to avoid 400 errors
while True:
try:
batch = playlist.items(offset=offset, limit=limit)
except TypeError:
batch = playlist.items(offset, limit)
if not batch:
break
# Filter to only include Track objects
tracks_batch = [item for item in batch if isinstance(item, Track)]
all_items.extend(tracks_batch)
# Progress
offset += len(batch)
# Safety: stop if no progress to avoid infinite loop
if len(batch) < limit:
break
except RequestException as e:
logger_gui.error(f"Failed to fetch playlist items for {playlist.id}: {e}")
raise
else:
# Silenced diagnostics: previously logged first few tracks for ID normalization
return all_items
def add_track_to_playlist(session: Session, playlist_id: str, track_id: str) -> None:
"""Add a track to a playlist.
Args:
session: Authenticated Tidal session
playlist_id: UUID of the playlist
track_id: UUID of the track to add
Raises:
RequestException: If API call fails
ValueError: If playlist not found
"""
try:
playlist = session.playlist(playlist_id)
if not playlist:
raise ValueError(f"Playlist {playlist_id} not found") # noqa: TRY003
# Normalize ID as int where supported
try:
norm_id = int(track_id)
except (TypeError, ValueError):
norm_id = track_id
playlist.add([norm_id])
# Silenced info log
except RequestException as e:
logger_gui.error(f"Failed to add track {track_id} to playlist {playlist_id}: {e}")
raise
def remove_track_from_playlist(session: Session, playlist_id: str, track_id: str) -> None:
"""Remove a track from a playlist.
Args:
session: Authenticated Tidal session
playlist_id: UUID of the playlist
track_id: UUID of the track to remove
Raises:
RequestException: If API call fails
ValueError: If playlist or track not found
"""
try:
playlist = session.playlist(playlist_id)
if not playlist:
raise ValueError(f"Playlist {playlist_id} not found") # noqa: TRY003
# Always use index-based removal with robust pagination
# Force refresh and paginate to get all items
playlist._items = None
items_all = []
offset = 0
limit = 100
while True:
try:
batch = playlist.items(offset=offset, limit=limit)
except TypeError:
batch = playlist.items(offset, limit)
if not batch:
break
items_all.extend(batch)
offset += len(batch)
if len(batch) < limit:
break
# Find the track index
track_index = None
for i, item in enumerate(items_all):
item_id = getattr(item, "id", None)
if str(item_id) == str(track_id):
track_index = i
break
if track_index is None:
# Silenced warning: skip quietly if not found
return
# Remove by index
playlist.remove_by_index(track_index)
# Silenced info log
except RequestException as e:
logger_gui.error(f"Failed to remove track {track_id} from playlist {playlist_id}: {e}")
raise
def get_playlist_metadata(playlist: UserPlaylist) -> dict[str, str | int]:
"""Extract metadata from a playlist object.
Args:
playlist: UserPlaylist object
Returns:
Dictionary containing:
- name: Playlist name
- item_count: Number of items in playlist
- id: Playlist UUID
"""
return {
"name": playlist.name if hasattr(playlist, "name") else f"Playlist {playlist.id}",
"item_count": playlist.num_tracks if hasattr(playlist, "num_tracks") else 0,
"id": str(playlist.id),
}
+28
View File
@@ -47,6 +47,30 @@ class QtHandler(logging.Handler):
XStream.stdout().write("%s\n" % record)
# --- Verbosity control -------------------------------------------------------
# If False: DEBUG and WARNING messages are suppressed (INFO/ERROR/CRITICAL shown).
# If True: DEBUG and WARNING messages are shown.
_VERBOSE_DEBUG = False
class DebugWarningFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
# Always allow INFO (and above except WARNING handled below)
# Suppress DEBUG and WARNING when _VERBOSE_DEBUG is False
if not _VERBOSE_DEBUG and record.levelno in (logging.DEBUG, logging.WARNING):
return False
return True
def enable_debug_and_warnings(enabled: bool = True):
"""Enable/disable showing DEBUG and WARNING log records globally.
INFO/ERROR/CRITICAL are unaffected and always shown.
"""
global _VERBOSE_DEBUG
_VERBOSE_DEBUG = bool(enabled)
logger_gui = logging.getLogger(__name__)
handler_qt: QtHandler = QtHandler()
# log_fmt: str = "[%(asctime)s] %(levelname)s: %(message)s"
@@ -57,6 +81,8 @@ level_styles = coloredlogs.DEFAULT_LEVEL_STYLES.copy()
level_styles["info"] = {"color": "green"}
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
handler_qt.setFormatter(formatter)
# Apply filter to control DEBUG/WARNING visibility
handler_qt.addFilter(DebugWarningFilter())
logger_gui.addHandler(handler_qt)
logger_gui.setLevel(logging.DEBUG)
@@ -64,5 +90,7 @@ logger_cli = logging.getLogger(__name__)
handler_stream: logging.StreamHandler = logging.StreamHandler()
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
handler_stream.setFormatter(formatter)
# Apply filter to control DEBUG/WARNING visibility
handler_stream.addFilter(DebugWarningFilter())
logger_cli.addHandler(handler_stream)
logger_cli.setLevel(logging.DEBUG)
+115
View File
@@ -0,0 +1,115 @@
################################################################################
## Form generated from reading UI file 'dialog_playlist_manager.ui'
##
## Created by: Qt User Interface Compiler version 6.10.0
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import QCoreApplication, QMetaObject, QRect, QSize, Qt
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QSizePolicy,
QSpacerItem,
QVBoxLayout,
QWidget,
)
class Ui_DialogPlaylistManager:
def setupUi(self, DialogPlaylistManager):
if not DialogPlaylistManager.objectName():
DialogPlaylistManager.setObjectName("DialogPlaylistManager")
DialogPlaylistManager.resize(609, 760)
DialogPlaylistManager.setMinimumSize(QSize(520, 560))
self.verticalLayoutRoot = QVBoxLayout(DialogPlaylistManager)
self.verticalLayoutRoot.setObjectName("verticalLayoutRoot")
self.horizontalLayoutHeader = QHBoxLayout()
self.horizontalLayoutHeader.setObjectName("horizontalLayoutHeader")
self.labelTitle = QLabel(DialogPlaylistManager)
self.labelTitle.setObjectName("labelTitle")
self.labelTitle.setWordWrap(True)
self.horizontalLayoutHeader.addWidget(self.labelTitle)
self.verticalLayoutRoot.addLayout(self.horizontalLayoutHeader)
self.frameSeparator = QFrame(DialogPlaylistManager)
self.frameSeparator.setObjectName("frameSeparator")
self.frameSeparator.setFrameShape(QFrame.Shape.HLine)
self.frameSeparator.setFrameShadow(QFrame.Shadow.Sunken)
self.verticalLayoutRoot.addWidget(self.frameSeparator)
self.scrollArea = QScrollArea(DialogPlaylistManager)
self.scrollArea.setObjectName("scrollArea")
self.scrollArea.setWidgetResizable(True)
self.scrollAreaWidgetContents = QWidget()
self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents")
self.scrollAreaWidgetContents.setGeometry(QRect(0, 0, 589, 666))
self.verticalLayoutList = QVBoxLayout(self.scrollAreaWidgetContents)
self.verticalLayoutList.setObjectName("verticalLayoutList")
self.labelEmpty = QLabel(self.scrollAreaWidgetContents)
self.labelEmpty.setObjectName("labelEmpty")
self.labelEmpty.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.verticalLayoutList.addWidget(self.labelEmpty)
self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.verticalLayoutList.addItem(self.verticalSpacer)
self.scrollArea.setWidget(self.scrollAreaWidgetContents)
self.verticalLayoutRoot.addWidget(self.scrollArea)
self.frameFooterSeparator = QFrame(DialogPlaylistManager)
self.frameFooterSeparator.setObjectName("frameFooterSeparator")
self.frameFooterSeparator.setFrameShape(QFrame.Shape.HLine)
self.frameFooterSeparator.setFrameShadow(QFrame.Shadow.Sunken)
self.verticalLayoutRoot.addWidget(self.frameFooterSeparator)
self.horizontalLayoutFooter = QHBoxLayout()
self.horizontalLayoutFooter.setObjectName("horizontalLayoutFooter")
self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.horizontalLayoutFooter.addItem(self.horizontalSpacer)
self.buttonClose = QPushButton(DialogPlaylistManager)
self.buttonClose.setObjectName("buttonClose")
self.horizontalLayoutFooter.addWidget(self.buttonClose)
self.verticalLayoutRoot.addLayout(self.horizontalLayoutFooter)
self.retranslateUi(DialogPlaylistManager)
self.buttonClose.clicked.connect(DialogPlaylistManager.accept)
self.buttonClose.setDefault(True)
QMetaObject.connectSlotsByName(DialogPlaylistManager)
# setupUi
def retranslateUi(self, DialogPlaylistManager):
DialogPlaylistManager.setWindowTitle(
QCoreApplication.translate("DialogPlaylistManager", "G\u00e9rer les playlists", None)
)
self.labelTitle.setText(
QCoreApplication.translate(
"DialogPlaylistManager",
'G\u00e9rer les playlists pour : <b><span style="color:#1e88e5;">Titre de la Piste</span></b>',
None,
)
)
self.labelEmpty.setText(
QCoreApplication.translate("DialogPlaylistManager", "Aucune playlist disponible.", None)
)
self.buttonClose.setText(QCoreApplication.translate("DialogPlaylistManager", "Fermer", None))
# retranslateUi
+147
View File
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>DialogPlaylistManager</class>
<widget class="QDialog" name="DialogPlaylistManager">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>609</width>
<height>760</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>520</width>
<height>560</height>
</size>
</property>
<property name="windowTitle">
<string>Gérer les playlists</string>
</property>
<layout class="QVBoxLayout" name="verticalLayoutRoot">
<item>
<layout class="QHBoxLayout" name="horizontalLayoutHeader">
<item>
<widget class="QLabel" name="labelTitle">
<property name="text">
<string>Gérer les playlists pour : &lt;b&gt;&lt;span style=&quot;color:#1e88e5;&quot;&gt;Titre de la Piste&lt;/span&gt;&lt;/b&gt;</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QFrame" name="frameSeparator">
<property name="frameShape">
<enum>QFrame::Shape::HLine</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Sunken</enum>
</property>
</widget>
</item>
<item>
<widget class="QScrollArea" name="scrollArea">
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="scrollAreaWidgetContents">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>589</width>
<height>666</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayoutList">
<item>
<widget class="QLabel" name="labelEmpty">
<property name="text">
<string>Aucune playlist disponible.</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<widget class="QFrame" name="frameFooterSeparator">
<property name="frameShape">
<enum>QFrame::Shape::HLine</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Sunken</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayoutFooter">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="buttonClose">
<property name="text">
<string>Fermer</string>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonClose</sender>
<signal>clicked()</signal>
<receiver>DialogPlaylistManager</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>20</x>
<y>20</y>
</hint>
<hint type="destinationlabel">
<x>20</x>
<y>20</y>
</hint>
</hints>
</connection>
</connections>
</ui>