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.
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user