feat: Add playlist management functionality and compliance checks

- Introduced a new "Playlists" column in results view with visual indicators for membership.
- Added `playlist_api.py` to handle playlist-related operations (fetch, add, remove tracks).
- Implemented `PlaylistManagerDialog` for seamless playlist addition/removal with caching.
- Integrated compliance check script (`check_agents_compliance.py`) for ensuring coding standards.
- Added extensive documentation (`playlist_membership_manager.md`) covering architecture and flow.
- Updated GUI logic and QStandardItemModel setup to include playlist data.
- Included unit tests for playlist management components and API.
This commit is contained in:
Warry
2025-12-29 17:37:39 +01:00
parent 75868b32d0
commit 296c6c7d6b
17 changed files with 3433 additions and 4 deletions
+93
View File
@@ -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.
+499
View File
@@ -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
+263
View File
@@ -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)
+203
View File
@@ -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)
+543
View File
@@ -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()
+2 -3
View File
@@ -54,10 +54,9 @@ def gui_activate(tidal: Tidal | None = None):
window = MainWindow(tidal=tidal) window = MainWindow(tidal=tidal)
window.show() 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__": if __name__ == "__main__":
+327
View File
@@ -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 : <b><span style="color:#1e88e5;">{track_title}</span></b>'
)
# 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)
+1
View File
@@ -127,6 +127,7 @@ class InitializationMixin:
"Quality", "Quality",
"Date", "Date",
"Downloaded?", "Downloaded?",
"Playlists",
] ]
model.setColumnCount(len(labels_column)) model.setColumnCount(len(labels_column))
model.setRowCount(0) model.setRowCount(0)
+9 -1
View File
@@ -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.history import HistoryMixin
from tidal_dl_ng.gui.initialization import InitializationMixin from tidal_dl_ng.gui.initialization import InitializationMixin
from tidal_dl_ng.gui.playlist import GuiPlaylistManager 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.progress import ProgressMixin
from tidal_dl_ng.gui.queue import GuiQueueManager from tidal_dl_ng.gui.queue import GuiQueueManager
from tidal_dl_ng.gui.search import GuiSearchManager from tidal_dl_ng.gui.search import GuiSearchManager
@@ -52,6 +53,7 @@ class MainWindow(
TreesResultsMixin, TreesResultsMixin,
ContextMenusMixin, ContextMenusMixin,
HistoryMixin, HistoryMixin,
PlaylistMembershipMixin,
): ):
"""Main application window for TIDAL Downloader Next Generation. """Main application window for TIDAL Downloader Next Generation.
@@ -169,7 +171,7 @@ class MainWindow(
self.init_tidal(tidal) self.init_tidal(tidal)
logger_gui.debug("All setup.") logger_gui.info("All setup.")
def closeEvent(self, event: QtGui.QCloseEvent) -> None: def closeEvent(self, event: QtGui.QCloseEvent) -> None:
"""Handle the close event of the main window. """Handle the close event of the main window.
@@ -177,6 +179,12 @@ class MainWindow(
Args: Args:
event (QtGui.QCloseEvent): The close event. 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 # Save the main window size and position
self.settings.data.window_x = self.x() self.settings.data.window_x = self.x()
self.settings.data.window_y = self.y() self.settings.data.window_y = self.y()
+725
View File
@@ -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)
@@ -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
+2
View File
@@ -56,6 +56,8 @@ class TidalSessionMixin:
if result: if result:
self._init_dl() self._init_dl()
self.thread_it(self.playlist_manager.tidal_user_lists) self.thread_it(self.playlist_manager.tidal_user_lists)
# Initialize playlist membership manager
self.init_playlist_membership_manager()
def on_logout(self) -> None: def on_logout(self) -> None:
"""Log out from TIDAL and close the application.""" """Log out from TIDAL and close the application."""
+5
View File
@@ -76,11 +76,15 @@ class TreesResultsMixin:
) )
child_downloaded: QtGui.QStandardItem = QtGui.QStandardItem() child_downloaded: QtGui.QStandardItem = QtGui.QStandardItem()
child_playlists: QtGui.QStandardItem = QtGui.QStandardItem()
if isinstance(item.obj, Track): if isinstance(item.obj, Track):
track_id = str(item.obj.id) track_id = str(item.obj.id)
if self.history_service.is_downloaded(track_id): if self.history_service.is_downloaded(track_id):
child_downloaded.setText("✅") child_downloaded.setText("✅")
child_downloaded.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) 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): if isinstance(item.obj, Mix | Playlist | Album | Artist):
child_dummy: QtGui.QStandardItem = QtGui.QStandardItem() child_dummy: QtGui.QStandardItem = QtGui.QStandardItem()
@@ -97,6 +101,7 @@ class TreesResultsMixin:
child_quality, child_quality,
child_date, child_date,
child_downloaded, child_downloaded,
child_playlists,
) )
def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]): def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]):
+191
View File
@@ -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),
}
+28
View File
@@ -47,6 +47,30 @@ class QtHandler(logging.Handler):
XStream.stdout().write("%s\n" % record) 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__) logger_gui = logging.getLogger(__name__)
handler_qt: QtHandler = QtHandler() handler_qt: QtHandler = QtHandler()
# log_fmt: str = "[%(asctime)s] %(levelname)s: %(message)s" # 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"} level_styles["info"] = {"color": "green"}
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles) formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
handler_qt.setFormatter(formatter) handler_qt.setFormatter(formatter)
# Apply filter to control DEBUG/WARNING visibility
handler_qt.addFilter(DebugWarningFilter())
logger_gui.addHandler(handler_qt) logger_gui.addHandler(handler_qt)
logger_gui.setLevel(logging.DEBUG) logger_gui.setLevel(logging.DEBUG)
@@ -64,5 +90,7 @@ logger_cli = logging.getLogger(__name__)
handler_stream: logging.StreamHandler = logging.StreamHandler() handler_stream: logging.StreamHandler = logging.StreamHandler()
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles) formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
handler_stream.setFormatter(formatter) handler_stream.setFormatter(formatter)
# Apply filter to control DEBUG/WARNING visibility
handler_stream.addFilter(DebugWarningFilter())
logger_cli.addHandler(handler_stream) logger_cli.addHandler(handler_stream)
logger_cli.setLevel(logging.DEBUG) logger_cli.setLevel(logging.DEBUG)
+115
View File
@@ -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 : <b><span style="color:#1e88e5;">Titre de la Piste</span></b>',
None,
)
)
self.labelEmpty.setText(
QCoreApplication.translate("DialogPlaylistManager", "Aucune playlist disponible.", None)
)
self.buttonClose.setText(QCoreApplication.translate("DialogPlaylistManager", "Fermer", None))
# retranslateUi
+147
View File
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>DialogPlaylistManager</class>
<widget class="QDialog" name="DialogPlaylistManager">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>609</width>
<height>760</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>520</width>
<height>560</height>
</size>
</property>
<property name="windowTitle">
<string>Gérer les playlists</string>
</property>
<layout class="QVBoxLayout" name="verticalLayoutRoot">
<item>
<layout class="QHBoxLayout" name="horizontalLayoutHeader">
<item>
<widget class="QLabel" name="labelTitle">
<property name="text">
<string>Gérer les playlists pour : &lt;b&gt;&lt;span style=&quot;color:#1e88e5;&quot;&gt;Titre de la Piste&lt;/span&gt;&lt;/b&gt;</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QFrame" name="frameSeparator">
<property name="frameShape">
<enum>QFrame::Shape::HLine</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Sunken</enum>
</property>
</widget>
</item>
<item>
<widget class="QScrollArea" name="scrollArea">
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="scrollAreaWidgetContents">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>589</width>
<height>666</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayoutList">
<item>
<widget class="QLabel" name="labelEmpty">
<property name="text">
<string>Aucune playlist disponible.</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<widget class="QFrame" name="frameFooterSeparator">
<property name="frameShape">
<enum>QFrame::Shape::HLine</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Sunken</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayoutFooter">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="buttonClose">
<property name="text">
<string>Fermer</string>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonClose</sender>
<signal>clicked()</signal>
<receiver>DialogPlaylistManager</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>20</x>
<y>20</y>
</hint>
<hint type="destinationlabel">
<x>20</x>
<y>20</y>
</hint>
</hints>
</connection>
</connections>
</ui>