diff options
| author | Warry <FunWarry@users.noreply.github.com> | 2025-12-29 17:37:39 +0100 |
|---|---|---|
| committer | Warry <FunWarry@users.noreply.github.com> | 2025-12-29 17:37:39 +0100 |
| commit | 296c6c7d6b319b38958b2aacb9904f18f6f46fd1 (patch) | |
| tree | a3e72a9326defee5804cc9387ecee779b9bf02a2 /docs | |
| parent | 75868b32d07aafe46b69649d2da0dc2c833f112d (diff) | |
| download | tidal-dl-ng-296c6c7d6b319b38958b2aacb9904f18f6f46fd1.tar.gz | |
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.
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/playlist_api_architecture.md | 93 | ||||
| -rw-r--r-- | docs/playlist_membership_manager.md | 499 |
2 files changed, 592 insertions, 0 deletions
diff --git a/docs/playlist_api_architecture.md b/docs/playlist_api_architecture.md new file mode 100644 index 0000000..d6d601f --- /dev/null +++ b/docs/playlist_api_architecture.md @@ -0,0 +1,93 @@ +# Architecture des appels API pour les playlists + +## Vue d'ensemble + +Tous les appels API liés aux playlists ont été centralisés dans un seul module pour une meilleure maintenabilité et cohérence. + +## Fichier centralisé : `tidal_dl_ng/helper/playlist_api.py` + +Ce module contient toutes les fonctions d'interaction avec l'API Tidal pour les playlists : + +### Fonctions disponibles + +#### 1. `get_user_playlists(session: Session) -> list[UserPlaylist]` +- **Description**: Récupère toutes les playlists de l'utilisateur +- **Paramètres**: Session Tidal authentifiée +- **Retour**: Liste d'objets UserPlaylist +- **Exceptions**: RequestException, ValueError + +#### 2. `get_playlist_items(playlist: UserPlaylist) -> list[Track]` +- **Description**: Récupère tous les morceaux d'une playlist +- **Paramètres**: Objet UserPlaylist +- **Retour**: Liste d'objets Track +- **Exceptions**: RequestException + +#### 3. `add_track_to_playlist(session: Session, playlist_id: str, track_id: str) -> None` +- **Description**: Ajoute un morceau à une playlist +- **Paramètres**: + - session: Session Tidal + - playlist_id: UUID de la playlist + - track_id: UUID du morceau +- **Exceptions**: RequestException, ValueError + +#### 4. `remove_track_from_playlist(session: Session, playlist_id: str, track_id: str) -> None` +- **Description**: Retire un morceau d'une playlist +- **Paramètres**: + - session: Session Tidal + - playlist_id: UUID de la playlist + - track_id: UUID du morceau +- **Exceptions**: RequestException, ValueError +- **Note**: Gère automatiquement la recherche de l'index du morceau + +#### 5. `get_playlist_metadata(playlist: UserPlaylist) -> dict[str, str | int]` +- **Description**: Extrait les métadonnées d'une playlist +- **Paramètres**: Objet UserPlaylist +- **Retour**: Dictionnaire avec `name`, `item_count`, `id` + +## Modules utilisant l'API centralisée + +### 1. `tidal_dl_ng/gui/dialog_playlist_manager.py` +Utilise : +- `add_track_to_playlist()` - Pour ajouter des morceaux via l'interface +- `remove_track_from_playlist()` - Pour retirer des morceaux via l'interface + +### 2. `tidal_dl_ng/gui/playlist_membership.py` +Utilise : +- `get_user_playlists()` - Pour charger les playlists au démarrage +- `get_playlist_items()` - Pour construire le cache de memberships +- `get_playlist_metadata()` - Pour afficher les noms et comptes + +## Avantages de cette architecture + +1. **Maintenabilité** : Un seul endroit pour modifier la logique API +2. **Cohérence** : Même gestion d'erreurs partout +3. **Testabilité** : Facile de mocker les fonctions API +4. **Logging centralisé** : Tous les logs API au même endroit +5. **Évolutivité** : Facile d'ajouter de nouvelles fonctions + +## Gestion des erreurs + +Toutes les fonctions : +- Loggent les erreurs avec `logger_gui` +- Propagent les exceptions (RequestException) pour que l'appelant puisse gérer +- Gèrent automatiquement les cas limites (playlist vide, morceau non trouvé, etc.) + +## Exemple d'utilisation + +```python +from tidal_dl_ng.helper.playlist_api import add_track_to_playlist, get_user_playlists + +# Récupérer les playlists +playlists = get_user_playlists(session) + +# Ajouter un morceau +try: + add_track_to_playlist(session, playlist_id="abc123", track_id="def456") + print("Morceau ajouté avec succès") +except RequestException as e: + print(f"Erreur: {e}") +``` + +## Migration future + +Si besoin de passer à une autre bibliothèque API ou d'ajouter un cache HTTP, il suffit de modifier `playlist_api.py` sans toucher aux autres fichiers. diff --git a/docs/playlist_membership_manager.md b/docs/playlist_membership_manager.md new file mode 100644 index 0000000..234aa28 --- /dev/null +++ b/docs/playlist_membership_manager.md @@ -0,0 +1,499 @@ +# Gestionnaire d'Appartenance aux Playlists (Playlist Membership Manager) + +## Vue d'ensemble architecturale + +### Problème utilisateur +L'application agit actuellement comme un silo de téléchargement. Pour organiser sa musique (ajouter à une playlist), l'utilisateur doit quitter l'app, ouvrir le client officiel Tidal, chercher à nouveau, puis agir. Ceci rompt le flux d'expérience. + +### Solution proposée +Transformer chaque vue de résultats (Album, Recherche, Playlist) en tableau interactif avec : +- Une colonne dédiée "Playlists" +- Indicateur visuel instantané de l'appartenance +- Gestion d'ajout/retrait sans friction + +--- + +## 1. Stratégie de Pré-chargement (Eager Loading & Caching) + +### 1.1 Objectif de performance +Éliminer toute latence au clic. L'utilisateur accepte d'attendre le spinner au rechargement de la vue, mais pas l'ouverture du dialogue. + +### 1.2 Architecture du Worker + +``` +Event: modelReset / layoutChanged (Main Table) + ↓ +Déclenche PlaylistContextLoader (QRunnable) + ↓ +Thread Pool Execution: + ├─ Fetch User Playlists (GET /users/{userId}/playlists) + │ └─ Pagination gérée (limit=50 par défaut) + │ └─ Filtre des playlists éditables + ├─ Parallèle : Fetch Playlist Contents + │ ├─ Pour chaque playlist : GET /playlists/{id}/items?offset=0&limit=300 + │ └─ Accumule les Track IDs dans Set pour O(1) lookup + └─ Spinlock/Event synchronisation + ↓ + Construit: Cache = Dict[TrackID, Set[PlaylistID]] + ↓ + Émet Signal: playlistCacheReady(cache_dict) + ↓ + Tableau met à jour Delegate → Spinner OFF, Bouton ON +``` + +### 1.3 Structure de données + +```python +# Cache structure +PlaylistCache = Dict[str, Set[str]] +# Exemple: +# { +# "track_uuid_1": {"playlist_id_1", "playlist_id_2"}, +# "track_uuid_2": {"playlist_id_3"}, +# } + +# Thread-safe wrapper +class ThreadSafePlaylistCache: + _lock: threading.RLock + _data: Dict[str, Set[str]] + _metadata: Dict[str, PlaylistMetadata] + + def get(self, track_id: str) -> Set[str]: + """O(1) lookup with thread safety""" + + def update_track(self, track_id: str, playlist_ids: Set[str]): + """Atomic update""" +``` + +### 1.4 Gestion d'erreurs et interruption + +- **Interruptibilité** : Signal `abort_requested` pendant le chargement +- **Fail-silent** : Les erreurs réseau n'affichent pas de popup +- **Timeout** : Limite de 30s par playlistId +- **Limite de taux** : Max 5 requêtes parallèles (QThreadPool.maxThreadCount() = 5) + +--- + +## 2. Interface - Colonne "Playlists" (Delegate & États) + +### 2.1 Machine à états visuels + +``` +┌─────────────────────────────────────────────────────────┐ +│ État 1: PENDING (Initial) │ +├─────────────────────────────────────────────────────────┤ +│ Trigger: Cache not ready for this track │ +│ Visual: Spinner animé (SVG/QMovie), désactivé │ +│ Interaction: Désactivée (curseur "wait") │ +│ Durée: 0-5 secondes max │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ État 2: READY (Normal) │ +├─────────────────────────────────────────────────────────┤ +│ Trigger: Cache ready pour ce track │ +│ Visual: Flat Button "📋 Playlists", gris neutre │ +│ Interaction: Cliquable (curseur "pointer") │ +│ Action: Ouvre PlaylistManagerDialog │ +└─────────────────────────────────────────────────────────┘ + ↙ (erreur lors du clic) +┌─────────────────────────────────────────────────────────┐ +│ État 3: ERROR (Optionnel) │ +├─────────────────────────────────────────────────────────┤ +│ Trigger: Préchargement échoué (API down) │ +│ Visual: Icône d'avertissement ⚠️ grise │ +│ Interaction: Désactivée avec tooltip │ +│ Durée: Persistant jusqu'à reload │ +└─────────────────────────────────────────────────────────┘ +``` + +### 2.2 Intégration QStyledItemDelegate + +``` +QTableView/QTreeView ModelReset + ↓ +playlistColumnDelegate.setModelData() + ├─ row = model_index.row() + ├─ track_id = model.data(row, TrackIdRole) + ├─ state = cache.get_state(track_id) → [PENDING|READY|ERROR] + └─ paint() selon state + ├─ PENDING: drawSpinner() + ├─ READY: drawButton() + └─ ERROR: drawWarningIcon() +``` + +--- + +## 3. Fenêtre Modale : PlaylistManagerDialog + +### 3.1 Design & Layout + +``` +╔════════════════════════════════════════════════════════════╗ +║ Gérer les playlists pour: [TRACK_TITLE] ║ ← QLabel +╠════════════════════════════════════════════════════════════╣ +║ ║ +║ ☐ Favorites (16) ║ ← QCheckBox + QLabel +║ ☐ Night Vibes (42) ║ +║ ☐ Workout Mix (103) ║ +║ ☐ Découvertes semaine (27) ║ +║ ║ +║ [Scroll Area: QScrollArea] ║ +║ ║ +╠════════════════════════════════════════════════════════════╣ +║ [Fermer] ║ ← QPushButton +╚════════════════════════════════════════════════════════════╝ +``` + +### 3.2 Composants + +```python +class PlaylistManagerDialog(QDialog): + """Dialogue de gestion des appartenance aux playlists.""" + + # 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, + tidal_session: Session, + parent: QWidget): + """Initialisation du dialogue. + + Args: + track: La piste courante + cache: Le cache pré-chargé + tidal_session: Session Tidal pour les appels API + parent: Widget parent + """ + + def populate_playlists(self) -> None: + """Remplit la liste des playlists depuis le cache. + + - Tri alphabétique + - Récupère l'état d'appartenance du cache + - Crée les checkboxes + """ + + def on_playlist_toggled(self, checkbox: QCheckBox, + playlist_id: str, + is_checked: bool) -> None: + """Gère le changement d'état d'une checkbox. + + Flux: + 1. Désactiver checkbox + afficher spinner + 2. Appel API (POST/DELETE) + 3. Succès: Mettre à jour cache local + 4. Erreur: Rollback + Toast notification + """ +``` + +### 3.3 Logique transactionnelle + +``` +User clicks checkbox + ↓ +on_playlist_toggled(playlist_id, is_checked) + ├─ Sauvegarder l'état précédent (rollback_state) + ├─ Désactiver checkbox visuellement + ├─ Afficher mini-spinner + ├─ Déterminer l'action: ADD (POST) ou REMOVE (DELETE) + ├─ Appel API threadé + │ ├─ Succès (200 OK): + │ │ ├─ Mettre à jour cache[track_id][playlist_id] + │ │ ├─ Réactiver checkbox + │ │ └─ Émettre signal (pour synchronisation UI) + │ │ + │ └─ Échec (4xx/5xx): + │ ├─ Restorer checkbox état = rollback_state + │ ├─ Réactiver checkbox + │ └─ Afficher Toast: "Impossible de modifier" + │ + └─ [FIN] +``` + +--- + +## 4. Implémentation API & Services + +### 4.1 PlaylistContextLoader (Worker Thread) + +```python +class PlaylistContextLoader(QtCore.QRunnable): + """Charge les contextes de playlists de manière concurrente.""" + + # Signals (communiquent du worker thread → main thread) + started: QtCore.Signal = QtCore.Signal() + progress: QtCore.Signal = QtCore.Signal(int, int) # current, total + cache_ready: QtCore.Signal = QtCore.Signal(dict) # ThreadSafePlaylistCache + error: QtCore.Signal = QtCore.Signal(str) + finished: QtCore.Signal = QtCore.Signal() + + def __init__(self, + session: Session, + user_id: str, + max_workers: int = 5): + """Initialise le loader. + + Args: + session: Session Tidal authentifiée + user_id: ID de l'utilisateur courant + max_workers: Nombre de threads concurrents pour les requêtes + """ + + def run(self) -> None: + """Point d'entrée du thread worker. + + Séquence: + 1. Fetch all user playlists (pagination) + 2. Pour chaque playlist (parallèle): + - Fetch all items (pagination) + - Extract track IDs + 3. Build cache Dict[track_id, Set[playlist_id]] + 4. Émettre signal cache_ready avec cache + """ + + def _fetch_user_playlists(self) -> list[Playlist]: + """Récupère toutes les playlists de l'utilisateur. + + Gère la pagination (offset/limit). + Filtre les playlists éditables uniquement. + """ + + def _fetch_playlist_items(self, + playlist_id: str, + limit: int = 300) -> set[str]: + """Récupère tous les IDs de pistes d'une playlist. + + Gère la pagination automatique. + Retourne un Set pour O(1) lookup. + """ + + def request_abort(self) -> None: + """Demande l'interruption du loader. + + Interruptibilité: Finit la requête en cours, puis s'arrête. + """ +``` + +### 4.2 Endpoints Tidal utilisés + +``` +# 1. Récupérer les playlists +GET /users/{userId}/playlists + ?offset=0 + &limit=50 + &includeOnly=EDITORIAL,COLLABORATIVE + +Response: {"items": [...], "totalNumberOfItems": N} + +# 2. Récupérer les items d'une playlist +GET /playlists/{playlistId}/items + ?offset=0 + &limit=300 + &includeOnly=TRACKS,VIDEOS + +Response: {"items": [...], "totalNumberOfItems": M} + où items[i].item.id = track_uuid + +# 3. Ajouter une piste à une playlist +POST /playlists/{playlistId}/items + Content-Type: application/json + {"trackIds": ["uuid1", "uuid2"]} + +# 4. Supprimer une piste d'une playlist +DELETE /playlists/{playlistId}/items/{itemId} + OU +DELETE /playlists/{playlistId}/items?itemIds=uuid1,uuid2 +``` + +### 4.3 Rate Limiting & Timeout + +- **Timeout par requête** : 30 secondes +- **Max concurrent requests** : 5 (limité par QThreadPool) +- **Backoff strategy** : Exponentiel (1s → 2s → 4s) sur 429/503 +- **Fail-silent** : Log warning, pas de popup utilisateur + +--- + +## 5. Intégration dans MainWindow + +### 5.1 Initialization Flow + +```python +class MainWindow: + def init_playlist_membership_manager(self): + """Initialise le gestionnaire d'appartenance aux playlists.""" + + # 1. Créer le cache thread-safe + self.playlist_cache = ThreadSafePlaylistCache() + + # 2. Créer le worker (pas de démarrage encore) + self.playlist_loader = PlaylistContextLoader( + session=self.tidal.session, + user_id=self.tidal.user_id, + max_workers=5 + ) + + # 3. Connecter les signaux du worker + self.playlist_loader.cache_ready.connect(self.on_playlist_cache_ready) + self.playlist_loader.error.connect(self.on_playlist_loader_error) + self.playlist_loader.progress.connect(self.on_playlist_loader_progress) + + # 4. Connecter les signaux du tableau + self.model_tr_results.modelReset.connect(self.on_results_layout_changed) + self.proxy_tr_results.layoutChanged.connect(self.on_results_layout_changed) + + # 5. Ajouter le delegate personnalisé + self.playlist_column_delegate = PlaylistColumnDelegate( + parent=self.tr_results + ) + self.tr_results.setItemDelegateForColumn(PLAYLIST_COLUMN_INDEX, + self.playlist_column_delegate) + + def on_results_layout_changed(self) -> None: + """Déclenche le pré-chargement lors d'un changement de résultats.""" + + # 1. Réinitialiser le cache + self.playlist_cache.clear() + + # 2. Lancer le worker de pré-chargement + self.threadpool.start(self.playlist_loader) + + def on_playlist_cache_ready(self, cache: dict) -> None: + """Appelé quand le cache est prêt. + + Notifie le delegate que le cache a changé. + """ + self.playlist_cache.update_from_dict(cache) + self.playlist_column_delegate.setCacheReady(True) + self.tr_results.viewport().update() # Redessine les cellules +``` + +--- + +## 6. Tests & Qualité + +### 6.1 Fichier de tests: tests/test_playlist_manager.py + +```python +class TestPlaylistContextLoader(unittest.TestCase): + """Tests du worker de pré-chargement.""" + + def test_fetch_user_playlists_pagination(self): + """Vérifie la gestion de la pagination.""" + + def test_build_cache_structure(self): + """Vérifie la construction correcte du cache.""" + + def test_thread_safety_concurrent_updates(self): + """Vérifie la thread-safety lors d'accès concurrents.""" + + def test_abort_request(self): + """Vérifie l'interruption du worker.""" + + def test_error_handling_api_failure(self): + """Vérifie la gestion des erreurs API.""" + + +class TestPlaylistManagerDialog(unittest.TestCase): + """Tests du dialogue modal.""" + + def test_dialog_initialization(self): + """Vérifie l'initialisation correcte du dialogue.""" + + def test_checkbox_state_from_cache(self): + """Vérifie que les checkboxes reflètent l'état du cache.""" + + def test_add_playlist_transaction(self): + """Vérifie l'ajout d'une piste à une playlist.""" + + def test_remove_playlist_transaction(self): + """Vérifie le retrait d'une piste d'une playlist.""" + + def test_api_error_rollback(self): + """Vérifie le rollback en cas d'erreur API.""" + + +class TestPlaylistColumnDelegate(unittest.TestCase): + """Tests du delegate personnalisé.""" + + def test_state_pending_spinner_rendering(self): + """Vérifie le rendu du spinner en état PENDING.""" + + def test_state_ready_button_rendering(self): + """Vérifie le rendu du bouton en état READY.""" + + def test_state_transition_pending_to_ready(self): + """Vérifie la transition d'état PENDING → READY.""" + + def test_click_opens_dialog(self): + """Vérifie que le clic ouvre le dialogue.""" + + +class TestThreadSafePlaylistCache(unittest.TestCase): + """Tests du cache thread-safe.""" + + def test_concurrent_reads_no_deadlock(self): + """Vérifie les lectures concurrentes.""" + + def test_add_track_to_playlist(self): + """Vérifie l'ajout d'une piste.""" + + def test_remove_track_from_playlist(self): + """Vérifie le retrait d'une piste.""" + + def test_contains_check_o1_performance(self): + """Vérifie la complexité O(1) des vérifications.""" +``` + +### 6.2 Métriques de qualité + +- **Coverage cible** : ≥ 85% (tests/test_playlist_manager.py) +- **Linting** : `make check` doit passer (Black, isort, Flake8) +- **Type checking** : `mypy` sans errors +- **Performance** : Cache lookup < 1ms, Dialog appearance < 50ms + +--- + +## 7. Dictionnaire d'erreurs + +| Code | Signification | Action | +|------|---------------|--------| +| 401 | Non authentifié | Redirection login | +| 403 | Pas de permission | Toast silencieux | +| 404 | Playlist non trouvée | Log + continue | +| 429 | Rate limited | Backoff exponentiel | +| 500+ | Erreur serveur | Retry automatique (3x) | +| Timeout | Dépassement délai | Abort + log | + +--- + +## 8. Résumé des fichiers à créer + +1. **tidal_dl_ng/gui/playlist_membership.py** + - `ThreadSafePlaylistCache` + - `PlaylistContextLoader` + - `PlaylistColumnDelegate` + +2. **tidal_dl_ng/gui/dialog_playlist_manager.py** + - `PlaylistManagerDialog` + +3. **tests/test_playlist_manager.py** + - Tests exhaustifs + +4. **docs/playlist_membership_manager.md** (ce fichier) + +5. **Modifications à tidal_dl_ng/gui/main_window.py** + - Intégration des signaux et initialization + +--- + +## 9. Chronologie estimée + +- **Phase 1** : Backend (Worker + Cache) : 4-5h +- **Phase 2** : UI (Delegate + Dialog) : 3-4h +- **Phase 3** : Tests + Intégration : 2-3h +- **Total** : ~10-12h |
