From 296c6c7d6b319b38958b2aacb9904f18f6f46fd1 Mon Sep 17 00:00:00 2001 From: Warry Date: Mon, 29 Dec 2025 17:37:39 +0100 Subject: [PATCH] feat: Add playlist management functionality and compliance checks - Introduced a new "Playlists" column in results view with visual indicators for membership. - Added `playlist_api.py` to handle playlist-related operations (fetch, add, remove tracks). - Implemented `PlaylistManagerDialog` for seamless playlist addition/removal with caching. - Integrated compliance check script (`check_agents_compliance.py`) for ensuring coding standards. - Added extensive documentation (`playlist_membership_manager.md`) covering architecture and flow. - Updated GUI logic and QStandardItemModel setup to include playlist data. - Included unit tests for playlist management components and API. --- docs/playlist_api_architecture.md | 93 +++ docs/playlist_membership_manager.md | 499 +++++++++++++ scripts/check_agents_compliance.py | 263 +++++++ scripts/verify_playlist_integration.py | 203 ++++++ tests/test_playlist_manager.py | 543 ++++++++++++++ tidal_dl_ng/gui/activate.py | 5 +- tidal_dl_ng/gui/dialog_playlist_manager.py | 327 +++++++++ tidal_dl_ng/gui/initialization.py | 1 + tidal_dl_ng/gui/main_window.py | 10 +- tidal_dl_ng/gui/playlist_membership.py | 725 +++++++++++++++++++ tidal_dl_ng/gui/playlist_membership_mixin.py | 280 +++++++ tidal_dl_ng/gui/tidal_session.py | 2 + tidal_dl_ng/gui/trees_results.py | 5 + tidal_dl_ng/helper/playlist_api.py | 191 +++++ tidal_dl_ng/logger.py | 28 + tidal_dl_ng/ui/dialog_playlist_manager.py | 115 +++ tidal_dl_ng/ui/dialog_playlist_manager.ui | 147 ++++ 17 files changed, 3433 insertions(+), 4 deletions(-) create mode 100644 docs/playlist_api_architecture.md create mode 100644 docs/playlist_membership_manager.md create mode 100644 scripts/check_agents_compliance.py create mode 100644 scripts/verify_playlist_integration.py create mode 100644 tests/test_playlist_manager.py create mode 100644 tidal_dl_ng/gui/dialog_playlist_manager.py create mode 100644 tidal_dl_ng/gui/playlist_membership.py create mode 100644 tidal_dl_ng/gui/playlist_membership_mixin.py create mode 100644 tidal_dl_ng/helper/playlist_api.py create mode 100644 tidal_dl_ng/ui/dialog_playlist_manager.py create mode 100644 tidal_dl_ng/ui/dialog_playlist_manager.ui 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 + + + + +