feat: Add playlist management functionality and compliance checks
- Introduced a new "Playlists" column in results view with visual indicators for membership. - Added `playlist_api.py` to handle playlist-related operations (fetch, add, remove tracks). - Implemented `PlaylistManagerDialog` for seamless playlist addition/removal with caching. - Integrated compliance check script (`check_agents_compliance.py`) for ensuring coding standards. - Added extensive documentation (`playlist_membership_manager.md`) covering architecture and flow. - Updated GUI logic and QStandardItemModel setup to include playlist data. - Included unit tests for playlist management components and API.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
# Architecture des appels API pour les playlists
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Tous les appels API liés aux playlists ont été centralisés dans un seul module pour une meilleure maintenabilité et cohérence.
|
||||
|
||||
## Fichier centralisé : `tidal_dl_ng/helper/playlist_api.py`
|
||||
|
||||
Ce module contient toutes les fonctions d'interaction avec l'API Tidal pour les playlists :
|
||||
|
||||
### Fonctions disponibles
|
||||
|
||||
#### 1. `get_user_playlists(session: Session) -> list[UserPlaylist]`
|
||||
- **Description**: Récupère toutes les playlists de l'utilisateur
|
||||
- **Paramètres**: Session Tidal authentifiée
|
||||
- **Retour**: Liste d'objets UserPlaylist
|
||||
- **Exceptions**: RequestException, ValueError
|
||||
|
||||
#### 2. `get_playlist_items(playlist: UserPlaylist) -> list[Track]`
|
||||
- **Description**: Récupère tous les morceaux d'une playlist
|
||||
- **Paramètres**: Objet UserPlaylist
|
||||
- **Retour**: Liste d'objets Track
|
||||
- **Exceptions**: RequestException
|
||||
|
||||
#### 3. `add_track_to_playlist(session: Session, playlist_id: str, track_id: str) -> None`
|
||||
- **Description**: Ajoute un morceau à une playlist
|
||||
- **Paramètres**:
|
||||
- session: Session Tidal
|
||||
- playlist_id: UUID de la playlist
|
||||
- track_id: UUID du morceau
|
||||
- **Exceptions**: RequestException, ValueError
|
||||
|
||||
#### 4. `remove_track_from_playlist(session: Session, playlist_id: str, track_id: str) -> None`
|
||||
- **Description**: Retire un morceau d'une playlist
|
||||
- **Paramètres**:
|
||||
- session: Session Tidal
|
||||
- playlist_id: UUID de la playlist
|
||||
- track_id: UUID du morceau
|
||||
- **Exceptions**: RequestException, ValueError
|
||||
- **Note**: Gère automatiquement la recherche de l'index du morceau
|
||||
|
||||
#### 5. `get_playlist_metadata(playlist: UserPlaylist) -> dict[str, str | int]`
|
||||
- **Description**: Extrait les métadonnées d'une playlist
|
||||
- **Paramètres**: Objet UserPlaylist
|
||||
- **Retour**: Dictionnaire avec `name`, `item_count`, `id`
|
||||
|
||||
## Modules utilisant l'API centralisée
|
||||
|
||||
### 1. `tidal_dl_ng/gui/dialog_playlist_manager.py`
|
||||
Utilise :
|
||||
- `add_track_to_playlist()` - Pour ajouter des morceaux via l'interface
|
||||
- `remove_track_from_playlist()` - Pour retirer des morceaux via l'interface
|
||||
|
||||
### 2. `tidal_dl_ng/gui/playlist_membership.py`
|
||||
Utilise :
|
||||
- `get_user_playlists()` - Pour charger les playlists au démarrage
|
||||
- `get_playlist_items()` - Pour construire le cache de memberships
|
||||
- `get_playlist_metadata()` - Pour afficher les noms et comptes
|
||||
|
||||
## Avantages de cette architecture
|
||||
|
||||
1. **Maintenabilité** : Un seul endroit pour modifier la logique API
|
||||
2. **Cohérence** : Même gestion d'erreurs partout
|
||||
3. **Testabilité** : Facile de mocker les fonctions API
|
||||
4. **Logging centralisé** : Tous les logs API au même endroit
|
||||
5. **Évolutivité** : Facile d'ajouter de nouvelles fonctions
|
||||
|
||||
## Gestion des erreurs
|
||||
|
||||
Toutes les fonctions :
|
||||
- Loggent les erreurs avec `logger_gui`
|
||||
- Propagent les exceptions (RequestException) pour que l'appelant puisse gérer
|
||||
- Gèrent automatiquement les cas limites (playlist vide, morceau non trouvé, etc.)
|
||||
|
||||
## Exemple d'utilisation
|
||||
|
||||
```python
|
||||
from tidal_dl_ng.helper.playlist_api import add_track_to_playlist, get_user_playlists
|
||||
|
||||
# Récupérer les playlists
|
||||
playlists = get_user_playlists(session)
|
||||
|
||||
# Ajouter un morceau
|
||||
try:
|
||||
add_track_to_playlist(session, playlist_id="abc123", track_id="def456")
|
||||
print("Morceau ajouté avec succès")
|
||||
except RequestException as e:
|
||||
print(f"Erreur: {e}")
|
||||
```
|
||||
|
||||
## Migration future
|
||||
|
||||
Si besoin de passer à une autre bibliothèque API ou d'ajouter un cache HTTP, il suffit de modifier `playlist_api.py` sans toucher aux autres fichiers.
|
||||
@@ -0,0 +1,499 @@
|
||||
# Gestionnaire d'Appartenance aux Playlists (Playlist Membership Manager)
|
||||
|
||||
## Vue d'ensemble architecturale
|
||||
|
||||
### Problème utilisateur
|
||||
L'application agit actuellement comme un silo de téléchargement. Pour organiser sa musique (ajouter à une playlist), l'utilisateur doit quitter l'app, ouvrir le client officiel Tidal, chercher à nouveau, puis agir. Ceci rompt le flux d'expérience.
|
||||
|
||||
### Solution proposée
|
||||
Transformer chaque vue de résultats (Album, Recherche, Playlist) en tableau interactif avec :
|
||||
- Une colonne dédiée "Playlists"
|
||||
- Indicateur visuel instantané de l'appartenance
|
||||
- Gestion d'ajout/retrait sans friction
|
||||
|
||||
---
|
||||
|
||||
## 1. Stratégie de Pré-chargement (Eager Loading & Caching)
|
||||
|
||||
### 1.1 Objectif de performance
|
||||
Éliminer toute latence au clic. L'utilisateur accepte d'attendre le spinner au rechargement de la vue, mais pas l'ouverture du dialogue.
|
||||
|
||||
### 1.2 Architecture du Worker
|
||||
|
||||
```
|
||||
Event: modelReset / layoutChanged (Main Table)
|
||||
↓
|
||||
Déclenche PlaylistContextLoader (QRunnable)
|
||||
↓
|
||||
Thread Pool Execution:
|
||||
├─ Fetch User Playlists (GET /users/{userId}/playlists)
|
||||
│ └─ Pagination gérée (limit=50 par défaut)
|
||||
│ └─ Filtre des playlists éditables
|
||||
├─ Parallèle : Fetch Playlist Contents
|
||||
│ ├─ Pour chaque playlist : GET /playlists/{id}/items?offset=0&limit=300
|
||||
│ └─ Accumule les Track IDs dans Set pour O(1) lookup
|
||||
└─ Spinlock/Event synchronisation
|
||||
↓
|
||||
Construit: Cache = Dict[TrackID, Set[PlaylistID]]
|
||||
↓
|
||||
Émet Signal: playlistCacheReady(cache_dict)
|
||||
↓
|
||||
Tableau met à jour Delegate → Spinner OFF, Bouton ON
|
||||
```
|
||||
|
||||
### 1.3 Structure de données
|
||||
|
||||
```python
|
||||
# Cache structure
|
||||
PlaylistCache = Dict[str, Set[str]]
|
||||
# Exemple:
|
||||
# {
|
||||
# "track_uuid_1": {"playlist_id_1", "playlist_id_2"},
|
||||
# "track_uuid_2": {"playlist_id_3"},
|
||||
# }
|
||||
|
||||
# Thread-safe wrapper
|
||||
class ThreadSafePlaylistCache:
|
||||
_lock: threading.RLock
|
||||
_data: Dict[str, Set[str]]
|
||||
_metadata: Dict[str, PlaylistMetadata]
|
||||
|
||||
def get(self, track_id: str) -> Set[str]:
|
||||
"""O(1) lookup with thread safety"""
|
||||
|
||||
def update_track(self, track_id: str, playlist_ids: Set[str]):
|
||||
"""Atomic update"""
|
||||
```
|
||||
|
||||
### 1.4 Gestion d'erreurs et interruption
|
||||
|
||||
- **Interruptibilité** : Signal `abort_requested` pendant le chargement
|
||||
- **Fail-silent** : Les erreurs réseau n'affichent pas de popup
|
||||
- **Timeout** : Limite de 30s par playlistId
|
||||
- **Limite de taux** : Max 5 requêtes parallèles (QThreadPool.maxThreadCount() = 5)
|
||||
|
||||
---
|
||||
|
||||
## 2. Interface - Colonne "Playlists" (Delegate & États)
|
||||
|
||||
### 2.1 Machine à états visuels
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ État 1: PENDING (Initial) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Trigger: Cache not ready for this track │
|
||||
│ Visual: Spinner animé (SVG/QMovie), désactivé │
|
||||
│ Interaction: Désactivée (curseur "wait") │
|
||||
│ Durée: 0-5 secondes max │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ État 2: READY (Normal) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Trigger: Cache ready pour ce track │
|
||||
│ Visual: Flat Button "📋 Playlists", gris neutre │
|
||||
│ Interaction: Cliquable (curseur "pointer") │
|
||||
│ Action: Ouvre PlaylistManagerDialog │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↙ (erreur lors du clic)
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ État 3: ERROR (Optionnel) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Trigger: Préchargement échoué (API down) │
|
||||
│ Visual: Icône d'avertissement ⚠️ grise │
|
||||
│ Interaction: Désactivée avec tooltip │
|
||||
│ Durée: Persistant jusqu'à reload │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 Intégration QStyledItemDelegate
|
||||
|
||||
```
|
||||
QTableView/QTreeView ModelReset
|
||||
↓
|
||||
playlistColumnDelegate.setModelData()
|
||||
├─ row = model_index.row()
|
||||
├─ track_id = model.data(row, TrackIdRole)
|
||||
├─ state = cache.get_state(track_id) → [PENDING|READY|ERROR]
|
||||
└─ paint() selon state
|
||||
├─ PENDING: drawSpinner()
|
||||
├─ READY: drawButton()
|
||||
└─ ERROR: drawWarningIcon()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Fenêtre Modale : PlaylistManagerDialog
|
||||
|
||||
### 3.1 Design & Layout
|
||||
|
||||
```
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Gérer les playlists pour: [TRACK_TITLE] ║ ← QLabel
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ ☐ Favorites (16) ║ ← QCheckBox + QLabel
|
||||
║ ☐ Night Vibes (42) ║
|
||||
║ ☐ Workout Mix (103) ║
|
||||
║ ☐ Découvertes semaine (27) ║
|
||||
║ ║
|
||||
║ [Scroll Area: QScrollArea] ║
|
||||
║ ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ [Fermer] ║ ← QPushButton
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
### 3.2 Composants
|
||||
|
||||
```python
|
||||
class PlaylistManagerDialog(QDialog):
|
||||
"""Dialogue de gestion des appartenance aux playlists."""
|
||||
|
||||
# Signals
|
||||
playlist_added: QtCore.Signal = QtCore.Signal(str, str) # track_id, playlist_id
|
||||
playlist_removed: QtCore.Signal = QtCore.Signal(str, str)
|
||||
|
||||
def __init__(self,
|
||||
track: Track,
|
||||
cache: ThreadSafePlaylistCache,
|
||||
tidal_session: Session,
|
||||
parent: QWidget):
|
||||
"""Initialisation du dialogue.
|
||||
|
||||
Args:
|
||||
track: La piste courante
|
||||
cache: Le cache pré-chargé
|
||||
tidal_session: Session Tidal pour les appels API
|
||||
parent: Widget parent
|
||||
"""
|
||||
|
||||
def populate_playlists(self) -> None:
|
||||
"""Remplit la liste des playlists depuis le cache.
|
||||
|
||||
- Tri alphabétique
|
||||
- Récupère l'état d'appartenance du cache
|
||||
- Crée les checkboxes
|
||||
"""
|
||||
|
||||
def on_playlist_toggled(self, checkbox: QCheckBox,
|
||||
playlist_id: str,
|
||||
is_checked: bool) -> None:
|
||||
"""Gère le changement d'état d'une checkbox.
|
||||
|
||||
Flux:
|
||||
1. Désactiver checkbox + afficher spinner
|
||||
2. Appel API (POST/DELETE)
|
||||
3. Succès: Mettre à jour cache local
|
||||
4. Erreur: Rollback + Toast notification
|
||||
"""
|
||||
```
|
||||
|
||||
### 3.3 Logique transactionnelle
|
||||
|
||||
```
|
||||
User clicks checkbox
|
||||
↓
|
||||
on_playlist_toggled(playlist_id, is_checked)
|
||||
├─ Sauvegarder l'état précédent (rollback_state)
|
||||
├─ Désactiver checkbox visuellement
|
||||
├─ Afficher mini-spinner
|
||||
├─ Déterminer l'action: ADD (POST) ou REMOVE (DELETE)
|
||||
├─ Appel API threadé
|
||||
│ ├─ Succès (200 OK):
|
||||
│ │ ├─ Mettre à jour cache[track_id][playlist_id]
|
||||
│ │ ├─ Réactiver checkbox
|
||||
│ │ └─ Émettre signal (pour synchronisation UI)
|
||||
│ │
|
||||
│ └─ Échec (4xx/5xx):
|
||||
│ ├─ Restorer checkbox état = rollback_state
|
||||
│ ├─ Réactiver checkbox
|
||||
│ └─ Afficher Toast: "Impossible de modifier"
|
||||
│
|
||||
└─ [FIN]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Implémentation API & Services
|
||||
|
||||
### 4.1 PlaylistContextLoader (Worker Thread)
|
||||
|
||||
```python
|
||||
class PlaylistContextLoader(QtCore.QRunnable):
|
||||
"""Charge les contextes de playlists de manière concurrente."""
|
||||
|
||||
# Signals (communiquent du worker thread → main thread)
|
||||
started: QtCore.Signal = QtCore.Signal()
|
||||
progress: QtCore.Signal = QtCore.Signal(int, int) # current, total
|
||||
cache_ready: QtCore.Signal = QtCore.Signal(dict) # ThreadSafePlaylistCache
|
||||
error: QtCore.Signal = QtCore.Signal(str)
|
||||
finished: QtCore.Signal = QtCore.Signal()
|
||||
|
||||
def __init__(self,
|
||||
session: Session,
|
||||
user_id: str,
|
||||
max_workers: int = 5):
|
||||
"""Initialise le loader.
|
||||
|
||||
Args:
|
||||
session: Session Tidal authentifiée
|
||||
user_id: ID de l'utilisateur courant
|
||||
max_workers: Nombre de threads concurrents pour les requêtes
|
||||
"""
|
||||
|
||||
def run(self) -> None:
|
||||
"""Point d'entrée du thread worker.
|
||||
|
||||
Séquence:
|
||||
1. Fetch all user playlists (pagination)
|
||||
2. Pour chaque playlist (parallèle):
|
||||
- Fetch all items (pagination)
|
||||
- Extract track IDs
|
||||
3. Build cache Dict[track_id, Set[playlist_id]]
|
||||
4. Émettre signal cache_ready avec cache
|
||||
"""
|
||||
|
||||
def _fetch_user_playlists(self) -> list[Playlist]:
|
||||
"""Récupère toutes les playlists de l'utilisateur.
|
||||
|
||||
Gère la pagination (offset/limit).
|
||||
Filtre les playlists éditables uniquement.
|
||||
"""
|
||||
|
||||
def _fetch_playlist_items(self,
|
||||
playlist_id: str,
|
||||
limit: int = 300) -> set[str]:
|
||||
"""Récupère tous les IDs de pistes d'une playlist.
|
||||
|
||||
Gère la pagination automatique.
|
||||
Retourne un Set pour O(1) lookup.
|
||||
"""
|
||||
|
||||
def request_abort(self) -> None:
|
||||
"""Demande l'interruption du loader.
|
||||
|
||||
Interruptibilité: Finit la requête en cours, puis s'arrête.
|
||||
"""
|
||||
```
|
||||
|
||||
### 4.2 Endpoints Tidal utilisés
|
||||
|
||||
```
|
||||
# 1. Récupérer les playlists
|
||||
GET /users/{userId}/playlists
|
||||
?offset=0
|
||||
&limit=50
|
||||
&includeOnly=EDITORIAL,COLLABORATIVE
|
||||
|
||||
Response: {"items": [...], "totalNumberOfItems": N}
|
||||
|
||||
# 2. Récupérer les items d'une playlist
|
||||
GET /playlists/{playlistId}/items
|
||||
?offset=0
|
||||
&limit=300
|
||||
&includeOnly=TRACKS,VIDEOS
|
||||
|
||||
Response: {"items": [...], "totalNumberOfItems": M}
|
||||
où items[i].item.id = track_uuid
|
||||
|
||||
# 3. Ajouter une piste à une playlist
|
||||
POST /playlists/{playlistId}/items
|
||||
Content-Type: application/json
|
||||
{"trackIds": ["uuid1", "uuid2"]}
|
||||
|
||||
# 4. Supprimer une piste d'une playlist
|
||||
DELETE /playlists/{playlistId}/items/{itemId}
|
||||
OU
|
||||
DELETE /playlists/{playlistId}/items?itemIds=uuid1,uuid2
|
||||
```
|
||||
|
||||
### 4.3 Rate Limiting & Timeout
|
||||
|
||||
- **Timeout par requête** : 30 secondes
|
||||
- **Max concurrent requests** : 5 (limité par QThreadPool)
|
||||
- **Backoff strategy** : Exponentiel (1s → 2s → 4s) sur 429/503
|
||||
- **Fail-silent** : Log warning, pas de popup utilisateur
|
||||
|
||||
---
|
||||
|
||||
## 5. Intégration dans MainWindow
|
||||
|
||||
### 5.1 Initialization Flow
|
||||
|
||||
```python
|
||||
class MainWindow:
|
||||
def init_playlist_membership_manager(self):
|
||||
"""Initialise le gestionnaire d'appartenance aux playlists."""
|
||||
|
||||
# 1. Créer le cache thread-safe
|
||||
self.playlist_cache = ThreadSafePlaylistCache()
|
||||
|
||||
# 2. Créer le worker (pas de démarrage encore)
|
||||
self.playlist_loader = PlaylistContextLoader(
|
||||
session=self.tidal.session,
|
||||
user_id=self.tidal.user_id,
|
||||
max_workers=5
|
||||
)
|
||||
|
||||
# 3. Connecter les signaux du worker
|
||||
self.playlist_loader.cache_ready.connect(self.on_playlist_cache_ready)
|
||||
self.playlist_loader.error.connect(self.on_playlist_loader_error)
|
||||
self.playlist_loader.progress.connect(self.on_playlist_loader_progress)
|
||||
|
||||
# 4. Connecter les signaux du tableau
|
||||
self.model_tr_results.modelReset.connect(self.on_results_layout_changed)
|
||||
self.proxy_tr_results.layoutChanged.connect(self.on_results_layout_changed)
|
||||
|
||||
# 5. Ajouter le delegate personnalisé
|
||||
self.playlist_column_delegate = PlaylistColumnDelegate(
|
||||
parent=self.tr_results
|
||||
)
|
||||
self.tr_results.setItemDelegateForColumn(PLAYLIST_COLUMN_INDEX,
|
||||
self.playlist_column_delegate)
|
||||
|
||||
def on_results_layout_changed(self) -> None:
|
||||
"""Déclenche le pré-chargement lors d'un changement de résultats."""
|
||||
|
||||
# 1. Réinitialiser le cache
|
||||
self.playlist_cache.clear()
|
||||
|
||||
# 2. Lancer le worker de pré-chargement
|
||||
self.threadpool.start(self.playlist_loader)
|
||||
|
||||
def on_playlist_cache_ready(self, cache: dict) -> None:
|
||||
"""Appelé quand le cache est prêt.
|
||||
|
||||
Notifie le delegate que le cache a changé.
|
||||
"""
|
||||
self.playlist_cache.update_from_dict(cache)
|
||||
self.playlist_column_delegate.setCacheReady(True)
|
||||
self.tr_results.viewport().update() # Redessine les cellules
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests & Qualité
|
||||
|
||||
### 6.1 Fichier de tests: tests/test_playlist_manager.py
|
||||
|
||||
```python
|
||||
class TestPlaylistContextLoader(unittest.TestCase):
|
||||
"""Tests du worker de pré-chargement."""
|
||||
|
||||
def test_fetch_user_playlists_pagination(self):
|
||||
"""Vérifie la gestion de la pagination."""
|
||||
|
||||
def test_build_cache_structure(self):
|
||||
"""Vérifie la construction correcte du cache."""
|
||||
|
||||
def test_thread_safety_concurrent_updates(self):
|
||||
"""Vérifie la thread-safety lors d'accès concurrents."""
|
||||
|
||||
def test_abort_request(self):
|
||||
"""Vérifie l'interruption du worker."""
|
||||
|
||||
def test_error_handling_api_failure(self):
|
||||
"""Vérifie la gestion des erreurs API."""
|
||||
|
||||
|
||||
class TestPlaylistManagerDialog(unittest.TestCase):
|
||||
"""Tests du dialogue modal."""
|
||||
|
||||
def test_dialog_initialization(self):
|
||||
"""Vérifie l'initialisation correcte du dialogue."""
|
||||
|
||||
def test_checkbox_state_from_cache(self):
|
||||
"""Vérifie que les checkboxes reflètent l'état du cache."""
|
||||
|
||||
def test_add_playlist_transaction(self):
|
||||
"""Vérifie l'ajout d'une piste à une playlist."""
|
||||
|
||||
def test_remove_playlist_transaction(self):
|
||||
"""Vérifie le retrait d'une piste d'une playlist."""
|
||||
|
||||
def test_api_error_rollback(self):
|
||||
"""Vérifie le rollback en cas d'erreur API."""
|
||||
|
||||
|
||||
class TestPlaylistColumnDelegate(unittest.TestCase):
|
||||
"""Tests du delegate personnalisé."""
|
||||
|
||||
def test_state_pending_spinner_rendering(self):
|
||||
"""Vérifie le rendu du spinner en état PENDING."""
|
||||
|
||||
def test_state_ready_button_rendering(self):
|
||||
"""Vérifie le rendu du bouton en état READY."""
|
||||
|
||||
def test_state_transition_pending_to_ready(self):
|
||||
"""Vérifie la transition d'état PENDING → READY."""
|
||||
|
||||
def test_click_opens_dialog(self):
|
||||
"""Vérifie que le clic ouvre le dialogue."""
|
||||
|
||||
|
||||
class TestThreadSafePlaylistCache(unittest.TestCase):
|
||||
"""Tests du cache thread-safe."""
|
||||
|
||||
def test_concurrent_reads_no_deadlock(self):
|
||||
"""Vérifie les lectures concurrentes."""
|
||||
|
||||
def test_add_track_to_playlist(self):
|
||||
"""Vérifie l'ajout d'une piste."""
|
||||
|
||||
def test_remove_track_from_playlist(self):
|
||||
"""Vérifie le retrait d'une piste."""
|
||||
|
||||
def test_contains_check_o1_performance(self):
|
||||
"""Vérifie la complexité O(1) des vérifications."""
|
||||
```
|
||||
|
||||
### 6.2 Métriques de qualité
|
||||
|
||||
- **Coverage cible** : ≥ 85% (tests/test_playlist_manager.py)
|
||||
- **Linting** : `make check` doit passer (Black, isort, Flake8)
|
||||
- **Type checking** : `mypy` sans errors
|
||||
- **Performance** : Cache lookup < 1ms, Dialog appearance < 50ms
|
||||
|
||||
---
|
||||
|
||||
## 7. Dictionnaire d'erreurs
|
||||
|
||||
| Code | Signification | Action |
|
||||
|------|---------------|--------|
|
||||
| 401 | Non authentifié | Redirection login |
|
||||
| 403 | Pas de permission | Toast silencieux |
|
||||
| 404 | Playlist non trouvée | Log + continue |
|
||||
| 429 | Rate limited | Backoff exponentiel |
|
||||
| 500+ | Erreur serveur | Retry automatique (3x) |
|
||||
| Timeout | Dépassement délai | Abort + log |
|
||||
|
||||
---
|
||||
|
||||
## 8. Résumé des fichiers à créer
|
||||
|
||||
1. **tidal_dl_ng/gui/playlist_membership.py**
|
||||
- `ThreadSafePlaylistCache`
|
||||
- `PlaylistContextLoader`
|
||||
- `PlaylistColumnDelegate`
|
||||
|
||||
2. **tidal_dl_ng/gui/dialog_playlist_manager.py**
|
||||
- `PlaylistManagerDialog`
|
||||
|
||||
3. **tests/test_playlist_manager.py**
|
||||
- Tests exhaustifs
|
||||
|
||||
4. **docs/playlist_membership_manager.md** (ce fichier)
|
||||
|
||||
5. **Modifications à tidal_dl_ng/gui/main_window.py**
|
||||
- Intégration des signaux et initialization
|
||||
|
||||
---
|
||||
|
||||
## 9. Chronologie estimée
|
||||
|
||||
- **Phase 1** : Backend (Worker + Cache) : 4-5h
|
||||
- **Phase 2** : UI (Delegate + Dialog) : 3-4h
|
||||
- **Phase 3** : Tests + Intégration : 2-3h
|
||||
- **Total** : ~10-12h
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -54,10 +54,9 @@ def gui_activate(tidal: Tidal | None = None):
|
||||
window = MainWindow(tidal=tidal)
|
||||
|
||||
window.show()
|
||||
# Check for updates
|
||||
window.s_update_check.emit(True)
|
||||
|
||||
sys.exit(app.exec())
|
||||
exit_code = app.exec()
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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)
|
||||
@@ -127,6 +127,7 @@ class InitializationMixin:
|
||||
"Quality",
|
||||
"Date",
|
||||
"Downloaded?",
|
||||
"Playlists",
|
||||
]
|
||||
model.setColumnCount(len(labels_column))
|
||||
model.setRowCount(0)
|
||||
|
||||
@@ -19,6 +19,7 @@ from tidal_dl_ng.gui.downloads import DownloadsMixin
|
||||
from tidal_dl_ng.gui.history import HistoryMixin
|
||||
from tidal_dl_ng.gui.initialization import InitializationMixin
|
||||
from tidal_dl_ng.gui.playlist import GuiPlaylistManager
|
||||
from tidal_dl_ng.gui.playlist_membership_mixin import PlaylistMembershipMixin
|
||||
from tidal_dl_ng.gui.progress import ProgressMixin
|
||||
from tidal_dl_ng.gui.queue import GuiQueueManager
|
||||
from tidal_dl_ng.gui.search import GuiSearchManager
|
||||
@@ -52,6 +53,7 @@ class MainWindow(
|
||||
TreesResultsMixin,
|
||||
ContextMenusMixin,
|
||||
HistoryMixin,
|
||||
PlaylistMembershipMixin,
|
||||
):
|
||||
"""Main application window for TIDAL Downloader Next Generation.
|
||||
|
||||
@@ -169,7 +171,7 @@ class MainWindow(
|
||||
|
||||
self.init_tidal(tidal)
|
||||
|
||||
logger_gui.debug("All setup.")
|
||||
logger_gui.info("All setup.")
|
||||
|
||||
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
|
||||
"""Handle the close event of the main window.
|
||||
@@ -177,6 +179,12 @@ class MainWindow(
|
||||
Args:
|
||||
event (QtGui.QCloseEvent): The close event.
|
||||
"""
|
||||
logger_gui.warning("⚠️ CLOSE EVENT TRIGGERED!")
|
||||
import traceback
|
||||
|
||||
logger_gui.debug("Close event traceback:")
|
||||
for line in traceback.format_stack():
|
||||
logger_gui.debug(line.strip())
|
||||
# Save the main window size and position
|
||||
self.settings.data.window_x = self.x()
|
||||
self.settings.data.window_y = self.y()
|
||||
|
||||
@@ -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
|
||||
@@ -56,6 +56,8 @@ class TidalSessionMixin:
|
||||
if result:
|
||||
self._init_dl()
|
||||
self.thread_it(self.playlist_manager.tidal_user_lists)
|
||||
# Initialize playlist membership manager
|
||||
self.init_playlist_membership_manager()
|
||||
|
||||
def on_logout(self) -> None:
|
||||
"""Log out from TIDAL and close the application."""
|
||||
|
||||
@@ -76,11 +76,15 @@ class TreesResultsMixin:
|
||||
)
|
||||
|
||||
child_downloaded: QtGui.QStandardItem = QtGui.QStandardItem()
|
||||
child_playlists: QtGui.QStandardItem = QtGui.QStandardItem()
|
||||
|
||||
if isinstance(item.obj, Track):
|
||||
track_id = str(item.obj.id)
|
||||
if self.history_service.is_downloaded(track_id):
|
||||
child_downloaded.setText("✅")
|
||||
child_downloaded.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
# Store track_id in playlists column for lookup
|
||||
child_playlists.setData(track_id, QtCore.Qt.ItemDataRole.UserRole)
|
||||
|
||||
if isinstance(item.obj, Mix | Playlist | Album | Artist):
|
||||
child_dummy: QtGui.QStandardItem = QtGui.QStandardItem()
|
||||
@@ -97,6 +101,7 @@ class TreesResultsMixin:
|
||||
child_quality,
|
||||
child_date,
|
||||
child_downloaded,
|
||||
child_playlists,
|
||||
)
|
||||
|
||||
def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]):
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -47,6 +47,30 @@ class QtHandler(logging.Handler):
|
||||
XStream.stdout().write("%s\n" % record)
|
||||
|
||||
|
||||
# --- Verbosity control -------------------------------------------------------
|
||||
# If False: DEBUG and WARNING messages are suppressed (INFO/ERROR/CRITICAL shown).
|
||||
# If True: DEBUG and WARNING messages are shown.
|
||||
_VERBOSE_DEBUG = False
|
||||
|
||||
|
||||
class DebugWarningFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
# Always allow INFO (and above except WARNING handled below)
|
||||
# Suppress DEBUG and WARNING when _VERBOSE_DEBUG is False
|
||||
if not _VERBOSE_DEBUG and record.levelno in (logging.DEBUG, logging.WARNING):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def enable_debug_and_warnings(enabled: bool = True):
|
||||
"""Enable/disable showing DEBUG and WARNING log records globally.
|
||||
|
||||
INFO/ERROR/CRITICAL are unaffected and always shown.
|
||||
"""
|
||||
global _VERBOSE_DEBUG
|
||||
_VERBOSE_DEBUG = bool(enabled)
|
||||
|
||||
|
||||
logger_gui = logging.getLogger(__name__)
|
||||
handler_qt: QtHandler = QtHandler()
|
||||
# log_fmt: str = "[%(asctime)s] %(levelname)s: %(message)s"
|
||||
@@ -57,6 +81,8 @@ level_styles = coloredlogs.DEFAULT_LEVEL_STYLES.copy()
|
||||
level_styles["info"] = {"color": "green"}
|
||||
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
|
||||
handler_qt.setFormatter(formatter)
|
||||
# Apply filter to control DEBUG/WARNING visibility
|
||||
handler_qt.addFilter(DebugWarningFilter())
|
||||
logger_gui.addHandler(handler_qt)
|
||||
logger_gui.setLevel(logging.DEBUG)
|
||||
|
||||
@@ -64,5 +90,7 @@ logger_cli = logging.getLogger(__name__)
|
||||
handler_stream: logging.StreamHandler = logging.StreamHandler()
|
||||
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
|
||||
handler_stream.setFormatter(formatter)
|
||||
# Apply filter to control DEBUG/WARNING visibility
|
||||
handler_stream.addFilter(DebugWarningFilter())
|
||||
logger_cli.addHandler(handler_stream)
|
||||
logger_cli.setLevel(logging.DEBUG)
|
||||
|
||||
@@ -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
|
||||
@@ -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 : <b><span style="color:#1e88e5;">Titre de la Piste</span></b></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>
|
||||
Reference in New Issue
Block a user