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 = MainWindow(tidal=tidal)
|
||||||
|
|
||||||
window.show()
|
window.show()
|
||||||
# Check for updates
|
|
||||||
window.s_update_check.emit(True)
|
|
||||||
|
|
||||||
sys.exit(app.exec())
|
exit_code = app.exec()
|
||||||
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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",
|
"Quality",
|
||||||
"Date",
|
"Date",
|
||||||
"Downloaded?",
|
"Downloaded?",
|
||||||
|
"Playlists",
|
||||||
]
|
]
|
||||||
model.setColumnCount(len(labels_column))
|
model.setColumnCount(len(labels_column))
|
||||||
model.setRowCount(0)
|
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.history import HistoryMixin
|
||||||
from tidal_dl_ng.gui.initialization import InitializationMixin
|
from tidal_dl_ng.gui.initialization import InitializationMixin
|
||||||
from tidal_dl_ng.gui.playlist import GuiPlaylistManager
|
from tidal_dl_ng.gui.playlist import GuiPlaylistManager
|
||||||
|
from tidal_dl_ng.gui.playlist_membership_mixin import PlaylistMembershipMixin
|
||||||
from tidal_dl_ng.gui.progress import ProgressMixin
|
from tidal_dl_ng.gui.progress import ProgressMixin
|
||||||
from tidal_dl_ng.gui.queue import GuiQueueManager
|
from tidal_dl_ng.gui.queue import GuiQueueManager
|
||||||
from tidal_dl_ng.gui.search import GuiSearchManager
|
from tidal_dl_ng.gui.search import GuiSearchManager
|
||||||
@@ -52,6 +53,7 @@ class MainWindow(
|
|||||||
TreesResultsMixin,
|
TreesResultsMixin,
|
||||||
ContextMenusMixin,
|
ContextMenusMixin,
|
||||||
HistoryMixin,
|
HistoryMixin,
|
||||||
|
PlaylistMembershipMixin,
|
||||||
):
|
):
|
||||||
"""Main application window for TIDAL Downloader Next Generation.
|
"""Main application window for TIDAL Downloader Next Generation.
|
||||||
|
|
||||||
@@ -169,7 +171,7 @@ class MainWindow(
|
|||||||
|
|
||||||
self.init_tidal(tidal)
|
self.init_tidal(tidal)
|
||||||
|
|
||||||
logger_gui.debug("All setup.")
|
logger_gui.info("All setup.")
|
||||||
|
|
||||||
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
|
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
|
||||||
"""Handle the close event of the main window.
|
"""Handle the close event of the main window.
|
||||||
@@ -177,6 +179,12 @@ class MainWindow(
|
|||||||
Args:
|
Args:
|
||||||
event (QtGui.QCloseEvent): The close event.
|
event (QtGui.QCloseEvent): The close event.
|
||||||
"""
|
"""
|
||||||
|
logger_gui.warning("⚠️ CLOSE EVENT TRIGGERED!")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
logger_gui.debug("Close event traceback:")
|
||||||
|
for line in traceback.format_stack():
|
||||||
|
logger_gui.debug(line.strip())
|
||||||
# Save the main window size and position
|
# Save the main window size and position
|
||||||
self.settings.data.window_x = self.x()
|
self.settings.data.window_x = self.x()
|
||||||
self.settings.data.window_y = self.y()
|
self.settings.data.window_y = self.y()
|
||||||
|
|||||||
@@ -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:
|
if result:
|
||||||
self._init_dl()
|
self._init_dl()
|
||||||
self.thread_it(self.playlist_manager.tidal_user_lists)
|
self.thread_it(self.playlist_manager.tidal_user_lists)
|
||||||
|
# Initialize playlist membership manager
|
||||||
|
self.init_playlist_membership_manager()
|
||||||
|
|
||||||
def on_logout(self) -> None:
|
def on_logout(self) -> None:
|
||||||
"""Log out from TIDAL and close the application."""
|
"""Log out from TIDAL and close the application."""
|
||||||
|
|||||||
@@ -76,11 +76,15 @@ class TreesResultsMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
child_downloaded: QtGui.QStandardItem = QtGui.QStandardItem()
|
child_downloaded: QtGui.QStandardItem = QtGui.QStandardItem()
|
||||||
|
child_playlists: QtGui.QStandardItem = QtGui.QStandardItem()
|
||||||
|
|
||||||
if isinstance(item.obj, Track):
|
if isinstance(item.obj, Track):
|
||||||
track_id = str(item.obj.id)
|
track_id = str(item.obj.id)
|
||||||
if self.history_service.is_downloaded(track_id):
|
if self.history_service.is_downloaded(track_id):
|
||||||
child_downloaded.setText("✅")
|
child_downloaded.setText("✅")
|
||||||
child_downloaded.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
child_downloaded.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||||
|
# Store track_id in playlists column for lookup
|
||||||
|
child_playlists.setData(track_id, QtCore.Qt.ItemDataRole.UserRole)
|
||||||
|
|
||||||
if isinstance(item.obj, Mix | Playlist | Album | Artist):
|
if isinstance(item.obj, Mix | Playlist | Album | Artist):
|
||||||
child_dummy: QtGui.QStandardItem = QtGui.QStandardItem()
|
child_dummy: QtGui.QStandardItem = QtGui.QStandardItem()
|
||||||
@@ -97,6 +101,7 @@ class TreesResultsMixin:
|
|||||||
child_quality,
|
child_quality,
|
||||||
child_date,
|
child_date,
|
||||||
child_downloaded,
|
child_downloaded,
|
||||||
|
child_playlists,
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]):
|
def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]):
|
||||||
|
|||||||
@@ -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)
|
XStream.stdout().write("%s\n" % record)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Verbosity control -------------------------------------------------------
|
||||||
|
# If False: DEBUG and WARNING messages are suppressed (INFO/ERROR/CRITICAL shown).
|
||||||
|
# If True: DEBUG and WARNING messages are shown.
|
||||||
|
_VERBOSE_DEBUG = False
|
||||||
|
|
||||||
|
|
||||||
|
class DebugWarningFilter(logging.Filter):
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
# Always allow INFO (and above except WARNING handled below)
|
||||||
|
# Suppress DEBUG and WARNING when _VERBOSE_DEBUG is False
|
||||||
|
if not _VERBOSE_DEBUG and record.levelno in (logging.DEBUG, logging.WARNING):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def enable_debug_and_warnings(enabled: bool = True):
|
||||||
|
"""Enable/disable showing DEBUG and WARNING log records globally.
|
||||||
|
|
||||||
|
INFO/ERROR/CRITICAL are unaffected and always shown.
|
||||||
|
"""
|
||||||
|
global _VERBOSE_DEBUG
|
||||||
|
_VERBOSE_DEBUG = bool(enabled)
|
||||||
|
|
||||||
|
|
||||||
logger_gui = logging.getLogger(__name__)
|
logger_gui = logging.getLogger(__name__)
|
||||||
handler_qt: QtHandler = QtHandler()
|
handler_qt: QtHandler = QtHandler()
|
||||||
# log_fmt: str = "[%(asctime)s] %(levelname)s: %(message)s"
|
# log_fmt: str = "[%(asctime)s] %(levelname)s: %(message)s"
|
||||||
@@ -57,6 +81,8 @@ level_styles = coloredlogs.DEFAULT_LEVEL_STYLES.copy()
|
|||||||
level_styles["info"] = {"color": "green"}
|
level_styles["info"] = {"color": "green"}
|
||||||
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
|
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
|
||||||
handler_qt.setFormatter(formatter)
|
handler_qt.setFormatter(formatter)
|
||||||
|
# Apply filter to control DEBUG/WARNING visibility
|
||||||
|
handler_qt.addFilter(DebugWarningFilter())
|
||||||
logger_gui.addHandler(handler_qt)
|
logger_gui.addHandler(handler_qt)
|
||||||
logger_gui.setLevel(logging.DEBUG)
|
logger_gui.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
@@ -64,5 +90,7 @@ logger_cli = logging.getLogger(__name__)
|
|||||||
handler_stream: logging.StreamHandler = logging.StreamHandler()
|
handler_stream: logging.StreamHandler = logging.StreamHandler()
|
||||||
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
|
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
|
||||||
handler_stream.setFormatter(formatter)
|
handler_stream.setFormatter(formatter)
|
||||||
|
# Apply filter to control DEBUG/WARNING visibility
|
||||||
|
handler_stream.addFilter(DebugWarningFilter())
|
||||||
logger_cli.addHandler(handler_stream)
|
logger_cli.addHandler(handler_stream)
|
||||||
logger_cli.setLevel(logging.DEBUG)
|
logger_cli.setLevel(logging.DEBUG)
|
||||||
|
|||||||
@@ -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