diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2ed1832..3e77731 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,6 +21,7 @@ repos: rev: "v3.1.0" hooks: - 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 hooks: diff --git a/docs/FEATURE_DOCUMENTATION.md b/docs/dl_history_&_duplicate_prev.md similarity index 100% rename from docs/FEATURE_DOCUMENTATION.md rename to docs/dl_history_&_duplicate_prev.md diff --git a/docs/gui_refactoring.md b/docs/gui_refactoring.md new file mode 100644 index 0000000..76fac79 --- /dev/null +++ b/docs/gui_refactoring.md @@ -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) diff --git a/tidal_dl_ng/dialog.py b/tidal_dl_ng/dialog.py index 6a2a8b7..2c01626 100644 --- a/tidal_dl_ng/dialog.py +++ b/tidal_dl_ng/dialog.py @@ -206,12 +206,21 @@ class DialogPreferences(QtWidgets.QDialog): self.ui.lw_categories.addItem("Paths & Formats") self.ui.lw_categories.addItem("Delimiters") - # Set the first category as selected - self.ui.lw_categories.setCurrentRow(0) - - # Connect category selection to page switching + # Connect category selection to page switching before setting default 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): """Handle category change to switch pages.""" 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()) 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) diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py index f6c2774..33717b3 100644 --- a/tidal_dl_ng/gui.py +++ b/tidal_dl_ng/gui.py @@ -43,1793 +43,16 @@ # nuitka-project: --force-stdout-spec="{TEMP}/tidal-dl-ng.out.log" # nuitka-project: --company-name=exislow +"""Backward-compatible GUI entry point. -import contextlib -import math -import sys -import time -import urllib.parse -from collections.abc import Callable, Iterable, Sequence -from typing import Any +The legacy :mod:`tidal_dl_ng.gui` module now delegates to the modular +implementation located under :mod:`tidal_dl_ng.gui.*`. +""" -from requests.exceptions import HTTPError -from tidalapi.session import LinkLogin +from tidal_dl_ng.gui.activate import gui_activate +from tidal_dl_ng.gui.main_window import MainWindow -from tidal_dl_ng import __version__, update_available -from tidal_dl_ng.dialog import DialogLogin, DialogPreferences, DialogVersion -from tidal_dl_ng.dialog_history import DialogHistory -from tidal_dl_ng.helper.gui import ( - FilterHeader, - HumanProxyModel, - get_queue_download_media, - get_queue_download_quality_audio, - get_queue_download_quality_video, - get_results_media_item, - get_user_list_media_item, - set_queue_download_media, - set_user_list_media, -) -from tidal_dl_ng.helper.hover_manager import HoverManager -from tidal_dl_ng.helper.path import get_format_template, resource_path -from tidal_dl_ng.helper.tidal import ( - extract_contributor_names, - favorite_function_factory, - fetch_raw_track_and_album, - items_results_all, - name_builder_artist, - parse_track_and_album_extras, - user_media_lists, -) - -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) - - -from ansi2html import Ansi2HTMLConverter -from rich.progress import Progress -from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video -from tidalapi.artist import Artist -from tidalapi.playlist import Folder -from tidalapi.session import SearchTypes - -from tidal_dl_ng.cache import TrackExtrasCache -from tidal_dl_ng.config import HandlingApp, Settings, Tidal -from tidal_dl_ng.constants import FAVORITES, QualityVideo, QueueDownloadStatus, TidalLists -from tidal_dl_ng.download import Download -from tidal_dl_ng.gui_covers import CoverManager -from tidal_dl_ng.gui_playlist import GuiPlaylistManager -from tidal_dl_ng.gui_queue import GuiQueueManager -from tidal_dl_ng.gui_search import GuiSearchManager -from tidal_dl_ng.history import HistoryService -from tidal_dl_ng.logger import XStream, logger_gui -from tidal_dl_ng.model.gui_data import ProgressBars, QueueDownloadItem, ResultItem, StatusbarMessage -from tidal_dl_ng.model.meta import ReleaseLatest -from tidal_dl_ng.ui.info_tab_widget import InfoTabWidget -from tidal_dl_ng.ui.main import Ui_MainWindow -from tidal_dl_ng.ui.spinner import QtWaitingSpinner -from tidal_dl_ng.worker import Worker - - -# TODO: Make more use of Exceptions -class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): - """Main application window for TIDAL Downloader Next Generation. - - Handles GUI setup, user interactions, and download logic. - """ - - 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 - 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) # track_id, extras - s_invoke_callback: QtCore.Signal = QtCore.Signal(str, object) # track_id, extras - for InfoTabWidget callback - 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) - converter_ansi_html: Ansi2HTMLConverter - - 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) - # XStream.stderr().messageWritten.connect(self._log_output) - - self.settings = Settings() - 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] = {} # Store callbacks by track_id - - # 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) - 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) - - 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 _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_tidal(self, tidal: Tidal | None = None): - """Initialize Tidal session and handle login flow. - - Args: - tidal (Tidal, optional): Existing Tidal session. Defaults to None. - """ - 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: - # If user has pressed cancel. - sys.exit(1) - - if result: - self._init_dl() - self.thread_it(self.playlist_manager.tidal_user_lists) - - 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.""" - # Init `Download` object. - 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.pb_progress.setVisible() - 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 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. - - Args: - track_id: The track ID to fetch extras for. - callback: Function to call when async fetch completes. - - Returns: - Cached extras dict if available, None if fetching async. - """ - 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 - - # Store the callback for this track_id - 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 # Return None on errors - finally: - self._pending_extras_workers.pop(track_id, None) - # Emit signal for any listeners - self.s_track_extras_ready.emit(track_id, extras) - # Emit signal to invoke the callback (will be handled by _on_invoke_callback in main thread) - 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. - - This slot is connected to s_invoke_callback signal and runs in the main GUI thread, - allowing safe UI updates from the callback. - """ - 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) - - def on_progress_reset(self): - """Reset progress bars to zero.""" - self.pb_list.setValue(0) - self.pb_item.setValue(0) - - def on_statusbar_message(self, data: StatusbarMessage): - """Show a message in the status bar. - - Args: - data (StatusbarMessage): Message and timeout. - """ - self.statusbar.showMessage(data.message, data.timeout) - - def _log_output(self, text: str) -> None: - """Redirect log output to the debug text area. - - Args: - text (str): Log message. - """ - 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 _populate_quality(self, ui_target: QtWidgets.QComboBox, options: Iterable[Any]) -> None: - """Populate a combo box with quality options. - - Args: - ui_target (QComboBox): Target combo box. - options (Iterable): Enum of 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. - - Args: - ui_target (QComboBox): Target combo box. - options (Iterable): Enum of search types. - """ - for item in options: - if item: - ui_target.addItem(item.__name__, item) - - self.cb_search_type.setCurrentIndex(2) - - 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 _init_tree_results(self, tree: QtWidgets.QTreeView, model: QtGui.QStandardItemModel) -> None: - """Initialize the results tree view and its model. - - Args: - tree (QTreeView): The tree view widget. - model (QStandardItemModel): The model for the tree. - """ - 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) - ## Styling - tree.sortByColumn(0, QtCore.Qt.SortOrder.AscendingOrder) - tree.setColumnHidden(1, True) - normal_width = max(150, (self.width() * 0.13)) # 12% for normal fields - narrow_width = max(90, (self.width() * 0.06)) # 6% for shorter fields - skinny_width = max(60, (self.width() * 0.03)) # 3% for very short fields - tree.setColumnWidth(2, normal_width) # artist - tree.setColumnWidth(3, normal_width) # title - tree.setColumnWidth(4, normal_width) # album - tree.setColumnWidth(5, skinny_width) # duration - tree.setColumnWidth(6, narrow_width) # quality - tree.setColumnWidth(7, narrow_width) # date - tree.setColumnWidth(8, skinny_width) # downloaded? - header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) - # Connect the contextmenu - tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - tree.customContextMenuRequested.connect(self.menu_context_tree_results) - - # Initialize hover manager for track preview - self.hover_manager = HoverManager( - tree_view=tree, - proxy_model=self.proxy_tr_results, - source_model=model, - debounce_delay_ms=50, - parent=self, - ) - # Connect hover signals - 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. - - Args: - model (QStandardItemModel): The model to initialize. - """ - 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. - - Args: - tree (QTableWidget): The 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 tidal_user_lists(self) -> None: - """Fetch and emit user playlists, mixes, and favorites from Tidal.""" - # Start loading spinner - 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. - - Args: - user_lists (dict[str, list]): Dictionary with 'playlists' (Folder/Playlist) and 'mixes' lists. - """ - 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] - - # Remove all children if present - for twi in [twi_playlists, twi_mixes]: - for i in reversed(range(twi.childCount())): - twi.removeChild(twi.child(i)) - - # Populate playlists (including folders) - 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) - - # Add disabled dummy child to show expansion arrow - 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) - - # Populate mixes - 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) - - # Remove all children from favorites to avoid duplication - for i in reversed(range(twi_favorites.childCount())): - twi_favorites.removeChild(twi_favorites.child(i)) - - # Populate static favorites - 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) - - # Stop load spinner - self.s_spinner_stop.emit() - self.s_pb_reload_status.emit(True) - - def _init_tree_lists(self, tree: QtWidgets.QTreeWidget) -> None: - """Initialize the user lists tree widget. - - Args: - tree (QTreeWidget): The tree widget. - """ - # Adjust Tree. - tree.setColumnWidth(0, 200) - tree.setColumnHidden(1, True) - tree.setColumnWidth(2, 300) - tree.expandAll() - - # Connect the contextmenu - 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.""" - # Create Tools menu if it doesn't exist - menubar = self.menuBar() - tools_menu = None - - # Find or create Tools menu - for action in menubar.actions(): - if action.text() == "Tools": - tools_menu = action.menu() - break - - if not tools_menu: - tools_menu = menubar.addMenu("Tools") - - # Create View History action - 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) - - # Add separator - tools_menu.addSeparator() - - # Add duplicate prevention toggle - 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 on_update_check(self, on_startup: bool = True) -> None: - """Check for application updates and emit update signals. - - Args: - on_startup (bool, optional): Whether this is called on startup. Defaults to True. - """ - 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 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 on_spinner_start(self, parent: QtWidgets.QWidget) -> None: - """Start a loading spinner on the given parent widget. - - Args: - parent (QWidget): The parent widget. - """ - # Stop any existing spinner for this parent - if parent in self.spinners: - spinner = self.spinners[parent] - - spinner.stop() - spinner.deleteLater() - del self.spinners[parent] - - # Create and start a new spinner for this 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.""" - # Stop all spinners - for spinner in list(self.spinners.values()): - spinner.stop() - spinner.deleteLater() - - self.spinners.clear() - - def menu_context_tree_results(self, point: QtCore.QPoint) -> None: - """Show context menu for results tree. - - Args: - point (QPoint): The point where the menu is requested. - """ - # Infos about the node selected. - index = self.tr_results.indexAt(point) - - # Do not open menu if something went wrong or a parent node is clicked. - if not index.isValid(): - return - - # Get the media item at this point - media = get_results_media_item(index, self.proxy_tr_results, self.model_tr_results) - - # We build the menu. - menu = QtWidgets.QMenu() - - # Add "Download Full Album" option if it's a track or video with an album - 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)) - - # Add mark/unmark as downloaded for Tracks - 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. - - Args: - point (QPoint): The point where the menu is requested. - """ - # Get the item at this point - item = self.tr_queue_download.itemAt(point) - - if not item: - return - - # Build the menu - menu = QtWidgets.QMenu() - - # Show remove option for waiting items - 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. - - Args: - item (QTreeWidgetItem): The item to remove. - """ - 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_mark_track_as_downloaded(self, track: Track, index: QtCore.QModelIndex) -> None: - """Mark a track as downloaded in history. - - Args: - track (Track): The track to mark. - index (QModelIndex): The model index of the track. - """ - track_id = str(track.id) - - # Determine source information (manual for now) - source_type = "manual" - source_id = None - source_name = None - - # Try to get album information if available - 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 - ) - - # Update the UI - refresh the downloaded column - 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. - - Args: - track_id (str): The track ID to remove. - index (QModelIndex): The model index of the track. - """ - success = self.history_service.remove_track_from_history(track_id) - - if success: - # Update the UI - refresh the downloaded column - 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. - - Args: - index (QModelIndex): The model index of the item. - is_downloaded (bool): Whether the track is downloaded. - """ - # Get the source index (column 0) - source_index = self.proxy_tr_results.mapToSource(index) - - # Get the item from the model - item = self.model_tr_results.itemFromIndex(source_index) - if not item: - return - - # Get the parent row (the row contains all columns) - row = item.row() - parent = item.parent() - - # Column 8 is "Downloaded?" - 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 thread_download_list_media(self, point: QtCore.QPoint) -> None: - """Start download of a list media item in a thread. - - Args: - point (QPoint): The point in the tree. - """ - self.thread_it(self.playlist_manager.on_download_list_media, point) - - def on_download_all_albums_from_playlist(self, point: QtCore.QPoint) -> None: - """Download all unique albums from tracks in a playlist. - - Args: - point (QPoint): The point in the tree where the playlist was right-clicked. - """ - try: - # Get and validate the playlist - 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 - - # Get all items from the playlist - logger_gui.info(f"Fetching all tracks from: {media_list.name}") - media_items = items_results_all(self.tidal.session, media_list) - - # Extract unique album IDs from tracks - 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...") - - # Load albums 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 - - # Prepare and queue albums - self._queue_loaded_albums(albums_dict) - - # Show confirmation - 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. - - Args: - media_items (list): List of media items (tracks/videos) from a playlist. - - Returns: - dict[int, Album]: Dictionary mapping album IDs to album stub objects. - """ - 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: - # Access album.id carefully as it might trigger API calls - 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. - - Args: - album_ids (dict[int, Album]): Dictionary of album IDs to album stubs. - - Returns: - dict[int, Album]: Dictionary of successfully loaded full album objects. - """ - 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: - # Add delay every N albums to avoid rate limiting - if idx > 1 and (idx - 1) % batch_size == 0: - logger_gui.info( - f"ðY>' RATE LIMITING: Processed {idx - 1} albums, pausing for {delay_sec} seconds..." - ) - time.sleep(delay_sec) - - # Check session validity before making API calls - if not self.tidal.session.check_login(): - logger_gui.error("Session expired. Please restart the application and login again.") - return albums_dict - - # Reload full album object - 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. - - Args: - error (Exception): The exception that was raised. - album_id (int): The ID of the album that failed to load. - - Returns: - bool: True if processing should continue, False if it should stop. - """ - # Check for OAuth/authentication errors using Tidal class method - 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. - - Args: - albums_dict (dict[int, Album]): Dictionary of successfully loaded albums. - """ - 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}") - - # Add all items to queue - 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_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. - - Args: - tree_target (QTreeWidget | QTreeView): The tree widget. - point (QPoint, optional): The point in the tree. Defaults to None. - """ - if isinstance(tree_target, QtWidgets.QTreeWidget): - - item: QtWidgets.QTreeWidgetItem = tree_target.itemAt(point) - media: Album | Artist | Mix | Playlist = get_user_list_media_item(item) - else: - index: QtCore.QModelIndex = tree_target.indexAt(point) - media: Track | Video | Album | Artist | Mix | Playlist = 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 populate_tree_results(self, results: list[ResultItem], parent: QtGui.QStandardItem | None = None) -> None: - """Populate the results tree with ResultItem objects. - - Args: - results (list[ResultItem]): The results to display. - parent (QStandardItem, optional): Parent item for nested results. Defaults to None. - """ - if not parent: - self.model_tr_results.removeRows(0, self.model_tr_results.rowCount()) - - # Count how many digits the list length has, - 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. - - Args: - item (ResultItem): The result item. - index_count_digits (int): Number of digits for index formatting. - - Returns: - Sequence[QStandardItem]: The row of items. - """ - duration: str = "" - - # TODO: Duration needs to be calculated later to properly fill with zeros. - if item.duration_sec > -1: - # Format seconds to mm:ss. - m, s = divmod(item.duration_sec, 60) - duration: str = f"{m:02d}:{s:02d}" - - # Since sorting happens only by string, we need to pad the index and add 1 (to avoid start at 0) - index: str = str(item.position + 1).zfill(index_count_digits) - - # Populate child - child_index: QtGui.QStandardItem = QtGui.QStandardItem(index) - # TODO: Move to own method - child_obj: QtGui.QStandardItem = QtGui.QStandardItem() - - child_obj.setData(item.obj, QtCore.Qt.ItemDataRole.UserRole) - # set_results_media(child, item.obj) - - 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 - ) - - # Check download history - 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): - # Add a disabled dummy child, so expansion arrow will appear. This Child will be replaced on expansion. - 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. - - Args: - item_child (Sequence[QStandardItem]): The row to add. - """ - self.model_tr_results.appendRow(item_child) - - 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() - - 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_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) - - # Map the search type string to the corresponding class - search_type_map = { - "artist": Artist, - "album": Album, - "track": Track, - "video": Video, - "playlist": Playlist, - } - # Default to the current selection if the type is unknown - search_category = search_type_map.get(search_type.lower(), self.cb_search_type.currentData()) - - # Find the index of the category in the combobox and set it - 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) - # Map the search type to the URL path component - search_path_map = { - "artist": "artists", - "album": "albums", - "track": "tracks", - "video": "videos", - "playlist": "playlists", - } - # Use a generic search if the type is not in the map - 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) - - def on_logout(self) -> None: - """Log out from TIDAL and close the application.""" - result: bool = self.tidal.logout() - - if result: - sys.exit(0) - - def on_progress_list(self, value: float) -> None: - """Update the progress of the list progress bar. - - Args: - value (float): The progress value as a percentage. - """ - self.pb_list.setValue(int(math.ceil(value))) - - def on_progress_item(self, value: float) -> None: - """Update the progress of the item progress bar. - - Args: - value (float): The progress value as a percentage. - """ - 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. - - Args: - value (str): The item name. - """ - self.pb_item.setFormat(f"%p% {value}") - - def on_progress_list_name(self, value: str) -> None: - """Set the format of the list progress bar. - - Args: - value (str): The list name. - """ - self.pb_list.setFormat(f"%p% {value}") - - def on_quality_set_audio(self, index: int) -> None: - """Set the audio quality for downloads. - - Args: - index: The index of the selected quality in the combo box. - """ - 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. - - Args: - index: The index of the selected quality in the combo box. - """ - self.settings.data.quality_video = QualityVideo(self.cb_quality_video.itemData(index)) - self.settings.save() - - if self.tidal: - self.tidal.settings_apply() - - def on_result_item_clicked(self, index: QtCore.QModelIndex) -> None: - """Handle the event when a result item is clicked. - - Args: - index (QtCore.QModelIndex): The index of the clicked item. - """ - media: Track | Video | Album | Artist = get_results_media_item( - index, self.proxy_tr_results, self.model_tr_results - ) - - # Update info tab widget with selected media (persists selection) - self.info_tab_widget.update_on_selection(media) - - # Load cover asynchronously to avoid blocking the GUI - self.thread_it(self.cover_manager.load_cover, media) - - 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. - - Args: - media_list (Album | Playlist | Mix | Artist | None, optional): The media list to show. Defaults to None. - point (QPoint | None, optional): The point in the tree. Defaults to None. - parent (QStandardItem | None, optional): Parent item for nested results. Defaults to None. - favorite_function (Callable | None, optional): Function to fetch favorite items. Defaults to None. - """ - if point: - item = self.tr_lists_user.itemAt(point) - media_list = get_user_list_media_item(item) - - # Get all results - 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 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. - """ - # Any other args, kwargs are passed to the run function - worker = Worker(fn, *args, **kwargs) - - # Execute - self.threadpool.start(worker) - - # TODO: Must happen in main thread. Do not thread this. - def on_download_results(self) -> None: - """Download the selected results in the results tree.""" - items: [HumanProxyModel | None] = self.tr_results.selectionModel().selectedRows() - - if len(items) == 0: - logger_gui.error("Please select a row first.") - else: - for item in items: - media: Track | Album | Playlist | Video | Artist = 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. - - Args: - queue_dl_item (QueueDownloadItem): The item to add to the queue. - """ - # Populate child - 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: list[QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( - QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0 - ) - - if len(items) > 0: - result: QueueDownloadStatus - item: QtWidgets.QTreeWidgetItem = items[0] - media: Track | Album | Playlist | Video | Mix | Artist = 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'. - - Args: - item (QtWidgets.QTreeWidgetItem): The item to update. - """ - 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'. - - Args: - item (QtWidgets.QTreeWidgetItem): The item to update. - """ - 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'. - - Args: - item (QtWidgets.QTreeWidgetItem): The item to update. - """ - 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'. - - Args: - item (QtWidgets.QTreeWidgetItem): The item to update. - """ - 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. - - Args: - item (QtWidgets.QTreeWidgetItem): The item to update. - status (str): The status text. - """ - 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. - - Args: - media (Track | Album | Playlist | Video | Mix | Artist): The media item(s) to download. - quality_audio (Quality | None, optional): Desired audio quality. Defaults to None. - quality_video (QualityVideo | None, optional): Desired video quality. Defaults to None. - - Returns: - QueueDownloadStatus: The status of the download operation. - """ - result: QueueDownloadStatus - items_media: [Track | Album | Playlist | Video | Mix | Artist] - - if isinstance(media, Artist): - items_media: [Album] = items_results_all(self.tidal.session, media) - else: - items_media = [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. - - Args: - media (Track | Album | Playlist | Video | Mix | Artist): The media item to download. - dl (Download): The Download object to use. - delay_track (bool, optional): Whether to apply download delay. Defaults to False. - quality_audio (Quality | None, optional): Desired audio quality. Defaults to None. - quality_video (QualityVideo | None, optional): Desired video quality. Defaults to None. - - Returns: - QueueDownloadStatus: The status of the download operation. - """ - 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): - # For individual tracks, try to get album info - 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, - ) - - # Dummy values - 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 - - def on_version( - self, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest | None = None - ) -> None: - """Show the version information dialog. - - Args: - update_check (bool, optional): Whether to check for updates. Defaults to False. - update_available (bool, optional): Whether an update is available. Defaults to False. - update_info (ReleaseLatest | None, optional): Information about the latest release. Defaults to None. - """ - DialogVersion(self, update_check, update_available, update_info) - - def on_preferences(self) -> None: - """Open the preferences dialog.""" - DialogPreferences(settings=self.settings, settings_save=self.s_settings_save, parent=self) - - 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. - - Args: - enabled (bool): Whether duplicate prevention is enabled. - """ - 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_tr_results_expanded(self, index: QtCore.QModelIndex) -> None: - """Handle the event when a result item group is expanded. - - Args: - index (QtCore.QModelIndex): The index of the expanded item. - """ - self.thread_it(self.tr_results_expanded, index) - - 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 - - # Update info tab widget with hover data - self.info_tab_widget.update_on_hover(media) - - # Load cover using CoverManager (with smart caching) - 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.""" - # Revert to the currently selected media - with contextlib.suppress(Exception): - self.info_tab_widget.revert_to_selection() - - # Reload the cover for the selected media if it exists - 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) - - def tr_results_expanded(self, index: QtCore.QModelIndex) -> None: - """Load and display the children of an expanded result item. - - Args: - index (QtCore.QModelIndex): The index of the expanded item. - """ - # If the child is a dummy the list_item has not been expanded before - 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: list[Mix | Album | Playlist | Artist] = get_results_media_item( - index, self.proxy_tr_results, self.model_tr_results - ) - - # Show spinner while loading children - 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 button_reload_status(self, status: bool) -> None: - """Update the reload button's state and text. - - Args: - status (bool): The new status. - """ - button_text: str = "Reloading..." - - if status: - button_text = "Reload" - - self.pb_reload_user_lists.setEnabled(status) - self.pb_reload_user_lists.setText(button_text) - - 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. - - Args: - point (QPoint): The point in the tree where the user clicked. - """ - 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. - - This method retrieves the album from a track selected in the results tree and attempts to add it to the download queue. If the album cannot be retrieved or an error occurs, a warning or error is logged. - - Args: - point (QtCore.QPoint): The point in the results tree where the user clicked. - """ - index: QtCore.QModelIndex = self.tr_results.indexAt(point) - media_track: Track = get_results_media_item(index, self.proxy_tr_results, self.model_tr_results) - - # Ensure we have a track and an album object with an ID - if isinstance(media_track, Track) and media_track.album and media_track.album.id: - try: - # Use the album ID from the track to fetch the FULL album object from TIDAL - full_album_object = self.tidal.session.album(media_track.album.id) - - # Convert the full album object into a queue item - queue_dl_item: QueueDownloadItem | None = self.search_manager.media_to_queue_download_model( - full_album_object - ) - - if queue_dl_item: - # Add the item to the download queue - 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.") - - -# TODO: Comment with Google Docstrings. -def gui_activate(tidal: Tidal | None = None): - # 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() - 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()) +__all__ = ["MainWindow", "gui_activate"] if __name__ == "__main__": diff --git a/tidal_dl_ng/gui/__init__.py b/tidal_dl_ng/gui/__init__.py new file mode 100644 index 0000000..fe564e5 --- /dev/null +++ b/tidal_dl_ng/gui/__init__.py @@ -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", +] diff --git a/tidal_dl_ng/gui/activate.py b/tidal_dl_ng/gui/activate.py new file mode 100644 index 0000000..4de3160 --- /dev/null +++ b/tidal_dl_ng/gui/activate.py @@ -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() diff --git a/tidal_dl_ng/gui/context_menus.py b/tidal_dl_ng/gui/context_menus.py new file mode 100644 index 0000000..825a3ca --- /dev/null +++ b/tidal_dl_ng/gui/context_menus.py @@ -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) diff --git a/tidal_dl_ng/gui_covers.py b/tidal_dl_ng/gui/covers.py similarity index 100% rename from tidal_dl_ng/gui_covers.py rename to tidal_dl_ng/gui/covers.py diff --git a/tidal_dl_ng/gui/downloads.py b/tidal_dl_ng/gui/downloads.py new file mode 100644 index 0000000..9585113 --- /dev/null +++ b/tidal_dl_ng/gui/downloads.py @@ -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 diff --git a/tidal_dl_ng/gui/history.py b/tidal_dl_ng/gui/history.py new file mode 100644 index 0000000..28b8f8d --- /dev/null +++ b/tidal_dl_ng/gui/history.py @@ -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() diff --git a/tidal_dl_ng/gui/initialization.py b/tidal_dl_ng/gui/initialization.py new file mode 100644 index 0000000..7c721a3 --- /dev/null +++ b/tidal_dl_ng/gui/initialization.py @@ -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) diff --git a/tidal_dl_ng/gui/main_window.py b/tidal_dl_ng/gui/main_window.py new file mode 100644 index 0000000..33806dc --- /dev/null +++ b/tidal_dl_ng/gui/main_window.py @@ -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) diff --git a/tidal_dl_ng/gui_playlist.py b/tidal_dl_ng/gui/playlist.py similarity index 99% rename from tidal_dl_ng/gui_playlist.py rename to tidal_dl_ng/gui/playlist.py index a787b99..0bb6c88 100644 --- a/tidal_dl_ng/gui_playlist.py +++ b/tidal_dl_ng/gui/playlist.py @@ -1,4 +1,4 @@ -# tidal_dl_ng/gui_playlist.py +# tidal_dl_ng/playlist.py from typing import TYPE_CHECKING, Any diff --git a/tidal_dl_ng/gui/progress.py b/tidal_dl_ng/gui/progress.py new file mode 100644 index 0000000..d46ea3d --- /dev/null +++ b/tidal_dl_ng/gui/progress.py @@ -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}") diff --git a/tidal_dl_ng/gui_queue.py b/tidal_dl_ng/gui/queue.py similarity index 99% rename from tidal_dl_ng/gui_queue.py rename to tidal_dl_ng/gui/queue.py index d716a08..ec425bc 100644 --- a/tidal_dl_ng/gui_queue.py +++ b/tidal_dl_ng/gui/queue.py @@ -1,4 +1,4 @@ -# tidal_dl_ng/gui_queue.py +# tidal_dl_ng/queue.py import time from typing import TYPE_CHECKING diff --git a/tidal_dl_ng/gui_search.py b/tidal_dl_ng/gui/search.py similarity index 99% rename from tidal_dl_ng/gui_search.py rename to tidal_dl_ng/gui/search.py index e9a1db5..1e871cb 100644 --- a/tidal_dl_ng/gui_search.py +++ b/tidal_dl_ng/gui/search.py @@ -1,4 +1,4 @@ -# tidal_dl_ng/gui_search.py +# tidal_dl_ng/search.py from typing import TYPE_CHECKING, Any diff --git a/tidal_dl_ng/gui/signals.py b/tidal_dl_ng/gui/signals.py new file mode 100644 index 0000000..4a8e43a --- /dev/null +++ b/tidal_dl_ng/gui/signals.py @@ -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() diff --git a/tidal_dl_ng/gui/tidal_session.py b/tidal_dl_ng/gui/tidal_session.py new file mode 100644 index 0000000..349414f --- /dev/null +++ b/tidal_dl_ng/gui/tidal_session.py @@ -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) diff --git a/tidal_dl_ng/gui/track_extras.py b/tidal_dl_ng/gui/track_extras.py new file mode 100644 index 0000000..8f1784a --- /dev/null +++ b/tidal_dl_ng/gui/track_extras.py @@ -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) diff --git a/tidal_dl_ng/gui/trees_results.py b/tidal_dl_ng/gui/trees_results.py new file mode 100644 index 0000000..0658078 --- /dev/null +++ b/tidal_dl_ng/gui/trees_results.py @@ -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) diff --git a/tidal_dl_ng/gui/ui_helpers.py b/tidal_dl_ng/gui/ui_helpers.py new file mode 100644 index 0000000..2011893 --- /dev/null +++ b/tidal_dl_ng/gui/ui_helpers.py @@ -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) diff --git a/tidal_dl_ng/gui/updates.py b/tidal_dl_ng/gui/updates.py new file mode 100644 index 0000000..099210c --- /dev/null +++ b/tidal_dl_ng/gui/updates.py @@ -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)