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:
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
@@ -127,6 +127,7 @@ class InitializationMixin:
|
||||
"Quality",
|
||||
"Date",
|
||||
"Downloaded?",
|
||||
"Playlists",
|
||||
]
|
||||
model.setColumnCount(len(labels_column))
|
||||
model.setRowCount(0)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]):
|
||||
|
||||
Reference in New Issue
Block a user