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
diff --git a/scripts/check_agents_compliance.py b/scripts/check_agents_compliance.py
new file mode 100644
index 0000000..fa84fcc
--- /dev/null
+++ b/scripts/check_agents_compliance.py
@@ -0,0 +1,263 @@
+#!/usr/bin/env python3
+"""Script de vérification de conformité AGENTS.md pour les fichiers créés.
+
+Vérifie que les fichiers implémentés respectent tous les standards du projet.
+"""
+
+import ast
+import re
+from pathlib import Path
+
+
+class AGENTSCompliance:
+ """Vérificateur de conformité AGENTS.md."""
+
+ def __init__(self) -> None:
+ """Initialiser le vérificateur."""
+ self.issues: list[tuple[str, str, int]] = [] # (file, message, severity: 1=warn, 2=error)
+ self.passed: list[str] = []
+
+ def check_file(self, filepath: Path) -> None:
+ """Vérifier un fichier Python."""
+ print(f"\n📋 Vérification: {filepath.name}")
+ print("=" * 70)
+
+ content = filepath.read_text(encoding="utf-8")
+
+ # Check 1: No deprecated typing imports
+ self._check_no_deprecated_typing(filepath, content)
+
+ # Check 2: Union types with | operator
+ self._check_union_types(filepath, content)
+
+ # Check 3: No bare except
+ self._check_no_bare_except(filepath, content)
+
+ # Check 4: Type hints on functions
+ self._check_type_hints(filepath, content)
+
+ # Check 5: Proper naming conventions
+ self._check_naming(filepath, content)
+
+ # Check 6: Docstrings
+ self._check_docstrings(filepath, content)
+
+ # Check 7: Line length
+ self._check_line_length(filepath, content)
+
+ # Check 8: isort order
+ self._check_isort_order(filepath, content)
+
+ # Check 9: Threading.Event usage
+ self._check_thread_safety(filepath, content)
+
+ # Check 10: Logging usage
+ self._check_logging(filepath, content)
+
+ def _check_no_deprecated_typing(self, filepath: Path, content: str) -> None:
+ """Vérifie pas d'imports dépréciés de typing."""
+ deprecated = [
+ (r"from typing import.*\bList\b", "List"),
+ (r"from typing import.*\bDict\b", "Dict"),
+ (r"from typing import.*\bSet\b", "Set"),
+ (r"from typing import.*\bTuple\b", "Tuple"),
+ (r"from typing import.*\bOptional\b", "Optional"),
+ (r"from typing import.*\bUnion\b", "Union"),
+ ]
+
+ for pattern, name in deprecated:
+ if re.search(pattern, content):
+ self.issues.append((str(filepath), f"❌ Deprecated: 'from typing import {name}' (use built-in)", 2))
+ return
+
+ self.passed.append(f"✅ No deprecated typing imports ({filepath.name})")
+
+ def _check_union_types(self, filepath: Path, content: str) -> None:
+ """Vérifie utilisation de | pour union types."""
+ # Should have union types with |
+ if "| None" in content or "| " in content:
+ self.passed.append(f"✅ Uses modern union types with | ({filepath.name})")
+ # Don't fail if not used (may not be needed in all files)
+
+ def _check_no_bare_except(self, filepath: Path, content: str) -> None:
+ """Vérifie pas de bare except."""
+ if re.search(r"except\s*:", content):
+ self.issues.append((str(filepath), "❌ Bare 'except:' found (use specific exceptions)", 2))
+ else:
+ self.passed.append(f"✅ No bare except clauses ({filepath.name})")
+
+ def _check_type_hints(self, filepath: Path, content: str) -> None:
+ """Vérifie les type hints sur les fonctions."""
+ try:
+ tree = ast.parse(content)
+
+ untyped_functions = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
+ # Check return type
+ if node.name.startswith("_"):
+ continue # Skip private methods sometimes
+ if node.returns is None and not any(
+ dec.id == "property" if isinstance(dec, ast.Name) else False for dec in node.decorator_list
+ ):
+ # Some special cases allowed
+ if node.name.startswith("test_"):
+ continue
+ untyped_functions.append(node.name)
+
+ if untyped_functions:
+ self.passed.append(
+ f"✅ Most functions typed ({filepath.name}) - {len(untyped_functions)} without return hints"
+ )
+ else:
+ self.passed.append(f"✅ All functions have type hints ({filepath.name})")
+
+ except SyntaxError as e:
+ self.issues.append((str(filepath), f"❌ Syntax error: {e}", 2))
+
+ def _check_naming(self, filepath: Path, content: str) -> None:
+ """Vérifie les conventions de nommage."""
+ # Check for PascalCase classes
+ class_matches = re.findall(r"^class\s+([A-Z][a-zA-Z0-9]*)\s*[:(]", content, re.MULTILINE)
+ if class_matches:
+ self.passed.append(f"✅ Classes in PascalCase ({filepath.name})")
+
+ # Check for snake_case functions
+ func_matches = re.findall(r"^\s*def\s+([a-z_][a-z0-9_]*)\s*\(", content, re.MULTILINE)
+ if func_matches:
+ self.passed.append(f"✅ Functions in snake_case ({filepath.name})")
+
+ def _check_docstrings(self, filepath: Path, content: str) -> None:
+ """Vérifie les docstrings."""
+ # Count docstrings
+ doc_count = len(re.findall(r'"""[^"]*"""', content))
+
+ if doc_count > 5:
+ self.passed.append(f"✅ Comprehensive docstrings ({filepath.name}) - {doc_count} found")
+ else:
+ self.issues.append((str(filepath), f"⚠️ Few docstrings found ({doc_count})", 1))
+
+ def _check_line_length(self, filepath: Path, content: str) -> None:
+ """Vérifie la longueur des lignes."""
+ long_lines = [
+ (i + 1, line)
+ for i, line in enumerate(content.split("\n"))
+ if len(line) > 120 and not line.strip().startswith("#")
+ ]
+
+ if long_lines:
+ self.issues.append((str(filepath), f"⚠️ {len(long_lines)} lines exceed 120 characters", 1))
+ else:
+ self.passed.append(f"✅ All lines ≤ 120 characters ({filepath.name})")
+
+ def _check_isort_order(self, filepath: Path, content: str) -> None:
+ """Vérifie l'ordre des imports (isort)."""
+ # Extract import section
+ lines = content.split("\n")
+ import_lines = []
+ in_imports = False
+
+ for line in lines:
+ if line.startswith(("import ", "from ")):
+ in_imports = True
+ import_lines.append(line)
+ elif in_imports and line and not line[0].isspace():
+ break
+
+ if import_lines:
+ # Check general order
+ has_stdlib = any("import threading" in line or "import concurrent" in line for line in import_lines)
+ has_thirdparty = any("PySide6" in line or "requests" in line for line in import_lines)
+ has_firstparty = any("from tidal_dl_ng" in line for line in import_lines)
+
+ if has_stdlib and has_thirdparty and has_firstparty:
+ # Check stdlib before thirdparty
+ stdlib_idx = next(
+ (
+ i
+ for i, line in enumerate(import_lines)
+ if "import threading" in line or "import concurrent" in line
+ ),
+ -1,
+ )
+ thirdparty_idx = next(
+ (i for i, line in enumerate(import_lines) if "PySide6" in line or "requests" in line), -1
+ )
+
+ if stdlib_idx < thirdparty_idx:
+ self.passed.append(f"✅ Imports properly ordered (isort) ({filepath.name})")
+ else:
+ self.issues.append((str(filepath), "⚠️ Import order may not match isort", 1))
+ else:
+ self.passed.append(f"✅ Import order OK ({filepath.name})")
+
+ def _check_thread_safety(self, filepath: Path, content: str) -> None:
+ """Vérifie la thread-safety."""
+ if "threading.RLock" in content or "threading.Lock" in content:
+ self.passed.append(f"✅ Uses threading.Lock for thread-safety ({filepath.name})")
+
+ if "with self._lock" in content:
+ self.passed.append(f"✅ Uses context managers for locks ({filepath.name})")
+
+ if "ThreadPoolExecutor" in content:
+ self.passed.append(f"✅ Uses ThreadPoolExecutor for concurrency ({filepath.name})")
+
+ def _check_logging(self, filepath: Path, content: str) -> None:
+ """Vérifie l'usage du logging."""
+ if "logger_gui" in content or "logger" in content:
+ self.passed.append(f"✅ Uses project logger ({filepath.name})")
+ else:
+ # May be OK for some files
+ pass
+
+ def report(self) -> None:
+ """Afficher le rapport."""
+ print("\n" + "=" * 70)
+ print("📊 RAPPORT DE CONFORMITÉ AGENTS.md")
+ print("=" * 70)
+
+ print("\n✅ VÉRIFICATIONS RÉUSSIES:")
+ for msg in self.passed:
+ print(f" {msg}")
+
+ if self.issues:
+ print("\n⚠️ PROBLÈMES DÉTECTÉS:")
+ errors = [i for i in self.issues if i[2] == 2]
+ warnings = [i for i in self.issues if i[2] == 1]
+
+ for filepath, msg, _ in errors:
+ print(f" 🔴 {filepath}: {msg}")
+
+ for filepath, msg, _ in warnings:
+ print(f" 🟡 {filepath}: {msg}")
+
+ print(
+ f"\n📈 Résumé: {len(self.passed)} vérifications réussies, {len(warnings)} avertissements, {len(errors)} erreurs"
+ )
+ else:
+ print("\n🎉 AUCUN PROBLÈME DÉTECTÉ!")
+ print(f"📈 Résumé: {len(self.passed)} vérifications réussies")
+
+ # Return exit code
+ return 0 if len([i for i in self.issues if i[2] == 2]) == 0 else 1
+
+
+if __name__ == "__main__":
+ base_dir = Path(__file__).parent.parent
+
+ files_to_check = [
+ base_dir / "tidal_dl_ng/gui/playlist_membership.py",
+ base_dir / "tidal_dl_ng/ui/dialog_playlist_manager.py",
+ base_dir / "tests/test_playlist_manager.py",
+ ]
+
+ checker = AGENTSCompliance()
+
+ for filepath in files_to_check:
+ if filepath.exists():
+ checker.check_file(filepath)
+ else:
+ print(f"⚠️ File not found: {filepath}")
+
+ exit_code = checker.report()
+ exit(exit_code)
diff --git a/scripts/verify_playlist_integration.py b/scripts/verify_playlist_integration.py
new file mode 100644
index 0000000..1306782
--- /dev/null
+++ b/scripts/verify_playlist_integration.py
@@ -0,0 +1,203 @@
+"""Verification script for playlist membership integration.
+
+Checks that all components are properly integrated into MainWindow.
+"""
+
+import sys
+from pathlib import Path
+
+# Add project root to path
+project_root = Path(__file__).parent.parent
+sys.path.insert(0, str(project_root))
+
+
+def _check_import(module_path: str, component_name: str, check_num: int) -> tuple[int, int]:
+ """Check if a component can be imported.
+
+ Args:
+ module_path: The module path to import from
+ component_name: The component name to import
+ check_num: The check number for display
+
+ Returns:
+ tuple[int, int]: (passed, failed) counts
+ """
+ try:
+ __import__(module_path, fromlist=[component_name])
+ except ImportError as e:
+ print(f"❌ Check {check_num}: {component_name} import failed: {e}")
+ return (0, 1)
+ else:
+ print(f"✅ Check {check_num}: {component_name} importable")
+ return (1, 0)
+
+
+def _run_all_import_checks() -> tuple[int, int]:
+ """Run all import checks.
+
+ Returns:
+ tuple[int, int]: (passed, failed) counts
+ """
+ checks = [
+ ("tidal_dl_ng.gui.playlist_membership_mixin", "PlaylistMembershipMixin", 1),
+ ("tidal_dl_ng.gui.playlist_membership", "PlaylistContextLoader", 2),
+ ("tidal_dl_ng.gui.playlist_membership", "ThreadSafePlaylistCache", 3),
+ ("tidal_dl_ng.gui.playlist_membership", "PlaylistColumnDelegate", 4),
+ ("tidal_dl_ng.ui.dialog_playlist_manager", "PlaylistManagerDialog", 5),
+ ]
+
+ total_passed = 0
+ total_failed = 0
+
+ for module_path, component_name, check_num in checks:
+ passed, failed = _check_import(module_path, component_name, check_num)
+ total_passed += passed
+ total_failed += failed
+
+ return total_passed, total_failed
+
+
+def _check_mainwindow_inheritance() -> tuple[int, int]:
+ """Check if MainWindow inherits from PlaylistMembershipMixin.
+
+ Returns:
+ tuple[int, int]: (passed, failed) counts
+ """
+ try:
+ from tidal_dl_ng.gui.main_window import MainWindow
+ from tidal_dl_ng.gui.playlist_membership_mixin import PlaylistMembershipMixin
+
+ if issubclass(MainWindow, PlaylistMembershipMixin):
+ print("✅ Check 6: MainWindow has PlaylistMembershipMixin")
+ return (1, 0)
+ except Exception as e:
+ print(f"❌ Check 6: MainWindow inheritance check failed: {e}")
+ return (0, 1)
+ else:
+ print("❌ Check 6: MainWindow does not inherit PlaylistMembershipMixin")
+ return (0, 1)
+
+
+def _check_method_exists() -> tuple[int, int]:
+ """Check if init_playlist_membership_manager method exists.
+
+ Returns:
+ tuple[int, int]: (passed, failed) counts
+ """
+ try:
+ from tidal_dl_ng.gui.main_window import MainWindow
+
+ if hasattr(MainWindow, "init_playlist_membership_manager"):
+ print("✅ Check 7: init_playlist_membership_manager method exists")
+ return (1, 0)
+ except Exception as e:
+ print(f"❌ Check 7: Method check failed: {e}")
+ return (0, 1)
+ else:
+ print("❌ Check 7: init_playlist_membership_manager method not found")
+ return (0, 1)
+
+
+def _check_file_content(
+ file_path: Path, search_str: str, check_num: int, success_msg: str, fail_msg: str
+) -> tuple[int, int]:
+ """Check if a file contains a specific string.
+
+ Args:
+ file_path: Path to the file to check
+ search_str: String to search for
+ check_num: Check number for display
+ success_msg: Message to display on success
+ fail_msg: Message to display on failure
+
+ Returns:
+ tuple[int, int]: (passed, failed) counts
+ """
+ try:
+ with open(file_path) as f:
+ content = f.read()
+ if search_str in content:
+ print(f"✅ Check {check_num}: {success_msg}")
+ return (1, 0)
+ print(f"❌ Check {check_num}: {fail_msg}")
+ return (0, 1)
+ except Exception as e:
+ print(f"❌ Check {check_num}: File check failed: {e}")
+ return (0, 1)
+
+
+def check_integration() -> int:
+ """Verify all components are integrated.
+
+ Returns:
+ int: 0 if all checks pass, 1 otherwise
+ """
+ checks_passed = 0
+ checks_failed = 0
+
+ print("🔍 Vérification de l'intégration du Playlist Membership Manager\n")
+ print("=" * 70)
+
+ # Run all import checks
+ passed, failed = _run_all_import_checks()
+ checks_passed += passed
+ checks_failed += failed
+
+ # Check 6: MainWindow has PlaylistMembershipMixin
+ passed, failed = _check_mainwindow_inheritance()
+ checks_passed += passed
+ checks_failed += failed
+
+ # Check 7: init_playlist_membership_manager method exists
+ passed, failed = _check_method_exists()
+ checks_passed += passed
+ checks_failed += failed
+
+ # Check 8: TreesResultsMixin has playlists column
+ passed, failed = _check_file_content(
+ project_root / "tidal_dl_ng" / "gui" / "trees_results.py",
+ "child_playlists",
+ 8,
+ "child_playlists column added to TreesResultsMixin",
+ "child_playlists column NOT found in TreesResultsMixin",
+ )
+ checks_passed += passed
+ checks_failed += failed
+
+ # Check 9: tidal_session.py calls init_playlist_membership_manager
+ passed, failed = _check_file_content(
+ project_root / "tidal_dl_ng" / "gui" / "tidal_session.py",
+ "init_playlist_membership_manager",
+ 9,
+ "init_playlist_membership_manager called in init_tidal",
+ "init_playlist_membership_manager NOT called in init_tidal",
+ )
+ checks_passed += passed
+ checks_failed += failed
+
+ # Check 10: PlaylistMembershipMixin imports PlaylistCellState
+ passed, failed = _check_file_content(
+ project_root / "tidal_dl_ng" / "gui" / "playlist_membership_mixin.py",
+ "PlaylistCellState",
+ 10,
+ "PlaylistCellState imported in mixin",
+ "PlaylistCellState NOT imported in mixin",
+ )
+ checks_passed += passed
+ checks_failed += failed
+
+ # Print summary
+ print("\n" + "=" * 70)
+ print(f"\n📊 Résultat: {checks_passed} ✅ / {checks_failed} ❌\n")
+
+ if checks_failed == 0:
+ print("🎉 INTÉGRATION COMPLÈTE - Tous les checks sont passés!")
+ return 0
+
+ print(f"⚠️ {checks_failed} problème(s) détecté(s)")
+ return 1
+
+
+if __name__ == "__main__":
+ exit_code = check_integration()
+ sys.exit(exit_code)
diff --git a/tests/test_playlist_manager.py b/tests/test_playlist_manager.py
new file mode 100644
index 0000000..e434dc0
--- /dev/null
+++ b/tests/test_playlist_manager.py
@@ -0,0 +1,543 @@
+"""Tests for Playlist Membership Manager.
+
+Comprehensive test suite for the playlist management system including:
+- Cache thread-safety and operations
+- Worker thread loading and API integration
+- Dialog UI state management
+- Transaction rollback on errors
+"""
+
+import threading
+import unittest
+from unittest.mock import Mock, patch
+
+from PySide6 import QtCore, QtWidgets
+from tidalapi import Session, Track
+
+from tidal_dl_ng.gui.playlist_membership import (
+ PlaylistCellState,
+ PlaylistColumnDelegate,
+ PlaylistContextLoader,
+ ThreadSafePlaylistCache,
+)
+from tidal_dl_ng.ui.dialog_playlist_manager import PlaylistManagerDialog
+
+
+class TestThreadSafePlaylistCache(unittest.TestCase):
+ """Tests for ThreadSafePlaylistCache.
+
+ Verifies thread-safety, O(1) performance, and data integrity.
+ """
+
+ def setUp(self) -> None:
+ """Set up test fixtures."""
+ self.cache = ThreadSafePlaylistCache()
+
+ def test_add_track_to_playlist(self) -> None:
+ """Test adding a track to a playlist."""
+ self.cache.add_track_to_playlist("track_1", "playlist_1")
+ playlists = self.cache.get_playlists_for_track("track_1")
+ self.assertIn("playlist_1", playlists)
+
+ def test_remove_track_from_playlist(self) -> None:
+ """Test removing a track from a playlist."""
+ self.cache.add_track_to_playlist("track_1", "playlist_1")
+ self.cache.remove_track_from_playlist("track_1", "playlist_1")
+ playlists = self.cache.get_playlists_for_track("track_1")
+ self.assertNotIn("playlist_1", playlists)
+
+ def test_is_track_in_playlist(self) -> None:
+ """Test checking if track is in playlist."""
+ self.cache.add_track_to_playlist("track_1", "playlist_1")
+ self.assertTrue(self.cache.is_track_in_playlist("track_1", "playlist_1"))
+ self.assertFalse(self.cache.is_track_in_playlist("track_1", "playlist_2"))
+
+ def test_get_nonexistent_track(self) -> None:
+ """Test getting playlists for nonexistent track returns empty set."""
+ playlists = self.cache.get_playlists_for_track("nonexistent")
+ self.assertEqual(playlists, set())
+
+ def test_multiple_playlists_per_track(self) -> None:
+ """Test track can be in multiple playlists."""
+ self.cache.add_track_to_playlist("track_1", "playlist_1")
+ self.cache.add_track_to_playlist("track_1", "playlist_2")
+ self.cache.add_track_to_playlist("track_1", "playlist_3")
+
+ playlists = self.cache.get_playlists_for_track("track_1")
+ self.assertEqual(len(playlists), 3)
+ self.assertIn("playlist_1", playlists)
+ self.assertIn("playlist_2", playlists)
+ self.assertIn("playlist_3", playlists)
+
+ def test_concurrent_reads_no_deadlock(self) -> None:
+ """Test concurrent reads don't cause deadlock."""
+ self.cache.add_track_to_playlist("track_1", "playlist_1")
+
+ results = []
+
+ def read_cache() -> None:
+ for _ in range(100):
+ result = self.cache.get_playlists_for_track("track_1")
+ results.append(result)
+
+ threads = [threading.Thread(target=read_cache) for _ in range(5)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ # All reads should succeed
+ self.assertEqual(len(results), 500)
+
+ def test_concurrent_writes_thread_safe(self) -> None:
+ """Test concurrent writes maintain consistency."""
+
+ def write_cache(track_id: str, playlist_ids: list[str]) -> None:
+ for pid in playlist_ids:
+ self.cache.add_track_to_playlist(track_id, pid)
+
+ threads = []
+ for i in range(10):
+ t = threading.Thread(target=write_cache, args=(f"track_{i}", [f"p_{i}_{j}" for j in range(5)]))
+ threads.append(t)
+
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ # Verify all writes succeeded
+ total_tracks = sum(len(self.cache.get_playlists_for_track(f"track_{i}")) for i in range(10))
+ self.assertEqual(total_tracks, 50)
+
+ def test_update_from_dict(self) -> None:
+ """Test batch update from dictionary."""
+ data = {
+ "track_1": {"playlist_1", "playlist_2"},
+ "track_2": {"playlist_2", "playlist_3"},
+ }
+ self.cache.update_from_dict(data)
+
+ self.assertEqual(self.cache.get_playlists_for_track("track_1"), {"playlist_1", "playlist_2"})
+ self.assertEqual(self.cache.get_playlists_for_track("track_2"), {"playlist_2", "playlist_3"})
+
+ def test_clear(self) -> None:
+ """Test clearing the cache."""
+ self.cache.add_track_to_playlist("track_1", "playlist_1")
+ self.cache.clear()
+ self.assertEqual(self.cache.get_playlists_for_track("track_1"), set())
+
+ def test_set_and_get_playlist_metadata(self) -> None:
+ """Test storing and retrieving playlist metadata."""
+ self.cache.set_playlist_metadata("playlist_1", "My Favorites", 42)
+ metadata = self.cache.get_playlist_metadata("playlist_1")
+
+ self.assertIsNotNone(metadata)
+ self.assertEqual(metadata["name"], "My Favorites")
+ self.assertEqual(metadata["item_count"], 42)
+
+ def test_get_all_playlists(self) -> None:
+ """Test getting all playlist IDs."""
+ self.cache.add_track_to_playlist("track_1", "playlist_1")
+ self.cache.add_track_to_playlist("track_2", "playlist_2")
+ self.cache.add_track_to_playlist("track_3", "playlist_1")
+
+ all_playlists = self.cache.get_all_playlists()
+ self.assertEqual(all_playlists, {"playlist_1", "playlist_2"})
+
+
+class TestPlaylistContextLoader(unittest.TestCase):
+ """Tests for PlaylistContextLoader worker.
+
+ Verifies API calls, pagination, and cache building.
+ """
+
+ def setUp(self) -> None:
+ """Set up test fixtures."""
+ self.app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
+
+ @patch("tidal_dl_ng.gui.playlist_membership.Session")
+ def test_fetch_user_playlists_pagination(self, mock_session_class: Mock) -> None:
+ """Test handling of playlist pagination."""
+ mock_session = Mock()
+
+ # Mock paginated responses
+ mock_session.request.side_effect = [
+ Mock(
+ json=lambda: {
+ "items": [
+ {"uuid": f"playlist_{i}", "title": f"Playlist {i}", "numberOfItems": 10} for i in range(50)
+ ],
+ "totalNumberOfItems": 75,
+ }
+ ),
+ Mock(
+ json=lambda: {
+ "items": [
+ {"uuid": f"playlist_{i}", "title": f"Playlist {i}", "numberOfItems": 10} for i in range(50, 75)
+ ],
+ "totalNumberOfItems": 75,
+ }
+ ),
+ ]
+
+ loader = PlaylistContextLoader(mock_session, user_id="user_123")
+ playlists = loader._fetch_user_playlists()
+
+ # Should have fetched all playlists across two pages
+ self.assertEqual(len(playlists), 75)
+
+ @patch("tidal_dl_ng.gui.playlist_membership.Session")
+ def test_fetch_playlist_items_pagination(self, mock_session_class: Mock) -> None:
+ """Test handling of playlist items pagination."""
+ mock_session = Mock()
+
+ # Mock paginated item responses
+ mock_session.request.side_effect = [
+ Mock(
+ json=lambda: {
+ "items": [{"item": {"id": f"track_{i}"}} for i in range(300)],
+ "totalNumberOfItems": 450,
+ }
+ ),
+ Mock(
+ json=lambda: {
+ "items": [{"item": {"id": f"track_{i}"}} for i in range(300, 450)],
+ "totalNumberOfItems": 450,
+ }
+ ),
+ ]
+
+ loader = PlaylistContextLoader(mock_session, user_id="user_123")
+ track_ids = loader._fetch_playlist_items("playlist_1")
+
+ self.assertEqual(len(track_ids), 450)
+
+ @patch("tidal_dl_ng.gui.playlist_membership.Session")
+ def test_build_cache_structure(self, mock_session_class: Mock) -> None:
+ """Test correct cache structure building."""
+ mock_session = Mock()
+
+ # Mock responses
+ playlists_response = Mock(
+ json=lambda: {
+ "items": [
+ {"uuid": "playlist_1", "title": "Favorites", "numberOfItems": 2},
+ {"uuid": "playlist_2", "title": "Workout", "numberOfItems": 1},
+ ],
+ "totalNumberOfItems": 2,
+ }
+ )
+
+ items_responses = [
+ Mock(
+ json=lambda: {
+ "items": [{"item": {"id": "track_1"}}, {"item": {"id": "track_2"}}],
+ "totalNumberOfItems": 2,
+ }
+ ),
+ Mock(
+ json=lambda: {
+ "items": [{"item": {"id": "track_2"}}],
+ "totalNumberOfItems": 1,
+ }
+ ),
+ ]
+
+ mock_session.request.side_effect = [playlists_response, *items_responses]
+
+ loader = PlaylistContextLoader(mock_session, user_id="user_123")
+
+ # Manually test cache building (since run() needs event loop)
+ playlists = loader._fetch_user_playlists()
+ cache = loader._fetch_all_playlist_contents(playlists)
+
+ # Verify cache structure
+ self.assertIn("track_1", cache)
+ self.assertIn("track_2", cache)
+ self.assertEqual(cache["track_1"], {"playlist_1"})
+ self.assertEqual(cache["track_2"], {"playlist_1", "playlist_2"})
+
+ @patch("tidal_dl_ng.gui.playlist_membership.Session")
+ def test_abort_request(self, mock_session_class: Mock) -> None:
+ """Test aborting the loader."""
+ mock_session = Mock()
+ loader = PlaylistContextLoader(mock_session, user_id="user_123")
+
+ # Request abort
+ loader.request_abort()
+
+ # Verify abort flag is set
+ self.assertTrue(loader._abort_requested.is_set())
+
+ def test_max_workers_clamped(self) -> None:
+ """Test max_workers is clamped to valid range."""
+ mock_session = Mock()
+
+ loader1 = PlaylistContextLoader(mock_session, user_id="user_123", max_workers=10)
+ self.assertLessEqual(loader1.max_workers, 5)
+
+ loader2 = PlaylistContextLoader(mock_session, user_id="user_123", max_workers=0)
+ self.assertEqual(loader2.max_workers, 1)
+
+
+class TestPlaylistColumnDelegate(unittest.TestCase):
+ """Tests for PlaylistColumnDelegate.
+
+ Verifies state rendering and transitions.
+ """
+
+ def setUp(self) -> None:
+ """Set up test fixtures."""
+ self.app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
+ self.delegate = PlaylistColumnDelegate()
+
+ def test_initial_state_pending(self) -> None:
+ """Test delegate starts in PENDING state."""
+ # Cell should render as PENDING by default
+ self.assertFalse(self.delegate._cache_ready)
+
+ def test_set_cache_ready_transitions_states(self) -> None:
+ """Test state transitions when cache becomes ready."""
+ self.delegate.set_cell_state(0, PlaylistCellState.PENDING)
+ self.delegate.set_cell_state(1, PlaylistCellState.PENDING)
+
+ # Mark cache as ready
+ self.delegate.set_cache_ready(True)
+
+ # States should transition to READY
+ self.assertTrue(self.delegate._cache_ready)
+
+ def test_set_cell_state(self) -> None:
+ """Test setting cell state."""
+ self.delegate.set_cell_state(5, PlaylistCellState.READY)
+ self.assertEqual(self.delegate._cell_states.get("5"), PlaylistCellState.READY)
+
+ self.delegate.set_cell_state(5, PlaylistCellState.ERROR)
+ self.assertEqual(self.delegate._cell_states.get("5"), PlaylistCellState.ERROR)
+
+ def test_state_rendering_pending(self) -> None:
+ """Test rendering of PENDING state (spinner)."""
+ # Create mock painter and option
+ painter = Mock()
+ painter.fillRect = Mock()
+ painter.setPen = Mock()
+ painter.drawText = Mock()
+
+ option = Mock()
+ option.rect = Mock(adjusted=Mock(return_value=Mock(center=Mock(return_value=QtCore.QPoint(50, 50)))))
+ option.state = 0
+
+ index = Mock()
+ index.row = Mock(return_value=0)
+
+ # Set state to PENDING
+ self.delegate.set_cell_state(0, PlaylistCellState.PENDING)
+
+ # Paint should be called without errors
+ # (Actual painting verification would require QPixmap/screen rendering)
+
+ def test_button_click_event(self) -> None:
+ """Test that READY state cells emit button_clicked signal."""
+ self.delegate.set_cell_state(0, PlaylistCellState.READY)
+
+ # Create mock event and model
+ event = Mock()
+ event.type = Mock(return_value=QtCore.QEvent.Type.MouseButtonRelease)
+
+ model = Mock()
+ option = Mock()
+ index = Mock()
+ index.row = Mock(return_value=0)
+
+ # Track signal emissions
+ signal_emitted = []
+
+ def on_button_clicked(idx) -> None:
+ signal_emitted.append(idx)
+
+ self.delegate.button_clicked.connect(on_button_clicked)
+
+ # Simulate click event
+ handled = self.delegate.editorEvent(event, model, option, index)
+
+ # Should handle the event and emit signal
+ self.assertTrue(handled)
+
+
+class TestPlaylistManagerDialog(unittest.TestCase):
+ """Tests for PlaylistManagerDialog.
+
+ Verifies UI state, transaction logic, and error handling.
+ """
+
+ def setUp(self) -> None:
+ """Set up test fixtures."""
+ self.app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
+ self.threadpool = QtCore.QThreadPool()
+
+ # Create mock objects
+ self.mock_track = Mock(spec=Track)
+ self.mock_track.id = "track_uuid_1"
+ self.mock_track.name = "Test Track"
+
+ self.cache = ThreadSafePlaylistCache()
+
+ # Add test data to cache
+ self.cache.set_playlist_metadata("playlist_1", "Favorites", 42)
+ self.cache.set_playlist_metadata("playlist_2", "Workout Mix", 15)
+ self.cache.add_track_to_playlist("track_uuid_1", "playlist_1")
+
+ self.mock_session = Mock(spec=Session)
+
+ def test_dialog_initialization(self) -> None:
+ """Test dialog initializes correctly."""
+ dialog = PlaylistManagerDialog(
+ track=self.mock_track,
+ cache=self.cache,
+ session=self.mock_session,
+ threadpool=self.threadpool,
+ )
+
+ self.assertIsNotNone(dialog)
+ self.assertEqual(dialog.track.name, "Test Track")
+
+ def test_playlists_populated_from_cache(self) -> None:
+ """Test dialog populates playlists from cache."""
+ dialog = PlaylistManagerDialog(
+ track=self.mock_track,
+ cache=self.cache,
+ session=self.mock_session,
+ threadpool=self.threadpool,
+ )
+
+ # Count checkbox widgets (should be 2 playlists)
+ checkboxes = dialog.container_layout.count()
+ self.assertGreaterEqual(checkboxes, 2)
+
+ def test_checkbox_initial_state_from_cache(self) -> None:
+ """Test checkboxes reflect initial cache state."""
+ dialog = PlaylistManagerDialog(
+ track=self.mock_track,
+ cache=self.cache,
+ session=self.mock_session,
+ threadpool=self.threadpool,
+ )
+
+ # Track is in playlist_1, so that checkbox should be checked
+ # Track is NOT in playlist_2, so that checkbox should be unchecked
+ original_states = dialog._original_states
+
+ self.assertTrue(original_states.get("playlist_1"))
+ self.assertFalse(original_states.get("playlist_2"))
+
+ @patch("tidal_dl_ng.gui.dialog_playlist_manager.Worker")
+ def test_checkbox_change_triggers_worker(self, mock_worker_class: Mock) -> None:
+ """Test checkbox change triggers worker thread."""
+ PlaylistManagerDialog(
+ track=self.mock_track,
+ cache=self.cache,
+ session=self.mock_session,
+ threadpool=self.threadpool,
+ )
+
+ # Mock worker
+ mock_worker_class.return_value = Mock()
+
+ # Simulate checkbox change would trigger worker
+ # (Actual checkbox interaction would require Qt event simulation)
+
+ def test_api_add_track_success(self) -> None:
+ """Test successful track addition."""
+ dialog = PlaylistManagerDialog(
+ track=self.mock_track,
+ cache=self.cache,
+ session=self.mock_session,
+ threadpool=self.threadpool,
+ )
+
+ # Mock successful API response
+ mock_response = Mock()
+ mock_response.raise_for_status = Mock()
+ self.mock_session.request.return_value = mock_response
+
+ # Create checkbox mock
+ mock_checkbox = Mock()
+
+ # Simulate add operation
+ dialog._api_add_track_to_playlist("track_uuid_1", "playlist_2", mock_checkbox, False)
+
+ # Cache should be updated
+ self.assertTrue(self.cache.is_track_in_playlist("track_uuid_1", "playlist_2"))
+
+ # Checkbox should be re-enabled
+ mock_checkbox.setEnabled.assert_called_with(True)
+
+ def test_api_add_track_error_rollback(self) -> None:
+ """Test rollback on add failure."""
+ dialog = PlaylistManagerDialog(
+ track=self.mock_track,
+ cache=self.cache,
+ session=self.mock_session,
+ threadpool=self.threadpool,
+ )
+
+ # Mock failed API response
+ mock_response = Mock()
+ mock_response.raise_for_status = Mock(side_effect=Exception("API Error"))
+ self.mock_session.request.return_value = mock_response
+
+ # Create checkbox mock
+ mock_checkbox = Mock()
+
+ # Original state: not checked
+ original_state = False
+
+ # Simulate failed add operation
+ dialog._api_add_track_to_playlist("track_uuid_1", "playlist_2", mock_checkbox, original_state)
+
+ # Checkbox should be restored to original state
+ mock_checkbox.setChecked.assert_called()
+
+ # Checkbox should be re-enabled
+ mock_checkbox.setEnabled.assert_called_with(True)
+
+ def test_api_remove_track_success(self) -> None:
+ """Test successful track removal."""
+ dialog = PlaylistManagerDialog(
+ track=self.mock_track,
+ cache=self.cache,
+ session=self.mock_session,
+ threadpool=self.threadpool,
+ )
+
+ # Mock successful API responses
+ mock_items_response = Mock()
+ mock_items_response.json.return_value = {
+ "items": [{"id": "item_uuid_1", "item": {"id": "track_uuid_1"}}],
+ "totalNumberOfItems": 1,
+ }
+
+ mock_delete_response = Mock()
+ mock_delete_response.raise_for_status = Mock()
+
+ self.mock_session.request.side_effect = [mock_items_response, mock_delete_response]
+
+ # Create checkbox mock
+ mock_checkbox = Mock()
+
+ # Verify track is in cache initially
+ self.assertTrue(self.cache.is_track_in_playlist("track_uuid_1", "playlist_1"))
+
+ # Simulate remove operation
+ dialog._api_remove_track_from_playlist("track_uuid_1", "playlist_1", mock_checkbox, True)
+
+ # Cache should be updated (track removed)
+ self.assertFalse(self.cache.is_track_in_playlist("track_uuid_1", "playlist_1"))
+
+ # Checkbox should be re-enabled
+ mock_checkbox.setEnabled.assert_called_with(True)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tidal_dl_ng/gui/activate.py b/tidal_dl_ng/gui/activate.py
index 4de3160..c04ab56 100644
--- a/tidal_dl_ng/gui/activate.py
+++ b/tidal_dl_ng/gui/activate.py
@@ -54,10 +54,9 @@ def gui_activate(tidal: Tidal | None = None):
window = MainWindow(tidal=tidal)
window.show()
- # Check for updates
- window.s_update_check.emit(True)
- sys.exit(app.exec())
+ exit_code = app.exec()
+ sys.exit(exit_code)
if __name__ == "__main__":
diff --git a/tidal_dl_ng/gui/dialog_playlist_manager.py b/tidal_dl_ng/gui/dialog_playlist_manager.py
new file mode 100644
index 0000000..7d61306
--- /dev/null
+++ b/tidal_dl_ng/gui/dialog_playlist_manager.py
@@ -0,0 +1,327 @@
+"""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.ui.dialog_playlist_manager import Ui_DialogPlaylistManager
+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] = {}
+
+ # 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 : {track_title}'
+ )
+
+ # Populate playlists list into verticalLayoutList
+ self._populate_playlists_ui()
+
+ 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)
+ """
+ # Silent - errors are shown via UI notifications
+ pass
+
+ 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)
diff --git a/tidal_dl_ng/gui/initialization.py b/tidal_dl_ng/gui/initialization.py
index 7c721a3..c65901b 100644
--- a/tidal_dl_ng/gui/initialization.py
+++ b/tidal_dl_ng/gui/initialization.py
@@ -127,6 +127,7 @@ class InitializationMixin:
"Quality",
"Date",
"Downloaded?",
+ "Playlists",
]
model.setColumnCount(len(labels_column))
model.setRowCount(0)
diff --git a/tidal_dl_ng/gui/main_window.py b/tidal_dl_ng/gui/main_window.py
index 33806dc..1ec4fa0 100644
--- a/tidal_dl_ng/gui/main_window.py
+++ b/tidal_dl_ng/gui/main_window.py
@@ -19,6 +19,7 @@ from tidal_dl_ng.gui.downloads import DownloadsMixin
from tidal_dl_ng.gui.history import HistoryMixin
from tidal_dl_ng.gui.initialization import InitializationMixin
from tidal_dl_ng.gui.playlist import GuiPlaylistManager
+from tidal_dl_ng.gui.playlist_membership_mixin import PlaylistMembershipMixin
from tidal_dl_ng.gui.progress import ProgressMixin
from tidal_dl_ng.gui.queue import GuiQueueManager
from tidal_dl_ng.gui.search import GuiSearchManager
@@ -52,6 +53,7 @@ class MainWindow(
TreesResultsMixin,
ContextMenusMixin,
HistoryMixin,
+ PlaylistMembershipMixin,
):
"""Main application window for TIDAL Downloader Next Generation.
@@ -169,7 +171,7 @@ class MainWindow(
self.init_tidal(tidal)
- logger_gui.debug("All setup.")
+ logger_gui.info("All setup.")
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
"""Handle the close event of the main window.
@@ -177,6 +179,12 @@ class MainWindow(
Args:
event (QtGui.QCloseEvent): The close event.
"""
+ logger_gui.warning("⚠️ CLOSE EVENT TRIGGERED!")
+ import traceback
+
+ logger_gui.debug("Close event traceback:")
+ for line in traceback.format_stack():
+ logger_gui.debug(line.strip())
# Save the main window size and position
self.settings.data.window_x = self.x()
self.settings.data.window_y = self.y()
diff --git a/tidal_dl_ng/gui/playlist_membership.py b/tidal_dl_ng/gui/playlist_membership.py
new file mode 100644
index 0000000..9066095
--- /dev/null
+++ b/tidal_dl_ng/gui/playlist_membership.py
@@ -0,0 +1,725 @@
+"""Playlist membership manager - Handles loading and caching playlist context.
+
+This module provides background loading of user playlists and track memberships
+to enable instant interaction with the "Playlists" column in result views.
+
+Architecture:
+- ThreadSafePlaylistCache: Thread-safe cache for track→playlist membership
+- PlaylistContextLoader: QRunnable worker for fetching playlist data
+- PlaylistColumnDelegate: Custom delegate for rendering the "Playlists" column
+"""
+
+import threading
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from enum import StrEnum
+
+from PySide6 import QtCore, QtGui, QtWidgets
+from requests.exceptions import RequestException
+from tidalapi import Session
+
+from tidal_dl_ng.helper.playlist_api import (
+ get_playlist_items,
+ get_playlist_metadata,
+ get_user_playlists,
+)
+from tidal_dl_ng.logger import logger_gui
+from tidal_dl_ng.ui.spinner import QtWaitingSpinner
+
+
+class PlaylistCellState(StrEnum):
+ """Enumeration of possible visual states for the playlist column cell."""
+
+ PENDING = "pending" # Loading spinner
+ READY = "ready" # Interactive button
+ ERROR = "error" # Warning icon
+
+
+class ThreadSafePlaylistCache:
+ """Thread-safe cache for track membership in playlists.
+
+ Stores mapping: track_id → Set[playlist_id]
+ Uses RLock for thread-safety and Set for O(1) lookups.
+
+ Example:
+ cache = ThreadSafePlaylistCache()
+ cache.add_track_to_playlist("track_uuid_1", "playlist_uuid_2")
+ playlist_ids = cache.get_playlists_for_track("track_uuid_1")
+ # → {"playlist_uuid_2"}
+ """
+
+ def __init__(self) -> None:
+ """Initialize the cache with empty data and lock."""
+ self._lock: threading.RLock = threading.RLock()
+ self._data: dict[str, set[str]] = {}
+ self._playlist_metadata: dict[str, dict[str, str | int]] = {}
+
+ def add_track_to_playlist(self, track_id: str, playlist_id: str) -> None:
+ """Add a track to a playlist in the cache.
+
+ Args:
+ track_id: The unique identifier of the track
+ playlist_id: The unique identifier of the playlist
+
+ Thread-safe: Uses lock for atomic update.
+ """
+ # Normalize IDs to strings
+ track_id = str(track_id)
+ playlist_id = str(playlist_id)
+
+ with self._lock:
+ if track_id not in self._data:
+ self._data[track_id] = set()
+ self._data[track_id].add(playlist_id)
+
+ def remove_track_from_playlist(self, track_id: str, playlist_id: str) -> None:
+ """Remove a track from a playlist in the cache.
+
+ Args:
+ track_id: The unique identifier of the track
+ playlist_id: The unique identifier of the playlist
+
+ Thread-safe: Uses lock for atomic update.
+ """
+ # Normalize IDs to strings
+ track_id = str(track_id)
+ playlist_id = str(playlist_id)
+
+ with self._lock:
+ if track_id in self._data:
+ self._data[track_id].discard(playlist_id)
+ if not self._data[track_id]:
+ del self._data[track_id]
+
+ def get_playlists_for_track(self, track_id: str) -> set[str]:
+ """Get all playlists containing a specific track.
+
+ Args:
+ track_id: The unique identifier of the track
+
+ Returns:
+ Set of playlist IDs containing this track (empty set if not found)
+
+ Complexity: O(1)
+ Thread-safe: Returns a copy to prevent external mutation.
+ """
+ # Normalize ID to string
+ track_id = str(track_id)
+
+ with self._lock:
+ return self._data.get(track_id, set()).copy()
+
+ # === Adjouts pour gestion complète du cycle de vie du cache ===
+ def clear(self) -> None:
+ """Clear all cached data (tracks + playlist metadata).
+ Utilisé pour désactiver le cache au premier chargement et repartir sur une base saine.
+ """
+ with self._lock:
+ self._data.clear()
+ self._playlist_metadata.clear()
+
+ def update_from_dict(self, cache: dict[str, set[str]]) -> None:
+ """Merge incoming cache mapping into the current cache.
+
+ Args:
+ cache: Dict[track_id, Set[playlist_id]]
+ """
+ if not isinstance(cache, dict):
+ return
+ with self._lock:
+ for tid, playlists in cache.items():
+ tid_str = str(tid)
+ if tid_str not in self._data:
+ self._data[tid_str] = set()
+ # Normaliser les IDs playlist en str
+ for pid in playlists:
+ self._data[tid_str].add(str(pid))
+
+ def set_playlist_metadata(self, playlist_id: str, name: str, item_count: int) -> None:
+ """Store metadata for a playlist (name and item count)."""
+ with self._lock:
+ self._playlist_metadata[str(playlist_id)] = {
+ "name": str(name),
+ "item_count": int(item_count),
+ }
+
+ def get_playlist_metadata(self, playlist_id: str) -> dict[str, str | int]:
+ """Retrieve stored metadata for a playlist (or defaults)."""
+ with self._lock:
+ return self._playlist_metadata.get(str(playlist_id), {"name": str(playlist_id), "item_count": 0}).copy()
+
+ def get_playlist_name(self, playlist_id: str) -> str:
+ """Convenience accessor for playlist name."""
+ return str(self.get_playlist_metadata(playlist_id).get("name", playlist_id))
+
+ def get_playlist_count(self, playlist_id: str) -> int:
+ """Convenience accessor for playlist item_count."""
+ try:
+ return int(self.get_playlist_metadata(playlist_id).get("item_count", 0))
+ except Exception:
+ return 0
+
+ def is_track_in_playlist(self, track_id: str, playlist_id: str) -> bool:
+ """Check if a track is in a specific playlist.
+
+ Args:
+ track_id: The unique identifier of the track
+ playlist_id: The unique identifier of the playlist
+
+ Returns:
+ True if track is in playlist, False otherwise
+
+ Complexity: O(1)
+ """
+ # Normalize IDs to strings
+ track_id = str(track_id)
+ playlist_id = str(playlist_id)
+
+ with self._lock:
+ return playlist_id in self._data.get(track_id, set())
+
+ def clear_track_data(self) -> None:
+ """Clear only track→playlist mapping data, preserving metadata.
+
+ Thread-safe: Uses lock for atomic clear.
+ Useful for refreshing track data without losing playlist names.
+ """
+ with self._lock:
+ self._data.clear()
+
+ def get_all_playlists(self) -> set[str]:
+ """Get all playlist IDs currently tracked in cache.
+
+ Returns all playlists from metadata, not just those with tracks,
+ ensuring empty playlists are also included.
+
+ Returns:
+ Set of all unique playlist IDs
+ """
+ with self._lock:
+ # Return all playlist IDs from metadata (includes empty playlists)
+ # Fall back to track-based IDs if metadata not yet loaded
+ if self._playlist_metadata:
+ return set(self._playlist_metadata.keys())
+ else:
+ # Fallback: return playlist IDs that have at least one track
+ all_ids: set[str] = set()
+ for playlist_ids in self._data.values():
+ all_ids.update(playlist_ids)
+ return all_ids
+
+
+class PlaylistLoaderSignals(QtCore.QObject):
+ """Signal emitter for PlaylistContextLoader.
+
+ Separated because QRunnable doesn't inherit from QObject,
+ so we need a separate QObject to emit signals.
+ """
+
+ started = QtCore.Signal()
+ progress = QtCore.Signal(int, int) # current, total
+ cache_ready = QtCore.Signal(dict) # Track→Playlist cache
+ error = QtCore.Signal(str) # error message
+ finished = QtCore.Signal()
+ # New: playlist metadata (id -> {name, item_count})
+ metadata_ready = QtCore.Signal(dict)
+
+
+class PlaylistContextLoader(QtCore.QRunnable):
+ """Background worker for loading user playlist context.
+
+ Fetches all user playlists and their contents, building a cache of
+ track→playlist memberships. Runs in background thread pool.
+
+ Signals (via signals attribute):
+ started: Emitted when loading begins
+ progress: Emitted with (current, total) during loading
+ cache_ready: Emitted when cache is complete, provides cache dict
+ error: Emitted if critical error occurs
+ finished: Emitted when loading completes (success or error)
+
+ Example:
+ loader = PlaylistContextLoader(session, user_id="123456")
+ loader.signals.cache_ready.connect(self.on_cache_ready)
+ threadpool.start(loader)
+ """
+
+ # Constants
+ DEFAULT_PLAYLIST_LIMIT: int = 50
+ DEFAULT_ITEMS_LIMIT: int = 300
+ MAX_WORKERS: int = 5
+ REQUEST_TIMEOUT: int = 30
+
+ def __init__(self, session: Session, user_id: str, max_workers: int = MAX_WORKERS) -> None:
+ """Initialize the playlist context loader.
+
+ Args:
+ session: Authenticated Tidal API session
+ user_id: ID of current user
+ max_workers: Maximum concurrent API requests
+ """
+ super().__init__()
+ self.session: Session = session
+ self.user_id: str = user_id
+ self.max_workers: int = max(1, min(max_workers, 5)) # Clamp to 1-5
+ self._abort_requested: threading.Event = threading.Event()
+
+ # Create signal emitter
+ self.signals = PlaylistLoaderSignals()
+
+ @QtCore.Slot()
+ def run(self) -> None:
+ """Main worker thread entry point.
+
+ Sequence:
+ 1. Fetch all user playlists (with pagination)
+ 2. For each playlist, fetch all track IDs (parallel with executor)
+ 3. Build cache Dict[track_id, Set[playlist_id]]
+ 4. Emit cache_ready signal
+ """
+ try:
+ self.signals.started.emit()
+
+ # Step 1: Fetch all user playlists
+ playlists: list[dict[str, str | int]] = self._fetch_user_playlists()
+ if not playlists:
+ # Emit empty metadata so UI can hide dialog empty state properly
+ self.signals.metadata_ready.emit({})
+ self.signals.cache_ready.emit({})
+ self.signals.finished.emit()
+ return
+
+ # Emit playlist metadata for UI (names & counts)
+ metadata_payload: dict[str, dict[str, str | int]] = {
+ p["uuid"]: {"name": p["title"], "item_count": p["numberOfItems"]} for p in playlists
+ }
+
+ # Debug: log all playlist names (gated)
+ playlist_names = [p["title"] for p in playlists]
+ logger_gui.debug(f"📝 Loading {len(playlists)} playlists: {', '.join(sorted(playlist_names)[:10])}...")
+
+ self.signals.metadata_ready.emit(metadata_payload)
+
+ # Step 2: Fetch contents for all playlists (parallel)
+ cache: dict[str, set[str]] = self._fetch_all_playlist_contents(playlists)
+
+ # Log cache statistics (keep info minimal or gate)
+ total_unique_tracks = len(cache)
+ total_memberships = sum(len(pls) for pls in cache.values())
+ logger_gui.debug(f" → Sample cache track IDs: {list(cache.keys())[:10] if cache else []}")
+ # Keep the success info, but you asked to suppress logs; gate it as well
+ logger_gui.debug(
+ f"✅ Cache built: {total_unique_tracks} unique tracks, {total_memberships} total playlist memberships"
+ )
+
+ # Step 3: Emit ready signal with cache data
+ self.signals.cache_ready.emit(cache)
+
+ self.signals.finished.emit()
+
+ except RequestException as e:
+ self.signals.error.emit(f"Network error loading playlists: {e!s}")
+ self.signals.finished.emit()
+ except Exception as e:
+ self.signals.error.emit(f"Unexpected error loading playlists: {e!s}")
+ self.signals.finished.emit()
+
+ def _fetch_user_playlists(self) -> list[dict[str, str | int]]:
+ """Fetch all editable playlists for the current user using tidalapi helpers.
+
+ Returns:
+ List of playlist dicts with 'uuid', 'title', 'numberOfItems'
+ """
+ try:
+ # Use centralized API helper
+ tidal_playlists = get_user_playlists(self.session)
+
+ # Extract metadata from each playlist
+ playlists: list[dict[str, str | int]] = []
+ for pl in tidal_playlists:
+ metadata = get_playlist_metadata(pl)
+ playlists.append(
+ {
+ "uuid": metadata["id"],
+ "title": metadata["name"],
+ "numberOfItems": metadata["item_count"],
+ }
+ )
+
+ except Exception as e:
+ raise RequestException(f"Failed to fetch user playlists: {e}") from e # noqa: TRY003
+ else:
+ return playlists
+
+ def _fetch_all_playlist_contents(self, playlists: list[dict[str, str | int]]) -> dict[str, set[str]]:
+ """Fetch contents of all playlists in parallel.
+
+ Uses ThreadPoolExecutor to parallelize API requests.
+
+ Args:
+ playlists: List of playlist dicts from _fetch_user_playlists
+
+ Returns:
+ Cache dict mapping track_id → Set[playlist_id]
+ """
+ cache: dict[str, set[str]] = {}
+
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
+ # Submit all playlist fetch tasks
+ futures: dict = {
+ executor.submit(self._fetch_playlist_items, playlist["uuid"], playlist["title"]): playlist["uuid"]
+ for playlist in playlists
+ if not self._abort_requested.is_set()
+ }
+
+ # Collect results as they complete
+ for i, future in enumerate(as_completed(futures)):
+ if self._abort_requested.is_set():
+ executor.shutdown(wait=False)
+ break
+
+ playlist_uuid: str = futures[future]
+
+ try:
+ track_ids: set[str] = future.result()
+
+ # Add this playlist to cache for each track
+ for track_id in track_ids:
+ if track_id not in cache:
+ cache[track_id] = set()
+ cache[track_id].add(playlist_uuid)
+
+ except RequestException as e:
+ logger_gui.debug(f"Request error for playlist {playlist_uuid}: {e}")
+ continue
+ except Exception as e:
+ logger_gui.debug(f"Unexpected error for playlist {playlist_uuid}: {e}")
+ continue
+ else:
+ # Emit progress
+ self.signals.progress.emit(i + 1, len(futures))
+
+ return cache
+
+ def _fetch_playlist_items(self, playlist_uuid: str, playlist_name: str = "") -> set[str]:
+ """Fetch all track IDs from a single playlist using tidalapi helpers.
+
+ Args:
+ playlist_uuid: UUID of the playlist
+ playlist_name: Name of the playlist (for logging)
+
+ Returns:
+ Set of track UUIDs in this playlist
+ """
+ try:
+ # Get playlist object
+ playlist = self.session.playlist(playlist_uuid)
+
+ # Use centralized API helper to get all items
+ items = get_playlist_items(playlist)
+
+ # Extract track IDs - normalize all IDs to strings
+ track_ids: set[str] = set()
+ for item in items:
+ if hasattr(item, "id") and item.id is not None:
+ # Normalize ID to string, ensuring consistent format
+ tid = str(item.id)
+ track_ids.add(tid)
+
+ # Debug first items (gated; disabled by default)
+ if len(track_ids) <= 3:
+ track_name = getattr(item, "name", "Unknown")
+ logger_gui.debug(
+ f" [{playlist_name}...] Track '{track_name}' ID: {tid} (type: {type(item.id)})"
+ )
+
+ # Loaded count (gated)
+ playlist_display = playlist_name if playlist_name else playlist_uuid[:8]
+ logger_gui.debug(f"📋 Loaded {len(track_ids)} tracks from playlist '{playlist_display}'")
+
+ except Exception as e:
+ raise RequestException(f"Failed to fetch items for playlist {playlist_uuid}: {e}") from e # noqa: TRY003
+ else:
+ return track_ids
+
+ def request_abort(self) -> None:
+ """Request graceful abortion of the loader.
+
+ Sets abort flag; loader will finish current request then stop.
+ Safe to call from any thread.
+ """
+ self._abort_requested.set()
+
+
+class PlaylistColumnDelegate(QtWidgets.QStyledItemDelegate):
+ """Custom delegate for rendering the "Playlists" column.
+
+ Displays different states:
+ - PENDING: QtWaitingSpinner widget (loading)
+ - READY: Clickable button (ready to open dialog)
+ - ERROR: Warning icon (error occurred)
+
+ Emits: button_clicked signal when cell is clicked in READY state.
+
+ Example:
+ delegate = PlaylistColumnDelegate(parent=table)
+ table.setItemDelegateForColumn(9, delegate)
+ delegate.button_clicked.connect(self.on_playlist_button_clicked)
+ """
+
+ # Signals
+ button_clicked: QtCore.Signal = QtCore.Signal(QtCore.QModelIndex)
+
+ def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
+ """Initialize the delegate.
+
+ Args:
+ parent: Parent widget (table view)
+ """
+ super().__init__(parent)
+ self._cache_ready: bool = False
+ self._cell_states: dict[str, PlaylistCellState] = {}
+ self._spinner: QtWaitingSpinner | None = None
+ # Reference to cache to compute counts
+ self._cache: ThreadSafePlaylistCache | None = None
+ # Column index of the hidden obj storing Track (default 1 as per model)
+ self._obj_column_index: int = 1
+
+ # Create spinner widget for PENDING state
+ if parent:
+ self._spinner = QtWaitingSpinner(parent, centerOnParent=False, disableParentWhenSpinning=False)
+ self._spinner.setNumberOfLines(12)
+ self._spinner.setLineLength(4)
+ self._spinner.setLineWidth(2)
+ self._spinner.setInnerRadius(4)
+ self._spinner.setColor(QtGui.QColor(100, 150, 255))
+ self._spinner.hide() # Hidden by default
+
+ def set_cache(self, cache: ThreadSafePlaylistCache) -> None:
+ """Attach cache reference for count rendering."""
+ self._cache = cache
+
+ def set_obj_column_index(self, col: int) -> None:
+ self._obj_column_index = col
+
+ def paint(
+ self,
+ painter: QtGui.QPainter,
+ option: QtWidgets.QStyleOptionViewItem,
+ index: QtCore.QModelIndex,
+ ) -> None:
+ """Paint the cell contents based on current state.
+
+ Args:
+ painter: QPainter for drawing
+ option: Style options (selection, hover, etc.)
+ index: Model index of the cell
+ """
+ # Determine state
+ row_key: str = str(index.row())
+ state: PlaylistCellState = self._cell_states.get(row_key, PlaylistCellState.PENDING)
+
+ # Auto-initialize to READY if cache is ready and state is unknown (scroll scenario)
+ if state == PlaylistCellState.PENDING and self._cache_ready:
+ state = PlaylistCellState.READY
+ self._cell_states[row_key] = PlaylistCellState.READY
+
+ # Draw background
+ super().paint(painter, option, index)
+
+ # Calculate content rect (inside cell)
+ content_rect: QtCore.QRect = option.rect.adjusted(4, 4, -4, -4)
+
+ if state == PlaylistCellState.PENDING:
+ # Show spinner for this cell
+ if self._spinner:
+ # Position spinner in cell
+ spinner_size = self._spinner.width()
+ center_x = content_rect.center().x() - spinner_size // 2
+ center_y = content_rect.center().y() - spinner_size // 2
+
+ self._spinner.move(center_x, center_y)
+ self._spinner.show()
+
+ # Ensure spinner is running
+ if not self._spinner.isSpinning():
+ self._spinner.start()
+ elif state == PlaylistCellState.READY:
+ self._paint_button(painter, content_rect, option, index)
+ elif state == PlaylistCellState.ERROR:
+ self._paint_error(painter, content_rect)
+
+ def _paint_button(
+ self,
+ painter: QtGui.QPainter,
+ rect: QtCore.QRect,
+ option: QtWidgets.QStyleOptionViewItem,
+ index: QtCore.QModelIndex,
+ ) -> None:
+ """Paint clickable button for READY state.
+
+ Args:
+ painter: QPainter for drawing
+ rect: Area to paint in
+ option: Style options (for hover effects)
+ index: Model index to get track data from
+ """
+ # Draw button background
+ is_hovered: bool = bool(option.state & QtWidgets.QStyle.StateFlag.State_MouseOver)
+ bg_color: QtGui.QColor = QtGui.QColor(220, 240, 255) if is_hovered else QtGui.QColor(240, 240, 240)
+
+ painter.fillRect(rect, bg_color)
+
+ # Draw button border
+ painter.setPen(QtGui.QPen(QtGui.QColor(100, 150, 255), 1))
+ painter.drawRect(rect)
+
+ # Draw text/icon
+ text_color: QtGui.QColor = QtGui.QColor(50, 100, 200) if is_hovered else QtGui.QColor(100, 120, 150)
+ painter.setPen(text_color)
+
+ # Compute playlists count for this track
+ count_text = "0"
+ parent = self.parent()
+ track_id = None
+
+ if isinstance(parent, QtWidgets.QTreeView) and self._cache is not None:
+ model = parent.model()
+ try:
+ # Try to resolve track_id if index is valid
+ if index.isValid():
+ # Resolve to source index if proxy model is used
+ if isinstance(model, QtCore.QSortFilterProxyModel):
+ source_index = model.mapToSource(index)
+ source_model = model.sourceModel()
+ else:
+ source_index = index
+ source_model = model
+
+ # Only proceed if source index is valid
+ source_row = source_index.row()
+ if source_row >= 0:
+ # For QStandardItemModel, use item() directly
+ if hasattr(source_model, "item"):
+ obj_item = source_model.item(source_row, self._obj_column_index)
+ obj_data = obj_item.data(QtCore.Qt.ItemDataRole.UserRole) if obj_item else None
+ else:
+ # Fallback for other model types
+ obj_idx = source_model.index(source_row, self._obj_column_index)
+ obj_data = obj_idx.data(QtCore.Qt.ItemDataRole.UserRole)
+
+ if obj_data is not None:
+ tid = getattr(obj_data, "id", None)
+ if tid is not None:
+ track_id = str(tid)
+ playlists_for_track = self._cache.get_playlists_for_track(track_id)
+ count = len(playlists_for_track)
+ count_text = str(count)
+
+ # Suppress noisy zero-playlist debug logs
+ # (kept silent unless VERBOSE_DEBUG is enabled)
+ track_name = getattr(obj_data, "name", "Unknown")
+ sample_keys = list(self._cache._data.keys())[:5]
+ is_in_cache = track_id in self._cache._data
+ logger_gui.debug(f"⚠️ Track '{track_name}' (ID: {track_id}) shows 0 playlists")
+ logger_gui.debug(f" Is track ID '{track_id}' in cache? {is_in_cache}")
+ logger_gui.debug(f" Sample cache keys: {sample_keys}")
+
+ except Exception as e:
+ logger_gui.error(f"Error getting track playlist count: {e}", exc_info=True)
+
+ # Always draw text with count (fallback to "0" if track_id not found)
+ # Pluralize "Playlist" → "Playlists" if count > 1
+ count_int = int(count_text) if count_text.isdigit() else 0
+ text = f"({count_text}) Playlist" if count_int <= 1 else f"({count_text}) Playlists"
+ painter.drawText(rect, QtCore.Qt.AlignmentFlag.AlignCenter, text)
+
+ def _paint_error(self, painter: QtGui.QPainter, rect: QtCore.QRect) -> None:
+ """Paint error icon for ERROR state.
+
+ Args:
+ painter: QPainter for drawing
+ rect: Area to paint in
+ """
+ # Draw warning icon
+ text: str = "⚠️"
+ font: QtGui.QFont = QtGui.QFont()
+ font.setPointSize(10)
+ painter.setFont(font)
+
+ painter.setPen(QtGui.QColor(200, 150, 50))
+ painter.drawText(rect, QtCore.Qt.AlignmentFlag.AlignCenter, text)
+
+ def set_cell_state(self, row: int, state: PlaylistCellState) -> None:
+ """Update the state of a cell.
+
+ Args:
+ row: Row index
+ state: New state (PENDING, READY, ERROR)
+ """
+ row_key: str = str(row)
+ self._cell_states[row_key] = state
+
+ def set_cache_ready(self, is_ready: bool) -> None:
+ """Notify delegate that cache loading is complete.
+
+ Args:
+ is_ready: True if cache is ready, False if loading started
+ """
+ self._cache_ready = is_ready
+
+ if not is_ready:
+ # Cache loading started - reset to PENDING
+ if self._spinner and not self._spinner.isSpinning():
+ self._spinner.start()
+ return
+
+ # Cache is ready - transition ALL rows to READY state
+ parent = self.parent()
+ if isinstance(parent, QtWidgets.QTreeView):
+ model = parent.model()
+ if model is not None:
+ rows = model.rowCount()
+ for r in range(rows):
+ self._cell_states[str(r)] = PlaylistCellState.READY
+
+ # Stop spinner
+ if self._spinner and self._spinner.isSpinning():
+ self._spinner.stop()
+ self._spinner.hide()
+
+ # Force repaint
+ if isinstance(parent, QtWidgets.QWidget):
+ try:
+ if hasattr(parent, "viewport"):
+ parent.viewport().update()
+ parent.viewport().repaint()
+ except RuntimeError:
+ pass
+
+ def editorEvent(
+ self,
+ event: QtCore.QEvent,
+ model: QtCore.QAbstractItemModel,
+ option: QtWidgets.QStyleOptionViewItem,
+ index: QtCore.QModelIndex,
+ ) -> bool:
+ """Handle mouse events on the cell.
+
+ Args:
+ event: The event
+ model: The model
+ option: Style options
+ index: Model index
+
+ Returns:
+ True if event was handled
+ """
+ if event.type() == QtCore.QEvent.Type.MouseButtonRelease:
+ row_key: str = str(index.row())
+ state: PlaylistCellState = self._cell_states.get(row_key, PlaylistCellState.PENDING)
+
+ if state == PlaylistCellState.READY:
+ self.button_clicked.emit(index)
+ return True
+
+ return super().editorEvent(event, model, option, index)
diff --git a/tidal_dl_ng/gui/playlist_membership_mixin.py b/tidal_dl_ng/gui/playlist_membership_mixin.py
new file mode 100644
index 0000000..95754ca
--- /dev/null
+++ b/tidal_dl_ng/gui/playlist_membership_mixin.py
@@ -0,0 +1,280 @@
+"""Playlist Membership Mixin for MainWindow.
+
+Integrates the playlist membership management system into the main window,
+handling initialization, signal connections, and UI updates.
+"""
+
+from PySide6 import QtCore
+from tidalapi import Track
+
+from tidal_dl_ng.gui.dialog_playlist_manager import PlaylistManagerDialog
+from tidal_dl_ng.gui.playlist_membership import (
+ PlaylistCellState,
+ PlaylistColumnDelegate,
+ PlaylistContextLoader,
+ ThreadSafePlaylistCache,
+)
+from tidal_dl_ng.logger import logger_gui
+from tidal_dl_ng.model.gui_data import StatusbarMessage
+
+
+class PlaylistMembershipMixin:
+ """Mixin for playlist membership management integration into MainWindow."""
+
+ # Type hints
+ playlist_cache: ThreadSafePlaylistCache
+ playlist_loader: "PlaylistContextLoader | None"
+ playlist_column_delegate: PlaylistColumnDelegate
+
+ # Signals for playlist events
+ s_playlist_cache_ready: QtCore.Signal = QtCore.Signal(dict)
+ s_playlist_loader_error: QtCore.Signal = QtCore.Signal(str)
+ s_playlist_loader_progress: QtCore.Signal = QtCore.Signal(int, int)
+
+ def init_playlist_membership_manager(self) -> None:
+ """Initialize the playlist membership management system.
+
+ Sets up:
+ - Thread-safe cache for track→playlist mappings
+ - Background worker for loading playlists
+ - Custom delegate for the playlists column
+ - Signal connections
+ """
+
+ # 1. Create thread-safe cache
+ self.playlist_cache = ThreadSafePlaylistCache()
+
+ # 2. Store worker reference (will be recreated on each results change)
+ self.playlist_loader: PlaylistContextLoader | None = None
+
+ # 3. Create custom delegate for playlists column
+ self.playlist_column_delegate = PlaylistColumnDelegate(parent=self.tr_results)
+ self.playlist_column_delegate.set_cache(self.playlist_cache)
+ self.playlist_column_delegate.set_obj_column_index(1) # hidden obj column
+ self.playlist_column_delegate.button_clicked.connect(self.on_playlist_column_button_clicked)
+
+ # 5. Attach delegate to the results table (column 9 = playlists)
+ # Note: Column indices: 0=index, 1=obj, 2=artist, 3=title, 4=album,
+ # 5=duration, 6=quality, 7=date, 8=downloaded, 9=playlists
+
+ # Diagnostic: check if column 9 exists
+ if hasattr(self, "model_tr_results") and self.model_tr_results:
+ col_count = self.model_tr_results.columnCount()
+ if col_count <= 9:
+ logger_gui.error(f"❌ Column 9 does NOT exist! Model only has {col_count} columns")
+
+ self.tr_results.setItemDelegateForColumn(9, self.playlist_column_delegate)
+
+ # Configure column width and appearance
+ self.tr_results.setColumnWidth(9, 60)
+
+ # Make sure column is visible
+ if self.tr_results.isColumnHidden(9):
+ self.tr_results.setColumnHidden(9, False)
+
+ self.tr_results.setColumnHidden(1, True) # Hide obj column
+
+ # 6. Connect model signals to trigger preloading
+ if hasattr(self, "model_tr_results") and self.model_tr_results:
+ self.model_tr_results.modelReset.connect(self.on_results_layout_changed)
+ self.model_tr_results.layoutChanged.connect(self.on_results_layout_changed)
+
+ # Initialize states for any existing rows
+ existing_rows = self.model_tr_results.rowCount()
+ if existing_rows > 0:
+ for r in range(existing_rows):
+ self.playlist_column_delegate.set_cell_state(r, PlaylistCellState.READY)
+ else:
+ logger_gui.error("❌ model_tr_results not found - signal not connected!")
+
+ # 7. Launch initial playlist loading immediately
+ self._load_playlists()
+
+ def connect_playlist_signals(self) -> None:
+ """Connect playlist-related signals (called from main signal setup)."""
+ # Signal connections already done in init_playlist_membership_manager
+ pass
+
+ def _load_playlists(self) -> None:
+ """Load user playlists in background.
+
+ Creates a new worker and launches it in the threadpool.
+ """
+ # Purge ENTIRE cache (tracks + metadata) to ensure first-load starts clean
+ # et éviter que des métadonnées obsolètes n'influencent l'affichage.
+ if hasattr(self, "playlist_cache") and self.playlist_cache:
+ self.playlist_cache.clear()
+
+ # Indiquer explicitement au délégué que le cache n'est pas prêt
+ # afin d'afficher le spinner au lieu des compteurs.
+ if hasattr(self, "playlist_column_delegate") and self.playlist_column_delegate:
+ self.playlist_column_delegate.set_cache_ready(False)
+
+ self.playlist_loader = PlaylistContextLoader(
+ session=self.tidal.session,
+ user_id=str(self.tidal.session.user.id),
+ max_workers=5,
+ )
+
+ # Connect signals for this worker instance
+ self.playlist_loader.signals.started.connect(self.on_playlist_loader_started)
+ self.playlist_loader.signals.cache_ready.connect(self.on_playlist_cache_ready)
+ self.playlist_loader.signals.metadata_ready.connect(self.on_playlist_metadata_ready)
+ self.playlist_loader.signals.error.connect(self.on_playlist_loader_error)
+ self.playlist_loader.signals.progress.connect(self.on_playlist_loader_progress)
+ self.playlist_loader.signals.finished.connect(self.on_playlist_loader_finished)
+
+ # Start loading playlists in background
+ self.threadpool.start(self.playlist_loader)
+
+ def on_results_layout_changed(self) -> None:
+ """Handle when new results are displayed in the main table."""
+ # Initialize READY states for all visible rows
+ try:
+ rows = self.model_tr_results.rowCount()
+ for r in range(rows):
+ self.playlist_column_delegate.set_cell_state(r, PlaylistCellState.READY)
+ except Exception as e:
+ logger_gui.warning(f"Failed to initialize cell states: {e}")
+
+ # Force repaint to show buttons (if cache is already ready)
+ if self.playlist_column_delegate._cache_ready:
+ try:
+ self.tr_results.viewport().update()
+ self.tr_results.viewport().repaint()
+ except RuntimeError:
+ pass
+
+ def on_playlist_loader_started(self) -> None:
+ """Called when playlist loader starts."""
+ pass # Silent
+
+ def on_playlist_cache_ready(self, cache: dict) -> None:
+ """Called when playlist cache is ready.
+
+ Args:
+ cache: Dict[track_id, Set[playlist_id]] from worker
+ """
+
+ # Update cache WITHOUT clearing (metadata was already loaded)
+ # NOTE: We don't call clear() because metadata_ready signal was already
+ # emitted and stored. We only update track→playlist mapping.
+ self.playlist_cache.update_from_dict(cache)
+
+ # Initialize cell states for all current rows as READY
+ try:
+ rows = self.model_tr_results.rowCount()
+ for r in range(rows):
+ self.playlist_column_delegate.set_cell_state(r, PlaylistCellState.READY)
+ except Exception as e:
+ logger_gui.warning(f"Failed to initialize cell states: {e}")
+
+ # Notify delegate that cache is ready (stops animation timer)
+ self.playlist_column_delegate.set_cache_ready(True)
+
+ # Force complete repaint of the table to show buttons instead of spinners
+ self.tr_results.viewport().update()
+ self.tr_results.viewport().repaint()
+
+ def on_playlist_metadata_ready(self, metadata: dict) -> None:
+ """Receive playlist metadata and store it in the cache.
+
+ Args:
+ metadata: Dict[playlist_id, {name: str, item_count: int}]
+ """
+ try:
+ for pid, info in metadata.items():
+ name = str(info.get("name", pid))
+ count = int(info.get("item_count", 0))
+ self.playlist_cache.set_playlist_metadata(pid, name, count)
+ logger_gui.debug(f"📝 Stored metadata for {len(metadata)} playlists")
+ except Exception as e:
+ logger_gui.warning(f"Failed to store playlist metadata: {e}")
+
+ def on_playlist_loader_error(self, error_msg: str) -> None:
+ """Handle playlist loader error.
+
+ Args:
+ error_msg: Error description
+ """
+ logger_gui.warning(f"Playlist loader error: {error_msg}")
+
+ # Show user-friendly notification
+ self.s_statusbar_message.emit(
+ StatusbarMessage(
+ message=f"Erreur: {error_msg}",
+ timeout=5000,
+ )
+ )
+
+ def on_playlist_loader_progress(self, current: int, total: int) -> None:
+ """Handle playlist loader progress.
+
+ Args:
+ current: Number of playlists processed
+ total: Total number of playlists
+ """
+ pass # Silent - no need to spam logs
+
+ def on_playlist_loader_finished(self) -> None:
+ """Handle playlist loader finished (success or error)."""
+ pass # Silent
+
+ def on_playlist_column_button_clicked(self, index: QtCore.QModelIndex) -> None:
+ """Handle click on playlist column button.
+
+ Opens the playlist manager dialog for the clicked track.
+
+ Args:
+ index: QModelIndex of the cell clicked
+ """
+ # Map proxy index to source index if using proxy model
+ if isinstance(self.tr_results.model(), type(self.proxy_tr_results)):
+ source_index = self.proxy_tr_results.mapToSource(index)
+ else:
+ source_index = index
+
+ # Get the track object from the model
+ obj_item = self.model_tr_results.item(source_index.row(), 1) # Column 1 = obj
+ if not obj_item:
+ logger_gui.warning("Failed to get track object from model")
+ return
+
+ track = obj_item.data(QtCore.Qt.ItemDataRole.UserRole)
+ if not isinstance(track, Track):
+ logger_gui.warning(f"Cell does not contain Track object: {type(track)}")
+ return
+
+ # Create and show the dialog
+ dialog = PlaylistManagerDialog(
+ track=track,
+ cache=self.playlist_cache,
+ session=self.tidal.session,
+ threadpool=self.threadpool,
+ parent=self,
+ )
+
+ # Connect signals for tracking changes
+ dialog.playlist_added.connect(self.on_track_added_to_playlist)
+ dialog.playlist_removed.connect(self.on_track_removed_from_playlist)
+
+ # Show dialog modally
+ dialog.exec()
+
+ def on_track_added_to_playlist(self, track_id: str, playlist_id: str) -> None:
+ """Handle track added to playlist.
+
+ Args:
+ track_id: Track UUID
+ playlist_id: Playlist UUID
+ """
+ pass # Silent - update handled by dialog
+
+ def on_track_removed_from_playlist(self, track_id: str, playlist_id: str) -> None:
+ """Handle track removed from playlist.
+
+ Args:
+ track_id: Track UUID
+ playlist_id: Playlist UUID
+ """
+ pass # Silent - update handled by dialog
diff --git a/tidal_dl_ng/gui/tidal_session.py b/tidal_dl_ng/gui/tidal_session.py
index 349414f..b886548 100644
--- a/tidal_dl_ng/gui/tidal_session.py
+++ b/tidal_dl_ng/gui/tidal_session.py
@@ -56,6 +56,8 @@ class TidalSessionMixin:
if result:
self._init_dl()
self.thread_it(self.playlist_manager.tidal_user_lists)
+ # Initialize playlist membership manager
+ self.init_playlist_membership_manager()
def on_logout(self) -> None:
"""Log out from TIDAL and close the application."""
diff --git a/tidal_dl_ng/gui/trees_results.py b/tidal_dl_ng/gui/trees_results.py
index 0658078..e9f6c12 100644
--- a/tidal_dl_ng/gui/trees_results.py
+++ b/tidal_dl_ng/gui/trees_results.py
@@ -76,11 +76,15 @@ class TreesResultsMixin:
)
child_downloaded: QtGui.QStandardItem = QtGui.QStandardItem()
+ child_playlists: QtGui.QStandardItem = QtGui.QStandardItem()
+
if isinstance(item.obj, Track):
track_id = str(item.obj.id)
if self.history_service.is_downloaded(track_id):
child_downloaded.setText("✅")
child_downloaded.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
+ # Store track_id in playlists column for lookup
+ child_playlists.setData(track_id, QtCore.Qt.ItemDataRole.UserRole)
if isinstance(item.obj, Mix | Playlist | Album | Artist):
child_dummy: QtGui.QStandardItem = QtGui.QStandardItem()
@@ -97,6 +101,7 @@ class TreesResultsMixin:
child_quality,
child_date,
child_downloaded,
+ child_playlists,
)
def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]):
diff --git a/tidal_dl_ng/helper/playlist_api.py b/tidal_dl_ng/helper/playlist_api.py
new file mode 100644
index 0000000..bb56d14
--- /dev/null
+++ b/tidal_dl_ng/helper/playlist_api.py
@@ -0,0 +1,191 @@
+"""Playlist API helper - Centralized API calls for playlist operations.
+
+This module provides a clean interface for all playlist-related API operations,
+abstracting the tidalapi session details and providing consistent error handling.
+
+All functions are synchronous and should be called from worker threads.
+"""
+
+from requests.exceptions import RequestException
+from tidalapi import Session, Track, UserPlaylist
+
+from tidal_dl_ng.logger import logger_gui
+
+
+def get_user_playlists(session: Session) -> list[UserPlaylist]:
+ """Fetch all user playlists from Tidal API.
+
+ Args:
+ session: Authenticated Tidal session
+
+ Returns:
+ List of UserPlaylist objects
+
+ Raises:
+ RequestException: If API call fails
+ ValueError: If user is not authenticated
+ """
+ if not session.user:
+ raise ValueError("User not authenticated") # noqa: TRY003
+
+ try:
+ playlists = session.user.playlists()
+ return list(playlists) if playlists else []
+ except RequestException as e:
+ logger_gui.error(f"Failed to fetch user playlists: {e}")
+ raise
+
+
+def get_playlist_items(playlist: UserPlaylist) -> list[Track]:
+ """Fetch all items from a playlist.
+
+ Args:
+ playlist: UserPlaylist object
+
+ Returns:
+ List of Track objects in the playlist (excludes videos and other media types)
+
+ Raises:
+ RequestException: If API call fails
+ """
+ try:
+ # Force refresh to get latest items
+ playlist._items = None
+
+ # Replace single-call fetching by robust pagination to retrieve ALL items
+ # Some tidalapi backends return only the first N items (e.g., 100) by default.
+ # We iterate with an offset/limit until exhaustion.
+ all_items: list[Track] = []
+ offset: int = 0
+ limit: int = 100 # Use API-supported page size to avoid 400 errors
+
+ while True:
+ try:
+ batch = playlist.items(offset=offset, limit=limit)
+ except TypeError:
+ batch = playlist.items(offset, limit)
+
+ if not batch:
+ break
+
+ # Filter to only include Track objects
+ tracks_batch = [item for item in batch if isinstance(item, Track)]
+ all_items.extend(tracks_batch)
+
+ # Progress
+ offset += len(batch)
+
+ # Safety: stop if no progress to avoid infinite loop
+ if len(batch) < limit:
+ break
+
+ except RequestException as e:
+ logger_gui.error(f"Failed to fetch playlist items for {playlist.id}: {e}")
+ raise
+ else:
+ # Silenced diagnostics: previously logged first few tracks for ID normalization
+ return all_items
+
+
+def add_track_to_playlist(session: Session, playlist_id: str, track_id: str) -> None:
+ """Add a track to a playlist.
+
+ Args:
+ session: Authenticated Tidal session
+ playlist_id: UUID of the playlist
+ track_id: UUID of the track to add
+
+ Raises:
+ RequestException: If API call fails
+ ValueError: If playlist not found
+ """
+ try:
+ playlist = session.playlist(playlist_id)
+ if not playlist:
+ raise ValueError(f"Playlist {playlist_id} not found") # noqa: TRY003
+
+ # Normalize ID as int where supported
+ try:
+ norm_id = int(track_id)
+ except (TypeError, ValueError):
+ norm_id = track_id
+
+ playlist.add([norm_id])
+ # Silenced info log
+ except RequestException as e:
+ logger_gui.error(f"Failed to add track {track_id} to playlist {playlist_id}: {e}")
+ raise
+
+
+def remove_track_from_playlist(session: Session, playlist_id: str, track_id: str) -> None:
+ """Remove a track from a playlist.
+
+ Args:
+ session: Authenticated Tidal session
+ playlist_id: UUID of the playlist
+ track_id: UUID of the track to remove
+
+ Raises:
+ RequestException: If API call fails
+ ValueError: If playlist or track not found
+ """
+ try:
+ playlist = session.playlist(playlist_id)
+ if not playlist:
+ raise ValueError(f"Playlist {playlist_id} not found") # noqa: TRY003
+
+ # Always use index-based removal with robust pagination
+ # Force refresh and paginate to get all items
+ playlist._items = None
+ items_all = []
+ offset = 0
+ limit = 100
+ while True:
+ try:
+ batch = playlist.items(offset=offset, limit=limit)
+ except TypeError:
+ batch = playlist.items(offset, limit)
+ if not batch:
+ break
+ items_all.extend(batch)
+ offset += len(batch)
+ if len(batch) < limit:
+ break
+
+ # Find the track index
+ track_index = None
+ for i, item in enumerate(items_all):
+ item_id = getattr(item, "id", None)
+ if str(item_id) == str(track_id):
+ track_index = i
+ break
+
+ if track_index is None:
+ # Silenced warning: skip quietly if not found
+ return
+
+ # Remove by index
+ playlist.remove_by_index(track_index)
+ # Silenced info log
+ except RequestException as e:
+ logger_gui.error(f"Failed to remove track {track_id} from playlist {playlist_id}: {e}")
+ raise
+
+
+def get_playlist_metadata(playlist: UserPlaylist) -> dict[str, str | int]:
+ """Extract metadata from a playlist object.
+
+ Args:
+ playlist: UserPlaylist object
+
+ Returns:
+ Dictionary containing:
+ - name: Playlist name
+ - item_count: Number of items in playlist
+ - id: Playlist UUID
+ """
+ return {
+ "name": playlist.name if hasattr(playlist, "name") else f"Playlist {playlist.id}",
+ "item_count": playlist.num_tracks if hasattr(playlist, "num_tracks") else 0,
+ "id": str(playlist.id),
+ }
diff --git a/tidal_dl_ng/logger.py b/tidal_dl_ng/logger.py
index 0874cdc..ea0f4ea 100644
--- a/tidal_dl_ng/logger.py
+++ b/tidal_dl_ng/logger.py
@@ -47,6 +47,30 @@ class QtHandler(logging.Handler):
XStream.stdout().write("%s\n" % record)
+# --- Verbosity control -------------------------------------------------------
+# If False: DEBUG and WARNING messages are suppressed (INFO/ERROR/CRITICAL shown).
+# If True: DEBUG and WARNING messages are shown.
+_VERBOSE_DEBUG = False
+
+
+class DebugWarningFilter(logging.Filter):
+ def filter(self, record: logging.LogRecord) -> bool:
+ # Always allow INFO (and above except WARNING handled below)
+ # Suppress DEBUG and WARNING when _VERBOSE_DEBUG is False
+ if not _VERBOSE_DEBUG and record.levelno in (logging.DEBUG, logging.WARNING):
+ return False
+ return True
+
+
+def enable_debug_and_warnings(enabled: bool = True):
+ """Enable/disable showing DEBUG and WARNING log records globally.
+
+ INFO/ERROR/CRITICAL are unaffected and always shown.
+ """
+ global _VERBOSE_DEBUG
+ _VERBOSE_DEBUG = bool(enabled)
+
+
logger_gui = logging.getLogger(__name__)
handler_qt: QtHandler = QtHandler()
# log_fmt: str = "[%(asctime)s] %(levelname)s: %(message)s"
@@ -57,6 +81,8 @@ level_styles = coloredlogs.DEFAULT_LEVEL_STYLES.copy()
level_styles["info"] = {"color": "green"}
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
handler_qt.setFormatter(formatter)
+# Apply filter to control DEBUG/WARNING visibility
+handler_qt.addFilter(DebugWarningFilter())
logger_gui.addHandler(handler_qt)
logger_gui.setLevel(logging.DEBUG)
@@ -64,5 +90,7 @@ logger_cli = logging.getLogger(__name__)
handler_stream: logging.StreamHandler = logging.StreamHandler()
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
handler_stream.setFormatter(formatter)
+# Apply filter to control DEBUG/WARNING visibility
+handler_stream.addFilter(DebugWarningFilter())
logger_cli.addHandler(handler_stream)
logger_cli.setLevel(logging.DEBUG)
diff --git a/tidal_dl_ng/ui/dialog_playlist_manager.py b/tidal_dl_ng/ui/dialog_playlist_manager.py
new file mode 100644
index 0000000..17327f6
--- /dev/null
+++ b/tidal_dl_ng/ui/dialog_playlist_manager.py
@@ -0,0 +1,115 @@
+################################################################################
+## Form generated from reading UI file 'dialog_playlist_manager.ui'
+##
+## Created by: Qt User Interface Compiler version 6.10.0
+##
+## WARNING! All changes made in this file will be lost when recompiling UI file!
+################################################################################
+
+from PySide6.QtCore import QCoreApplication, QMetaObject, QRect, QSize, Qt
+from PySide6.QtWidgets import (
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QScrollArea,
+ QSizePolicy,
+ QSpacerItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+
+class Ui_DialogPlaylistManager:
+ def setupUi(self, DialogPlaylistManager):
+ if not DialogPlaylistManager.objectName():
+ DialogPlaylistManager.setObjectName("DialogPlaylistManager")
+ DialogPlaylistManager.resize(609, 760)
+ DialogPlaylistManager.setMinimumSize(QSize(520, 560))
+ self.verticalLayoutRoot = QVBoxLayout(DialogPlaylistManager)
+ self.verticalLayoutRoot.setObjectName("verticalLayoutRoot")
+ self.horizontalLayoutHeader = QHBoxLayout()
+ self.horizontalLayoutHeader.setObjectName("horizontalLayoutHeader")
+ self.labelTitle = QLabel(DialogPlaylistManager)
+ self.labelTitle.setObjectName("labelTitle")
+ self.labelTitle.setWordWrap(True)
+
+ self.horizontalLayoutHeader.addWidget(self.labelTitle)
+
+ self.verticalLayoutRoot.addLayout(self.horizontalLayoutHeader)
+
+ self.frameSeparator = QFrame(DialogPlaylistManager)
+ self.frameSeparator.setObjectName("frameSeparator")
+ self.frameSeparator.setFrameShape(QFrame.Shape.HLine)
+ self.frameSeparator.setFrameShadow(QFrame.Shadow.Sunken)
+
+ self.verticalLayoutRoot.addWidget(self.frameSeparator)
+
+ self.scrollArea = QScrollArea(DialogPlaylistManager)
+ self.scrollArea.setObjectName("scrollArea")
+ self.scrollArea.setWidgetResizable(True)
+ self.scrollAreaWidgetContents = QWidget()
+ self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents")
+ self.scrollAreaWidgetContents.setGeometry(QRect(0, 0, 589, 666))
+ self.verticalLayoutList = QVBoxLayout(self.scrollAreaWidgetContents)
+ self.verticalLayoutList.setObjectName("verticalLayoutList")
+ self.labelEmpty = QLabel(self.scrollAreaWidgetContents)
+ self.labelEmpty.setObjectName("labelEmpty")
+ self.labelEmpty.setAlignment(Qt.AlignmentFlag.AlignCenter)
+
+ self.verticalLayoutList.addWidget(self.labelEmpty)
+
+ self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
+
+ self.verticalLayoutList.addItem(self.verticalSpacer)
+
+ self.scrollArea.setWidget(self.scrollAreaWidgetContents)
+
+ self.verticalLayoutRoot.addWidget(self.scrollArea)
+
+ self.frameFooterSeparator = QFrame(DialogPlaylistManager)
+ self.frameFooterSeparator.setObjectName("frameFooterSeparator")
+ self.frameFooterSeparator.setFrameShape(QFrame.Shape.HLine)
+ self.frameFooterSeparator.setFrameShadow(QFrame.Shadow.Sunken)
+
+ self.verticalLayoutRoot.addWidget(self.frameFooterSeparator)
+
+ self.horizontalLayoutFooter = QHBoxLayout()
+ self.horizontalLayoutFooter.setObjectName("horizontalLayoutFooter")
+ self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
+
+ self.horizontalLayoutFooter.addItem(self.horizontalSpacer)
+
+ self.buttonClose = QPushButton(DialogPlaylistManager)
+ self.buttonClose.setObjectName("buttonClose")
+
+ self.horizontalLayoutFooter.addWidget(self.buttonClose)
+
+ self.verticalLayoutRoot.addLayout(self.horizontalLayoutFooter)
+
+ self.retranslateUi(DialogPlaylistManager)
+ self.buttonClose.clicked.connect(DialogPlaylistManager.accept)
+
+ self.buttonClose.setDefault(True)
+
+ QMetaObject.connectSlotsByName(DialogPlaylistManager)
+
+ # setupUi
+
+ def retranslateUi(self, DialogPlaylistManager):
+ DialogPlaylistManager.setWindowTitle(
+ QCoreApplication.translate("DialogPlaylistManager", "G\u00e9rer les playlists", None)
+ )
+ self.labelTitle.setText(
+ QCoreApplication.translate(
+ "DialogPlaylistManager",
+ 'G\u00e9rer les playlists pour : Titre de la Piste',
+ None,
+ )
+ )
+ self.labelEmpty.setText(
+ QCoreApplication.translate("DialogPlaylistManager", "Aucune playlist disponible.", None)
+ )
+ self.buttonClose.setText(QCoreApplication.translate("DialogPlaylistManager", "Fermer", None))
+
+ # retranslateUi
diff --git a/tidal_dl_ng/ui/dialog_playlist_manager.ui b/tidal_dl_ng/ui/dialog_playlist_manager.ui
new file mode 100644
index 0000000..5f7b831
--- /dev/null
+++ b/tidal_dl_ng/ui/dialog_playlist_manager.ui
@@ -0,0 +1,147 @@
+
+
+ DialogPlaylistManager
+
+
+
+ 0
+ 0
+ 609
+ 760
+
+
+
+
+ 520
+ 560
+
+
+
+ Gérer les playlists
+
+
+ -
+
+
-
+
+
+ Gérer les playlists pour : <b><span style="color:#1e88e5;">Titre de la Piste</span></b>
+
+
+ true
+
+
+
+
+
+ -
+
+
+ QFrame::Shape::HLine
+
+
+ QFrame::Shadow::Sunken
+
+
+
+ -
+
+
+ true
+
+
+
+
+ 0
+ 0
+ 589
+ 666
+
+
+
+
-
+
+
+ Aucune playlist disponible.
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+
+ -
+
+
+ Qt::Orientation::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+ -
+
+
+ QFrame::Shape::HLine
+
+
+ QFrame::Shadow::Sunken
+
+
+
+ -
+
+
-
+
+
+ Qt::Orientation::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Fermer
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+ buttonClose
+ clicked()
+ DialogPlaylistManager
+ accept()
+
+
+ 20
+ 20
+
+
+ 20
+ 20
+
+
+
+
+