Files

500 lines
18 KiB
Markdown
Raw Permalink Normal View History

# 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