summaryrefslogtreecommitdiff
path: root/tidal_dl_ng/gui/dialog_playlist_manager.py
blob: f665543700362ee16c338f4b5122a7ffee6653c9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
"""Playlist Manager Dialog - UI for managing track membership in playlists.

This module provides a modal dialog for users to quickly add/remove tracks
from their playlists without leaving the application.

Design:
- Displays all user playlists as checkboxes
- Pre-populated with current membership state from cache
- Handles add/remove transactions with rollback on error
- Provides visual feedback (loading spinner, success/error notifications)
"""

from PySide6 import QtCore, QtGui, QtWidgets
from requests.exceptions import RequestException
from tidalapi import Session, Track

from tidal_dl_ng.gui.playlist_membership import ThreadSafePlaylistCache
from tidal_dl_ng.helper.playlist_api import add_track_to_playlist, remove_track_from_playlist
from tidal_dl_ng.logger import logger_gui
from tidal_dl_ng.worker import Worker


class PlaylistManagerDialog(QtWidgets.QDialog):
    """Modal dialog for managing track membership in playlists.

    Displays current user playlists with checkboxes indicating whether
    the track is currently in each playlist. Users can check/uncheck
    to add/remove tracks with immediate visual feedback.

    Features:
    - Thread-safe API calls (no main thread blocking)
    - Optimistic UI updates with rollback on error
    - Toast notifications for user feedback
    - Alphabetical playlist sorting

    Example:
        dialog = PlaylistManagerDialog(
            track=track,
            cache=cache,
            session=tidal.session,
            threadpool=main_window.threadpool,
            parent=main_window
        )
        dialog.playlist_changed.connect(on_playlist_changed)
        dialog.exec()
    """

    # Signals
    playlist_added: QtCore.Signal = QtCore.Signal(str, str)  # track_id, playlist_id
    playlist_removed: QtCore.Signal = QtCore.Signal(str, str)

    def __init__(
        self,
        track: Track,
        cache: ThreadSafePlaylistCache,
        session: Session,
        threadpool: QtCore.QThreadPool,
        parent: QtWidgets.QWidget | None = None,
    ) -> None:
        """Initialize the playlist manager dialog.

        Args:
            track: The track to manage playlists for
            cache: Thread-safe playlist cache with pre-loaded data
            session: Authenticated Tidal session for API calls
            threadpool: QThreadPool for background tasks
            parent: Parent widget
        """
        super().__init__(parent)
        self.track: Track = track
        self.cache: ThreadSafePlaylistCache = cache
        self.session: Session = session
        self.threadpool: QtCore.QThreadPool = threadpool

        # Store current states for rollback
        self._original_states: dict[str, bool] = {}
        self._pending_tasks: dict[str, Worker] = {}

        # Import the generated UI here to avoid circular dependency and ensure availability
        from tidal_dl_ng.ui.dialog_playlist_manager import Ui_DialogPlaylistManager

        # Use compiled .ui
        self.ui = Ui_DialogPlaylistManager()
        self.ui.setupUi(self)

        # Set dynamic title with track name
        track_title: str = getattr(self.track, "name", "Unknown Track")
        self.ui.labelTitle.setText(
            f'Gérer les playlists pour : <b><span style="color:#1e88e5;">{track_title}</span></b>'
        )

        # Populate playlists list into verticalLayoutList
        self._populate_playlists_ui()

        # Expose container layout for tests
        self.container_layout = self.ui.verticalLayoutList

    def _populate_playlists_ui(self) -> None:
        """Populate dialog with user playlists from cache.

        Fetches all playlists from cache, sorts alphabetically,
        and creates checkbox items with current membership state.
        """
        # Get all playlist IDs from cache
        all_playlist_ids: set[str] = self.cache.get_all_playlists()

        # Hide empty label if we have playlists
        self.ui.labelEmpty.setVisible(len(all_playlist_ids) == 0)
        if not all_playlist_ids:
            return

        # Sort playlists alphabetically by name/ID
        sorted_playlist_ids: list[str] = sorted(
            all_playlist_ids,
            key=lambda pid: (
                self.cache.get_playlist_metadata(pid).get("name", pid).lower()
                if self.cache.get_playlist_metadata(pid)
                else pid.lower()
            ),
        )

        # Create checkbox for each playlist
        track_id: str = str(self.track.id)

        for playlist_id in sorted_playlist_ids:
            # Get playlist info
            metadata: dict | None = self.cache.get_playlist_metadata(playlist_id)

            if metadata is None:
                # Try to fetch metadata from Tidal session as a fallback
                try:
                    playlist_obj = self.session.playlist(playlist_id)
                    if playlist_obj:
                        fetched_name = getattr(playlist_obj, "name", None) or f"Playlist {playlist_id}"
                        fetched_count = getattr(playlist_obj, "num_tracks", None)
                        item_count_val = int(fetched_count) if isinstance(fetched_count, int) else 0
                        # Store back into cache for future lookups
                        self.cache.set_playlist_metadata(playlist_id, fetched_name, item_count_val)
                        metadata = {"name": fetched_name, "item_count": item_count_val, "id": str(playlist_id)}
                        logger_gui.debug(
                            f"(i) Fetched missing metadata for playlist '{playlist_id}': name='{fetched_name}', count={item_count_val}"
                        )
                    else:
                        logger_gui.warning(f"⚠️ No metadata found for playlist {playlist_id} (session returned None)")
                except Exception as e:
                    logger_gui.warning(f"⚠️ Failed to fetch metadata for playlist {playlist_id}: {e}")

            playlist_name: str = (
                metadata.get("name", f"Playlist {playlist_id}") if metadata else f"Playlist {playlist_id}"
            )
            item_count: int = metadata.get("item_count", 0) if metadata else 0

            # Check if track is in this playlist
            is_in_playlist: bool = self.cache.is_track_in_playlist(track_id, playlist_id)
            self._original_states[playlist_id] = is_in_playlist

            # Row widget
            row_widget = QtWidgets.QWidget()
            row_layout = QtWidgets.QHBoxLayout(row_widget)
            row_layout.setContentsMargins(0, 0, 0, 0)
            row_layout.setSpacing(8)

            # Checkbox
            checkbox: QtWidgets.QCheckBox = QtWidgets.QCheckBox()
            checkbox.setChecked(is_in_playlist)
            checkbox.setProperty("playlist_id", playlist_id)
            checkbox.stateChanged.connect(
                lambda state, cbox=checkbox, pid=playlist_id: (self._on_playlist_checkbox_changed(cbox, pid, state))
            )
            row_layout.addWidget(checkbox)

            # Playlist name
            name_label: QtWidgets.QLabel = QtWidgets.QLabel(playlist_name)
            row_layout.addWidget(name_label)

            # Item count
            count_label: QtWidgets.QLabel = QtWidgets.QLabel(f"({item_count})")
            count_label.setStyleSheet("color: gray; font-size: 0.85em;")
            row_layout.addWidget(count_label)

            # Stretch
            row_layout.addStretch()

            self.ui.verticalLayoutList.insertWidget(self.ui.verticalLayoutList.count() - 1, row_widget)  # before spacer

    def _on_playlist_checkbox_changed(self, checkbox: QtWidgets.QCheckBox, playlist_id: str, state: int) -> None:
        """Handle checkbox state change for a playlist.

        Implements transactional logic:
        1. Disable checkbox and show spinner
        2. Make API call (POST/DELETE)
        3. On success: update cache, re-enable
        4. On error: rollback state, show toast

        Args:
            checkbox: The checkbox widget
            playlist_id: ID of the playlist
            state: Qt CheckState (2=checked, 0=unchecked)
        """
        is_checked: bool = state == QtCore.Qt.CheckState.Checked.value

        # Save previous state for rollback
        previous_state: bool = not is_checked

        # Disable UI during transaction
        checkbox.setEnabled(False)

        # Start transaction
        track_id: str = str(self.track.id)

        if is_checked:
            # Add track to playlist
            worker: Worker = Worker(
                self._api_add_track_to_playlist,
                track_id,
                playlist_id,
                checkbox,
                previous_state,
            )
        else:
            # Remove track from playlist
            worker: Worker = Worker(
                self._api_remove_track_from_playlist,
                track_id,
                playlist_id,
                checkbox,
                previous_state,
            )

        # Store worker reference for potential cancellation
        self._pending_tasks[f"{playlist_id}"] = worker
        self.threadpool.start(worker)

    def _api_add_track_to_playlist(
        self, track_id: str, playlist_id: str, checkbox: QtWidgets.QCheckBox, previous_state: bool
    ) -> None:
        """API call to add track to playlist (runs in worker thread).

        Args:
            track_id: Track UUID
            playlist_id: Playlist UUID
            checkbox: Checkbox widget to update on completion
            previous_state: Previous checkbox state for rollback
        """
        try:
            # Use centralized API helper
            add_track_to_playlist(self.session, playlist_id, track_id)

            # Success: update cache and UI
            self.cache.add_track_to_playlist(track_id, playlist_id)
            self.playlist_added.emit(track_id, playlist_id)  # Notify listeners

            # Re-enable checkbox
            checkbox.setEnabled(True)

        except RequestException:
            # Rollback: restore previous state
            checkbox.blockSignals(True)
            checkbox.setChecked(previous_state)
            checkbox.blockSignals(False)
            checkbox.setEnabled(True)

            # Show error notification (via statusbar or toast)
            self._show_error_notification("Impossible d'ajouter à la playlist")

        except Exception:
            checkbox.blockSignals(True)
            checkbox.setChecked(previous_state)
            checkbox.blockSignals(False)
            checkbox.setEnabled(True)
            self._show_error_notification("Erreur lors de la modification")

    def _api_remove_track_from_playlist(
        self, track_id: str, playlist_id: str, checkbox: QtWidgets.QCheckBox, previous_state: bool
    ) -> None:
        """API call to remove track from playlist (runs in worker thread).

        Args:
            track_id: Track UUID
            playlist_id: Playlist UUID
            checkbox: Checkbox widget to update on completion
            previous_state: Previous checkbox state for rollback
        """
        try:
            # Use centralized API helper
            remove_track_from_playlist(self.session, playlist_id, track_id)

            # Success: update cache and UI
            self.cache.remove_track_from_playlist(track_id, playlist_id)
            self.playlist_removed.emit(track_id, playlist_id)

            # Re-enable checkbox
            checkbox.setEnabled(True)

        except RequestException:
            # Rollback: restore previous state
            checkbox.blockSignals(True)
            checkbox.setChecked(previous_state)
            checkbox.blockSignals(False)
            checkbox.setEnabled(True)

            self._show_error_notification("Impossible de retirer de la playlist")

        except Exception:
            checkbox.blockSignals(True)
            checkbox.setChecked(previous_state)
            checkbox.blockSignals(False)
            checkbox.setEnabled(True)
            self._show_error_notification("Erreur lors de la modification")

    def _show_error_notification(self, message: str) -> None:
        """Show a non-intrusive error notification.

        Args:
            message: Error message to display

        TODO: Integrate with app's notification system (Toast/Snackbar)
        """
        logger_gui.debug(f"PlaylistManagerDialog notification: {message}")

    def closeEvent(self, event: QtGui.QCloseEvent) -> None:
        """Handle dialog close event.

        Cancels any pending operations.

        Args:
            event: Close event
        """
        # Cancel pending tasks (Worker doesn't have built-in abort, but we can clean up references)
        self._pending_tasks.clear()
        super().closeEvent(event)


# Attach implementation class to the generated UI module for import compatibility
try:
    import sys

    import tidal_dl_ng.ui.dialog_playlist_manager as _ui_mod

    _ui_mod.PlaylistManagerDialog = PlaylistManagerDialog
    # Ensure module is in sys.modules for proper imports
    sys.modules["tidal_dl_ng.ui.dialog_playlist_manager"] = _ui_mod
except Exception as e:
    # If UI module isn't importable in some contexts, log and continue
    from tidal_dl_ng.logger import logger_gui

    logger_gui.debug(f"Could not attach PlaylistManagerDialog to UI module: {e}")