2025-01-04 11:52:34 +01:00
# Compilation mode, support OS-specific options
2025-01-05 18:38:12 +01:00
# nuitka-project-if: {OS} in ("Darwin"):
# nuitka-project: --macos-create-app-bundle
# nuitka-project: --macos-app-icon=tidal_dl_ng/ui/icon.icns
# nuitka-project: --macos-signed-app-name=com.exislow.TidalDlNg
# nuitka-project: --macos-app-mode=gui
# nuitka-project-if: {OS} in ("Linux", "FreeBSD"):
2025-08-12 16:02:41 +02:00
# nuitka-project: --linux-icon=tidal_dl_ng/ui/icon512.png
2025-01-05 18:38:12 +01:00
# nuitka-project-if: {OS} in ("Windows"):
# nuitka-project: --windows-icon-from-ico=tidal_dl_ng/ui/icon.ico
# nuitka-project: --file-description="TIDAL media downloader next generation."
# Debugging options, controlled via environment variable at compile time.
# nuitka-project-if: {OS} == "Windows" and os.getenv("DEBUG_COMPILATION", "no") == "yes":
# nuitka-project: --windows-console-mode=hide
# nuitka-project-else:
# nuitka-project: --windows-console-mode=disable
# nuitka-project-if: os.getenv("DEBUG_COMPILATION", "no") == "yes":
# nuitka-project: --debug
# nuitka-project: --debugger
# nuitka-project: --experimental=allow-c-warnings
# nuitka-project: --no-debug-immortal-assumptions
# nuitka-project: --run
# nuitka-project-else:
# nuitka-project: --assume-yes-for-downloads
# nuitka-project-if: os.getenv("DEPLOYMENT", "no") == "yes":
# nuitka-project: --deployment
2025-01-04 11:52:34 +01:00
# The PySide6 plugin covers qt-plugins
2025-01-04 12:31:09 +01:00
# nuitka-project: --standalone
2025-01-05 18:38:12 +01:00
# nuitka-project: --output-dir=dist
2025-01-04 11:52:34 +01:00
# nuitka-project: --enable-plugin=pyside6
# nuitka-project: --include-qt-plugins=qml
2025-01-05 18:38:12 +01:00
# nuitka-project: --noinclude-dlls=libQt6Charts*
# nuitka-project: --noinclude-dlls=libQt6Quick3D*
# nuitka-project: --noinclude-dlls=libQt6Sensors*
# nuitka-project: --noinclude-dlls=libQt6Test*
# nuitka-project: --noinclude-dlls=libQt6WebEngine*
2025-10-22 22:50:25 +02:00
# nuitka-project: --include-data-files={MAIN_DIRECTORY}/ui/icon*=tidal_dl_ng/ui/
2025-01-04 11:52:34 +01:00
# nuitka-project: --include-data-files={MAIN_DIRECTORY}/ui/default_album_image.png=tidal_dl_ng/ui/default_album_image.png
2025-01-05 18:38:12 +01:00
# nuitka-project: --include-data-files=./pyproject.toml=pyproject.toml
# nuitka-project: --force-stderr-spec="{TEMP}/tidal-dl-ng.err.log"
# nuitka-project: --force-stdout-spec="{TEMP}/tidal-dl-ng.out.log"
2025-01-04 11:52:34 +01:00
# nuitka-project: --company-name=exislow
2025-11-29 12:10:06 +01:00
import contextlib
2023-12-19 23:58:57 +01:00
import math
import sys
2024-04-01 13:09:42 +02:00
import time
2025-07-01 15:52:30 +02:00
from collections.abc import Callable , Iterable , Sequence
from typing import Any
2023-12-19 23:58:57 +01:00
2024-02-01 07:38:01 +01:00
from requests.exceptions import HTTPError
2024-11-13 13:26:46 +01:00
from tidalapi.session import LinkLogin
2024-02-01 07:38:01 +01:00
2024-03-27 07:17:21 +01:00
from tidal_dl_ng import __version__ , update_available
2024-02-04 12:19:40 +01:00
from tidal_dl_ng.dialog import DialogLogin , DialogPreferences , DialogVersion
2024-03-29 12:34:55 +01:00
from tidal_dl_ng.helper.gui import (
2024-05-14 07:31:57 +02:00
FilterHeader ,
HumanProxyModel ,
2024-03-29 12:34:55 +01:00
get_results_media_item ,
get_user_list_media_item ,
)
2025-11-29 12:10:06 +01:00
from tidal_dl_ng.helper.hover_manager import HoverManager
2024-02-26 07:27:53 +01:00
from tidal_dl_ng.helper.tidal import (
2025-11-29 12:10:06 +01:00
extract_contributor_names ,
2024-05-24 10:01:01 +02:00
favorite_function_factory ,
2025-11-29 12:10:06 +01:00
fetch_raw_track_and_album ,
2024-02-26 07:27:53 +01:00
items_results_all ,
2024-03-14 06:30:47 +01:00
name_builder_artist ,
2025-11-29 12:10:06 +01:00
parse_track_and_album_extras ,
2024-02-26 07:27:53 +01:00
)
2024-01-14 23:09:43 +01:00
2023-12-19 23:58:57 +01:00
try :
import qdarktheme
from PySide6 import QtCore , QtGui , QtWidgets
except ImportError as e :
print ( e )
2025-01-04 15:43:07 +01:00
print ( "Qt dependencies missing. Cannot start GUI. Please read the 'README.md' carefully." )
2023-12-19 23:58:57 +01:00
sys . exit ( 1 )
2025-10-21 07:58:00 +02:00
from ansi2html import Ansi2HTMLConverter
2023-12-19 23:58:57 +01:00
from rich.progress import Progress
2025-11-29 12:10:06 +01:00
from tidalapi import Album , Mix , Playlist , Quality , Track , Video
2024-03-22 17:31:33 +01:00
from tidalapi.artist import Artist
2023-12-19 23:58:57 +01:00
from tidalapi.session import SearchTypes
2024-01-12 10:21:34 +01:00
2025-11-29 12:10:06 +01:00
from tidal_dl_ng.cache import TrackExtrasCache
2025-03-23 21:28:58 +01:00
from tidal_dl_ng.config import HandlingApp , Settings , Tidal
2025-11-29 12:10:06 +01:00
from tidal_dl_ng.constants import QualityVideo
2023-12-19 23:58:57 +01:00
from tidal_dl_ng.download import Download
2025-11-29 12:10:06 +01:00
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
2024-01-12 10:21:34 +01:00
from tidal_dl_ng.logger import XStream , logger_gui
2025-11-29 12:10:06 +01:00
from tidal_dl_ng.model.gui_data import ProgressBars , ResultItem , StatusbarMessage
2024-03-28 07:44:47 +01:00
from tidal_dl_ng.model.meta import ReleaseLatest
2025-11-29 12:10:06 +01:00
from tidal_dl_ng.ui.info_tab_widget import InfoTabWidget
2023-12-19 23:58:57 +01:00
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 ):
2025-07-01 14:30:10 +02:00
"""Main application window for TIDAL Downloader Next Generation.
Handles GUI setup, user interactions, and download logic.
"""
2024-01-23 17:22:10 +01:00
settings : Settings
tidal : Tidal
dl : Download
threadpool : QtCore . QThreadPool
tray : QtWidgets . QSystemTrayIcon
2025-06-29 20:33:22 +02:00
spinners : dict
2024-03-29 13:10:22 +01:00
cover_url_current : str = ""
2025-01-03 14:37:42 +01:00
shutdown : bool = False
2024-05-14 07:31:57 +02:00
model_tr_results : QtGui . QStandardItemModel = QtGui . QStandardItemModel ()
proxy_tr_results : HumanProxyModel
2025-11-29 12:10:06 +01:00
info_tab_widget : InfoTabWidget
hover_manager : HoverManager
queue_manager : GuiQueueManager
playlist_manager : GuiPlaylistManager
search_manager : GuiSearchManager
2024-02-05 06:31:18 +01:00
s_spinner_start : QtCore . Signal = QtCore . Signal ( QtWidgets . QWidget )
s_spinner_stop : QtCore . Signal = QtCore . Signal ()
2025-11-29 12:10:06 +01:00
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
2024-01-23 17:22:10 +01:00
pb_item : QtWidgets . QProgressBar
2023-12-19 23:58:57 +01:00
s_item_advance : QtCore . Signal = QtCore . Signal ( float )
s_item_name : QtCore . Signal = QtCore . Signal ( str )
2024-01-19 07:25:41 +01:00
s_list_name : QtCore . Signal = QtCore . Signal ( str )
2024-01-23 17:22:10 +01:00
pb_list : QtWidgets . QProgressBar
2023-12-19 23:58:57 +01:00
s_list_advance : QtCore . Signal = QtCore . Signal ( float )
2024-01-15 21:55:51 +01:00
s_pb_reset : QtCore . Signal = QtCore . Signal ()
2025-11-12 15:05:20 +01:00
s_populate_tree_lists : QtCore . Signal = QtCore . Signal ( dict )
s_populate_folder_children : QtCore . Signal = QtCore . Signal ( object , list , list )
2024-01-19 07:25:41 +01:00
s_statusbar_message : QtCore . Signal = QtCore . Signal ( object )
s_tr_results_add_top_level_item : QtCore . Signal = QtCore . Signal ( object )
2024-02-04 12:50:19 +01:00
s_settings_save : QtCore . Signal = QtCore . Signal ()
2024-02-05 06:31:18 +01:00
s_pb_reload_status : QtCore . Signal = QtCore . Signal ( bool )
2024-05-03 20:31:27 +02:00
s_update_check : QtCore . Signal = QtCore . Signal ( bool )
2024-03-28 07:44:47 +01:00
s_update_show : QtCore . Signal = QtCore . Signal ( bool , bool , object )
2024-04-01 13:09:42 +02:00
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 )
2024-04-20 15:37:53 +02:00
s_queue_download_item_skipped : QtCore . Signal = QtCore . Signal ( object )
2025-10-21 07:58:00 +02:00
converter_ansi_html : Ansi2HTMLConverter
2023-12-19 23:58:57 +01:00
2025-07-01 15:52:30 +02:00
def __init__ ( self , tidal : Tidal | None = None ) -> None :
2025-07-01 14:30:10 +02:00
"""Initialize the main window and all components.
Args:
tidal (Tidal | None): Optional Tidal session object.
"""
2023-12-19 23:58:57 +01:00
super () . __init__ ()
self . setupUi ( self )
2024-04-01 19:02:00 +02:00
self . setWindowTitle ( "TIDAL Downloader Next Generation!" )
2023-12-19 23:58:57 +01:00
2025-11-29 12:10:06 +01:00
# 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 )
2023-12-19 23:58:57 +01:00
# Logging redirect.
XStream . stdout () . messageWritten . connect ( self . _log_output )
# XStream.stderr().messageWritten.connect(self._log_output)
2025-11-29 12:10:06 +01:00
# Core components
2024-04-01 13:09:42 +02:00
self . _init_threads ()
2025-06-29 20:33:22 +02:00
self . _init_gui ()
2025-11-29 12:10:06 +01:00
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 )
2024-05-14 07:31:57 +02:00
self . _init_tree_results_model ( self . model_tr_results )
self . _init_tree_results ( self . tr_results , self . model_tr_results )
2025-11-29 12:10:06 +01:00
self . playlist_manager . init_ui ()
self . queue_manager . init_ui ()
2023-12-19 23:58:57 +01:00
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_signals ()
2025-11-29 12:10:06 +01:00
# Connect signal for invoking track extras callbacks
self . s_invoke_callback . connect ( self . _on_invoke_callback )
2024-01-16 07:09:15 +01:00
self . init_tidal ( tidal )
2023-12-19 23:58:57 +01:00
logger_gui . debug ( "All setup." )
2025-11-29 12:10:06 +01:00
def closeEvent ( self , event : QtGui . QCloseEvent ) -> None :
"""Ensure background workers and hover hooks stop before exit."""
HandlingApp () . event_abort . set ()
if hasattr ( self , "hover_manager" ) and self . hover_manager :
with contextlib . suppress ( Exception ):
self . hover_manager . stop ()
self . threadpool . waitForDone ( 2000 )
super () . closeEvent ( event )
2025-07-01 15:52:30 +02:00
def _init_gui ( self ) -> None :
2025-07-01 14:30:10 +02:00
"""Initialize GUI-specific variables and state."""
2025-09-08 09:59:29 -04:00
self . setGeometry (
self . settings . data . window_x ,
self . settings . data . window_y ,
self . settings . data . window_w ,
self . settings . data . window_h ,
)
2025-07-01 15:52:30 +02:00
self . spinners : dict [ QtWidgets . QWidget , QtWaitingSpinner ] = {}
2025-10-21 07:58:00 +02:00
self . converter_ansi_html : Ansi2HTMLConverter = Ansi2HTMLConverter ()
2025-06-29 20:33:22 +02:00
2025-07-01 15:52:30 +02:00
def init_tidal ( self , tidal : Tidal | None = None ):
2025-07-01 14:30:10 +02:00
"""Initialize Tidal session and handle login flow.
Args:
tidal (Tidal, optional): Existing Tidal session. Defaults to None.
"""
2023-12-19 23:58:57 +01:00
result : bool = False
if tidal :
self . tidal = tidal
result = True
else :
self . tidal = Tidal ( self . settings )
2024-02-01 07:38:01 +01:00
result = self . tidal . login_token ()
2023-12-19 23:58:57 +01:00
2024-02-01 07:38:01 +01:00
if not result :
2024-11-12 19:15:33 +01:00
hint : str = "After you have finished the TIDAL login via web browser click the 'OK' button."
2024-02-01 07:38:01 +01:00
while not result :
2024-11-14 14:36:52 +01:00
link_login : LinkLogin = self . tidal . session . get_link_login ()
2025-07-01 15:52:30 +02:00
expires_in = int ( link_login . expires_in ) if hasattr ( link_login , "expires_in" ) else 0
2024-11-13 13:26:46 +01:00
d_login : DialogLogin = DialogLogin (
url_login = link_login . verification_uri_complete ,
hint = hint ,
2025-07-01 15:52:30 +02:00
expires_in = expires_in ,
2024-11-13 13:26:46 +01:00
parent = self ,
)
2023-12-19 23:58:57 +01:00
2024-02-01 07:38:01 +01:00
if d_login . return_code == 1 :
try :
2024-11-14 14:36:52 +01:00
self . tidal . session . process_link_login ( link_login , until_expiry = False )
2024-02-01 07:38:01 +01:00
self . tidal . login_finalize ()
result = True
2024-06-23 03:47:53 +03:00
logger_gui . info ( "Login successful. Have fun!" )
2024-02-01 07:38:01 +01:00
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 )
2023-12-19 23:58:57 +01:00
if result :
2024-02-26 07:49:37 +01:00
self . _init_dl ()
2025-11-29 12:10:06 +01:00
self . thread_it ( self . playlist_manager . tidal_user_lists )
2023-12-19 23:58:57 +01:00
2024-04-01 13:09:42 +02:00
def _init_threads ( self ):
2025-07-01 14:30:10 +02:00
"""Initialize thread pool and start background workers."""
2024-04-01 13:09:42 +02:00
self . threadpool = QtCore . QThreadPool ()
2025-11-29 12:10:06 +01:00
self . thread_it ( self . queue_manager . watcher_queue_download )
2024-04-01 13:09:42 +02:00
2024-02-26 07:49:37 +01:00
def _init_dl ( self ):
2025-07-01 14:30:10 +02:00
"""Initialize Download object and related progress bars."""
2024-02-26 07:49:37 +01:00
# 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 ()
2025-03-03 09:31:37 +01:00
handling_app : HandlingApp = HandlingApp ()
2024-02-26 07:49:37 +01:00
self . dl = Download (
2025-10-17 11:18:59 -06:00
tidal_obj = self . tidal ,
2024-02-26 07:49:37 +01:00
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 ,
2025-03-03 09:31:37 +01:00
event_abort = handling_app . event_abort ,
event_run = handling_app . event_run ,
2024-02-26 07:49:37 +01:00
)
2023-12-19 23:58:57 +01:00
def _init_progressbar ( self ):
2025-07-01 14:30:10 +02:00
"""Initialize and add progress bars to the status bar."""
2023-12-19 23:58:57 +01:00
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 )
2025-11-29 12:10:06 +01:00
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.
2024-04-01 19:54:11 +02:00
2025-11-29 12:10:06 +01:00
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 )
2024-03-23 14:11:11 +01:00
2024-01-18 22:46:34 +01:00
def on_progress_reset ( self ):
2025-07-01 14:30:10 +02:00
"""Reset progress bars to zero."""
2023-12-19 23:58:57 +01:00
self . pb_list . setValue ( 0 )
self . pb_item . setValue ( 0 )
2024-01-19 07:25:41 +01:00
def on_statusbar_message ( self , data : StatusbarMessage ):
2025-07-01 14:30:10 +02:00
"""Show a message in the status bar.
Args:
data (StatusbarMessage): Message and timeout.
"""
2025-06-30 10:44:51 +02:00
self . statusbar . showMessage ( data . message , data . timeout )
2024-01-19 07:25:41 +01:00
2025-07-01 14:30:10 +02:00
def _log_output ( self , text : str ) -> None :
"""Redirect log output to the debug text area.
Args:
text (str): Log message.
"""
2023-12-19 23:58:57 +01:00
cursor : QtGui . QTextCursor = self . te_debug . textCursor ()
2025-10-21 07:58:00 +02:00
html = self . converter_ansi_html . convert ( text )
2023-12-19 23:58:57 +01:00
2025-10-21 07:58:00 +02:00
cursor . movePosition ( QtGui . QTextCursor . End )
cursor . insertHtml ( html )
2023-12-19 23:58:57 +01:00
self . te_debug . setTextCursor ( cursor )
self . te_debug . ensureCursorVisible ()
2025-07-01 15:52:30 +02:00
def _populate_quality ( self , ui_target : QtWidgets . QComboBox , options : Iterable [ Any ]) -> None :
2025-07-01 14:30:10 +02:00
"""Populate a combo box with quality options.
Args:
ui_target (QComboBox): Target combo box.
2025-07-01 15:52:30 +02:00
options (Iterable): Enum of quality options.
2025-07-01 14:30:10 +02:00
"""
2023-12-19 23:58:57 +01:00
for item in options :
ui_target . addItem ( item . name , item )
2025-07-01 15:52:30 +02:00
def _populate_search_types ( self , ui_target : QtWidgets . QComboBox , options : Iterable [ Any ]) -> None :
2025-07-01 14:30:10 +02:00
"""Populate a combo box with search type options.
Args:
ui_target (QComboBox): Target combo box.
2025-07-01 15:52:30 +02:00
options (Iterable): Enum of search types.
2025-07-01 14:30:10 +02:00
"""
2023-12-19 23:58:57 +01:00
for item in options :
2024-03-22 17:31:33 +01:00
if item :
2023-12-19 23:58:57 +01:00
ui_target . addItem ( item . __name__ , item )
2024-03-22 17:31:33 +01:00
self . cb_search_type . setCurrentIndex ( 2 )
2023-12-19 23:58:57 +01:00
2025-07-01 14:30:10 +02:00
def handle_filter_activated ( self ) -> None :
"""Handle activation of filter headers in the results tree."""
2025-07-01 15:52:30 +02:00
header = self . tr_results . header ()
2025-07-02 08:47:59 +02:00
filters : list [ tuple [ int , str ]] = []
2024-05-14 07:31:57 +02:00
for i in range ( header . count ()):
2025-07-01 15:58:51 +02:00
text : str = header . filter_text ( i )
if text :
filters . append (( i , text ))
proxy_model : HumanProxyModel = self . tr_results . model ()
proxy_model . filters = filters
2024-05-14 07:31:57 +02:00
def _init_tree_results ( self , tree : QtWidgets . QTreeView , model : QtGui . QStandardItemModel ) -> None :
2025-07-01 14:30:10 +02:00
"""Initialize the results tree view and its model.
Args:
tree (QTreeView): The tree view widget.
model (QStandardItemModel): The model for the tree.
"""
2024-05-15 06:33:46 +02:00
header : FilterHeader = FilterHeader ( tree )
self . proxy_tr_results : HumanProxyModel = HumanProxyModel ( self )
2024-05-14 07:31:57 +02:00
tree . setHeader ( header )
tree . setModel ( model )
self . proxy_tr_results . setSourceModel ( model )
tree . setModel ( self . proxy_tr_results )
header . set_filter_boxes ( model . columnCount ())
2024-05-15 06:33:46 +02:00
header . filter_activated . connect ( self . handle_filter_activated )
## Styling
2024-01-16 06:12:22 +01:00
tree . sortByColumn ( 0 , QtCore . Qt . SortOrder . AscendingOrder )
2024-03-23 14:00:33 +01:00
tree . setColumnHidden ( 1 , True )
2025-09-01 15:34:29 -04:00
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
2024-04-01 16:06:02 +02:00
header . setSectionResizeMode ( 0 , QtWidgets . QHeaderView . ResizeToContents )
2024-11-11 11:18:25 +01:00
# Connect the contextmenu
tree . setContextMenuPolicy ( QtCore . Qt . CustomContextMenu )
tree . customContextMenuRequested . connect ( self . menu_context_tree_results )
2024-04-01 16:06:02 +02:00
2025-11-29 12:10:06 +01:00
# 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 )
2024-05-14 07:31:57 +02:00
def _init_tree_results_model ( self , model : QtGui . QStandardItemModel ) -> None :
2025-07-01 14:30:10 +02:00
"""Initialize the model for the results tree view.
Args:
model (QStandardItemModel): The model to initialize.
"""
2025-07-02 08:47:59 +02:00
labels_column : list [ str ] = [ "#" , "obj" , "Artist" , "Title" , "Album" , "Duration" , "Quality" , "Date" ]
2024-11-11 12:06:21 +01:00
model . setColumnCount ( len ( labels_column ))
2024-05-14 07:31:57 +02:00
model . setRowCount ( 0 )
2024-11-11 12:06:21 +01:00
model . setHorizontalHeaderLabels ( labels_column )
2024-05-14 07:31:57 +02:00
2025-07-01 14:30:10 +02:00
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.
"""
2024-03-27 07:17:21 +01:00
is_available , info = update_available ()
2024-05-03 20:31:27 +02:00
if ( on_startup and is_available ) or not on_startup :
self . s_update_show . emit ( True , is_available , info )
2024-03-27 07:17:21 +01:00
2025-07-01 14:30:10 +02:00
def apply_settings ( self , settings : Settings ) -> None :
"""Apply user settings to the GUI.
Args:
settings (Settings): The settings object.
"""
2025-07-01 15:52:30 +02:00
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 },
2023-12-19 23:58:57 +01:00
]
2025-07-01 15:52:30 +02:00
for item in elements :
2024-04-20 17:29:42 +02:00
idx = item [ "element" ] . findData ( item [ "setting" ])
2024-01-18 22:46:34 +01:00
2023-12-19 23:58:57 +01:00
if idx > - 1 :
item [ "element" ] . setCurrentIndex ( idx )
else :
item [ "element" ] . setCurrentIndex ( item [ "default_id" ])
2025-07-01 14:30:10 +02:00
def on_spinner_start ( self , parent : QtWidgets . QWidget ) -> None :
"""Start a loading spinner on the given parent widget.
Args:
parent (QWidget): The parent widget.
"""
2025-06-29 20:33:22 +02:00
# 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
2023-12-19 23:58:57 +01:00
2025-07-01 14:30:10 +02:00
def on_spinner_stop ( self ) -> None :
"""Stop all active loading spinners."""
2025-06-29 20:33:22 +02:00
# Stop all spinners
for spinner in list ( self . spinners . values ()):
spinner . stop ()
spinner . deleteLater ()
self . spinners . clear ()
2023-12-19 23:58:57 +01:00
2025-07-01 14:30:10 +02:00
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.
"""
2024-11-11 11:18:25 +01:00
# 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
2025-10-17 15:17:02 -05:00
# Get the media item at this point
2025-10-17 14:05:48 -06:00
media = get_results_media_item ( index , self . proxy_tr_results , self . model_tr_results )
2025-10-20 09:37:01 +02:00
2024-11-11 11:18:25 +01:00
# We build the menu.
menu = QtWidgets . QMenu ()
2025-10-17 15:17:02 -05:00
# Add "Download Full Album" option if it's a track or video with an album
2025-10-20 16:12:46 -05:00
if isinstance ( media , Track | Video ) and hasattr ( media , "album" ) and media . album :
2025-10-20 09:37:01 +02:00
menu . addAction ( "Download Full Album" , lambda : self . thread_download_album_from_track ( point ))
2025-03-23 10:46:16 +01:00
menu . addAction ( "Copy Share URL" , lambda : self . on_copy_url_share ( self . tr_results , point ))
2024-11-11 11:18:25 +01:00
menu . exec ( self . tr_results . mapToGlobal ( point ))
2025-10-20 18:58:46 -05:00
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 = {}
2025-10-21 15:23:18 -05:00
batch_size = self . settings . data . api_rate_limit_batch_size
delay_sec = self . settings . data . api_rate_limit_delay_sec
2025-10-20 18:58:46 -05:00
for idx , album_id in enumerate ( album_ids . keys (), start = 1 ):
try :
2025-10-21 15:23:18 -05:00
# Add delay every N albums to avoid rate limiting
if idx > 1 and ( idx - 1 ) % batch_size == 0 :
2025-11-29 12:10:06 +01:00
logger_gui . info (
f "ðY>' RATE LIMITING: Processed { idx - 1 } albums, pausing for { delay_sec } seconds..."
)
2025-10-21 15:23:18 -05:00
time . sleep ( delay_sec )
2025-10-20 18:58:46 -05:00
# Check session validity before making API calls
2025-10-22 21:52:20 +02:00
if not self . tidal . session . check_login ():
2025-10-21 15:23:18 -05:00
logger_gui . error ( "Session expired. Please restart the application and login again." )
2025-10-20 18:58:46 -05:00
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.
"""
2025-10-21 15:23:18 -05:00
# Check for OAuth/authentication errors using Tidal class method
if self . tidal . is_authentication_error ( error ):
error_msg = str ( error )
2025-10-20 18:58:46 -05:00
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
2025-10-21 15:23:18 -05:00
logger_gui . warning ( f "Failed to load album { album_id } : { error !s} " )
2025-10-20 18:58:46 -05:00
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 ():
2025-11-29 12:10:06 +01:00
queue_dl_item = self . queue_manager . media_to_queue_download_model ( album )
2025-10-20 18:58:46 -05:00
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 :
2025-11-29 12:10:06 +01:00
self . queue_manager . queue_download_media ( queue_dl_item )
2025-10-20 18:58:46 -05:00
logger_gui . info ( f "Added: { name_builder_artist ( album ) } - { album . name } " )
2025-07-01 14:30:10 +02:00
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.
"""
2024-11-11 11:18:25 +01:00
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 )
2023-12-19 23:58:57 +01:00
2025-07-01 15:52:30 +02:00
def populate_tree_results ( self , results : list [ ResultItem ], parent : QtGui . QStandardItem | None = None ) -> None :
2025-07-01 14:30:10 +02:00
"""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.
"""
2024-01-19 14:29:39 +01:00
if not parent :
2024-05-14 07:31:57 +02:00
self . model_tr_results . removeRows ( 0 , self . model_tr_results . rowCount ())
2024-01-19 14:29:39 +01:00
# Count how many digits the list length has,
2024-01-27 16:46:26 +01:00
count_digits : int = int ( math . log10 ( len ( results ) if results else 1 )) + 1
2023-12-19 23:58:57 +01:00
for item in results :
2024-05-14 07:31:57 +02:00
child : tuple = self . populate_tree_result_child ( item = item , index_count_digits = count_digits )
2023-12-19 23:58:57 +01:00
2024-01-19 14:29:39 +01:00
if parent :
2024-05-15 08:33:39 +02:00
parent . appendRow ( child )
2024-01-19 14:29:39 +01:00
else :
self . s_tr_results_add_top_level_item . emit ( child )
2023-12-19 23:58:57 +01:00
2025-06-30 10:44:51 +02:00
def populate_tree_result_child ( self , item : ResultItem , index_count_digits : int ) -> Sequence [ QtGui . QStandardItem ]:
2025-07-01 14:30:10 +02:00
"""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.
"""
2024-03-22 17:31:33 +01:00
duration : str = ""
2024-02-21 06:26:26 +01:00
# TODO: Duration needs to be calculated later to properly fill with zeros.
2024-03-22 17:31:33 +01:00
if item . duration_sec > - 1 :
# Format seconds to mm:ss.
m , s = divmod ( item . duration_sec , 60 )
duration : str = f " { m : 02d } : { s : 02d } "
2024-01-19 14:29:39 +01:00
# 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
2024-05-14 07:31:57 +02:00
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 )
2025-06-30 10:44:51 +02:00
child_date : QtGui . QStandardItem = QtGui . QStandardItem (
item . date_user_added if item . date_user_added != "" else item . date_release
)
2024-01-19 14:29:39 +01:00
2024-03-22 17:31:33 +01:00
if isinstance ( item . obj , Mix | Playlist | Album | Artist ):
2024-02-21 06:26:26 +01:00
# Add a disabled dummy child, so expansion arrow will appear. This Child will be replaced on expansion.
2024-05-14 07:31:57 +02:00
child_dummy : QtGui . QStandardItem = QtGui . QStandardItem ()
2024-02-21 06:26:26 +01:00
2024-05-14 07:31:57 +02:00
child_dummy . setEnabled ( False )
child_index . appendRow ( child_dummy )
2024-01-19 14:29:39 +01:00
2024-11-11 12:06:21 +01:00
return (
child_index ,
child_obj ,
child_artist ,
child_title ,
child_album ,
child_duration ,
child_quality ,
2025-06-30 10:44:51 +02:00
child_date ,
2024-11-11 12:06:21 +01:00
)
2024-01-19 07:25:41 +01:00
2024-05-14 07:31:57 +02:00
def on_tr_results_add_top_level_item ( self , item_child : Sequence [ QtGui . QStandardItem ]):
2025-07-01 14:30:10 +02:00
"""Add a top-level item to the results tree model.
Args:
item_child (Sequence[QStandardItem]): The row to add.
"""
2024-05-14 07:31:57 +02:00
self . model_tr_results . appendRow ( item_child )
2023-12-19 23:58:57 +01:00
2025-07-01 14:30:10 +02:00
def on_settings_save ( self ) -> None :
"""Save settings and re-apply them to the GUI."""
2024-02-04 12:50:19 +01:00
self . settings . save ()
self . apply_settings ( self . settings )
2024-02-26 07:49:37 +01:00
self . _init_dl ()
2024-02-04 12:19:40 +01:00
2025-07-01 14:30:10 +02:00
def _init_signals ( self ) -> None :
"""Connect signals to their respective slots."""
2024-03-22 15:02:26 +01:00
self . pb_download . clicked . connect ( lambda : self . thread_it ( self . on_download_results ))
2025-11-29 12:10:06 +01:00
self . pb_download_list . clicked . connect ( lambda : self . thread_it ( self . playlist_manager . on_download_list_media ))
2023-12-19 23:58:57 +01:00
self . l_search . returnPressed . connect (
2025-11-29 12:10:06 +01:00
lambda : self . search_manager . search_populate_results ( self . l_search . text (), self . cb_search_type . currentData ())
2023-12-19 23:58:57 +01:00
)
2024-03-22 15:02:26 +01:00
self . pb_search . clicked . connect (
2025-11-29 12:10:06 +01:00
lambda : self . search_manager . search_populate_results ( self . l_search . text (), self . cb_search_type . currentData ())
2023-12-19 23:58:57 +01:00
)
2024-01-18 22:46:34 +01:00
self . cb_quality_audio . currentIndexChanged . connect ( self . on_quality_set_audio )
self . cb_quality_video . currentIndexChanged . connect ( self . on_quality_set_video )
2024-02-05 06:31:18 +01:00
self . s_spinner_start [ QtWidgets . QWidget ] . connect ( self . on_spinner_start )
self . s_spinner_stop . connect ( self . on_spinner_stop )
2024-01-18 22:46:34 +01:00
self . s_item_advance . connect ( self . on_progress_item )
self . s_item_name . connect ( self . on_progress_item_name )
2024-01-19 07:25:41 +01:00
self . s_list_name . connect ( self . on_progress_list_name )
2024-01-18 22:46:34 +01:00
self . s_list_advance . connect ( self . on_progress_list )
self . s_pb_reset . connect ( self . on_progress_reset )
2024-01-19 07:25:41 +01:00
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 )
2024-02-04 12:19:40 +01:00
self . s_settings_save . connect ( self . on_settings_save )
2024-02-05 06:31:18 +01:00
self . s_pb_reload_status . connect ( self . button_reload_status )
2024-03-27 07:17:21 +01:00
self . s_update_check . connect ( lambda : self . thread_it ( self . on_update_check ))
2024-03-28 07:44:47 +01:00
self . s_update_show . connect ( self . on_version )
2023-12-19 23:58:57 +01:00
2024-01-19 10:00:27 +01:00
# Menubar
2025-03-03 09:31:37 +01:00
self . a_exit . triggered . connect ( self . close )
2024-01-19 10:00:27 +01:00
self . a_version . triggered . connect ( self . on_version )
2024-02-04 12:19:40 +01:00
self . a_preferences . triggered . connect ( self . on_preferences )
2024-02-05 06:31:18 +01:00
self . a_logout . triggered . connect ( self . on_logout )
2024-05-14 07:31:57 +02:00
self . a_updates_check . triggered . connect ( lambda : self . on_update_check ( False ))
2024-01-19 10:00:27 +01:00
2024-01-19 14:29:39 +01:00
# Results
2024-05-14 07:31:57 +02:00
self . tr_results . expanded . connect ( self . on_tr_results_expanded )
self . tr_results . clicked . connect ( self . on_result_item_clicked )
2025-09-01 13:18:50 -04:00
self . tr_results . doubleClicked . connect ( lambda : self . thread_it ( self . on_download_results ))
2024-01-19 14:29:39 +01:00
2025-11-29 12:10:06 +01:00
# Managers
self . queue_manager . connect_signals ()
self . playlist_manager . connect_signals ()
2025-03-03 09:31:37 +01:00
2025-07-01 14:30:10 +02:00
def on_logout ( self ) -> None :
"""Log out from TIDAL and close the application."""
2024-02-05 06:31:18 +01:00
result : bool = self . tidal . logout ()
if result :
sys . exit ( 0 )
2025-07-01 14:30:10 +02:00
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.
"""
2023-12-19 23:58:57 +01:00
self . pb_list . setValue ( int ( math . ceil ( value )))
2025-07-01 14:30:10 +02:00
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.
"""
2023-12-19 23:58:57 +01:00
self . pb_item . setValue ( int ( math . ceil ( value )))
2025-07-01 14:30:10 +02:00
def on_progress_item_name ( self , value : str ) -> None :
"""Set the format of the item progress bar.
Args:
value (str): The item name.
"""
2023-12-19 23:58:57 +01:00
self . pb_item . setFormat ( f "%p% { value } " )
2025-07-01 14:30:10 +02:00
def on_progress_list_name ( self , value : str ) -> None :
"""Set the format of the list progress bar.
Args:
value (str): The list name.
"""
2023-12-19 23:58:57 +01:00
self . pb_list . setFormat ( f "%p% { value } " )
2025-07-01 14:30:10 +02:00
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.
"""
2025-10-17 11:18:59 -06:00
quality_data = self . cb_quality_audio . itemData ( index )
2025-10-20 20:38:34 -06:00
2025-10-20 20:17:32 -06:00
self . settings . data . quality_audio = Quality ( quality_data )
2023-12-19 23:58:57 +01:00
self . settings . save ()
if self . tidal :
self . tidal . settings_apply ()
2025-07-01 14:30:10 +02:00
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.
"""
2024-04-02 16:26:57 +02:00
self . settings . data . quality_video = QualityVideo ( self . cb_quality_video . itemData ( index ))
2023-12-19 23:58:57 +01:00
self . settings . save ()
if self . tidal :
self . tidal . settings_apply ()
2024-05-14 07:31:57 +02:00
def on_result_item_clicked ( self , index : QtCore . QModelIndex ) -> None :
2025-07-01 14:30:10 +02:00
"""Handle the event when a result item is clicked.
Args:
index (QtCore.QModelIndex): The index of the clicked item.
"""
2024-05-14 07:31:57 +02:00
media : Track | Video | Album | Artist = get_results_media_item (
index , self . proxy_tr_results , self . model_tr_results
)
2024-03-29 12:02:12 +01:00
2025-11-29 12:10:06 +01:00
# Update info tab widget with selected media (persists selection)
self . info_tab_widget . update_on_selection ( media )
2024-04-01 12:26:10 +02:00
2025-06-29 20:33:22 +02:00
# Load cover asynchronously to avoid blocking the GUI
2025-11-29 12:10:06 +01:00
self . thread_it ( self . cover_manager . load_cover , media )
2024-03-25 16:53:51 +01:00
2024-01-19 14:29:39 +01:00
def list_items_show_result (
self ,
2024-03-22 17:31:33 +01:00
media_list : Album | Playlist | Mix | Artist | None = None ,
2024-01-19 14:29:39 +01:00
point : QtCore . QPoint | None = None ,
2025-07-02 08:47:59 +02:00
parent : QtGui . QStandardItem | None = None ,
favorite_function : Callable | None = None ,
2024-01-19 14:29:39 +01:00
) -> None :
2025-07-01 14:30:10 +02:00
"""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.
2025-07-02 08:47:59 +02:00
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.
2025-07-01 14:30:10 +02:00
"""
2023-12-19 23:58:57 +01:00
if point :
item = self . tr_lists_user . itemAt ( point )
2024-03-29 12:57:46 +01:00
media_list = get_user_list_media_item ( item )
2023-12-19 23:58:57 +01:00
2024-01-18 21:57:35 +01:00
# Get all results
2024-05-24 09:31:37 +02:00
if favorite_function or isinstance ( media_list , str ):
if isinstance ( media_list , str ):
favorite_function = favorite_function_factory ( self . tidal , media_list )
2025-07-02 08:47:59 +02:00
media_items : list [ Track | Video | Album ] = favorite_function ()
2024-05-24 07:23:10 +02:00
else :
2025-11-29 12:10:06 +01:00
media_items : list [ Track | Video | Album ] = items_results_all ( self . tidal . session , media_list )
2024-05-24 07:23:10 +02:00
2025-11-29 12:10:06 +01:00
result : list [ ResultItem ] = self . search_manager . search_result_to_model ( media_items )
2023-12-19 23:58:57 +01:00
2024-01-19 14:29:39 +01:00
self . populate_tree_results ( result , parent = parent )
2023-12-19 23:58:57 +01:00
2025-07-01 14:30:10 +02:00
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.
"""
2023-12-19 23:58:57 +01:00
# Any other args, kwargs are passed to the run function
worker = Worker ( fn , * args , ** kwargs )
# Execute
self . threadpool . start ( worker )
2024-04-01 12:16:06 +02:00
# TODO: Must happen in main thread. Do not thread this.
def on_download_results ( self ) -> None :
2025-07-01 14:30:10 +02:00
"""Download the selected results in the results tree."""
2024-05-15 06:33:46 +02:00
items : [ HumanProxyModel | None ] = self . tr_results . selectionModel () . selectedRows ()
2024-03-29 12:57:46 +01:00
if len ( items ) == 0 :
logger_gui . error ( "Please select a row first." )
else :
for item in items :
2024-05-15 08:33:39 +02:00
media : Track | Album | Playlist | Video | Artist = get_results_media_item (
item , self . proxy_tr_results , self . model_tr_results
)
2025-11-29 12:10:06 +01:00
queue_dl_item = self . queue_manager . media_to_queue_download_model ( media )
2024-03-29 12:57:46 +01:00
2024-04-01 15:40:58 +02:00
if queue_dl_item :
2025-11-29 12:10:06 +01:00
self . queue_manager . queue_download_media ( queue_dl_item )
2024-04-20 15:37:53 +02:00
2024-03-28 07:44:47 +01:00
def on_version (
2025-07-01 15:52:30 +02:00
self , update_check : bool = False , update_available : bool = False , update_info : ReleaseLatest | None = None
2024-03-28 07:44:47 +01:00
) -> None :
2025-07-01 14:30:10 +02:00
"""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.
2025-07-01 15:52:30 +02:00
update_info (ReleaseLatest | None, optional): Information about the latest release. Defaults to None.
2025-07-01 14:30:10 +02:00
"""
2024-03-28 07:44:47 +01:00
DialogVersion ( self , update_check , update_available , update_info )
2024-01-19 10:00:27 +01:00
2024-02-04 12:19:40 +01:00
def on_preferences ( self ) -> None :
2025-07-01 14:30:10 +02:00
"""Open the preferences dialog."""
2024-02-04 12:50:19 +01:00
DialogPreferences ( settings = self . settings , settings_save = self . s_settings_save , parent = self )
2024-02-04 12:19:40 +01:00
2024-05-15 08:33:39 +02:00
def on_tr_results_expanded ( self , index : QtCore . QModelIndex ) -> None :
2025-07-01 14:30:10 +02:00
"""Handle the event when a result item group is expanded.
Args:
index (QtCore.QModelIndex): The index of the expanded item.
"""
2025-06-29 21:50:12 +02:00
self . thread_it ( self . tr_results_expanded , index )
2025-11-29 12:10:06 +01:00
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 )
2025-06-29 21:50:12 +02:00
def tr_results_expanded ( self , index : QtCore . QModelIndex ) -> None :
2025-07-01 14:30:10 +02:00
"""Load and display the children of an expanded result item.
Args:
index (QtCore.QModelIndex): The index of the expanded item.
"""
2024-02-21 06:26:26 +01:00
# If the child is a dummy the list_item has not been expanded before
2024-05-15 08:33:39 +02:00
item : QtGui . QStandardItem = self . model_tr_results . itemFromIndex ( self . proxy_tr_results . mapToSource ( index ))
load_children : bool = not item . child ( 0 , 0 ) . isEnabled ()
2024-01-19 14:29:39 +01:00
2024-02-21 06:26:26 +01:00
if load_children :
2024-05-15 08:33:39 +02:00
item . removeRow ( 0 )
2025-07-01 15:52:30 +02:00
media_list : list [ Mix | Album | Playlist | Artist ] = get_results_media_item (
2024-05-15 08:33:39 +02:00
index , self . proxy_tr_results , self . model_tr_results
)
2024-02-21 06:26:26 +01:00
2025-06-29 21:50:12 +02:00
# Show spinner while loading children
self . s_spinner_start . emit ( self . tr_results )
2025-06-29 21:53:50 +02:00
2025-06-29 21:50:12 +02:00
try :
self . list_items_show_result ( media_list = media_list , parent = item )
finally :
self . s_spinner_stop . emit ()
2024-01-19 14:29:39 +01:00
2025-07-01 15:52:30 +02:00
def button_reload_status ( self , status : bool ) -> None :
2025-07-01 14:30:10 +02:00
"""Update the reload button's state and text.
Args:
status (bool): The new status.
"""
2024-02-05 06:31:18 +01:00
button_text : str = "Reloading..."
2025-06-30 08:39:33 +02:00
2024-02-05 06:31:18 +01:00
if status :
button_text = "Reload"
self . pb_reload_user_lists . setEnabled ( status )
self . pb_reload_user_lists . setText ( button_text )
2025-10-17 14:05:48 -06:00
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 :
2025-10-20 09:37:01 +02:00
"""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.
"""
2025-10-17 14:05:48 -06:00
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
2025-11-29 12:10:06 +01:00
queue_dl_item = self . queue_manager . media_to_queue_download_model ( full_album_object )
2025-10-17 14:05:48 -06:00
if queue_dl_item :
# Add the item to the download queue
2025-11-29 12:10:06 +01:00
self . queue_manager . queue_download_media ( queue_dl_item )
2025-10-17 14:05:48 -06:00
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." )
2023-12-19 23:58:57 +01:00
# TODO: Comment with Google Docstrings.
def gui_activate ( tidal : Tidal | None = None ):
2024-01-19 11:06:38 +01:00
# Set dark theme and create QT app.
2023-12-19 23:58:57 +01:00
qdarktheme . enable_hi_dpi ()
2025-03-03 09:31:37 +01:00
2023-12-19 23:58:57 +01:00
app = QtWidgets . QApplication ( sys . argv )
2025-03-03 09:31:37 +01:00
2024-03-22 16:28:31 +01:00
# 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; }" )
2023-12-19 23:58:57 +01:00
2024-01-19 11:06:38 +01:00
# Create icon object and apply it to app window.
2025-08-12 16:02:41 +02:00
icon : QtGui . QIcon = QtGui . QIcon ()
2025-03-03 09:31:37 +01:00
2025-08-12 16:02:41 +02:00
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 ))
2024-01-19 11:06:38 +01:00
app . setWindowIcon ( icon )
2024-01-19 11:16:33 +01:00
# 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
2024-03-26 07:10:01 +01:00
my_app_id : str = "exislow.tidal.dl-ng." + __version__
2024-01-19 11:22:30 +01:00
ctypes . windll . shell32 . SetCurrentProcessExplicitAppUserModelID ( my_app_id )
2024-01-19 11:16:33 +01:00
2024-01-19 11:06:38 +01:00
window = MainWindow ( tidal = tidal )
2025-03-03 09:31:37 +01:00
2023-12-19 23:58:57 +01:00
window . show ()
2024-03-27 07:17:21 +01:00
# Check for updates
2024-05-03 20:31:27 +02:00
window . s_update_check . emit ( True )
2023-12-19 23:58:57 +01:00
sys . exit ( app . exec ())
if __name__ == "__main__" :
gui_activate ()