summaryrefslogtreecommitdiff
path: root/tidal_dl_ng/gui/playlist_membership.py
diff options
context:
space:
mode:
Diffstat (limited to 'tidal_dl_ng/gui/playlist_membership.py')
-rw-r--r--tidal_dl_ng/gui/playlist_membership.py725
1 files changed, 725 insertions, 0 deletions
diff --git a/tidal_dl_ng/gui/playlist_membership.py b/tidal_dl_ng/gui/playlist_membership.py
new file mode 100644
index 0000000..9066095
--- /dev/null
+++ b/tidal_dl_ng/gui/playlist_membership.py
@@ -0,0 +1,725 @@
+"""Playlist membership manager - Handles loading and caching playlist context.
+
+This module provides background loading of user playlists and track memberships
+to enable instant interaction with the "Playlists" column in result views.
+
+Architecture:
+- ThreadSafePlaylistCache: Thread-safe cache for track→playlist membership
+- PlaylistContextLoader: QRunnable worker for fetching playlist data
+- PlaylistColumnDelegate: Custom delegate for rendering the "Playlists" column
+"""
+
+import threading
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from enum import StrEnum
+
+from PySide6 import QtCore, QtGui, QtWidgets
+from requests.exceptions import RequestException
+from tidalapi import Session
+
+from tidal_dl_ng.helper.playlist_api import (
+ get_playlist_items,
+ get_playlist_metadata,
+ get_user_playlists,
+)
+from tidal_dl_ng.logger import logger_gui
+from tidal_dl_ng.ui.spinner import QtWaitingSpinner
+
+
+class PlaylistCellState(StrEnum):
+ """Enumeration of possible visual states for the playlist column cell."""
+
+ PENDING = "pending" # Loading spinner
+ READY = "ready" # Interactive button
+ ERROR = "error" # Warning icon
+
+
+class ThreadSafePlaylistCache:
+ """Thread-safe cache for track membership in playlists.
+
+ Stores mapping: track_id → Set[playlist_id]
+ Uses RLock for thread-safety and Set for O(1) lookups.
+
+ Example:
+ cache = ThreadSafePlaylistCache()
+ cache.add_track_to_playlist("track_uuid_1", "playlist_uuid_2")
+ playlist_ids = cache.get_playlists_for_track("track_uuid_1")
+ # → {"playlist_uuid_2"}
+ """
+
+ def __init__(self) -> None:
+ """Initialize the cache with empty data and lock."""
+ self._lock: threading.RLock = threading.RLock()
+ self._data: dict[str, set[str]] = {}
+ self._playlist_metadata: dict[str, dict[str, str | int]] = {}
+
+ def add_track_to_playlist(self, track_id: str, playlist_id: str) -> None:
+ """Add a track to a playlist in the cache.
+
+ Args:
+ track_id: The unique identifier of the track
+ playlist_id: The unique identifier of the playlist
+
+ Thread-safe: Uses lock for atomic update.
+ """
+ # Normalize IDs to strings
+ track_id = str(track_id)
+ playlist_id = str(playlist_id)
+
+ with self._lock:
+ if track_id not in self._data:
+ self._data[track_id] = set()
+ self._data[track_id].add(playlist_id)
+
+ def remove_track_from_playlist(self, track_id: str, playlist_id: str) -> None:
+ """Remove a track from a playlist in the cache.
+
+ Args:
+ track_id: The unique identifier of the track
+ playlist_id: The unique identifier of the playlist
+
+ Thread-safe: Uses lock for atomic update.
+ """
+ # Normalize IDs to strings
+ track_id = str(track_id)
+ playlist_id = str(playlist_id)
+
+ with self._lock:
+ if track_id in self._data:
+ self._data[track_id].discard(playlist_id)
+ if not self._data[track_id]:
+ del self._data[track_id]
+
+ def get_playlists_for_track(self, track_id: str) -> set[str]:
+ """Get all playlists containing a specific track.
+
+ Args:
+ track_id: The unique identifier of the track
+
+ Returns:
+ Set of playlist IDs containing this track (empty set if not found)
+
+ Complexity: O(1)
+ Thread-safe: Returns a copy to prevent external mutation.
+ """
+ # Normalize ID to string
+ track_id = str(track_id)
+
+ with self._lock:
+ return self._data.get(track_id, set()).copy()
+
+ # === Adjouts pour gestion complète du cycle de vie du cache ===
+ def clear(self) -> None:
+ """Clear all cached data (tracks + playlist metadata).
+ Utilisé pour désactiver le cache au premier chargement et repartir sur une base saine.
+ """
+ with self._lock:
+ self._data.clear()
+ self._playlist_metadata.clear()
+
+ def update_from_dict(self, cache: dict[str, set[str]]) -> None:
+ """Merge incoming cache mapping into the current cache.
+
+ Args:
+ cache: Dict[track_id, Set[playlist_id]]
+ """
+ if not isinstance(cache, dict):
+ return
+ with self._lock:
+ for tid, playlists in cache.items():
+ tid_str = str(tid)
+ if tid_str not in self._data:
+ self._data[tid_str] = set()
+ # Normaliser les IDs playlist en str
+ for pid in playlists:
+ self._data[tid_str].add(str(pid))
+
+ def set_playlist_metadata(self, playlist_id: str, name: str, item_count: int) -> None:
+ """Store metadata for a playlist (name and item count)."""
+ with self._lock:
+ self._playlist_metadata[str(playlist_id)] = {
+ "name": str(name),
+ "item_count": int(item_count),
+ }
+
+ def get_playlist_metadata(self, playlist_id: str) -> dict[str, str | int]:
+ """Retrieve stored metadata for a playlist (or defaults)."""
+ with self._lock:
+ return self._playlist_metadata.get(str(playlist_id), {"name": str(playlist_id), "item_count": 0}).copy()
+
+ def get_playlist_name(self, playlist_id: str) -> str:
+ """Convenience accessor for playlist name."""
+ return str(self.get_playlist_metadata(playlist_id).get("name", playlist_id))
+
+ def get_playlist_count(self, playlist_id: str) -> int:
+ """Convenience accessor for playlist item_count."""
+ try:
+ return int(self.get_playlist_metadata(playlist_id).get("item_count", 0))
+ except Exception:
+ return 0
+
+ def is_track_in_playlist(self, track_id: str, playlist_id: str) -> bool:
+ """Check if a track is in a specific playlist.
+
+ Args:
+ track_id: The unique identifier of the track
+ playlist_id: The unique identifier of the playlist
+
+ Returns:
+ True if track is in playlist, False otherwise
+
+ Complexity: O(1)
+ """
+ # Normalize IDs to strings
+ track_id = str(track_id)
+ playlist_id = str(playlist_id)
+
+ with self._lock:
+ return playlist_id in self._data.get(track_id, set())
+
+ def clear_track_data(self) -> None:
+ """Clear only track→playlist mapping data, preserving metadata.
+
+ Thread-safe: Uses lock for atomic clear.
+ Useful for refreshing track data without losing playlist names.
+ """
+ with self._lock:
+ self._data.clear()
+
+ def get_all_playlists(self) -> set[str]:
+ """Get all playlist IDs currently tracked in cache.
+
+ Returns all playlists from metadata, not just those with tracks,
+ ensuring empty playlists are also included.
+
+ Returns:
+ Set of all unique playlist IDs
+ """
+ with self._lock:
+ # Return all playlist IDs from metadata (includes empty playlists)
+ # Fall back to track-based IDs if metadata not yet loaded
+ if self._playlist_metadata:
+ return set(self._playlist_metadata.keys())
+ else:
+ # Fallback: return playlist IDs that have at least one track
+ all_ids: set[str] = set()
+ for playlist_ids in self._data.values():
+ all_ids.update(playlist_ids)
+ return all_ids
+
+
+class PlaylistLoaderSignals(QtCore.QObject):
+ """Signal emitter for PlaylistContextLoader.
+
+ Separated because QRunnable doesn't inherit from QObject,
+ so we need a separate QObject to emit signals.
+ """
+
+ started = QtCore.Signal()
+ progress = QtCore.Signal(int, int) # current, total
+ cache_ready = QtCore.Signal(dict) # Track→Playlist cache
+ error = QtCore.Signal(str) # error message
+ finished = QtCore.Signal()
+ # New: playlist metadata (id -> {name, item_count})
+ metadata_ready = QtCore.Signal(dict)
+
+
+class PlaylistContextLoader(QtCore.QRunnable):
+ """Background worker for loading user playlist context.
+
+ Fetches all user playlists and their contents, building a cache of
+ track→playlist memberships. Runs in background thread pool.
+
+ Signals (via signals attribute):
+ started: Emitted when loading begins
+ progress: Emitted with (current, total) during loading
+ cache_ready: Emitted when cache is complete, provides cache dict
+ error: Emitted if critical error occurs
+ finished: Emitted when loading completes (success or error)
+
+ Example:
+ loader = PlaylistContextLoader(session, user_id="123456")
+ loader.signals.cache_ready.connect(self.on_cache_ready)
+ threadpool.start(loader)
+ """
+
+ # Constants
+ DEFAULT_PLAYLIST_LIMIT: int = 50
+ DEFAULT_ITEMS_LIMIT: int = 300
+ MAX_WORKERS: int = 5
+ REQUEST_TIMEOUT: int = 30
+
+ def __init__(self, session: Session, user_id: str, max_workers: int = MAX_WORKERS) -> None:
+ """Initialize the playlist context loader.
+
+ Args:
+ session: Authenticated Tidal API session
+ user_id: ID of current user
+ max_workers: Maximum concurrent API requests
+ """
+ super().__init__()
+ self.session: Session = session
+ self.user_id: str = user_id
+ self.max_workers: int = max(1, min(max_workers, 5)) # Clamp to 1-5
+ self._abort_requested: threading.Event = threading.Event()
+
+ # Create signal emitter
+ self.signals = PlaylistLoaderSignals()
+
+ @QtCore.Slot()
+ def run(self) -> None:
+ """Main worker thread entry point.
+
+ Sequence:
+ 1. Fetch all user playlists (with pagination)
+ 2. For each playlist, fetch all track IDs (parallel with executor)
+ 3. Build cache Dict[track_id, Set[playlist_id]]
+ 4. Emit cache_ready signal
+ """
+ try:
+ self.signals.started.emit()
+
+ # Step 1: Fetch all user playlists
+ playlists: list[dict[str, str | int]] = self._fetch_user_playlists()
+ if not playlists:
+ # Emit empty metadata so UI can hide dialog empty state properly
+ self.signals.metadata_ready.emit({})
+ self.signals.cache_ready.emit({})
+ self.signals.finished.emit()
+ return
+
+ # Emit playlist metadata for UI (names & counts)
+ metadata_payload: dict[str, dict[str, str | int]] = {
+ p["uuid"]: {"name": p["title"], "item_count": p["numberOfItems"]} for p in playlists
+ }
+
+ # Debug: log all playlist names (gated)
+ playlist_names = [p["title"] for p in playlists]
+ logger_gui.debug(f"📝 Loading {len(playlists)} playlists: {', '.join(sorted(playlist_names)[:10])}...")
+
+ self.signals.metadata_ready.emit(metadata_payload)
+
+ # Step 2: Fetch contents for all playlists (parallel)
+ cache: dict[str, set[str]] = self._fetch_all_playlist_contents(playlists)
+
+ # Log cache statistics (keep info minimal or gate)
+ total_unique_tracks = len(cache)
+ total_memberships = sum(len(pls) for pls in cache.values())
+ logger_gui.debug(f" → Sample cache track IDs: {list(cache.keys())[:10] if cache else []}")
+ # Keep the success info, but you asked to suppress logs; gate it as well
+ logger_gui.debug(
+ f"✅ Cache built: {total_unique_tracks} unique tracks, {total_memberships} total playlist memberships"
+ )
+
+ # Step 3: Emit ready signal with cache data
+ self.signals.cache_ready.emit(cache)
+
+ self.signals.finished.emit()
+
+ except RequestException as e:
+ self.signals.error.emit(f"Network error loading playlists: {e!s}")
+ self.signals.finished.emit()
+ except Exception as e:
+ self.signals.error.emit(f"Unexpected error loading playlists: {e!s}")
+ self.signals.finished.emit()
+
+ def _fetch_user_playlists(self) -> list[dict[str, str | int]]:
+ """Fetch all editable playlists for the current user using tidalapi helpers.
+
+ Returns:
+ List of playlist dicts with 'uuid', 'title', 'numberOfItems'
+ """
+ try:
+ # Use centralized API helper
+ tidal_playlists = get_user_playlists(self.session)
+
+ # Extract metadata from each playlist
+ playlists: list[dict[str, str | int]] = []
+ for pl in tidal_playlists:
+ metadata = get_playlist_metadata(pl)
+ playlists.append(
+ {
+ "uuid": metadata["id"],
+ "title": metadata["name"],
+ "numberOfItems": metadata["item_count"],
+ }
+ )
+
+ except Exception as e:
+ raise RequestException(f"Failed to fetch user playlists: {e}") from e # noqa: TRY003
+ else:
+ return playlists
+
+ def _fetch_all_playlist_contents(self, playlists: list[dict[str, str | int]]) -> dict[str, set[str]]:
+ """Fetch contents of all playlists in parallel.
+
+ Uses ThreadPoolExecutor to parallelize API requests.
+
+ Args:
+ playlists: List of playlist dicts from _fetch_user_playlists
+
+ Returns:
+ Cache dict mapping track_id → Set[playlist_id]
+ """
+ cache: dict[str, set[str]] = {}
+
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
+ # Submit all playlist fetch tasks
+ futures: dict = {
+ executor.submit(self._fetch_playlist_items, playlist["uuid"], playlist["title"]): playlist["uuid"]
+ for playlist in playlists
+ if not self._abort_requested.is_set()
+ }
+
+ # Collect results as they complete
+ for i, future in enumerate(as_completed(futures)):
+ if self._abort_requested.is_set():
+ executor.shutdown(wait=False)
+ break
+
+ playlist_uuid: str = futures[future]
+
+ try:
+ track_ids: set[str] = future.result()
+
+ # Add this playlist to cache for each track
+ for track_id in track_ids:
+ if track_id not in cache:
+ cache[track_id] = set()
+ cache[track_id].add(playlist_uuid)
+
+ except RequestException as e:
+ logger_gui.debug(f"Request error for playlist {playlist_uuid}: {e}")
+ continue
+ except Exception as e:
+ logger_gui.debug(f"Unexpected error for playlist {playlist_uuid}: {e}")
+ continue
+ else:
+ # Emit progress
+ self.signals.progress.emit(i + 1, len(futures))
+
+ return cache
+
+ def _fetch_playlist_items(self, playlist_uuid: str, playlist_name: str = "") -> set[str]:
+ """Fetch all track IDs from a single playlist using tidalapi helpers.
+
+ Args:
+ playlist_uuid: UUID of the playlist
+ playlist_name: Name of the playlist (for logging)
+
+ Returns:
+ Set of track UUIDs in this playlist
+ """
+ try:
+ # Get playlist object
+ playlist = self.session.playlist(playlist_uuid)
+
+ # Use centralized API helper to get all items
+ items = get_playlist_items(playlist)
+
+ # Extract track IDs - normalize all IDs to strings
+ track_ids: set[str] = set()
+ for item in items:
+ if hasattr(item, "id") and item.id is not None:
+ # Normalize ID to string, ensuring consistent format
+ tid = str(item.id)
+ track_ids.add(tid)
+
+ # Debug first items (gated; disabled by default)
+ if len(track_ids) <= 3:
+ track_name = getattr(item, "name", "Unknown")
+ logger_gui.debug(
+ f" [{playlist_name}...] Track '{track_name}' ID: {tid} (type: {type(item.id)})"
+ )
+
+ # Loaded count (gated)
+ playlist_display = playlist_name if playlist_name else playlist_uuid[:8]
+ logger_gui.debug(f"📋 Loaded {len(track_ids)} tracks from playlist '{playlist_display}'")
+
+ except Exception as e:
+ raise RequestException(f"Failed to fetch items for playlist {playlist_uuid}: {e}") from e # noqa: TRY003
+ else:
+ return track_ids
+
+ def request_abort(self) -> None:
+ """Request graceful abortion of the loader.
+
+ Sets abort flag; loader will finish current request then stop.
+ Safe to call from any thread.
+ """
+ self._abort_requested.set()
+
+
+class PlaylistColumnDelegate(QtWidgets.QStyledItemDelegate):
+ """Custom delegate for rendering the "Playlists" column.
+
+ Displays different states:
+ - PENDING: QtWaitingSpinner widget (loading)
+ - READY: Clickable button (ready to open dialog)
+ - ERROR: Warning icon (error occurred)
+
+ Emits: button_clicked signal when cell is clicked in READY state.
+
+ Example:
+ delegate = PlaylistColumnDelegate(parent=table)
+ table.setItemDelegateForColumn(9, delegate)
+ delegate.button_clicked.connect(self.on_playlist_button_clicked)
+ """
+
+ # Signals
+ button_clicked: QtCore.Signal = QtCore.Signal(QtCore.QModelIndex)
+
+ def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
+ """Initialize the delegate.
+
+ Args:
+ parent: Parent widget (table view)
+ """
+ super().__init__(parent)
+ self._cache_ready: bool = False
+ self._cell_states: dict[str, PlaylistCellState] = {}
+ self._spinner: QtWaitingSpinner | None = None
+ # Reference to cache to compute counts
+ self._cache: ThreadSafePlaylistCache | None = None
+ # Column index of the hidden obj storing Track (default 1 as per model)
+ self._obj_column_index: int = 1
+
+ # Create spinner widget for PENDING state
+ if parent:
+ self._spinner = QtWaitingSpinner(parent, centerOnParent=False, disableParentWhenSpinning=False)
+ self._spinner.setNumberOfLines(12)
+ self._spinner.setLineLength(4)
+ self._spinner.setLineWidth(2)
+ self._spinner.setInnerRadius(4)
+ self._spinner.setColor(QtGui.QColor(100, 150, 255))
+ self._spinner.hide() # Hidden by default
+
+ def set_cache(self, cache: ThreadSafePlaylistCache) -> None:
+ """Attach cache reference for count rendering."""
+ self._cache = cache
+
+ def set_obj_column_index(self, col: int) -> None:
+ self._obj_column_index = col
+
+ def paint(
+ self,
+ painter: QtGui.QPainter,
+ option: QtWidgets.QStyleOptionViewItem,
+ index: QtCore.QModelIndex,
+ ) -> None:
+ """Paint the cell contents based on current state.
+
+ Args:
+ painter: QPainter for drawing
+ option: Style options (selection, hover, etc.)
+ index: Model index of the cell
+ """
+ # Determine state
+ row_key: str = str(index.row())
+ state: PlaylistCellState = self._cell_states.get(row_key, PlaylistCellState.PENDING)
+
+ # Auto-initialize to READY if cache is ready and state is unknown (scroll scenario)
+ if state == PlaylistCellState.PENDING and self._cache_ready:
+ state = PlaylistCellState.READY
+ self._cell_states[row_key] = PlaylistCellState.READY
+
+ # Draw background
+ super().paint(painter, option, index)
+
+ # Calculate content rect (inside cell)
+ content_rect: QtCore.QRect = option.rect.adjusted(4, 4, -4, -4)
+
+ if state == PlaylistCellState.PENDING:
+ # Show spinner for this cell
+ if self._spinner:
+ # Position spinner in cell
+ spinner_size = self._spinner.width()
+ center_x = content_rect.center().x() - spinner_size // 2
+ center_y = content_rect.center().y() - spinner_size // 2
+
+ self._spinner.move(center_x, center_y)
+ self._spinner.show()
+
+ # Ensure spinner is running
+ if not self._spinner.isSpinning():
+ self._spinner.start()
+ elif state == PlaylistCellState.READY:
+ self._paint_button(painter, content_rect, option, index)
+ elif state == PlaylistCellState.ERROR:
+ self._paint_error(painter, content_rect)
+
+ def _paint_button(
+ self,
+ painter: QtGui.QPainter,
+ rect: QtCore.QRect,
+ option: QtWidgets.QStyleOptionViewItem,
+ index: QtCore.QModelIndex,
+ ) -> None:
+ """Paint clickable button for READY state.
+
+ Args:
+ painter: QPainter for drawing
+ rect: Area to paint in
+ option: Style options (for hover effects)
+ index: Model index to get track data from
+ """
+ # Draw button background
+ is_hovered: bool = bool(option.state & QtWidgets.QStyle.StateFlag.State_MouseOver)
+ bg_color: QtGui.QColor = QtGui.QColor(220, 240, 255) if is_hovered else QtGui.QColor(240, 240, 240)
+
+ painter.fillRect(rect, bg_color)
+
+ # Draw button border
+ painter.setPen(QtGui.QPen(QtGui.QColor(100, 150, 255), 1))
+ painter.drawRect(rect)
+
+ # Draw text/icon
+ text_color: QtGui.QColor = QtGui.QColor(50, 100, 200) if is_hovered else QtGui.QColor(100, 120, 150)
+ painter.setPen(text_color)
+
+ # Compute playlists count for this track
+ count_text = "0"
+ parent = self.parent()
+ track_id = None
+
+ if isinstance(parent, QtWidgets.QTreeView) and self._cache is not None:
+ model = parent.model()
+ try:
+ # Try to resolve track_id if index is valid
+ if index.isValid():
+ # Resolve to source index if proxy model is used
+ if isinstance(model, QtCore.QSortFilterProxyModel):
+ source_index = model.mapToSource(index)
+ source_model = model.sourceModel()
+ else:
+ source_index = index
+ source_model = model
+
+ # Only proceed if source index is valid
+ source_row = source_index.row()
+ if source_row >= 0:
+ # For QStandardItemModel, use item() directly
+ if hasattr(source_model, "item"):
+ obj_item = source_model.item(source_row, self._obj_column_index)
+ obj_data = obj_item.data(QtCore.Qt.ItemDataRole.UserRole) if obj_item else None
+ else:
+ # Fallback for other model types
+ obj_idx = source_model.index(source_row, self._obj_column_index)
+ obj_data = obj_idx.data(QtCore.Qt.ItemDataRole.UserRole)
+
+ if obj_data is not None:
+ tid = getattr(obj_data, "id", None)
+ if tid is not None:
+ track_id = str(tid)
+ playlists_for_track = self._cache.get_playlists_for_track(track_id)
+ count = len(playlists_for_track)
+ count_text = str(count)
+
+ # Suppress noisy zero-playlist debug logs
+ # (kept silent unless VERBOSE_DEBUG is enabled)
+ track_name = getattr(obj_data, "name", "Unknown")
+ sample_keys = list(self._cache._data.keys())[:5]
+ is_in_cache = track_id in self._cache._data
+ logger_gui.debug(f"⚠️ Track '{track_name}' (ID: {track_id}) shows 0 playlists")
+ logger_gui.debug(f" Is track ID '{track_id}' in cache? {is_in_cache}")
+ logger_gui.debug(f" Sample cache keys: {sample_keys}")
+
+ except Exception as e:
+ logger_gui.error(f"Error getting track playlist count: {e}", exc_info=True)
+
+ # Always draw text with count (fallback to "0" if track_id not found)
+ # Pluralize "Playlist" → "Playlists" if count > 1
+ count_int = int(count_text) if count_text.isdigit() else 0
+ text = f"({count_text}) Playlist" if count_int <= 1 else f"({count_text}) Playlists"
+ painter.drawText(rect, QtCore.Qt.AlignmentFlag.AlignCenter, text)
+
+ def _paint_error(self, painter: QtGui.QPainter, rect: QtCore.QRect) -> None:
+ """Paint error icon for ERROR state.
+
+ Args:
+ painter: QPainter for drawing
+ rect: Area to paint in
+ """
+ # Draw warning icon
+ text: str = "⚠️"
+ font: QtGui.QFont = QtGui.QFont()
+ font.setPointSize(10)
+ painter.setFont(font)
+
+ painter.setPen(QtGui.QColor(200, 150, 50))
+ painter.drawText(rect, QtCore.Qt.AlignmentFlag.AlignCenter, text)
+
+ def set_cell_state(self, row: int, state: PlaylistCellState) -> None:
+ """Update the state of a cell.
+
+ Args:
+ row: Row index
+ state: New state (PENDING, READY, ERROR)
+ """
+ row_key: str = str(row)
+ self._cell_states[row_key] = state
+
+ def set_cache_ready(self, is_ready: bool) -> None:
+ """Notify delegate that cache loading is complete.
+
+ Args:
+ is_ready: True if cache is ready, False if loading started
+ """
+ self._cache_ready = is_ready
+
+ if not is_ready:
+ # Cache loading started - reset to PENDING
+ if self._spinner and not self._spinner.isSpinning():
+ self._spinner.start()
+ return
+
+ # Cache is ready - transition ALL rows to READY state
+ parent = self.parent()
+ if isinstance(parent, QtWidgets.QTreeView):
+ model = parent.model()
+ if model is not None:
+ rows = model.rowCount()
+ for r in range(rows):
+ self._cell_states[str(r)] = PlaylistCellState.READY
+
+ # Stop spinner
+ if self._spinner and self._spinner.isSpinning():
+ self._spinner.stop()
+ self._spinner.hide()
+
+ # Force repaint
+ if isinstance(parent, QtWidgets.QWidget):
+ try:
+ if hasattr(parent, "viewport"):
+ parent.viewport().update()
+ parent.viewport().repaint()
+ except RuntimeError:
+ pass
+
+ def editorEvent(
+ self,
+ event: QtCore.QEvent,
+ model: QtCore.QAbstractItemModel,
+ option: QtWidgets.QStyleOptionViewItem,
+ index: QtCore.QModelIndex,
+ ) -> bool:
+ """Handle mouse events on the cell.
+
+ Args:
+ event: The event
+ model: The model
+ option: Style options
+ index: Model index
+
+ Returns:
+ True if event was handled
+ """
+ if event.type() == QtCore.QEvent.Type.MouseButtonRelease:
+ row_key: str = str(index.row())
+ state: PlaylistCellState = self._cell_states.get(row_key, PlaylistCellState.PENDING)
+
+ if state == PlaylistCellState.READY:
+ self.button_clicked.emit(index)
+ return True
+
+ return super().editorEvent(event, model, option, index)