summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorWarry <FunWarry@users.noreply.github.com>2025-12-29 17:37:39 +0100
committerWarry <FunWarry@users.noreply.github.com>2025-12-29 17:37:39 +0100
commit296c6c7d6b319b38958b2aacb9904f18f6f46fd1 (patch)
treea3e72a9326defee5804cc9387ecee779b9bf02a2 /docs
parent75868b32d07aafe46b69649d2da0dc2c833f112d (diff)
downloadtidal-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.md93
-rw-r--r--docs/playlist_membership_manager.md499
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