refactor: Restructure GUI components into separate modules for improved maintainability
This commit is contained in:
@@ -21,6 +21,7 @@ repos:
|
|||||||
rev: "v3.1.0"
|
rev: "v3.1.0"
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
|
files: "docs/(?:index|modules|missing_metadata|delimiters_category|feature_hover_info|gui_refactoring|refactor_settings_ui|testing_summary|ui_styles)\\.md"
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Architecture GUI Refactorée
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Le fichier `gui.py` a été refactorisé pour améliorer la maintenabilité en divisant la classe `MainWindow` monolithique (1837 lignes) en plusieurs modules organisés dans le dossier `gui/`.
|
||||||
|
|
||||||
|
## Structure des fichiers
|
||||||
|
|
||||||
|
```
|
||||||
|
tidal_dl_ng/
|
||||||
|
├── gui/
|
||||||
|
│ ├── __init__.py # Point d'entrée du module GUI
|
||||||
|
│ ├── activate.py # Fonction d'activation de l'application GUI
|
||||||
|
│ ├── main_window.py # Classe MainWindow principale
|
||||||
|
│ ├── initialization.py # Mixin: Initialisation des composants UI
|
||||||
|
│ ├── tidal_session.py # Mixin: Gestion de la session Tidal
|
||||||
|
│ ├── signals.py # Mixin: Gestion des signaux Qt
|
||||||
|
│ ├── progress.py # Mixin: Barres de progression
|
||||||
|
│ ├── ui_helpers.py # Mixin: Helpers UI (spinners, logs, statusbar)
|
||||||
|
│ ├── track_extras.py # Mixin: Cache et gestion des extras de tracks
|
||||||
|
│ ├── updates.py # Mixin: Vérification des mises à jour
|
||||||
|
│ ├── downloads.py # Mixin: Gestion des téléchargements
|
||||||
|
│ ├── trees_results.py # Mixin: Gestion des arbres et résultats
|
||||||
|
│ ├── context_menus.py # Mixin: Menus contextuels et actions
|
||||||
|
│ ├── history.py # Mixin: Historique et prévention des doublons
|
||||||
|
│ ├── covers.py # Manager de couvertures
|
||||||
|
│ ├── playlist.py # Manager de playlists
|
||||||
|
│ ├── queue.py # Manager de queue
|
||||||
|
│ └── search.py # Manager de recherche
|
||||||
|
├── gui.py # Point de compatibilité (redirige vers gui/)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mixins et Responsabilités
|
||||||
|
|
||||||
|
### 1. InitializationMixin (`initialization.py`)
|
||||||
|
|
||||||
|
Responsable de l'initialisation de tous les composants de l'interface utilisateur.
|
||||||
|
|
||||||
|
**Méthodes:**
|
||||||
|
|
||||||
|
- `_init_gui()` - Configuration de la fenêtre principale
|
||||||
|
- `_init_threads()` - Initialisation du pool de threads
|
||||||
|
- `_init_dl()` - Configuration de l'objet Download
|
||||||
|
- `_init_progressbar()` - Initialisation des barres de progression
|
||||||
|
- `_init_info()` - Configuration de l'image de couverture par défaut
|
||||||
|
- `_init_tree_results()` - Configuration de l'arbre des résultats
|
||||||
|
- `_init_tree_results_model()` - Configuration du modèle de l'arbre
|
||||||
|
- `_init_tree_queue()` - Configuration de la queue de téléchargement
|
||||||
|
- `_init_tree_lists()` - Configuration de l'arbre des listes utilisateur
|
||||||
|
- `_init_menu_actions()` - Ajout d'actions de menu personnalisées
|
||||||
|
- `_populate_quality()` - Remplissage des options de qualité
|
||||||
|
- `_populate_search_types()` - Remplissage des types de recherche
|
||||||
|
|
||||||
|
### 2. TidalSessionMixin (`tidal_session.py`)
|
||||||
|
|
||||||
|
Gère l'authentification et la session Tidal.
|
||||||
|
|
||||||
|
**Méthodes:**
|
||||||
|
|
||||||
|
- `init_tidal()` - Initialisation de la session Tidal avec gestion du login
|
||||||
|
- `on_logout()` - Déconnexion de Tidal
|
||||||
|
|
||||||
|
### 3. SignalsMixin (`signals.py`)
|
||||||
|
|
||||||
|
Configure toutes les connexions de signaux Qt.
|
||||||
|
|
||||||
|
**Méthodes:**
|
||||||
|
|
||||||
|
- `_init_signals()` - Connexion de tous les signaux aux slots
|
||||||
|
- `on_result_item_clicked()` - Gestion du clic sur un élément de résultat
|
||||||
|
- `on_quality_set_audio()` - Configuration de la qualité audio
|
||||||
|
- `on_quality_set_video()` - Configuration de la qualité vidéo
|
||||||
|
|
||||||
|
### 4. ProgressMixin (`progress.py`)
|
||||||
|
|
||||||
|
Gère les barres de progression.
|
||||||
|
|
||||||
|
**Méthodes:**
|
||||||
|
|
||||||
|
- `on_progress_reset()` - Réinitialisation des barres de progression
|
||||||
|
- `on_progress_list()` - Mise à jour de la barre de progression de liste
|
||||||
|
- `on_progress_item()` - Mise à jour de la barre de progression d'élément
|
||||||
|
- `on_progress_item_name()` - Définition du nom d'élément
|
||||||
|
- `on_progress_list_name()` - Définition du nom de liste
|
||||||
|
|
||||||
|
### 5. UIHelpersMixin (`ui_helpers.py`)
|
||||||
|
|
||||||
|
Fournit des fonctions utilitaires pour l'interface utilisateur.
|
||||||
|
|
||||||
|
**Méthodes:**
|
||||||
|
|
||||||
|
- `on_spinner_start()` - Démarrage d'un spinner de chargement
|
||||||
|
- `on_spinner_stop()` - Arrêt de tous les spinners
|
||||||
|
- `on_statusbar_message()` - Affichage d'un message dans la barre d'état
|
||||||
|
- `_log_output()` - Redirection des logs vers l'interface
|
||||||
|
- `button_reload_status()` - Mise à jour du statut du bouton de rechargement
|
||||||
|
|
||||||
|
### 6. TrackExtrasMixin (`track_extras.py`)
|
||||||
|
|
||||||
|
Gère le cache et la récupération des informations supplémentaires des tracks.
|
||||||
|
|
||||||
|
**Méthodes:**
|
||||||
|
|
||||||
|
- `get_track_extras()` - Récupération des extras (avec cache)
|
||||||
|
- `_on_invoke_callback()` - Invocation du callback dans le thread principal
|
||||||
|
- `_decorate_extras()` - Ajout de champs formatés aux extras
|
||||||
|
- `preload_covers_for_playlist()` - Préchargement des couvertures
|
||||||
|
|
||||||
|
### 7. UpdatesMixin (`updates.py`)
|
||||||
|
|
||||||
|
Gère la vérification des mises à jour de l'application.
|
||||||
|
|
||||||
|
**Méthodes:**
|
||||||
|
|
||||||
|
- `on_update_check()` - Vérification des mises à jour disponibles
|
||||||
|
- `on_version()` - Affichage du dialogue de version
|
||||||
|
|
||||||
|
### 8. DownloadsMixin (`downloads.py`)
|
||||||
|
|
||||||
|
Gère la queue de téléchargement et les opérations de téléchargement.
|
||||||
|
|
||||||
|
**Méthodes:**
|
||||||
|
|
||||||
|
- `on_download_results()` - Téléchargement des résultats sélectionnés
|
||||||
|
- `queue_download_media()` - Ajout d'un élément à la queue
|
||||||
|
- `watcher_queue_download()` - Surveillance de la queue
|
||||||
|
- `on_queue_download()` - Exécution d'un téléchargement
|
||||||
|
- `download()` - Téléchargement d'un élément média
|
||||||
|
- Méthodes de gestion de statut: `on_queue_download_item_downloading()`, etc.
|
||||||
|
|
||||||
|
### 9. TreesResultsMixin (`trees_results.py`)
|
||||||
|
|
||||||
|
Gère les arbres de vue et l'affichage des résultats.
|
||||||
|
|
||||||
|
**Méthodes:**
|
||||||
|
|
||||||
|
- `handle_filter_activated()` - Gestion des filtres d'en-tête
|
||||||
|
- `populate_tree_results()` - Remplissage de l'arbre des résultats
|
||||||
|
- `populate_tree_result_child()` - Création d'un élément enfant
|
||||||
|
- `on_tr_results_expanded()` - Gestion de l'expansion des résultats
|
||||||
|
- `tr_results_expanded()` - Chargement des enfants d'un élément
|
||||||
|
- `list_items_show_result()` - Affichage des éléments d'une liste
|
||||||
|
- `tidal_user_lists()` - Récupération des listes utilisateur
|
||||||
|
- `on_populate_tree_lists()` - Remplissage de l'arbre des listes
|
||||||
|
- `on_track_hover_confirmed()` - Gestion du survol d'un track
|
||||||
|
- `on_track_hover_left()` - Gestion de la sortie du survol
|
||||||
|
|
||||||
|
### 10. ContextMenusMixin (`context_menus.py`)
|
||||||
|
|
||||||
|
Gère les menus contextuels et leurs actions.
|
||||||
|
|
||||||
|
**Méthodes:**
|
||||||
|
|
||||||
|
- `menu_context_tree_results()` - Menu contextuel pour les résultats
|
||||||
|
- `menu_context_queue_download()` - Menu contextuel pour la queue
|
||||||
|
- `on_copy_url_share()` - Copie de l'URL de partage
|
||||||
|
- `on_download_album_from_track()` - Téléchargement de l'album d'un track
|
||||||
|
- `on_download_all_albums_from_playlist()` - Téléchargement de tous les albums
|
||||||
|
- `_extract_album_ids_from_tracks()` - Extraction des IDs d'albums
|
||||||
|
- `_load_albums_with_rate_limiting()` - Chargement avec limitation de débit
|
||||||
|
- `on_search_in_app()` - Recherche dans l'application
|
||||||
|
- `on_search_in_browser()` - Ouverture de recherche dans le navigateur
|
||||||
|
|
||||||
|
### 11. HistoryMixin (`history.py`)
|
||||||
|
|
||||||
|
Gère l'historique des téléchargements et la prévention des doublons.
|
||||||
|
|
||||||
|
**Méthodes:**
|
||||||
|
|
||||||
|
- `on_view_history()` - Affichage du dialogue d'historique
|
||||||
|
- `on_toggle_duplicate_prevention()` - Activation/désactivation de la prévention
|
||||||
|
- `on_mark_track_as_downloaded()` - Marquer un track comme téléchargé
|
||||||
|
- `on_mark_track_as_not_downloaded()` - Retirer le marquage
|
||||||
|
- `_update_downloaded_column()` - Mise à jour de la colonne UI
|
||||||
|
- `on_preferences()` - Ouverture du dialogue de préférences
|
||||||
|
- `on_settings_save()` - Sauvegarde des paramètres
|
||||||
|
|
||||||
|
## Classe MainWindow
|
||||||
|
|
||||||
|
La classe `MainWindow` hérite de tous les mixins et de `QtWidgets.QMainWindow` + `Ui_MainWindow`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MainWindow(
|
||||||
|
QtWidgets.QMainWindow,
|
||||||
|
Ui_MainWindow,
|
||||||
|
InitializationMixin,
|
||||||
|
TidalSessionMixin,
|
||||||
|
SignalsMixin,
|
||||||
|
ProgressMixin,
|
||||||
|
UIHelpersMixin,
|
||||||
|
TrackExtrasMixin,
|
||||||
|
UpdatesMixin,
|
||||||
|
DownloadsMixin,
|
||||||
|
TreesResultsMixin,
|
||||||
|
ContextMenusMixin,
|
||||||
|
HistoryMixin,
|
||||||
|
):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compatibilité
|
||||||
|
|
||||||
|
Le fichier `gui.py` original est maintenu comme point de compatibilité :
|
||||||
|
|
||||||
|
- Redirige les imports vers `tidal_dl_ng.gui`
|
||||||
|
- Conserve les directives de compilation nuitka
|
||||||
|
- Permet une migration progressive
|
||||||
|
|
||||||
|
## Avantages de cette architecture
|
||||||
|
|
||||||
|
1. **Maintenabilité** : Chaque mixin a une responsabilité claire et limitée
|
||||||
|
2. **Lisibilité** : Fichiers plus petits et focalisés (100-300 lignes vs 1837)
|
||||||
|
3. **Testabilité** : Les mixins peuvent être testés individuellement
|
||||||
|
4. **Extensibilité** : Facile d'ajouter de nouvelles fonctionnalités
|
||||||
|
5. **Réutilisabilité** : Les mixins peuvent être réutilisés si nécessaire
|
||||||
|
6. **Organisation** : Structure logique par fonctionnalité
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Pour utiliser la nouvelle structure :
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Ancienne méthode (toujours supportée)
|
||||||
|
from tidal_dl_ng.gui import gui_activate
|
||||||
|
|
||||||
|
# Nouvelle méthode recommandée
|
||||||
|
from tidal_dl_ng.gui import MainWindow
|
||||||
|
from tidal_dl_ng.gui.activate import gui_activate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Les avertissements IDE sur les "unresolved attributes" dans les mixins sont normaux
|
||||||
|
- Les mixins accèdent à `self` qui sera fourni par `MainWindow` via l'héritage multiple
|
||||||
|
- L'ordre des mixins dans l'héritage est important pour la résolution de méthode (MRO)
|
||||||
+20
-4
@@ -206,12 +206,21 @@ class DialogPreferences(QtWidgets.QDialog):
|
|||||||
self.ui.lw_categories.addItem("Paths & Formats")
|
self.ui.lw_categories.addItem("Paths & Formats")
|
||||||
self.ui.lw_categories.addItem("Delimiters")
|
self.ui.lw_categories.addItem("Delimiters")
|
||||||
|
|
||||||
# Set the first category as selected
|
# Connect category selection to page switching before setting default
|
||||||
self.ui.lw_categories.setCurrentRow(0)
|
|
||||||
|
|
||||||
# Connect category selection to page switching
|
|
||||||
self.ui.lw_categories.currentRowChanged.connect(self._on_category_changed)
|
self.ui.lw_categories.currentRowChanged.connect(self._on_category_changed)
|
||||||
|
|
||||||
|
# Set the first category as selected and force corresponding page
|
||||||
|
self.ui.lw_categories.setCurrentRow(0)
|
||||||
|
self._on_category_changed(0)
|
||||||
|
|
||||||
|
# Add keyboard navigation for convenience
|
||||||
|
shortcut_next = QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key.Key_Tab), self)
|
||||||
|
shortcut_prev = QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key.Key_Backtab), self)
|
||||||
|
shortcut_next.activated.connect(lambda: self._navigate_categories(1) if self.ui.lw_categories.count() else None)
|
||||||
|
shortcut_prev.activated.connect(
|
||||||
|
lambda: self._navigate_categories(-1) if self.ui.lw_categories.count() else None
|
||||||
|
)
|
||||||
|
|
||||||
def _on_category_changed(self, index: int):
|
def _on_category_changed(self, index: int):
|
||||||
"""Handle category change to switch pages."""
|
"""Handle category change to switch pages."""
|
||||||
self.ui.sw_categories.setCurrentIndex(index)
|
self.ui.sw_categories.setCurrentIndex(index)
|
||||||
@@ -335,3 +344,10 @@ class DialogPreferences(QtWidgets.QDialog):
|
|||||||
setattr(self.settings.data, item, getattr(self.ui, self.prefix_spin_box + item).value())
|
setattr(self.settings.data, item, getattr(self.ui, self.prefix_spin_box + item).value())
|
||||||
|
|
||||||
self.s_settings_save.emit()
|
self.s_settings_save.emit()
|
||||||
|
|
||||||
|
def _navigate_categories(self, direction: int) -> None:
|
||||||
|
"""Move category selection forward/backward with wrap-around."""
|
||||||
|
current = self.ui.lw_categories.currentRow()
|
||||||
|
count = self.ui.lw_categories.count()
|
||||||
|
target = (current + direction) % count
|
||||||
|
self.ui.lw_categories.setCurrentRow(target)
|
||||||
|
|||||||
+7
-1784
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
"""GUI module for TIDAL Downloader Next Generation.
|
||||||
|
|
||||||
|
This module provides the main window and all GUI-related functionality
|
||||||
|
organized into manageable components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
except ImportError: # pragma: no cover - GUI deps optional in some environments
|
||||||
|
QtCore = QtGui = QtWidgets = None
|
||||||
|
|
||||||
|
from tidal_dl_ng.gui.main_window import MainWindow
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MainWindow",
|
||||||
|
"QtCore",
|
||||||
|
"QtGui",
|
||||||
|
"QtWidgets",
|
||||||
|
]
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""GUI activation and entry point for TIDAL Downloader Next Generation."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from tidal_dl_ng import __version__
|
||||||
|
from tidal_dl_ng.config import Tidal
|
||||||
|
|
||||||
|
try:
|
||||||
|
import qdarktheme
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
except ImportError as e:
|
||||||
|
print(e)
|
||||||
|
print("Qt dependencies missing. Cannot start GUI. Please read the 'README.md' carefully.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def gui_activate(tidal: Tidal | None = None):
|
||||||
|
"""Activate the GUI application.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tidal (Tidal | None): Optional pre-existing Tidal session.
|
||||||
|
"""
|
||||||
|
from tidal_dl_ng.gui.main_window import MainWindow
|
||||||
|
|
||||||
|
# Set dark theme and create QT app
|
||||||
|
qdarktheme.enable_hi_dpi()
|
||||||
|
app = QtWidgets.QApplication(sys.argv)
|
||||||
|
|
||||||
|
# Fix for Windows: Tooltips have bright font color
|
||||||
|
# https://github.com/5yutan5/PyQtDarkTheme/issues/239
|
||||||
|
qdarktheme.setup_theme(additional_qss="QToolTip { border: 0px; }")
|
||||||
|
|
||||||
|
# Create icon object and apply it to app window
|
||||||
|
icon: QtGui.QIcon = QtGui.QIcon()
|
||||||
|
|
||||||
|
icon.addFile("tidal_dl_ng/ui/icon16.png", QtCore.QSize(16, 16))
|
||||||
|
icon.addFile("tidal_dl_ng/ui/icon32.png", QtCore.QSize(32, 32))
|
||||||
|
icon.addFile("tidal_dl_ng/ui/icon48.png", QtCore.QSize(48, 48))
|
||||||
|
icon.addFile("tidal_dl_ng/ui/icon64.png", QtCore.QSize(64, 64))
|
||||||
|
icon.addFile("tidal_dl_ng/ui/icon256.png", QtCore.QSize(256, 256))
|
||||||
|
icon.addFile("tidal_dl_ng/ui/icon512.png", QtCore.QSize(512, 512))
|
||||||
|
app.setWindowIcon(icon)
|
||||||
|
|
||||||
|
# This bit gets the taskbar icon working properly in Windows
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
# Make sure Pyinstaller icons are still grouped
|
||||||
|
if not sys.argv[0].endswith(".exe"):
|
||||||
|
# Arbitrary string
|
||||||
|
my_app_id: str = "exislow.tidal.dl-ng." + __version__
|
||||||
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_app_id)
|
||||||
|
|
||||||
|
window = MainWindow(tidal=tidal)
|
||||||
|
|
||||||
|
window.show()
|
||||||
|
# Check for updates
|
||||||
|
window.s_update_check.emit(True)
|
||||||
|
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
gui_activate()
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""Context menus mixin for MainWindow.
|
||||||
|
|
||||||
|
Handles context menu creation and actions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
from tidalapi import Album, Artist, Mix, Playlist, Track, UserPlaylist, Video
|
||||||
|
|
||||||
|
from tidal_dl_ng.constants import QueueDownloadStatus
|
||||||
|
from tidal_dl_ng.helper.gui import get_results_media_item, get_user_list_media_item
|
||||||
|
from tidal_dl_ng.helper.tidal import items_results_all, name_builder_artist
|
||||||
|
from tidal_dl_ng.logger import logger_gui
|
||||||
|
from tidal_dl_ng.model.gui_data import StatusbarMessage
|
||||||
|
|
||||||
|
|
||||||
|
class ContextMenusMixin:
|
||||||
|
"""Mixin containing context menu methods."""
|
||||||
|
|
||||||
|
def menu_context_tree_results(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Show context menu for results tree."""
|
||||||
|
index = self.tr_results.indexAt(point)
|
||||||
|
|
||||||
|
if not index.isValid():
|
||||||
|
return
|
||||||
|
|
||||||
|
media = get_results_media_item(index, self.proxy_tr_results, self.model_tr_results)
|
||||||
|
|
||||||
|
menu = QtWidgets.QMenu()
|
||||||
|
|
||||||
|
if isinstance(media, Track | Video) and hasattr(media, "album") and media.album:
|
||||||
|
menu.addAction("Download Full Album", lambda: self.thread_download_album_from_track(point))
|
||||||
|
|
||||||
|
if isinstance(media, Track):
|
||||||
|
track_id = str(media.id)
|
||||||
|
is_downloaded = self.history_service.is_downloaded(track_id)
|
||||||
|
|
||||||
|
if is_downloaded:
|
||||||
|
menu.addAction(
|
||||||
|
"✖️ Mark as Not Downloaded", lambda: self.on_mark_track_as_not_downloaded(track_id, index)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
menu.addAction("✅ Mark as Downloaded", lambda: self.on_mark_track_as_downloaded(media, index))
|
||||||
|
|
||||||
|
menu.addAction("Copy Share URL", lambda: self.on_copy_url_share(self.tr_results, point))
|
||||||
|
|
||||||
|
menu.exec(self.tr_results.mapToGlobal(point))
|
||||||
|
|
||||||
|
def menu_context_queue_download(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Show context menu for download queue."""
|
||||||
|
item = self.tr_queue_download.itemAt(point)
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
|
||||||
|
menu = QtWidgets.QMenu()
|
||||||
|
|
||||||
|
status = item.text(0)
|
||||||
|
if status == QueueDownloadStatus.Waiting:
|
||||||
|
menu.addAction("🗑️ Remove from Queue", lambda: self.on_queue_download_remove_item(item))
|
||||||
|
|
||||||
|
if menu.isEmpty():
|
||||||
|
return
|
||||||
|
|
||||||
|
menu.exec(self.tr_queue_download.mapToGlobal(point))
|
||||||
|
|
||||||
|
def on_queue_download_remove_item(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Remove a specific item from the download queue."""
|
||||||
|
index = self.tr_queue_download.indexOfTopLevelItem(item)
|
||||||
|
if index >= 0:
|
||||||
|
self.tr_queue_download.takeTopLevelItem(index)
|
||||||
|
logger_gui.info("Removed item from download queue")
|
||||||
|
|
||||||
|
def on_copy_url_share(
|
||||||
|
self, tree_target: QtWidgets.QTreeWidget | QtWidgets.QTreeView, point: QtCore.QPoint = None
|
||||||
|
) -> None:
|
||||||
|
"""Copy the share URL of a media item to the clipboard."""
|
||||||
|
if isinstance(tree_target, QtWidgets.QTreeWidget):
|
||||||
|
item: QtWidgets.QTreeWidgetItem = tree_target.itemAt(point)
|
||||||
|
media = get_user_list_media_item(item)
|
||||||
|
else:
|
||||||
|
index: QtCore.QModelIndex = tree_target.indexAt(point)
|
||||||
|
media = get_results_media_item(index, self.proxy_tr_results, self.model_tr_results)
|
||||||
|
|
||||||
|
clipboard = QtWidgets.QApplication.clipboard()
|
||||||
|
url_share = media.share_url if hasattr(media, "share_url") else "No share URL available."
|
||||||
|
|
||||||
|
clipboard.clear()
|
||||||
|
clipboard.setText(url_share)
|
||||||
|
|
||||||
|
def thread_download_list_media(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Start download of a list media item in a thread."""
|
||||||
|
self.thread_it(self.playlist_manager.on_download_list_media, point)
|
||||||
|
|
||||||
|
def thread_download_album_from_track(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Starts the download of the full album from a selected track in a new thread."""
|
||||||
|
self.thread_it(self.on_download_album_from_track, point)
|
||||||
|
|
||||||
|
def on_download_album_from_track(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Adds the album associated with a selected track to the download queue."""
|
||||||
|
index: QtCore.QModelIndex = self.tr_results.indexAt(point)
|
||||||
|
media_track: Track = get_results_media_item(index, self.proxy_tr_results, self.model_tr_results)
|
||||||
|
|
||||||
|
if isinstance(media_track, Track) and media_track.album and media_track.album.id:
|
||||||
|
try:
|
||||||
|
full_album_object = self.tidal.session.album(media_track.album.id)
|
||||||
|
|
||||||
|
queue_dl_item = self.search_manager.media_to_queue_download_model(full_album_object)
|
||||||
|
|
||||||
|
if queue_dl_item:
|
||||||
|
self.queue_download_media(queue_dl_item)
|
||||||
|
else:
|
||||||
|
logger_gui.warning(f"Failed to create a queue item for album ID: {full_album_object.id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger_gui.error(f"Could not fetch the full album from TIDAL. Error: {e}")
|
||||||
|
else:
|
||||||
|
logger_gui.warning("Could not retrieve album information from the selected track.")
|
||||||
|
|
||||||
|
def on_download_all_albums_from_playlist(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Download all unique albums from tracks in a playlist."""
|
||||||
|
try:
|
||||||
|
item = self.tr_lists_user.itemAt(point)
|
||||||
|
media_list = get_user_list_media_item(item)
|
||||||
|
|
||||||
|
if not isinstance(media_list, Playlist | UserPlaylist | Mix):
|
||||||
|
logger_gui.error("Please select a playlist or mix.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger_gui.info(f"Fetching all tracks from: {media_list.name}")
|
||||||
|
media_items = items_results_all(self.tidal.session, media_list)
|
||||||
|
|
||||||
|
album_ids = self._extract_album_ids_from_tracks(media_items)
|
||||||
|
|
||||||
|
if not album_ids:
|
||||||
|
logger_gui.warning("No albums found in this playlist.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger_gui.info(f"Found {len(album_ids)} unique albums. Loading with rate limiting...")
|
||||||
|
|
||||||
|
albums_dict = self._load_albums_with_rate_limiting(album_ids)
|
||||||
|
|
||||||
|
if not albums_dict:
|
||||||
|
logger_gui.error("Failed to load any albums from playlist.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._queue_loaded_albums(albums_dict)
|
||||||
|
|
||||||
|
message = f"Added {len(albums_dict)} albums to download queue"
|
||||||
|
self.s_statusbar_message.emit(StatusbarMessage(message=message, timeout=3000))
|
||||||
|
logger_gui.info(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error downloading albums from playlist: {e!s}"
|
||||||
|
logger_gui.error(error_msg)
|
||||||
|
self.s_statusbar_message.emit(StatusbarMessage(message=error_msg, timeout=3000))
|
||||||
|
|
||||||
|
def _extract_album_ids_from_tracks(self, media_items: list) -> dict[int, Album]:
|
||||||
|
"""Extract unique album IDs from a list of media items."""
|
||||||
|
album_ids = {}
|
||||||
|
|
||||||
|
for media_item in media_items:
|
||||||
|
if not isinstance(media_item, Track | Video):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not hasattr(media_item, "album") or not media_item.album:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
album_id = media_item.album.id
|
||||||
|
if album_id:
|
||||||
|
album_ids[album_id] = media_item.album
|
||||||
|
except Exception as e:
|
||||||
|
logger_gui.debug(f"Skipping track with unavailable album: {e!s}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return album_ids
|
||||||
|
|
||||||
|
def _load_albums_with_rate_limiting(self, album_ids: dict[int, Album]) -> dict[int, Album]:
|
||||||
|
"""Load full album objects with rate limiting to prevent API throttling."""
|
||||||
|
albums_dict = {}
|
||||||
|
batch_size = self.settings.data.api_rate_limit_batch_size
|
||||||
|
delay_sec = self.settings.data.api_rate_limit_delay_sec
|
||||||
|
|
||||||
|
for idx, album_id in enumerate(album_ids.keys(), start=1):
|
||||||
|
try:
|
||||||
|
if idx > 1 and (idx - 1) % batch_size == 0:
|
||||||
|
logger_gui.info(f"⏰ RATE LIMITING: Processed {idx - 1} albums, pausing for {delay_sec} seconds...")
|
||||||
|
time.sleep(delay_sec)
|
||||||
|
|
||||||
|
if not self.tidal.session.check_login():
|
||||||
|
logger_gui.error("Session expired. Please restart the application and login again.")
|
||||||
|
return albums_dict
|
||||||
|
|
||||||
|
album = self.tidal.session.album(album_id)
|
||||||
|
albums_dict[album.id] = album
|
||||||
|
logger_gui.debug(f"Loaded album {idx}/{len(album_ids)}: {name_builder_artist(album)} - {album.name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if not self._handle_album_load_error(e, album_id):
|
||||||
|
return albums_dict
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger_gui.info(f"Successfully loaded {len(albums_dict)} albums.")
|
||||||
|
return albums_dict
|
||||||
|
|
||||||
|
def _handle_album_load_error(self, error: Exception, album_id: int) -> bool:
|
||||||
|
"""Handle errors that occur when loading an album."""
|
||||||
|
if self.tidal.is_authentication_error(error):
|
||||||
|
error_msg = str(error)
|
||||||
|
logger_gui.error(f"Authentication error: {error_msg}")
|
||||||
|
logger_gui.error("Your session has expired. Please restart the application and login again.")
|
||||||
|
self.s_statusbar_message.emit(
|
||||||
|
StatusbarMessage(message="Session expired - please restart and login", timeout=5000)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger_gui.warning(f"Failed to load album {album_id}: {error!s}")
|
||||||
|
logger_gui.info(
|
||||||
|
"Note: Some albums may be unavailable due to region restrictions or removal from TIDAL. This is normal."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _queue_loaded_albums(self, albums_dict: dict[int, Album]) -> None:
|
||||||
|
"""Prepare and add loaded albums to the download queue."""
|
||||||
|
logger_gui.info(f"Preparing queue items for {len(albums_dict)} albums...")
|
||||||
|
|
||||||
|
queue_items = []
|
||||||
|
for album in albums_dict.values():
|
||||||
|
queue_dl_item = self.queue_manager.media_to_queue_download_model(album)
|
||||||
|
if queue_dl_item:
|
||||||
|
queue_items.append((queue_dl_item, album))
|
||||||
|
logger_gui.debug(f"Prepared: {name_builder_artist(album)} - {album.name}")
|
||||||
|
|
||||||
|
logger_gui.info(f"Adding {len(queue_items)} albums to queue...")
|
||||||
|
for queue_dl_item, album in queue_items:
|
||||||
|
self.queue_manager.queue_download_media(queue_dl_item)
|
||||||
|
logger_gui.info(f"Added: {name_builder_artist(album)} - {album.name}")
|
||||||
|
|
||||||
|
def on_search_in_app(self, search_term: str, search_type: str):
|
||||||
|
"""Perform a search within the application, selecting the correct category."""
|
||||||
|
self.l_search.setText(search_term)
|
||||||
|
|
||||||
|
search_type_map = {
|
||||||
|
"artist": Artist,
|
||||||
|
"album": Album,
|
||||||
|
"track": Track,
|
||||||
|
"video": Video,
|
||||||
|
"playlist": Playlist,
|
||||||
|
}
|
||||||
|
search_category = search_type_map.get(search_type.lower(), self.cb_search_type.currentData())
|
||||||
|
|
||||||
|
for i in range(self.cb_search_type.count()):
|
||||||
|
if self.cb_search_type.itemData(i) == search_category:
|
||||||
|
self.cb_search_type.setCurrentIndex(i)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.search_manager.search_populate_results(search_term, search_category)
|
||||||
|
|
||||||
|
def on_search_in_browser(self, search_term: str, search_type: str):
|
||||||
|
"""Open a search in the default web browser."""
|
||||||
|
safe_term = urllib.parse.quote(search_term)
|
||||||
|
search_path_map = {
|
||||||
|
"artist": "artists",
|
||||||
|
"album": "albums",
|
||||||
|
"track": "tracks",
|
||||||
|
"video": "videos",
|
||||||
|
"playlist": "playlists",
|
||||||
|
}
|
||||||
|
search_path = search_path_map.get(search_type.lower())
|
||||||
|
|
||||||
|
if search_path:
|
||||||
|
url = QtCore.QUrl(f"https://listen.tidal.com/search/{search_path}?q={safe_term}")
|
||||||
|
else:
|
||||||
|
url = QtCore.QUrl(f"https://listen.tidal.com/search?q={safe_term}")
|
||||||
|
|
||||||
|
QtGui.QDesktopServices.openUrl(url)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""Downloads mixin for MainWindow.
|
||||||
|
|
||||||
|
Handles download queue and download operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtWidgets
|
||||||
|
from tidalapi import Album, Artist, Mix, Playlist, Quality, Track, UserPlaylist, Video
|
||||||
|
|
||||||
|
from tidal_dl_ng.config import HandlingApp
|
||||||
|
from tidal_dl_ng.constants import QualityVideo, QueueDownloadStatus
|
||||||
|
from tidal_dl_ng.download import Download
|
||||||
|
from tidal_dl_ng.helper.gui import (
|
||||||
|
get_queue_download_media,
|
||||||
|
get_queue_download_quality_audio,
|
||||||
|
get_queue_download_quality_video,
|
||||||
|
get_results_media_item,
|
||||||
|
set_queue_download_media,
|
||||||
|
)
|
||||||
|
from tidal_dl_ng.helper.path import get_format_template
|
||||||
|
from tidal_dl_ng.helper.tidal import items_results_all
|
||||||
|
from tidal_dl_ng.logger import logger_gui
|
||||||
|
from tidal_dl_ng.model.gui_data import QueueDownloadItem, StatusbarMessage
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadsMixin:
|
||||||
|
"""Mixin containing download-related methods."""
|
||||||
|
|
||||||
|
def on_download_results(self) -> None:
|
||||||
|
"""Download the selected results in the results tree."""
|
||||||
|
items = self.tr_results.selectionModel().selectedRows()
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
logger_gui.error("Please select a row first.")
|
||||||
|
else:
|
||||||
|
for item in items:
|
||||||
|
media = get_results_media_item(item, self.proxy_tr_results, self.model_tr_results)
|
||||||
|
queue_dl_item: QueueDownloadItem = self.search_manager.media_to_queue_download_model(media)
|
||||||
|
|
||||||
|
if queue_dl_item:
|
||||||
|
self.queue_download_media(queue_dl_item)
|
||||||
|
|
||||||
|
def queue_download_media(self, queue_dl_item: QueueDownloadItem) -> None:
|
||||||
|
"""Add a media item to the download queue."""
|
||||||
|
child: QtWidgets.QTreeWidgetItem = QtWidgets.QTreeWidgetItem()
|
||||||
|
|
||||||
|
child.setText(0, queue_dl_item.status)
|
||||||
|
set_queue_download_media(child, queue_dl_item.obj)
|
||||||
|
child.setText(2, queue_dl_item.name)
|
||||||
|
child.setText(3, queue_dl_item.type_media)
|
||||||
|
child.setText(4, queue_dl_item.quality_audio)
|
||||||
|
child.setText(5, queue_dl_item.quality_video)
|
||||||
|
self.tr_queue_download.addTopLevelItem(child)
|
||||||
|
|
||||||
|
def watcher_queue_download(self) -> None:
|
||||||
|
"""Monitor the download queue and process items as they become available."""
|
||||||
|
handling_app: HandlingApp = HandlingApp()
|
||||||
|
|
||||||
|
while not handling_app.event_abort.is_set():
|
||||||
|
items = self.tr_queue_download.findItems(
|
||||||
|
QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(items) > 0:
|
||||||
|
item: QtWidgets.QTreeWidgetItem = items[0]
|
||||||
|
media = get_queue_download_media(item)
|
||||||
|
quality_audio: Quality = get_queue_download_quality_audio(item)
|
||||||
|
quality_video: QualityVideo = get_queue_download_quality_video(item)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.s_queue_download_item_downloading.emit(item)
|
||||||
|
result = self.on_queue_download(media, quality_audio=quality_audio, quality_video=quality_video)
|
||||||
|
|
||||||
|
if result == QueueDownloadStatus.Finished:
|
||||||
|
self.s_queue_download_item_finished.emit(item)
|
||||||
|
elif result == QueueDownloadStatus.Skipped:
|
||||||
|
self.s_queue_download_item_skipped.emit(item)
|
||||||
|
except Exception as e:
|
||||||
|
logger_gui.error(e)
|
||||||
|
self.s_queue_download_item_failed.emit(item)
|
||||||
|
else:
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def on_queue_download_item_downloading(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Update the status of a queue download item to 'Downloading'."""
|
||||||
|
self.queue_download_item_status(item, QueueDownloadStatus.Downloading)
|
||||||
|
|
||||||
|
def on_queue_download_item_finished(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Update the status of a queue download item to 'Finished'."""
|
||||||
|
self.queue_download_item_status(item, QueueDownloadStatus.Finished)
|
||||||
|
|
||||||
|
def on_queue_download_item_failed(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Update the status of a queue download item to 'Failed'."""
|
||||||
|
self.queue_download_item_status(item, QueueDownloadStatus.Failed)
|
||||||
|
|
||||||
|
def on_queue_download_item_skipped(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Update the status of a queue download item to 'Skipped'."""
|
||||||
|
self.queue_download_item_status(item, QueueDownloadStatus.Skipped)
|
||||||
|
|
||||||
|
def queue_download_item_status(self, item: QtWidgets.QTreeWidgetItem, status: str) -> None:
|
||||||
|
"""Set the status text of a queue download item."""
|
||||||
|
item.setText(0, status)
|
||||||
|
|
||||||
|
def on_queue_download(
|
||||||
|
self,
|
||||||
|
media: Track | Album | Playlist | Video | Mix | Artist,
|
||||||
|
quality_audio: Quality | None = None,
|
||||||
|
quality_video: QualityVideo | None = None,
|
||||||
|
) -> QueueDownloadStatus:
|
||||||
|
"""Download the specified media item(s) and return the result status."""
|
||||||
|
result: QueueDownloadStatus
|
||||||
|
|
||||||
|
items_media: list = items_results_all(self.tidal.session, media) if isinstance(media, Artist) else [media]
|
||||||
|
|
||||||
|
download_delay: bool = bool(isinstance(media, Track | Video) and self.settings.data.download_delay)
|
||||||
|
|
||||||
|
for item_media in items_media:
|
||||||
|
result = self.download(
|
||||||
|
item_media,
|
||||||
|
self.dl,
|
||||||
|
delay_track=download_delay,
|
||||||
|
quality_audio=quality_audio,
|
||||||
|
quality_video=quality_video,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def download(
|
||||||
|
self,
|
||||||
|
media: Track | Album | Playlist | Video | Mix | Artist,
|
||||||
|
dl: Download,
|
||||||
|
delay_track: bool = False,
|
||||||
|
quality_audio: Quality | None = None,
|
||||||
|
quality_video: QualityVideo | None = None,
|
||||||
|
) -> QueueDownloadStatus:
|
||||||
|
"""Download a media item and return the result status."""
|
||||||
|
result_dl: bool
|
||||||
|
path_file: str
|
||||||
|
result: QueueDownloadStatus
|
||||||
|
self.s_pb_reset.emit()
|
||||||
|
self.s_statusbar_message.emit(StatusbarMessage(message="Download started..."))
|
||||||
|
|
||||||
|
file_template = get_format_template(media, self.settings)
|
||||||
|
|
||||||
|
# Determine source information
|
||||||
|
source_type = "manual"
|
||||||
|
source_id = None
|
||||||
|
source_name = None
|
||||||
|
|
||||||
|
if isinstance(media, Album):
|
||||||
|
source_type = "album"
|
||||||
|
source_id = str(media.id)
|
||||||
|
source_name = media.name
|
||||||
|
elif isinstance(media, Playlist | UserPlaylist):
|
||||||
|
source_type = "playlist"
|
||||||
|
source_id = str(media.id) if hasattr(media, "id") else None
|
||||||
|
source_name = media.name if hasattr(media, "name") else None
|
||||||
|
elif isinstance(media, Mix):
|
||||||
|
source_type = "mix"
|
||||||
|
source_id = str(media.id)
|
||||||
|
source_name = media.title
|
||||||
|
elif isinstance(media, Track):
|
||||||
|
if hasattr(media, "album") and media.album:
|
||||||
|
source_type = "album"
|
||||||
|
source_id = str(media.album.id)
|
||||||
|
source_name = media.album.name
|
||||||
|
else:
|
||||||
|
source_type = "track"
|
||||||
|
source_id = str(media.id)
|
||||||
|
source_name = media.name
|
||||||
|
|
||||||
|
if isinstance(media, Track | Video):
|
||||||
|
result_dl, path_file = dl.item(
|
||||||
|
media=media,
|
||||||
|
file_template=file_template,
|
||||||
|
download_delay=delay_track,
|
||||||
|
quality_audio=quality_audio,
|
||||||
|
quality_video=quality_video,
|
||||||
|
source_type=source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
source_name=source_name,
|
||||||
|
)
|
||||||
|
elif isinstance(media, Album | Playlist | Mix):
|
||||||
|
dl.items(
|
||||||
|
media=media,
|
||||||
|
file_template=file_template,
|
||||||
|
video_download=self.settings.data.video_download,
|
||||||
|
download_delay=self.settings.data.download_delay,
|
||||||
|
quality_audio=quality_audio,
|
||||||
|
quality_video=quality_video,
|
||||||
|
source_type=source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
source_name=source_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
result_dl = True
|
||||||
|
path_file = "dummy"
|
||||||
|
|
||||||
|
self.s_statusbar_message.emit(StatusbarMessage(message="Download finished.", timeout=2000))
|
||||||
|
|
||||||
|
if result_dl and path_file:
|
||||||
|
result = QueueDownloadStatus.Finished
|
||||||
|
elif not result_dl and path_file:
|
||||||
|
result = QueueDownloadStatus.Skipped
|
||||||
|
else:
|
||||||
|
result = QueueDownloadStatus.Failed
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""History management mixin for MainWindow.
|
||||||
|
|
||||||
|
Handles download history and duplicate prevention.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6 import QtCore
|
||||||
|
from tidalapi import Track
|
||||||
|
|
||||||
|
from tidal_dl_ng.dialog import DialogPreferences
|
||||||
|
from tidal_dl_ng.dialog_history import DialogHistory
|
||||||
|
from tidal_dl_ng.logger import logger_gui
|
||||||
|
from tidal_dl_ng.model.gui_data import StatusbarMessage
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryMixin:
|
||||||
|
"""Mixin containing download history management methods."""
|
||||||
|
|
||||||
|
def on_view_history(self) -> None:
|
||||||
|
"""Open the download history dialog."""
|
||||||
|
DialogHistory(history_service=self.history_service, parent=self)
|
||||||
|
|
||||||
|
def on_toggle_duplicate_prevention(self, enabled: bool) -> None:
|
||||||
|
"""Toggle duplicate download prevention on or off."""
|
||||||
|
self.history_service.update_settings(preventDuplicates=enabled)
|
||||||
|
status_msg = "enabled" if enabled else "disabled"
|
||||||
|
logger_gui.info(f"Duplicate download prevention {status_msg}")
|
||||||
|
self.s_statusbar_message.emit(StatusbarMessage(message=f"Duplicate prevention {status_msg}.", timeout=2500))
|
||||||
|
|
||||||
|
def on_mark_track_as_downloaded(self, track: Track, index: QtCore.QModelIndex) -> None:
|
||||||
|
"""Mark a track as downloaded in history."""
|
||||||
|
track_id = str(track.id)
|
||||||
|
|
||||||
|
source_type = "manual"
|
||||||
|
source_id = None
|
||||||
|
source_name = None
|
||||||
|
|
||||||
|
if hasattr(track, "album") and track.album:
|
||||||
|
source_type = "album"
|
||||||
|
source_id = str(track.album.id)
|
||||||
|
source_name = track.album.name
|
||||||
|
|
||||||
|
self.history_service.add_track_to_history(
|
||||||
|
track_id=track_id, source_type=source_type, source_id=source_id, source_name=source_name
|
||||||
|
)
|
||||||
|
|
||||||
|
self._update_downloaded_column(index, True)
|
||||||
|
logger_gui.info(f"Marked track as downloaded: {track.name}")
|
||||||
|
|
||||||
|
def on_mark_track_as_not_downloaded(self, track_id: str, index: QtCore.QModelIndex) -> None:
|
||||||
|
"""Remove a track from download history."""
|
||||||
|
success = self.history_service.remove_track_from_history(track_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self._update_downloaded_column(index, False)
|
||||||
|
logger_gui.info(f"Unmarked track (ID: {track_id})")
|
||||||
|
|
||||||
|
def _update_downloaded_column(self, index: QtCore.QModelIndex, is_downloaded: bool) -> None:
|
||||||
|
"""Update the Downloaded? column for a specific index."""
|
||||||
|
source_index = self.proxy_tr_results.mapToSource(index)
|
||||||
|
|
||||||
|
item = self.model_tr_results.itemFromIndex(source_index)
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
|
||||||
|
row = item.row()
|
||||||
|
parent = item.parent()
|
||||||
|
|
||||||
|
downloaded_item = self.model_tr_results.item(row, 8) if parent is None else parent.child(row, 8)
|
||||||
|
|
||||||
|
if downloaded_item:
|
||||||
|
if is_downloaded:
|
||||||
|
downloaded_item.setText("✅")
|
||||||
|
downloaded_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||||
|
else:
|
||||||
|
downloaded_item.setText("")
|
||||||
|
|
||||||
|
def on_preferences(self) -> None:
|
||||||
|
"""Open the preferences dialog."""
|
||||||
|
DialogPreferences(settings=self.settings, settings_save=self.s_settings_save, parent=self)
|
||||||
|
|
||||||
|
def on_settings_save(self) -> None:
|
||||||
|
"""Save settings and re-apply them to the GUI."""
|
||||||
|
self.settings.save()
|
||||||
|
self.apply_settings(self.settings)
|
||||||
|
self._init_dl()
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Initialization mixin for MainWindow.
|
||||||
|
|
||||||
|
Handles all initialization-related methods for the GUI components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ansi2html import Ansi2HTMLConverter
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
from rich.progress import Progress
|
||||||
|
|
||||||
|
from tidal_dl_ng.config import HandlingApp
|
||||||
|
from tidal_dl_ng.download import Download
|
||||||
|
from tidal_dl_ng.helper.gui import FilterHeader, HumanProxyModel
|
||||||
|
from tidal_dl_ng.helper.path import resource_path
|
||||||
|
from tidal_dl_ng.logger import logger_gui
|
||||||
|
from tidal_dl_ng.model.gui_data import ProgressBars
|
||||||
|
from tidal_dl_ng.ui.spinner import QtWaitingSpinner
|
||||||
|
|
||||||
|
|
||||||
|
class InitializationMixin:
|
||||||
|
"""Mixin containing initialization methods for MainWindow."""
|
||||||
|
|
||||||
|
def _init_gui(self) -> None:
|
||||||
|
"""Initialize GUI-specific variables and state."""
|
||||||
|
self.setGeometry(
|
||||||
|
self.settings.data.window_x,
|
||||||
|
self.settings.data.window_y,
|
||||||
|
self.settings.data.window_w,
|
||||||
|
self.settings.data.window_h,
|
||||||
|
)
|
||||||
|
self.spinners: dict[QtWidgets.QWidget, QtWaitingSpinner] = {}
|
||||||
|
self.converter_ansi_html: Ansi2HTMLConverter = Ansi2HTMLConverter()
|
||||||
|
|
||||||
|
def _init_threads(self):
|
||||||
|
"""Initialize thread pool and start background workers."""
|
||||||
|
self.threadpool = QtCore.QThreadPool()
|
||||||
|
self.thread_it(self.queue_manager.watcher_queue_download)
|
||||||
|
|
||||||
|
def _init_dl(self):
|
||||||
|
"""Initialize Download object and related progress bars."""
|
||||||
|
data_pb: ProgressBars = ProgressBars(
|
||||||
|
item=self.s_item_advance,
|
||||||
|
list_item=self.s_list_advance,
|
||||||
|
item_name=self.s_item_name,
|
||||||
|
list_name=self.s_list_name,
|
||||||
|
)
|
||||||
|
progress: Progress = Progress()
|
||||||
|
handling_app: HandlingApp = HandlingApp()
|
||||||
|
self.dl = Download(
|
||||||
|
tidal_obj=self.tidal,
|
||||||
|
skip_existing=self.tidal.settings.data.skip_existing,
|
||||||
|
path_base=self.settings.data.download_base_path,
|
||||||
|
fn_logger=logger_gui,
|
||||||
|
progress_gui=data_pb,
|
||||||
|
progress=progress,
|
||||||
|
event_abort=handling_app.event_abort,
|
||||||
|
event_run=handling_app.event_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _init_progressbar(self):
|
||||||
|
"""Initialize and add progress bars to the status bar."""
|
||||||
|
self.pb_list = QtWidgets.QProgressBar()
|
||||||
|
self.pb_item = QtWidgets.QProgressBar()
|
||||||
|
pbs = [self.pb_list, self.pb_item]
|
||||||
|
|
||||||
|
for pb in pbs:
|
||||||
|
pb.setRange(0, 100)
|
||||||
|
self.statusbar.addPermanentWidget(pb)
|
||||||
|
|
||||||
|
def _init_info(self):
|
||||||
|
"""Set default album cover image in the GUI."""
|
||||||
|
path_image: str = resource_path("tidal_dl_ng/ui/default_album_image.png")
|
||||||
|
self.l_pm_cover.setPixmap(QtGui.QPixmap(path_image))
|
||||||
|
|
||||||
|
def _init_tree_results(self, tree: QtWidgets.QTreeView, model: QtGui.QStandardItemModel) -> None:
|
||||||
|
"""Initialize the results tree view and its model."""
|
||||||
|
header: FilterHeader = FilterHeader(tree)
|
||||||
|
self.proxy_tr_results: HumanProxyModel = HumanProxyModel(self)
|
||||||
|
|
||||||
|
tree.setHeader(header)
|
||||||
|
tree.setModel(model)
|
||||||
|
self.proxy_tr_results.setSourceModel(model)
|
||||||
|
tree.setModel(self.proxy_tr_results)
|
||||||
|
header.set_filter_boxes(model.columnCount())
|
||||||
|
header.filter_activated.connect(self.handle_filter_activated)
|
||||||
|
|
||||||
|
tree.sortByColumn(0, QtCore.Qt.SortOrder.AscendingOrder)
|
||||||
|
tree.setColumnHidden(1, True)
|
||||||
|
normal_width = max(150, (self.width() * 0.13))
|
||||||
|
narrow_width = max(90, (self.width() * 0.06))
|
||||||
|
skinny_width = max(60, (self.width() * 0.03))
|
||||||
|
tree.setColumnWidth(2, normal_width)
|
||||||
|
tree.setColumnWidth(3, normal_width)
|
||||||
|
tree.setColumnWidth(4, normal_width)
|
||||||
|
tree.setColumnWidth(5, skinny_width)
|
||||||
|
tree.setColumnWidth(6, narrow_width)
|
||||||
|
tree.setColumnWidth(7, narrow_width)
|
||||||
|
tree.setColumnWidth(8, skinny_width)
|
||||||
|
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
|
||||||
|
|
||||||
|
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||||
|
tree.customContextMenuRequested.connect(self.menu_context_tree_results)
|
||||||
|
|
||||||
|
from tidal_dl_ng.helper.hover_manager import HoverManager
|
||||||
|
|
||||||
|
self.hover_manager = HoverManager(
|
||||||
|
tree_view=tree,
|
||||||
|
proxy_model=self.proxy_tr_results,
|
||||||
|
source_model=model,
|
||||||
|
debounce_delay_ms=50,
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
|
self.hover_manager.s_hover_confirmed.connect(self.on_track_hover_confirmed)
|
||||||
|
self.hover_manager.s_hover_left.connect(self.on_track_hover_left)
|
||||||
|
|
||||||
|
def _init_tree_results_model(self, model: QtGui.QStandardItemModel) -> None:
|
||||||
|
"""Initialize the model for the results tree view."""
|
||||||
|
labels_column: list[str] = [
|
||||||
|
"#",
|
||||||
|
"obj",
|
||||||
|
"Artist",
|
||||||
|
"Title",
|
||||||
|
"Album",
|
||||||
|
"Duration",
|
||||||
|
"Quality",
|
||||||
|
"Date",
|
||||||
|
"Downloaded?",
|
||||||
|
]
|
||||||
|
model.setColumnCount(len(labels_column))
|
||||||
|
model.setRowCount(0)
|
||||||
|
model.setHorizontalHeaderLabels(labels_column)
|
||||||
|
|
||||||
|
def _init_tree_queue(self, tree: QtWidgets.QTableWidget) -> None:
|
||||||
|
"""Initialize the download queue table widget."""
|
||||||
|
tree.setColumnHidden(1, True)
|
||||||
|
tree.setColumnWidth(2, 200)
|
||||||
|
|
||||||
|
header = tree.header()
|
||||||
|
if hasattr(header, "setSectionResizeMode"):
|
||||||
|
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
|
||||||
|
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||||
|
tree.customContextMenuRequested.connect(self.menu_context_queue_download)
|
||||||
|
|
||||||
|
def _init_tree_lists(self, tree: QtWidgets.QTreeWidget) -> None:
|
||||||
|
"""Initialize the user lists tree widget."""
|
||||||
|
tree.setColumnWidth(0, 200)
|
||||||
|
tree.setColumnHidden(1, True)
|
||||||
|
tree.setColumnWidth(2, 300)
|
||||||
|
tree.expandAll()
|
||||||
|
|
||||||
|
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||||
|
tree.customContextMenuRequested.connect(self.playlist_manager.menu_context_tree_lists)
|
||||||
|
|
||||||
|
def _init_menu_actions(self) -> None:
|
||||||
|
"""Initialize custom menu actions."""
|
||||||
|
menubar = self.menuBar()
|
||||||
|
tools_menu = None
|
||||||
|
|
||||||
|
for action in menubar.actions():
|
||||||
|
if action.text() == "Tools":
|
||||||
|
tools_menu = action.menu()
|
||||||
|
break
|
||||||
|
|
||||||
|
if not tools_menu:
|
||||||
|
tools_menu = menubar.addMenu("Tools")
|
||||||
|
|
||||||
|
self.a_view_history = QtGui.QAction("View Download History...", self)
|
||||||
|
self.a_view_history.triggered.connect(self.on_view_history)
|
||||||
|
tools_menu.addAction(self.a_view_history)
|
||||||
|
|
||||||
|
tools_menu.addSeparator()
|
||||||
|
|
||||||
|
self.a_toggle_duplicate_prevention = QtGui.QAction("Prevent Duplicate Downloads", self)
|
||||||
|
self.a_toggle_duplicate_prevention.setCheckable(True)
|
||||||
|
is_preventing = self.history_service.get_settings().get("preventDuplicates", True)
|
||||||
|
self.a_toggle_duplicate_prevention.setChecked(is_preventing)
|
||||||
|
self.a_toggle_duplicate_prevention.triggered.connect(self.on_toggle_duplicate_prevention)
|
||||||
|
tools_menu.addAction(self.a_toggle_duplicate_prevention)
|
||||||
|
|
||||||
|
def _populate_quality(self, ui_target: QtWidgets.QComboBox, options: Iterable[Any]) -> None:
|
||||||
|
"""Populate a combo box with quality options."""
|
||||||
|
for item in options:
|
||||||
|
ui_target.addItem(item.name, item)
|
||||||
|
|
||||||
|
def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: Iterable[Any]) -> None:
|
||||||
|
"""Populate a combo box with search type options."""
|
||||||
|
for item in options:
|
||||||
|
if item:
|
||||||
|
ui_target.addItem(item.__name__, item)
|
||||||
|
self.cb_search_type.setCurrentIndex(2)
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Main window for TIDAL Downloader Next Generation.
|
||||||
|
|
||||||
|
This module combines all GUI functionality through mixins to create
|
||||||
|
the main application window.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
from tidalapi import Quality
|
||||||
|
|
||||||
|
from tidal_dl_ng.cache import TrackExtrasCache
|
||||||
|
from tidal_dl_ng.config import HandlingApp, Settings, Tidal
|
||||||
|
from tidal_dl_ng.constants import QualityVideo
|
||||||
|
from tidal_dl_ng.download import Download
|
||||||
|
from tidal_dl_ng.gui.context_menus import ContextMenusMixin
|
||||||
|
from tidal_dl_ng.gui.covers import CoverManager
|
||||||
|
from tidal_dl_ng.gui.downloads import DownloadsMixin
|
||||||
|
from tidal_dl_ng.gui.history import HistoryMixin
|
||||||
|
from tidal_dl_ng.gui.initialization import InitializationMixin
|
||||||
|
from tidal_dl_ng.gui.playlist import GuiPlaylistManager
|
||||||
|
from tidal_dl_ng.gui.progress import ProgressMixin
|
||||||
|
from tidal_dl_ng.gui.queue import GuiQueueManager
|
||||||
|
from tidal_dl_ng.gui.search import GuiSearchManager
|
||||||
|
from tidal_dl_ng.gui.signals import SignalsMixin
|
||||||
|
from tidal_dl_ng.gui.tidal_session import TidalSessionMixin
|
||||||
|
from tidal_dl_ng.gui.track_extras import TrackExtrasMixin
|
||||||
|
from tidal_dl_ng.gui.trees_results import TreesResultsMixin
|
||||||
|
from tidal_dl_ng.gui.ui_helpers import UIHelpersMixin
|
||||||
|
from tidal_dl_ng.gui.updates import UpdatesMixin
|
||||||
|
from tidal_dl_ng.helper.gui import HumanProxyModel
|
||||||
|
from tidal_dl_ng.helper.hover_manager import HoverManager
|
||||||
|
from tidal_dl_ng.history import HistoryService
|
||||||
|
from tidal_dl_ng.logger import XStream, logger_gui
|
||||||
|
from tidal_dl_ng.ui.info_tab_widget import InfoTabWidget
|
||||||
|
from tidal_dl_ng.ui.main import Ui_MainWindow
|
||||||
|
from tidal_dl_ng.worker import Worker
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Make more use of Exceptions
|
||||||
|
class MainWindow(
|
||||||
|
QtWidgets.QMainWindow,
|
||||||
|
Ui_MainWindow,
|
||||||
|
InitializationMixin,
|
||||||
|
TidalSessionMixin,
|
||||||
|
SignalsMixin,
|
||||||
|
ProgressMixin,
|
||||||
|
UIHelpersMixin,
|
||||||
|
TrackExtrasMixin,
|
||||||
|
UpdatesMixin,
|
||||||
|
DownloadsMixin,
|
||||||
|
TreesResultsMixin,
|
||||||
|
ContextMenusMixin,
|
||||||
|
HistoryMixin,
|
||||||
|
):
|
||||||
|
"""Main application window for TIDAL Downloader Next Generation.
|
||||||
|
|
||||||
|
Handles GUI setup, user interactions, and download logic through
|
||||||
|
a combination of mixins for better code organization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Type hints for class attributes
|
||||||
|
settings: Settings
|
||||||
|
tidal: Tidal
|
||||||
|
dl: Download
|
||||||
|
history_service: HistoryService
|
||||||
|
threadpool: QtCore.QThreadPool
|
||||||
|
tray: QtWidgets.QSystemTrayIcon
|
||||||
|
spinners: dict
|
||||||
|
cover_url_current: str = ""
|
||||||
|
shutdown: bool = False
|
||||||
|
model_tr_results: QtGui.QStandardItemModel = QtGui.QStandardItemModel()
|
||||||
|
proxy_tr_results: HumanProxyModel
|
||||||
|
info_tab_widget: InfoTabWidget
|
||||||
|
hover_manager: HoverManager
|
||||||
|
queue_manager: GuiQueueManager
|
||||||
|
playlist_manager: GuiPlaylistManager
|
||||||
|
search_manager: GuiSearchManager
|
||||||
|
cover_manager: CoverManager
|
||||||
|
track_extras_cache: TrackExtrasCache
|
||||||
|
_pending_extras_workers: dict[str, Worker]
|
||||||
|
_track_extras_callbacks: dict[str, Callable]
|
||||||
|
|
||||||
|
# Qt Signals
|
||||||
|
s_spinner_start: QtCore.Signal = QtCore.Signal(QtWidgets.QWidget)
|
||||||
|
s_spinner_stop: QtCore.Signal = QtCore.Signal()
|
||||||
|
s_track_extras_ready: QtCore.Signal = QtCore.Signal(str, object)
|
||||||
|
s_invoke_callback: QtCore.Signal = QtCore.Signal(str, object)
|
||||||
|
pb_item: QtWidgets.QProgressBar
|
||||||
|
s_item_advance: QtCore.Signal = QtCore.Signal(float)
|
||||||
|
s_item_name: QtCore.Signal = QtCore.Signal(str)
|
||||||
|
s_list_name: QtCore.Signal = QtCore.Signal(str)
|
||||||
|
pb_list: QtWidgets.QProgressBar
|
||||||
|
s_list_advance: QtCore.Signal = QtCore.Signal(float)
|
||||||
|
s_pb_reset: QtCore.Signal = QtCore.Signal()
|
||||||
|
s_populate_tree_lists: QtCore.Signal = QtCore.Signal(dict)
|
||||||
|
s_populate_folder_children: QtCore.Signal = QtCore.Signal(object, list, list)
|
||||||
|
s_statusbar_message: QtCore.Signal = QtCore.Signal(object)
|
||||||
|
s_tr_results_add_top_level_item: QtCore.Signal = QtCore.Signal(object)
|
||||||
|
s_settings_save: QtCore.Signal = QtCore.Signal()
|
||||||
|
s_pb_reload_status: QtCore.Signal = QtCore.Signal(bool)
|
||||||
|
s_update_check: QtCore.Signal = QtCore.Signal(bool)
|
||||||
|
s_update_show: QtCore.Signal = QtCore.Signal(bool, bool, object)
|
||||||
|
s_queue_download_item_downloading: QtCore.Signal = QtCore.Signal(object)
|
||||||
|
s_queue_download_item_finished: QtCore.Signal = QtCore.Signal(object)
|
||||||
|
s_queue_download_item_failed: QtCore.Signal = QtCore.Signal(object)
|
||||||
|
s_queue_download_item_skipped: QtCore.Signal = QtCore.Signal(object)
|
||||||
|
|
||||||
|
def __init__(self, tidal: Tidal | None = None) -> None:
|
||||||
|
"""Initialize the main window and all components.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tidal (Tidal | None): Optional Tidal session object.
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.setupUi(self)
|
||||||
|
self.setWindowTitle("TIDAL Downloader Next Generation!")
|
||||||
|
|
||||||
|
# Initialize settings first
|
||||||
|
self.settings = Settings()
|
||||||
|
|
||||||
|
# Initialize managers that depend on settings
|
||||||
|
self.queue_manager = GuiQueueManager(self)
|
||||||
|
self.playlist_manager = GuiPlaylistManager(self)
|
||||||
|
self.search_manager = GuiSearchManager(self)
|
||||||
|
self.info_tab_widget = InfoTabWidget(self, self.tabWidget)
|
||||||
|
|
||||||
|
# Logging redirect
|
||||||
|
XStream.stdout().messageWritten.connect(self._log_output)
|
||||||
|
|
||||||
|
self.history_service = HistoryService()
|
||||||
|
|
||||||
|
# Core components
|
||||||
|
self._init_threads()
|
||||||
|
self._init_gui()
|
||||||
|
self.track_extras_cache = TrackExtrasCache()
|
||||||
|
self._pending_extras_workers: dict[str, Worker] = {}
|
||||||
|
self._track_extras_callbacks: dict[str, Callable] = {}
|
||||||
|
|
||||||
|
# Managers that have dependencies
|
||||||
|
self.cover_manager = CoverManager(self, self.threadpool, self.info_tab_widget)
|
||||||
|
|
||||||
|
# Initialize the rest of the UI
|
||||||
|
self.info_tab_widget.set_track_extras_provider(self.get_track_extras)
|
||||||
|
self._init_tree_results_model(self.model_tr_results)
|
||||||
|
self._init_tree_results(self.tr_results, self.model_tr_results)
|
||||||
|
self.playlist_manager.init_ui()
|
||||||
|
self.queue_manager.init_ui()
|
||||||
|
self._init_tree_lists(self.tr_lists_user)
|
||||||
|
self._init_tree_queue(self.tr_queue_download)
|
||||||
|
self._init_info()
|
||||||
|
self._init_progressbar()
|
||||||
|
self._populate_quality(self.cb_quality_audio, Quality)
|
||||||
|
self._populate_quality(self.cb_quality_video, QualityVideo)
|
||||||
|
|
||||||
|
from tidalapi.session import SearchTypes
|
||||||
|
|
||||||
|
self._populate_search_types(self.cb_search_type, SearchTypes)
|
||||||
|
|
||||||
|
self.apply_settings(self.settings)
|
||||||
|
self._init_menu_actions()
|
||||||
|
self._init_signals()
|
||||||
|
|
||||||
|
# Connect signal for invoking track extras callbacks
|
||||||
|
self.s_invoke_callback.connect(self._on_invoke_callback)
|
||||||
|
|
||||||
|
# Connect playlist manager signals
|
||||||
|
self.s_populate_tree_lists.connect(self.on_populate_tree_lists)
|
||||||
|
|
||||||
|
self.init_tidal(tidal)
|
||||||
|
|
||||||
|
logger_gui.debug("All setup.")
|
||||||
|
|
||||||
|
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
|
||||||
|
"""Handle the close event of the main window.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event (QtGui.QCloseEvent): The close event.
|
||||||
|
"""
|
||||||
|
# Save the main window size and position
|
||||||
|
self.settings.data.window_x = self.x()
|
||||||
|
self.settings.data.window_y = self.y()
|
||||||
|
self.settings.data.window_w = self.width()
|
||||||
|
self.settings.data.window_h = self.height()
|
||||||
|
self.settings.save()
|
||||||
|
|
||||||
|
self.shutdown = True
|
||||||
|
|
||||||
|
handling_app: HandlingApp = HandlingApp()
|
||||||
|
handling_app.event_abort.set()
|
||||||
|
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
def apply_settings(self, settings: Settings) -> None:
|
||||||
|
"""Apply user settings to the GUI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings (Settings): The settings object.
|
||||||
|
"""
|
||||||
|
quality_audio = getattr(getattr(settings, "data", None), "quality_audio", 1)
|
||||||
|
quality_video = getattr(getattr(settings, "data", None), "quality_video", 0)
|
||||||
|
elements = [
|
||||||
|
{"element": self.cb_quality_audio, "setting": quality_audio, "default_id": 1},
|
||||||
|
{"element": self.cb_quality_video, "setting": quality_video, "default_id": 0},
|
||||||
|
]
|
||||||
|
|
||||||
|
for item in elements:
|
||||||
|
idx = item["element"].findData(item["setting"])
|
||||||
|
|
||||||
|
if idx > -1:
|
||||||
|
item["element"].setCurrentIndex(idx)
|
||||||
|
else:
|
||||||
|
item["element"].setCurrentIndex(item["default_id"])
|
||||||
|
|
||||||
|
def thread_it(self, fn: Callable, *args, **kwargs) -> None:
|
||||||
|
"""Run a function in a separate thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fn (Callable): The function to run.
|
||||||
|
*args: Positional arguments for the function.
|
||||||
|
**kwargs: Keyword arguments for the function.
|
||||||
|
"""
|
||||||
|
worker = Worker(fn, *args, **kwargs)
|
||||||
|
self.threadpool.start(worker)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# tidal_dl_ng/gui_playlist.py
|
# tidal_dl_ng/playlist.py
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Progress bars mixin for MainWindow.
|
||||||
|
|
||||||
|
Handles progress bar updates and formatting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressMixin:
|
||||||
|
"""Mixin containing progress bar management methods."""
|
||||||
|
|
||||||
|
def on_progress_reset(self):
|
||||||
|
"""Reset progress bars to zero."""
|
||||||
|
self.pb_list.setValue(0)
|
||||||
|
self.pb_item.setValue(0)
|
||||||
|
|
||||||
|
def on_progress_list(self, value: float) -> None:
|
||||||
|
"""Update the progress of the list progress bar."""
|
||||||
|
self.pb_list.setValue(int(math.ceil(value)))
|
||||||
|
|
||||||
|
def on_progress_item(self, value: float) -> None:
|
||||||
|
"""Update the progress of the item progress bar."""
|
||||||
|
self.pb_item.setValue(int(math.ceil(value)))
|
||||||
|
|
||||||
|
def on_progress_item_name(self, value: str) -> None:
|
||||||
|
"""Set the format of the item progress bar."""
|
||||||
|
self.pb_item.setFormat(f"%p% {value}")
|
||||||
|
|
||||||
|
def on_progress_list_name(self, value: str) -> None:
|
||||||
|
"""Set the format of the list progress bar."""
|
||||||
|
self.pb_list.setFormat(f"%p% {value}")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# tidal_dl_ng/gui_queue.py
|
# tidal_dl_ng/queue.py
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# tidal_dl_ng/gui_search.py
|
# tidal_dl_ng/search.py
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Signals mixin for MainWindow.
|
||||||
|
|
||||||
|
Handles all Qt signal definitions and connections.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtWidgets
|
||||||
|
from tidalapi import Album, Artist, Track, Video
|
||||||
|
|
||||||
|
from tidal_dl_ng.helper.gui import get_results_media_item
|
||||||
|
|
||||||
|
|
||||||
|
class SignalsMixin:
|
||||||
|
"""Mixin containing Qt signal definitions and signal connection methods."""
|
||||||
|
|
||||||
|
def _init_signals(self) -> None:
|
||||||
|
"""Connect signals to their respective slots."""
|
||||||
|
self.pb_download.clicked.connect(lambda: self.thread_it(self.on_download_results))
|
||||||
|
self.pb_download_list.clicked.connect(lambda: self.thread_it(self.playlist_manager.on_download_list_media))
|
||||||
|
self.l_search.returnPressed.connect(
|
||||||
|
lambda: self.search_manager.search_populate_results(self.l_search.text(), self.cb_search_type.currentData())
|
||||||
|
)
|
||||||
|
self.pb_search.clicked.connect(
|
||||||
|
lambda: self.search_manager.search_populate_results(self.l_search.text(), self.cb_search_type.currentData())
|
||||||
|
)
|
||||||
|
self.cb_quality_audio.currentIndexChanged.connect(self.on_quality_set_audio)
|
||||||
|
self.cb_quality_video.currentIndexChanged.connect(self.on_quality_set_video)
|
||||||
|
self.s_spinner_start[QtWidgets.QWidget].connect(self.on_spinner_start)
|
||||||
|
self.s_spinner_stop.connect(self.on_spinner_stop)
|
||||||
|
self.s_item_advance.connect(self.on_progress_item)
|
||||||
|
self.s_item_name.connect(self.on_progress_item_name)
|
||||||
|
self.s_list_name.connect(self.on_progress_list_name)
|
||||||
|
self.s_list_advance.connect(self.on_progress_list)
|
||||||
|
self.s_pb_reset.connect(self.on_progress_reset)
|
||||||
|
self.s_statusbar_message.connect(self.on_statusbar_message)
|
||||||
|
self.s_tr_results_add_top_level_item.connect(self.on_tr_results_add_top_level_item)
|
||||||
|
self.s_settings_save.connect(self.on_settings_save)
|
||||||
|
self.s_pb_reload_status.connect(self.button_reload_status)
|
||||||
|
self.s_update_check.connect(lambda: self.thread_it(self.on_update_check))
|
||||||
|
self.s_update_show.connect(self.on_version)
|
||||||
|
|
||||||
|
# Menubar
|
||||||
|
self.a_exit.triggered.connect(self.close)
|
||||||
|
self.a_version.triggered.connect(self.on_version)
|
||||||
|
self.a_preferences.triggered.connect(self.on_preferences)
|
||||||
|
self.a_logout.triggered.connect(self.on_logout)
|
||||||
|
self.a_updates_check.triggered.connect(lambda: self.on_update_check(False))
|
||||||
|
|
||||||
|
# Results
|
||||||
|
self.tr_results.expanded.connect(self.on_tr_results_expanded)
|
||||||
|
self.tr_results.clicked.connect(self.on_result_item_clicked)
|
||||||
|
self.tr_results.doubleClicked.connect(lambda: self.thread_it(self.on_download_results))
|
||||||
|
|
||||||
|
# Managers
|
||||||
|
self.queue_manager.connect_signals()
|
||||||
|
self.playlist_manager.connect_signals()
|
||||||
|
self.info_tab_widget.s_search_in_app.connect(self.on_search_in_app)
|
||||||
|
self.info_tab_widget.s_search_in_browser.connect(self.on_search_in_browser)
|
||||||
|
|
||||||
|
def on_result_item_clicked(self, index: QtCore.QModelIndex) -> None:
|
||||||
|
"""Handle the event when a result item is clicked."""
|
||||||
|
media: Track | Video | Album | Artist = get_results_media_item(
|
||||||
|
index, self.proxy_tr_results, self.model_tr_results
|
||||||
|
)
|
||||||
|
|
||||||
|
self.info_tab_widget.update_on_selection(media)
|
||||||
|
self.thread_it(self.cover_manager.load_cover, media)
|
||||||
|
|
||||||
|
def on_quality_set_audio(self, index: int) -> None:
|
||||||
|
"""Set the audio quality for downloads."""
|
||||||
|
from tidalapi import Quality
|
||||||
|
|
||||||
|
quality_data = self.cb_quality_audio.itemData(index)
|
||||||
|
self.settings.data.quality_audio = Quality(quality_data)
|
||||||
|
self.settings.save()
|
||||||
|
if self.tidal:
|
||||||
|
self.tidal.settings_apply()
|
||||||
|
|
||||||
|
def on_quality_set_video(self, index: int) -> None:
|
||||||
|
"""Set the video quality for downloads."""
|
||||||
|
from tidal_dl_ng.constants import QualityVideo
|
||||||
|
|
||||||
|
self.settings.data.quality_video = QualityVideo(self.cb_quality_video.itemData(index))
|
||||||
|
self.settings.save()
|
||||||
|
if self.tidal:
|
||||||
|
self.tidal.settings_apply()
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Tidal session mixin for MainWindow.
|
||||||
|
|
||||||
|
Handles Tidal authentication and session management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from requests.exceptions import HTTPError
|
||||||
|
from tidalapi.session import LinkLogin
|
||||||
|
|
||||||
|
from tidal_dl_ng.config import Tidal
|
||||||
|
from tidal_dl_ng.dialog import DialogLogin
|
||||||
|
from tidal_dl_ng.logger import logger_gui
|
||||||
|
|
||||||
|
|
||||||
|
class TidalSessionMixin:
|
||||||
|
"""Mixin containing Tidal session management methods."""
|
||||||
|
|
||||||
|
def init_tidal(self, tidal: Tidal | None = None):
|
||||||
|
"""Initialize Tidal session and handle login flow."""
|
||||||
|
result: bool = False
|
||||||
|
|
||||||
|
if tidal:
|
||||||
|
self.tidal = tidal
|
||||||
|
result = True
|
||||||
|
else:
|
||||||
|
self.tidal = Tidal(self.settings)
|
||||||
|
result = self.tidal.login_token()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
hint: str = "After you have finished the TIDAL login via web browser click the 'OK' button."
|
||||||
|
|
||||||
|
while not result:
|
||||||
|
link_login: LinkLogin = self.tidal.session.get_link_login()
|
||||||
|
expires_in = int(link_login.expires_in) if hasattr(link_login, "expires_in") else 0
|
||||||
|
d_login: DialogLogin = DialogLogin(
|
||||||
|
url_login=link_login.verification_uri_complete,
|
||||||
|
hint=hint,
|
||||||
|
expires_in=expires_in,
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
|
|
||||||
|
if d_login.return_code == 1:
|
||||||
|
try:
|
||||||
|
self.tidal.session.process_link_login(link_login, until_expiry=False)
|
||||||
|
self.tidal.login_finalize()
|
||||||
|
|
||||||
|
result = True
|
||||||
|
logger_gui.info("Login successful. Have fun!")
|
||||||
|
except (HTTPError, Exception):
|
||||||
|
hint = "Something was wrong with your redirect url. Please try again!"
|
||||||
|
logger_gui.warning("Login not successful. Try again...")
|
||||||
|
else:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
self._init_dl()
|
||||||
|
self.thread_it(self.playlist_manager.tidal_user_lists)
|
||||||
|
|
||||||
|
def on_logout(self) -> None:
|
||||||
|
"""Log out from TIDAL and close the application."""
|
||||||
|
result: bool = self.tidal.logout()
|
||||||
|
if result:
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Track extras mixin for MainWindow.
|
||||||
|
|
||||||
|
Handles track extras caching and retrieval.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from PySide6 import QtCore
|
||||||
|
|
||||||
|
from tidal_dl_ng.helper.tidal import extract_contributor_names, fetch_raw_track_and_album, parse_track_and_album_extras
|
||||||
|
from tidal_dl_ng.worker import Worker
|
||||||
|
|
||||||
|
|
||||||
|
class TrackExtrasMixin:
|
||||||
|
"""Mixin containing track extras management methods."""
|
||||||
|
|
||||||
|
def get_track_extras(self, track_id: str, callback: Callable[[str, dict | None], None]) -> dict | None:
|
||||||
|
"""Return cached extras for a track or start async fetch."""
|
||||||
|
cached = self.track_extras_cache.get(track_id)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
if track_id in self._pending_extras_workers:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if callback:
|
||||||
|
self._track_extras_callbacks[track_id] = callback
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
extras = None
|
||||||
|
try:
|
||||||
|
track_json, album_json = fetch_raw_track_and_album(self.tidal.session, track_id)
|
||||||
|
extras = parse_track_and_album_extras(track_json, album_json)
|
||||||
|
extras = self._decorate_extras(extras)
|
||||||
|
self.track_extras_cache.set(track_id, extras)
|
||||||
|
except Exception:
|
||||||
|
extras = None
|
||||||
|
finally:
|
||||||
|
self._pending_extras_workers.pop(track_id, None)
|
||||||
|
self.s_track_extras_ready.emit(track_id, extras)
|
||||||
|
self.s_invoke_callback.emit(track_id, extras)
|
||||||
|
|
||||||
|
worker_obj = Worker(worker)
|
||||||
|
self._pending_extras_workers[track_id] = worker_obj
|
||||||
|
self.threadpool.start(worker_obj)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@QtCore.Slot(str, object)
|
||||||
|
def _on_invoke_callback(self, track_id: str, extras: dict | None) -> None:
|
||||||
|
"""Invoke the stored callback for a track in the main thread."""
|
||||||
|
callback = self._track_extras_callbacks.pop(track_id, None)
|
||||||
|
if callback:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
callback(track_id, extras)
|
||||||
|
|
||||||
|
def _decorate_extras(self, extras: dict | None) -> dict:
|
||||||
|
"""Add formatted string fields to extras dict."""
|
||||||
|
if not extras:
|
||||||
|
return {}
|
||||||
|
result = dict(extras)
|
||||||
|
result["genres_text"] = ", ".join(result.get("genres", []))
|
||||||
|
for role, key in [
|
||||||
|
("producer", "producers_text"),
|
||||||
|
("composer", "composers_text"),
|
||||||
|
("lyricist", "lyricists_text"),
|
||||||
|
]:
|
||||||
|
result[key] = extract_contributor_names(result.get("contributors_by_role"), role)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def preload_covers_for_playlist(self, items: list) -> None:
|
||||||
|
"""Preload cover pixmaps for a list of tracks in background."""
|
||||||
|
if self.cover_manager:
|
||||||
|
self.cover_manager.preload_covers_for_playlist(items)
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""Trees and results mixin for MainWindow.
|
||||||
|
|
||||||
|
Handles tree view population and results display.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import math
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
from tidalapi import Album, Artist, Mix, Playlist, Track, UserPlaylist, Video
|
||||||
|
from tidalapi.playlist import Folder
|
||||||
|
|
||||||
|
from tidal_dl_ng.constants import FAVORITES, TidalLists
|
||||||
|
from tidal_dl_ng.helper.gui import (
|
||||||
|
HumanProxyModel,
|
||||||
|
get_results_media_item,
|
||||||
|
get_user_list_media_item,
|
||||||
|
set_user_list_media,
|
||||||
|
)
|
||||||
|
from tidal_dl_ng.helper.tidal import favorite_function_factory, items_results_all, user_media_lists
|
||||||
|
from tidal_dl_ng.model.gui_data import ResultItem
|
||||||
|
|
||||||
|
|
||||||
|
class TreesResultsMixin:
|
||||||
|
"""Mixin containing tree view and results management methods."""
|
||||||
|
|
||||||
|
def handle_filter_activated(self) -> None:
|
||||||
|
"""Handle activation of filter headers in the results tree."""
|
||||||
|
header = self.tr_results.header()
|
||||||
|
filters: list[tuple[int, str]] = []
|
||||||
|
for i in range(header.count()):
|
||||||
|
text: str = header.filter_text(i)
|
||||||
|
if text:
|
||||||
|
filters.append((i, text))
|
||||||
|
|
||||||
|
proxy_model: HumanProxyModel = self.tr_results.model()
|
||||||
|
proxy_model.filters = filters
|
||||||
|
|
||||||
|
def populate_tree_results(self, results: list[ResultItem], parent: QtGui.QStandardItem | None = None) -> None:
|
||||||
|
"""Populate the results tree with ResultItem objects."""
|
||||||
|
if not parent:
|
||||||
|
self.model_tr_results.removeRows(0, self.model_tr_results.rowCount())
|
||||||
|
|
||||||
|
count_digits: int = int(math.log10(len(results) if results else 1)) + 1
|
||||||
|
|
||||||
|
for item in results:
|
||||||
|
child: tuple = self.populate_tree_result_child(item=item, index_count_digits=count_digits)
|
||||||
|
|
||||||
|
if parent:
|
||||||
|
parent.appendRow(child)
|
||||||
|
else:
|
||||||
|
self.s_tr_results_add_top_level_item.emit(child)
|
||||||
|
|
||||||
|
def populate_tree_result_child(self, item: ResultItem, index_count_digits: int) -> Sequence[QtGui.QStandardItem]:
|
||||||
|
"""Create a row of QStandardItems for a ResultItem."""
|
||||||
|
duration: str = ""
|
||||||
|
|
||||||
|
if item.duration_sec > -1:
|
||||||
|
m, s = divmod(item.duration_sec, 60)
|
||||||
|
duration: str = f"{m:02d}:{s:02d}"
|
||||||
|
|
||||||
|
index: str = str(item.position + 1).zfill(index_count_digits)
|
||||||
|
|
||||||
|
child_index: QtGui.QStandardItem = QtGui.QStandardItem(index)
|
||||||
|
child_obj: QtGui.QStandardItem = QtGui.QStandardItem()
|
||||||
|
child_obj.setData(item.obj, QtCore.Qt.ItemDataRole.UserRole)
|
||||||
|
|
||||||
|
child_artist: QtGui.QStandardItem = QtGui.QStandardItem(item.artist)
|
||||||
|
child_title: QtGui.QStandardItem = QtGui.QStandardItem(item.title)
|
||||||
|
child_album: QtGui.QStandardItem = QtGui.QStandardItem(item.album)
|
||||||
|
child_duration: QtGui.QStandardItem = QtGui.QStandardItem(duration)
|
||||||
|
child_quality: QtGui.QStandardItem = QtGui.QStandardItem(item.quality)
|
||||||
|
child_date: QtGui.QStandardItem = QtGui.QStandardItem(
|
||||||
|
item.date_user_added if item.date_user_added != "" else item.date_release
|
||||||
|
)
|
||||||
|
|
||||||
|
child_downloaded: QtGui.QStandardItem = QtGui.QStandardItem()
|
||||||
|
if isinstance(item.obj, Track):
|
||||||
|
track_id = str(item.obj.id)
|
||||||
|
if self.history_service.is_downloaded(track_id):
|
||||||
|
child_downloaded.setText("✅")
|
||||||
|
child_downloaded.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
if isinstance(item.obj, Mix | Playlist | Album | Artist):
|
||||||
|
child_dummy: QtGui.QStandardItem = QtGui.QStandardItem()
|
||||||
|
child_dummy.setEnabled(False)
|
||||||
|
child_index.appendRow(child_dummy)
|
||||||
|
|
||||||
|
return (
|
||||||
|
child_index,
|
||||||
|
child_obj,
|
||||||
|
child_artist,
|
||||||
|
child_title,
|
||||||
|
child_album,
|
||||||
|
child_duration,
|
||||||
|
child_quality,
|
||||||
|
child_date,
|
||||||
|
child_downloaded,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]):
|
||||||
|
"""Add a top-level item to the results tree model."""
|
||||||
|
self.model_tr_results.appendRow(item_child)
|
||||||
|
|
||||||
|
def on_tr_results_expanded(self, index: QtCore.QModelIndex) -> None:
|
||||||
|
"""Handle the event when a result item group is expanded."""
|
||||||
|
self.thread_it(self.tr_results_expanded, index)
|
||||||
|
|
||||||
|
def tr_results_expanded(self, index: QtCore.QModelIndex) -> None:
|
||||||
|
"""Load and display the children of an expanded result item."""
|
||||||
|
item: QtGui.QStandardItem = self.model_tr_results.itemFromIndex(self.proxy_tr_results.mapToSource(index))
|
||||||
|
load_children: bool = not item.child(0, 0).isEnabled()
|
||||||
|
|
||||||
|
if load_children:
|
||||||
|
item.removeRow(0)
|
||||||
|
media_list = get_results_media_item(index, self.proxy_tr_results, self.model_tr_results)
|
||||||
|
|
||||||
|
self.s_spinner_start.emit(self.tr_results)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.list_items_show_result(media_list=media_list, parent=item)
|
||||||
|
finally:
|
||||||
|
self.s_spinner_stop.emit()
|
||||||
|
|
||||||
|
def list_items_show_result(
|
||||||
|
self,
|
||||||
|
media_list: Album | Playlist | Mix | Artist | None = None,
|
||||||
|
point: QtCore.QPoint | None = None,
|
||||||
|
parent: QtGui.QStandardItem | None = None,
|
||||||
|
favorite_function: Callable | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Populate the results tree with the items of a media list."""
|
||||||
|
if point:
|
||||||
|
item = self.tr_lists_user.itemAt(point)
|
||||||
|
media_list = get_user_list_media_item(item)
|
||||||
|
|
||||||
|
if favorite_function or isinstance(media_list, str):
|
||||||
|
if isinstance(media_list, str):
|
||||||
|
favorite_function = favorite_function_factory(self.tidal, media_list)
|
||||||
|
media_items: list[Track | Video | Album] = favorite_function()
|
||||||
|
else:
|
||||||
|
media_items: list[Track | Video | Album] = items_results_all(self.tidal.session, media_list)
|
||||||
|
|
||||||
|
result: list[ResultItem] = self.search_manager.search_result_to_model(media_items)
|
||||||
|
self.populate_tree_results(result, parent=parent)
|
||||||
|
|
||||||
|
def tidal_user_lists(self) -> None:
|
||||||
|
"""Fetch and emit user playlists, mixes, and favorites from Tidal."""
|
||||||
|
self.s_spinner_start.emit(self.tr_lists_user)
|
||||||
|
self.s_pb_reload_status.emit(False)
|
||||||
|
|
||||||
|
user_all: dict[str, list] = user_media_lists(self.tidal.session)
|
||||||
|
self.s_populate_tree_lists.emit(user_all)
|
||||||
|
|
||||||
|
def on_populate_tree_lists(self, user_lists: dict[str, list]) -> None:
|
||||||
|
"""Populate the user lists tree with playlists, mixes, and favorites."""
|
||||||
|
twi_playlists: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems(
|
||||||
|
TidalLists.Playlists, QtCore.Qt.MatchExactly, 0
|
||||||
|
)[0]
|
||||||
|
twi_mixes: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems(
|
||||||
|
TidalLists.Mixes, QtCore.Qt.MatchExactly, 0
|
||||||
|
)[0]
|
||||||
|
twi_favorites: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems(
|
||||||
|
TidalLists.Favorites, QtCore.Qt.MatchExactly, 0
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
for twi in [twi_playlists, twi_mixes]:
|
||||||
|
for i in reversed(range(twi.childCount())):
|
||||||
|
twi.removeChild(twi.child(i))
|
||||||
|
|
||||||
|
for item in user_lists.get("playlists", []):
|
||||||
|
if isinstance(item, Folder):
|
||||||
|
twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
|
||||||
|
name: str = f"📁 {item.name}"
|
||||||
|
info: str = f"({item.total_number_of_items} items)" if item.total_number_of_items else ""
|
||||||
|
twi_child.setText(0, name)
|
||||||
|
set_user_list_media(twi_child, item)
|
||||||
|
twi_child.setText(2, info)
|
||||||
|
|
||||||
|
dummy_child = QtWidgets.QTreeWidgetItem(twi_child)
|
||||||
|
dummy_child.setDisabled(True)
|
||||||
|
elif isinstance(item, UserPlaylist | Playlist):
|
||||||
|
twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
|
||||||
|
name: str = item.name if getattr(item, "name", None) is not None else ""
|
||||||
|
description: str = f" {item.description}" if item.description else ""
|
||||||
|
info: str = f"({item.num_tracks + item.num_videos} Tracks){description}"
|
||||||
|
twi_child.setText(0, name)
|
||||||
|
set_user_list_media(twi_child, item)
|
||||||
|
twi_child.setText(2, info)
|
||||||
|
|
||||||
|
for item in user_lists.get("mixes", []):
|
||||||
|
if isinstance(item, Mix):
|
||||||
|
twi_child = QtWidgets.QTreeWidgetItem(twi_mixes)
|
||||||
|
name: str = item.title
|
||||||
|
info: str = item.sub_title
|
||||||
|
twi_child.setText(0, name)
|
||||||
|
set_user_list_media(twi_child, item)
|
||||||
|
twi_child.setText(2, info)
|
||||||
|
|
||||||
|
for i in reversed(range(twi_favorites.childCount())):
|
||||||
|
twi_favorites.removeChild(twi_favorites.child(i))
|
||||||
|
|
||||||
|
for key, favorite in FAVORITES.items():
|
||||||
|
twi_child = QtWidgets.QTreeWidgetItem(twi_favorites)
|
||||||
|
name: str = favorite["name"]
|
||||||
|
info: str = ""
|
||||||
|
|
||||||
|
twi_child.setText(0, name)
|
||||||
|
set_user_list_media(twi_child, key)
|
||||||
|
twi_child.setText(2, info)
|
||||||
|
|
||||||
|
self.s_spinner_stop.emit()
|
||||||
|
self.s_pb_reload_status.emit(True)
|
||||||
|
|
||||||
|
def on_track_hover_confirmed(self, media: Track | Video | Album | Mix | Playlist | Artist) -> None:
|
||||||
|
"""Handle confirmed hover event over a track (after debounce delay)."""
|
||||||
|
if not media:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.info_tab_widget.update_on_hover(media)
|
||||||
|
|
||||||
|
if self.cover_manager:
|
||||||
|
self.cover_manager.load_cover(media, use_cache_check=True)
|
||||||
|
|
||||||
|
def on_track_hover_left(self) -> None:
|
||||||
|
"""Handle hover leaving the track list."""
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self.info_tab_widget.revert_to_selection()
|
||||||
|
|
||||||
|
if self.info_tab_widget.current_media_selected and self.cover_manager:
|
||||||
|
self.cover_manager.load_cover(self.info_tab_widget.current_media_selected, use_cache_check=True)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""UI helpers mixin for MainWindow.
|
||||||
|
|
||||||
|
Handles UI helper functions like spinners, logs, and status bar messages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6 import QtGui, QtWidgets
|
||||||
|
|
||||||
|
from tidal_dl_ng.model.gui_data import StatusbarMessage
|
||||||
|
from tidal_dl_ng.ui.spinner import QtWaitingSpinner
|
||||||
|
|
||||||
|
|
||||||
|
class UIHelpersMixin:
|
||||||
|
"""Mixin containing UI helper methods."""
|
||||||
|
|
||||||
|
def on_spinner_start(self, parent: QtWidgets.QWidget) -> None:
|
||||||
|
"""Start a loading spinner on the given parent widget."""
|
||||||
|
if parent in self.spinners:
|
||||||
|
spinner = self.spinners[parent]
|
||||||
|
spinner.stop()
|
||||||
|
spinner.deleteLater()
|
||||||
|
del self.spinners[parent]
|
||||||
|
|
||||||
|
spinner = QtWaitingSpinner(parent, True, True)
|
||||||
|
spinner.setColor(QtGui.QColor(255, 255, 255))
|
||||||
|
spinner.start()
|
||||||
|
self.spinners[parent] = spinner
|
||||||
|
|
||||||
|
def on_spinner_stop(self) -> None:
|
||||||
|
"""Stop all active loading spinners."""
|
||||||
|
for spinner in list(self.spinners.values()):
|
||||||
|
spinner.stop()
|
||||||
|
spinner.deleteLater()
|
||||||
|
self.spinners.clear()
|
||||||
|
|
||||||
|
def on_statusbar_message(self, data: StatusbarMessage):
|
||||||
|
"""Show a message in the status bar."""
|
||||||
|
self.statusbar.showMessage(data.message, data.timeout)
|
||||||
|
|
||||||
|
def _log_output(self, text: str) -> None:
|
||||||
|
"""Redirect log output to the debug text area."""
|
||||||
|
cursor: QtGui.QTextCursor = self.te_debug.textCursor()
|
||||||
|
html = self.converter_ansi_html.convert(text)
|
||||||
|
|
||||||
|
cursor.movePosition(QtGui.QTextCursor.End)
|
||||||
|
cursor.insertHtml(html)
|
||||||
|
self.te_debug.setTextCursor(cursor)
|
||||||
|
self.te_debug.ensureCursorVisible()
|
||||||
|
|
||||||
|
def button_reload_status(self, status: bool) -> None:
|
||||||
|
"""Update the reload button's state and text."""
|
||||||
|
button_text: str = "Reloading..." if not status else "Reload"
|
||||||
|
self.pb_reload_user_lists.setEnabled(status)
|
||||||
|
self.pb_reload_user_lists.setText(button_text)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Updates mixin for MainWindow.
|
||||||
|
|
||||||
|
Handles application update checking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from tidal_dl_ng import update_available
|
||||||
|
from tidal_dl_ng.dialog import DialogVersion
|
||||||
|
from tidal_dl_ng.model.meta import ReleaseLatest
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatesMixin:
|
||||||
|
"""Mixin containing update checking methods."""
|
||||||
|
|
||||||
|
def on_update_check(self, on_startup: bool = True) -> None:
|
||||||
|
"""Check for application updates and emit update signals."""
|
||||||
|
is_available, info = update_available()
|
||||||
|
|
||||||
|
if (on_startup and is_available) or not on_startup:
|
||||||
|
self.s_update_show.emit(True, is_available, info)
|
||||||
|
|
||||||
|
def on_version(
|
||||||
|
self, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Show the version information dialog."""
|
||||||
|
DialogVersion(self, update_check, update_available, update_info)
|
||||||
Reference in New Issue
Block a user