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
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-04-01 12:26:10 +02:00
get_queue_download_media ,
2025-01-14 07:56:50 +01:00
get_queue_download_quality_audio ,
get_queue_download_quality_video ,
2024-03-29 12:34:55 +01:00
get_results_media_item ,
get_user_list_media_item ,
2024-04-01 12:16:06 +02:00
set_queue_download_media ,
2024-03-29 12:34:55 +01:00
set_user_list_media ,
)
2024-04-01 19:54:11 +02:00
from tidal_dl_ng.helper.path import get_format_template , resource_path
2024-02-26 07:27:53 +01:00
from tidal_dl_ng.helper.tidal import (
2024-05-24 10:01:01 +02:00
favorite_function_factory ,
2024-02-26 07:27:53 +01:00
get_tidal_media_id ,
get_tidal_media_type ,
instantiate_media ,
items_results_all ,
2024-03-14 06:30:47 +01:00
name_builder_artist ,
name_builder_title ,
2024-04-20 11:28:38 +02:00
quality_audio_highest ,
2024-02-26 07:27:53 +01:00
search_results_all ,
2025-09-07 18:27:30 +02:00
url_ending_clean ,
2024-02-26 07:27:53 +01:00
user_media_lists ,
)
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
from tidalapi import Album , Mix , Playlist , Quality , Track , UserPlaylist , Video
2024-03-22 17:31:33 +01:00
from tidalapi.artist import Artist
2025-10-20 20:17:32 -06:00
from tidalapi.media import AudioMode
2025-11-12 15:05:20 +01:00
from tidalapi.playlist import Folder
2023-12-19 23:58:57 +01:00
from tidalapi.session import SearchTypes
2024-01-12 10:21:34 +01:00
2025-03-23 21:28:58 +01:00
from tidal_dl_ng.config import HandlingApp , Settings , Tidal
2024-05-24 07:23:10 +02:00
from tidal_dl_ng.constants import FAVORITES , QualityVideo , QueueDownloadStatus , TidalLists
2023-12-19 23:58:57 +01:00
from tidal_dl_ng.download import Download
2024-01-12 10:21:34 +01:00
from tidal_dl_ng.logger import XStream , logger_gui
2024-04-01 15:40:58 +02:00
from tidal_dl_ng.model.gui_data import ProgressBars , QueueDownloadItem , ResultItem , StatusbarMessage
2024-03-28 07:44:47 +01:00
from tidal_dl_ng.model.meta import ReleaseLatest
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
2024-02-05 06:31:18 +01:00
s_spinner_start : QtCore . Signal = QtCore . Signal ( QtWidgets . QWidget )
s_spinner_stop : QtCore . Signal = QtCore . Signal ()
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
# Logging redirect.
XStream . stdout () . messageWritten . connect ( self . _log_output )
# XStream.stderr().messageWritten.connect(self._log_output)
self . settings = Settings ()
2025-09-30 22:43:22 +02:00
2024-04-01 13:09:42 +02:00
self . _init_threads ()
2025-06-29 20:33:22 +02:00
self . _init_gui ()
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 )
2023-12-19 23:58:57 +01:00
self . _init_tree_lists ( self . tr_lists_user )
2024-04-01 16:06:02 +02:00
self . _init_tree_queue ( self . tr_queue_download )
2024-03-23 14:11:11 +01:00
self . _init_info ()
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-03-03 09:31:37 +01:00
self . _init_buttons ()
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-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 ()
2024-01-16 07:09:15 +01:00
self . thread_it ( self . 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 ()
self . thread_it ( self . watcher_queue_download )
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 )
2024-03-23 14:11:11 +01:00
def _init_info ( self ):
2025-07-01 14:30:10 +02:00
"""Set default album cover image in the GUI."""
2024-04-01 19:54:11 +02:00
path_image : str = resource_path ( "tidal_dl_ng/ui/default_album_image.png" )
self . l_pm_cover . setPixmap ( QtGui . QPixmap ( path_image ))
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
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 _init_tree_queue ( self , tree : QtWidgets . QTableWidget ) -> None :
"""Initialize the download queue table widget.
Args:
tree (QTableWidget): The table widget.
"""
2024-03-23 14:11:11 +01:00
tree . setColumnHidden ( 1 , True )
2024-04-01 16:06:02 +02:00
tree . setColumnWidth ( 2 , 200 )
header = tree . header ()
2025-07-01 15:52:30 +02:00
if hasattr ( header , "setSectionResizeMode" ):
header . setSectionResizeMode ( 0 , QtWidgets . QHeaderView . ResizeToContents )
tree . setContextMenuPolicy ( QtCore . Qt . CustomContextMenu )
2025-10-17 15:17:02 -05:00
tree . customContextMenuRequested . connect ( self . menu_context_queue_download )
2023-12-19 23:58:57 +01:00
2025-07-01 14:30:10 +02:00
def tidal_user_lists ( self ) -> None :
"""Fetch and emit user playlists, mixes, and favorites from Tidal."""
2023-12-19 23:58:57 +01:00
# Start loading spinner
2024-02-05 06:31:18 +01:00
self . s_spinner_start . emit ( self . tr_lists_user )
self . s_pb_reload_status . emit ( False )
2024-01-16 07:09:15 +01:00
2025-11-12 15:05:20 +01:00
user_all : dict [ str , list ] = user_media_lists ( self . tidal . session )
2024-01-16 07:09:15 +01:00
self . s_populate_tree_lists . emit ( user_all )
2025-11-12 15:05:20 +01:00
def on_populate_tree_lists ( self , user_lists : dict [ str , list ]) -> None :
2025-07-01 14:30:10 +02:00
"""Populate the user lists tree with playlists, mixes, and favorites.
Args:
2025-11-12 15:05:20 +01:00
user_lists (dict[str, list]): Dictionary with 'playlists' (Folder/Playlist) and 'mixes' lists.
2025-07-01 14:30:10 +02:00
"""
2024-01-16 06:35:59 +01:00
twi_playlists : QtWidgets . QTreeWidgetItem = self . tr_lists_user . findItems (
2024-04-02 16:26:57 +02:00
TidalLists . Playlists , QtCore . Qt . MatchExactly , 0
2024-01-16 06:32:00 +01:00
)[ 0 ]
2024-05-22 22:30:06 +02:00
twi_mixes : QtWidgets . QTreeWidgetItem = self . tr_lists_user . findItems (
2024-04-02 16:26:57 +02:00
TidalLists . Mixes , QtCore . Qt . MatchExactly , 0
2023-12-19 23:58:57 +01:00
)[ 0 ]
2024-05-24 07:23:10 +02:00
twi_favorites : QtWidgets . QTreeWidgetItem = self . tr_lists_user . findItems (
TidalLists . Favorites , QtCore . Qt . MatchExactly , 0
)[ 0 ]
2023-12-19 23:58:57 +01:00
2024-02-05 06:31:18 +01:00
# Remove all children if present
2024-05-24 07:23:10 +02:00
for twi in [ twi_playlists , twi_mixes ]:
2024-02-05 06:31:18 +01:00
for i in reversed ( range ( twi . childCount ())):
twi . removeChild ( twi . child ( i ))
2025-11-12 15:05:20 +01:00
# 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 ):
2023-12-19 23:58:57 +01:00
twi_child = QtWidgets . QTreeWidgetItem ( twi_playlists )
2025-07-01 15:52:30 +02:00
name : str = item . name if getattr ( item , "name" , None ) is not None else ""
2024-05-22 22:30:06 +02:00
description : str = f " { item . description } " if item . description else ""
info : str = f "( { item . num_tracks + item . num_videos } Tracks) { description } "
2025-11-12 15:05:20 +01:00
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 ):
2024-05-22 22:30:06 +02:00
twi_child = QtWidgets . QTreeWidgetItem ( twi_mixes )
2023-12-19 23:58:57 +01:00
name : str = item . title
info : str = item . sub_title
2025-11-12 15:05:20 +01:00
twi_child . setText ( 0 , name )
set_user_list_media ( twi_child , item )
twi_child . setText ( 2 , info )
2023-12-19 23:58:57 +01:00
2025-06-30 08:39:33 +02:00
# Remove all children from favorites to avoid duplication
for i in reversed ( range ( twi_favorites . childCount ())):
twi_favorites . removeChild ( twi_favorites . child ( i ))
2024-05-24 07:23:10 +02:00
# 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 )
2023-12-19 23:58:57 +01:00
# Stop load spinner
2024-02-05 06:31:18 +01:00
self . s_spinner_stop . emit ()
self . s_pb_reload_status . emit ( True )
2023-12-19 23:58:57 +01:00
2025-07-01 14:30:10 +02:00
def _init_tree_lists ( self , tree : QtWidgets . QTreeWidget ) -> None :
"""Initialize the user lists tree widget.
Args:
tree (QTreeWidget): The tree widget.
"""
2024-01-16 07:09:15 +01:00
# Adjust Tree.
2023-12-19 23:58:57 +01:00
tree . setColumnWidth ( 0 , 200 )
2024-03-23 14:00:33 +01:00
tree . setColumnHidden ( 1 , True )
tree . setColumnWidth ( 2 , 300 )
2023-12-19 23:58:57 +01:00
tree . expandAll ()
# Connect the contextmenu
tree . setContextMenuPolicy ( QtCore . Qt . CustomContextMenu )
tree . customContextMenuRequested . connect ( self . menu_context_tree_lists )
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_lists ( self , point : QtCore . QPoint ) -> None :
"""Show context menu for user lists tree.
Args:
point (QPoint): The point where the menu is requested.
"""
2023-12-19 23:58:57 +01:00
# Infos about the node selected.
index = self . tr_lists_user . indexAt ( point )
# Do not open menu if something went wrong or a parent node is clicked.
if not index . isValid () or not index . parent () . data ():
return
2025-11-12 15:20:11 +01:00
# Get the media item to determine type
item = self . tr_lists_user . itemAt ( point )
media = get_user_list_media_item ( item )
2023-12-19 23:58:57 +01:00
# We build the menu.
menu = QtWidgets . QMenu ()
2025-11-12 15:20:11 +01:00
if isinstance ( media , Folder ):
# Folder-specific menu items
menu . addAction (
"Download All Playlists in Folder" , lambda : self . thread_it ( self . on_download_folder_playlists , point )
)
menu . addAction (
"Download All Albums from Folder" , lambda : self . thread_it ( self . on_download_folder_albums , point )
)
else :
# Playlist/Mix menu items (existing)
menu . addAction ( "Download Playlist" , lambda : self . thread_download_list_media ( point ))
menu . addAction (
"Download All Albums in Playlist" ,
lambda : self . thread_it ( self . on_download_all_albums_from_playlist , point ),
)
menu . addAction ( "Copy Share URL" , lambda : self . on_copy_url_share ( self . tr_lists_user , point ))
2023-12-19 23:58:57 +01:00
menu . exec ( self . tr_lists_user . mapToGlobal ( point ))
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-17 15:17:02 -05:00
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.
2025-10-20 16:12:46 -05:00
2025-10-17 15:17:02 -05:00
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" )
2025-07-01 14:30:10 +02:00
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.
"""
2023-12-19 23:58:57 +01:00
self . thread_it ( self . on_download_list_media , point )
2024-11-11 11:18:25 +01:00
2025-10-17 15:17:02 -05:00
def on_download_all_albums_from_playlist ( self , point : QtCore . QPoint ) -> None :
"""Download all unique albums from tracks in a playlist.
2025-10-20 16:12:46 -05:00
2025-10-17 15:17:02 -05:00
Args:
point (QPoint): The point in the tree where the playlist was right-clicked.
"""
try :
2025-10-20 18:58:46 -05:00
# Get and validate the playlist
2025-10-17 15:17:02 -05:00
item = self . tr_lists_user . itemAt ( point )
media_list = get_user_list_media_item ( item )
2025-10-20 16:12:46 -05:00
if not isinstance ( media_list , Playlist | UserPlaylist | Mix ):
2025-10-17 15:17:02 -05:00
logger_gui . error ( "Please select a playlist or mix." )
return
2025-10-20 16:12:46 -05:00
2025-10-17 15:17:02 -05:00
# Get all items from the playlist
logger_gui . info ( f "Fetching all tracks from: { media_list . name } " )
media_items = items_results_all ( media_list )
2025-10-20 16:12:46 -05:00
2025-10-17 23:40:31 -05:00
# Extract unique album IDs from tracks
2025-10-20 18:58:46 -05:00
album_ids = self . _extract_album_ids_from_tracks ( media_items )
2025-10-20 16:12:46 -05:00
2025-10-17 23:40:31 -05:00
if not album_ids :
2025-10-17 15:17:02 -05:00
logger_gui . warning ( "No albums found in this playlist." )
return
2025-10-20 16:12:46 -05:00
2025-10-17 23:40:31 -05:00
logger_gui . info ( f "Found { len ( album_ids ) } unique albums. Loading with rate limiting..." )
2025-10-20 16:12:46 -05:00
2025-10-17 23:40:31 -05:00
# Load albums with rate limiting
2025-10-20 18:58:46 -05:00
albums_dict = self . _load_albums_with_rate_limiting ( album_ids )
2025-10-20 16:12:46 -05:00
2025-10-17 23:40:31 -05:00
if not albums_dict :
logger_gui . error ( "Failed to load any albums from playlist." )
return
2025-10-20 16:12:46 -05:00
2025-10-20 18:58:46 -05:00
# Prepare and queue albums
self . _queue_loaded_albums ( albums_dict )
2025-10-20 16:12:46 -05:00
2025-10-17 15:17:02 -05:00
# Show confirmation
2025-10-20 18:58:46 -05:00
message = f "Added { len ( albums_dict ) } albums to download queue"
2025-10-17 15:17:02 -05:00
self . s_statusbar_message . emit ( StatusbarMessage ( message = message , timeout = 3000 ))
logger_gui . info ( message )
2025-10-20 16:12:46 -05:00
2025-10-17 15:17:02 -05:00
except Exception as e :
2025-10-20 16:12:46 -05:00
error_msg = f "Error downloading albums from playlist: { e !s} "
2025-10-17 15:17:02 -05:00
logger_gui . error ( error_msg )
self . s_statusbar_message . emit ( StatusbarMessage ( message = error_msg , timeout = 3000 ))
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 :
logger_gui . info ( f "🛑 RATE LIMITING: Processed { idx - 1 } albums, pausing for { delay_sec } seconds..." )
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 ():
queue_dl_item = self . 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_download_media ( queue_dl_item )
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 on_download_list_media ( self , point : QtCore . QPoint | None = None ) -> None :
2025-07-01 14:30:10 +02:00
"""Download all media items in a selected list.
Args:
2025-07-02 08:47:59 +02:00
point (QPoint | None, optional): The point in the tree. Defaults to None.
2025-07-01 14:30:10 +02:00
"""
2025-07-01 15:52:30 +02:00
items : list [ QtWidgets . QTreeWidgetItem ] = []
2023-12-19 23:58:57 +01:00
2024-03-29 12:24:42 +01:00
if point :
2024-04-01 19:02:00 +02:00
items = [ self . tr_lists_user . itemAt ( point )]
2024-03-29 12:24:42 +01:00
else :
2024-04-01 19:02:00 +02:00
items = self . tr_lists_user . selectedItems ()
2024-03-29 12:24:42 +01:00
if len ( items ) == 0 :
2024-04-01 12:44:41 +02:00
logger_gui . error ( "Please select a mix or playlist first." )
2024-03-29 12:24:42 +01:00
2024-04-01 19:02:00 +02:00
for item in items :
2024-03-29 12:24:42 +01:00
media = get_user_list_media_item ( item )
2025-07-01 15:52:30 +02:00
queue_dl_item : QueueDownloadItem | None = self . media_to_queue_download_model ( media )
2024-03-29 12:24:42 +01:00
2024-04-01 15:40:58 +02:00
if queue_dl_item :
self . queue_download_media ( queue_dl_item )
2024-01-19 07:25:41 +01:00
2025-11-12 15:20:11 +01:00
def on_download_folder_playlists ( self , point : QtCore . QPoint ) -> None :
"""Download all playlists in a folder.
Args:
point (QPoint): The point in the tree where the folder was right-clicked.
"""
try :
# Get and validate the folder
item = self . tr_lists_user . itemAt ( point )
media = get_user_list_media_item ( item )
if not isinstance ( media , Folder ):
logger_gui . error ( "Please select a folder." )
return
# Fetch all playlists in the folder
logger_gui . info ( f "Fetching playlists from folder: { media . name } " )
playlists = self . _get_folder_playlists ( media )
if not playlists :
logger_gui . info ( f "No playlists found in folder: { media . name } " )
return
# Queue each playlist for download
logger_gui . info ( f "Queueing { len ( playlists ) } playlists from folder: { media . name } " )
for playlist in playlists :
queue_dl_item : QueueDownloadItem | None = self . media_to_queue_download_model ( playlist )
if queue_dl_item :
self . queue_download_media ( queue_dl_item )
logger_gui . info ( f "✅ Successfully queued { len ( playlists ) } playlists from folder: { media . name } " )
except Exception as e :
logger_gui . exception ( f "Error downloading playlists from folder: { e } " )
logger_gui . error ( "Failed to download playlists from folder. See log for details." )
def on_download_folder_albums ( self , point : QtCore . QPoint ) -> None :
"""Download all unique albums from all playlists in a folder.
Args:
point (QPoint): The point in the tree where the folder was right-clicked.
"""
try :
# Get and validate the folder
item = self . tr_lists_user . itemAt ( point )
media = get_user_list_media_item ( item )
if not isinstance ( media , Folder ):
logger_gui . error ( "Please select a folder." )
return
# Fetch all playlists in the folder
logger_gui . info ( f "Fetching playlists from folder: { media . name } " )
playlists = self . _get_folder_playlists ( media )
if not playlists :
logger_gui . info ( f "No playlists found in folder: { media . name } " )
return
logger_gui . info ( f "Found { len ( playlists ) } playlists in folder: { media . name } " )
# Collect all tracks from all playlists
all_tracks : list [ Track ] = []
for playlist in playlists :
try :
tracks = self . _get_playlist_tracks ( playlist )
all_tracks . extend ( tracks )
logger_gui . debug ( f "Collected { len ( tracks ) } tracks from playlist: { playlist . name } " )
except Exception as e :
logger_gui . error ( f "Error getting tracks from playlist ' { playlist . name } ': { e } " )
continue
if not all_tracks :
logger_gui . info ( f "No tracks found in folder playlists: { media . name } " )
return
logger_gui . info ( f "Collected { len ( all_tracks ) } total tracks from all playlists" )
# Extract unique album IDs
album_ids = self . _extract_album_ids_from_tracks ( all_tracks )
logger_gui . info ( f "Found { len ( album_ids ) } unique albums across all playlists in folder: { media . name } " )
if not album_ids :
logger_gui . info ( "No albums found to download." )
return
# Load full album objects 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." )
return
# Queue the albums for download
self . _queue_loaded_albums ( albums_dict )
logger_gui . info ( f "✅ Successfully queued { len ( albums_dict ) } unique albums from folder: { media . name } " )
except Exception as e :
logger_gui . exception ( f "Error downloading albums from folder: { e } " )
logger_gui . error ( "Failed to download albums from folder. See log for details." )
2025-07-01 15:52:30 +02:00
def search_populate_results ( self , query : str , type_media : Any ) -> None :
2025-07-01 14:30:10 +02:00
"""Populate the results tree with search results.
Args:
query (str): The search query.
type_media (SearchTypes): The type of media to search for.
"""
2025-07-01 15:52:30 +02:00
results : list [ ResultItem ] = self . search ( query , [ type_media ])
2023-12-19 23:58:57 +01:00
self . populate_tree_results ( results )
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-02 08:47:59 +02:00
def search ( self , query : str , types_media : list [ Any ]) -> list [ ResultItem ]:
2025-07-01 14:30:10 +02:00
"""Perform a search and return a list of ResultItems.
Args:
query (str): The search query.
2025-07-02 08:47:59 +02:00
types_media (list[Any]): The types of media to search for.
2025-07-01 14:30:10 +02:00
Returns:
list[ResultItem]: The search results.
"""
2025-09-07 18:27:30 +02:00
query_clean : str = query . strip ()
2024-11-14 15:10:14 +01:00
2024-02-26 07:27:53 +01:00
# If a direct link was searched for, skip search and create the object from the link directly.
2025-09-07 18:27:30 +02:00
if "http" in query_clean :
query_clean : str = url_ending_clean ( query_clean )
media_type = get_tidal_media_type ( query_clean )
item_id = get_tidal_media_id ( query_clean )
2024-02-26 07:34:07 +01:00
try :
media = instantiate_media ( self . tidal . session , media_type , item_id )
2024-03-25 14:12:28 +01:00
except :
logger_gui . error ( f "Media not found (ID: { item_id } ). Maybe it is not available anymore." )
2024-02-26 07:34:07 +01:00
media = None
2024-02-26 07:27:53 +01:00
result_search = { "direct" : [ media ]}
else :
2025-07-02 08:47:59 +02:00
result_search : dict [ str , list [ SearchTypes ]] = search_results_all (
2025-09-07 18:27:30 +02:00
session = self . tidal . session , needle = query_clean , types_media = types_media
2024-02-26 07:27:53 +01:00
)
2025-07-01 15:52:30 +02:00
result : list [ ResultItem ] = []
2023-12-19 23:58:57 +01:00
2024-01-12 10:21:34 +01:00
for _media_type , l_media in result_search . items ():
2023-12-19 23:58:57 +01:00
if isinstance ( l_media , list ):
result = result + self . search_result_to_model ( l_media )
return result
2025-07-01 13:35:17 +02:00
def search_result_to_model ( self , items : list [ SearchTypes ]) -> list [ ResultItem ]:
"""Convert search results to ResultItem models.
Args:
items (list[SearchTypes]): List of search result items.
Returns:
list[ResultItem]: List of ResultItem models.
"""
2025-07-02 08:47:59 +02:00
result : list [ ResultItem ] = []
2023-12-19 23:58:57 +01:00
for idx , item in enumerate ( items ):
2025-07-01 13:35:17 +02:00
result_item = self . _to_result_item ( idx , item )
2024-03-22 17:31:33 +01:00
2025-07-01 13:35:17 +02:00
if result_item is not None :
2023-12-19 23:58:57 +01:00
result . append ( result_item )
return result
2025-07-01 13:35:17 +02:00
def _to_result_item ( self , idx : int , item ) -> ResultItem | None :
2025-07-01 14:30:10 +02:00
"""Helper to convert a single item to ResultItem, or None if not valid.
Args:
idx (int): Index of the item.
item: The item to convert.
Returns:
ResultItem | None: The converted ResultItem or None if not valid.
"""
2025-11-12 15:05:20 +01:00
if not item or ( hasattr ( item , "available" ) and not item . available ):
2025-07-01 13:35:17 +02:00
return None
2025-11-12 15:05:20 +01:00
# Prepare common data
explicit = " 🅴" if isinstance ( item , Track | Video | Album ) and item . explicit else ""
date_user_added = (
2025-07-01 13:35:17 +02:00
item . user_date_added . strftime ( "%Y-%m- %d _%H:%M" ) if getattr ( item , "user_date_added" , None ) else ""
)
2025-11-12 15:05:20 +01:00
date_release = self . _get_date_release ( item )
2025-07-01 13:35:17 +02:00
2025-11-12 15:05:20 +01:00
# Map item types to their conversion methods
type_handlers = {
Track : lambda : self . _result_item_from_track ( idx , item , explicit , date_user_added , date_release ),
Video : lambda : self . _result_item_from_video ( idx , item , explicit , date_user_added , date_release ),
Playlist : lambda : self . _result_item_from_playlist ( idx , item , date_user_added , date_release ),
Album : lambda : self . _result_item_from_album ( idx , item , explicit , date_user_added , date_release ),
Mix : lambda : self . _result_item_from_mix ( idx , item , date_user_added , date_release ),
Artist : lambda : self . _result_item_from_artist ( idx , item , date_user_added , date_release ),
Folder : lambda : self . _result_item_from_folder ( idx , item , date_user_added ),
}
2025-07-01 13:35:17 +02:00
2025-11-12 15:05:20 +01:00
# Find and execute the appropriate handler
for item_type , handler in type_handlers . items ():
if isinstance ( item , item_type ):
return handler ()
2025-07-01 13:35:17 +02:00
return None
def _get_date_release ( self , item ) -> str :
2025-07-01 14:30:10 +02:00
"""Get the release date string for an item.
Args:
item: The item to extract the release date from.
Returns:
str: The formatted release date or empty string.
"""
2025-07-01 13:35:17 +02:00
if hasattr ( item , "album" ) and item . album and getattr ( item . album , "release_date" , None ):
return item . album . release_date . strftime ( "%Y-%m- %d _%H:%M" )
if hasattr ( item , "release_date" ) and item . release_date :
return item . release_date . strftime ( "%Y-%m- %d _%H:%M" )
return ""
def _result_item_from_track (
self , idx : int , item , explicit : str , date_user_added : str , date_release : str
) -> ResultItem :
2025-07-01 14:30:10 +02:00
"""Create a ResultItem from a Track.
Args:
idx (int): Index of the item.
item: The Track item.
explicit (str): Explicit tag.
date_user_added (str): Date user added.
date_release (str): Release date.
Returns:
ResultItem: The constructed ResultItem.
"""
2025-10-17 11:18:59 -06:00
2025-10-14 19:53:19 -06:00
final_quality = quality_audio_highest ( item )
2025-10-20 20:17:32 -06:00
if hasattr ( item , "audio_modes" ) and AudioMode . dolby_atmos . value in item . audio_modes :
final_quality = f " { final_quality } / Dolby Atmos"
2025-10-14 19:53:19 -06:00
2025-07-01 13:35:17 +02:00
return ResultItem (
position = idx ,
artist = name_builder_artist ( item ),
title = f " { name_builder_title ( item ) }{ explicit } " ,
album = item . album . name ,
duration_sec = item . duration ,
obj = item ,
2025-10-14 19:53:19 -06:00
quality = final_quality ,
2025-07-01 13:35:17 +02:00
explicit = bool ( item . explicit ),
date_user_added = date_user_added ,
date_release = date_release ,
)
def _result_item_from_video (
self , idx : int , item , explicit : str , date_user_added : str , date_release : str
) -> ResultItem :
2025-07-01 14:30:10 +02:00
"""Create a ResultItem from a Video.
Args:
idx (int): Index of the item.
item: The Video item.
explicit (str): Explicit tag.
date_user_added (str): Date user added.
date_release (str): Release date.
Returns:
ResultItem: The constructed ResultItem.
"""
2025-07-01 13:35:17 +02:00
return ResultItem (
position = idx ,
artist = name_builder_artist ( item ),
title = f " { name_builder_title ( item ) }{ explicit } " ,
album = item . album . name if item . album else "" ,
duration_sec = item . duration ,
obj = item ,
quality = item . video_quality ,
explicit = bool ( item . explicit ),
date_user_added = date_user_added ,
date_release = date_release ,
)
def _result_item_from_playlist ( self , idx : int , item , date_user_added : str , date_release : str ) -> ResultItem :
2025-07-01 14:30:10 +02:00
"""Create a ResultItem from a Playlist.
Args:
idx (int): Index of the item.
item: The Playlist item.
date_user_added (str): Date user added.
date_release (str): Release date.
Returns:
ResultItem: The constructed ResultItem.
"""
2025-07-01 13:35:17 +02:00
return ResultItem (
position = idx ,
artist = ", " . join ( artist . name for artist in item . promoted_artists ) if item . promoted_artists else "" ,
title = item . name ,
album = "" ,
duration_sec = item . duration ,
obj = item ,
quality = "" ,
explicit = False ,
date_user_added = date_user_added ,
date_release = date_release ,
)
def _result_item_from_album (
self , idx : int , item , explicit : str , date_user_added : str , date_release : str
) -> ResultItem :
2025-07-01 14:30:10 +02:00
"""Create a ResultItem from an Album.
Args:
idx (int): Index of the item.
item: The Album item.
explicit (str): Explicit tag.
date_user_added (str): Date user added.
date_release (str): Release date.
Returns:
ResultItem: The constructed ResultItem.
"""
2025-07-01 13:35:17 +02:00
return ResultItem (
position = idx ,
artist = name_builder_artist ( item ),
title = "" ,
album = f " { item . name }{ explicit } " ,
duration_sec = item . duration ,
obj = item ,
quality = quality_audio_highest ( item ),
explicit = bool ( item . explicit ),
date_user_added = date_user_added ,
date_release = date_release ,
)
def _result_item_from_mix ( self , idx : int , item , date_user_added : str , date_release : str ) -> ResultItem :
2025-07-01 14:30:10 +02:00
"""Create a ResultItem from a Mix.
Args:
idx (int): Index of the item.
item: The Mix item.
date_user_added (str): Date user added.
date_release (str): Release date.
Returns:
ResultItem: The constructed ResultItem.
"""
2025-07-01 13:35:17 +02:00
return ResultItem (
position = idx ,
artist = item . sub_title ,
title = item . title ,
album = "" ,
duration_sec =- 1 , # TODO: Calculate total duration.
obj = item ,
quality = "" ,
explicit = False ,
date_user_added = date_user_added ,
date_release = date_release ,
)
def _result_item_from_artist ( self , idx : int , item , date_user_added : str , date_release : str ) -> ResultItem :
2025-07-01 14:30:10 +02:00
"""Create a ResultItem from an Artist.
Args:
idx (int): Index of the item.
item: The Artist item.
date_user_added (str): Date user added.
date_release (str): Release date.
Returns:
ResultItem: The constructed ResultItem.
"""
2025-07-01 13:35:17 +02:00
return ResultItem (
position = idx ,
artist = item . name ,
title = "" ,
album = "" ,
duration_sec =- 1 ,
obj = item ,
quality = "" ,
explicit = False ,
date_user_added = date_user_added ,
date_release = date_release ,
)
2025-11-12 15:05:20 +01:00
def _result_item_from_folder ( self , idx : int , item : Folder , date_user_added : str ) -> ResultItem :
"""Create a ResultItem from a Folder.
Args:
idx (int): Index of the item.
item (Folder): The Folder item.
date_user_added (str): Date user added.
Returns:
ResultItem: The constructed ResultItem.
"""
total_items : int = item . total_number_of_items if hasattr ( item , "total_number_of_items" ) else 0
return ResultItem (
position = idx ,
artist = "" ,
title = f "📁 { item . name } ( { total_items } items)" ,
album = "" ,
duration_sec =- 1 ,
obj = item ,
quality = "" ,
explicit = False ,
date_user_added = date_user_added ,
date_release = "" ,
)
2024-04-01 15:40:58 +02:00
def media_to_queue_download_model (
self , media : Artist | Track | Video | Album | Playlist | Mix
) -> QueueDownloadItem | bool :
2025-07-01 14:30:10 +02:00
"""Convert a media object to a QueueDownloadItem for the download queue.
Args:
media (Artist | Track | Video | Album | Playlist | Mix): The media object.
Returns:
QueueDownloadItem | bool: The queue item or False if not available.
"""
2024-04-01 15:40:58 +02:00
result : QueueDownloadItem | False
name : str = ""
2025-01-12 17:42:09 +01:00
quality_audio : Quality = self . settings . data . quality_audio
quality_video : QualityVideo = self . settings . data . quality_video
2024-04-20 11:28:38 +02:00
explicit : str = ""
2024-04-01 15:40:58 +02:00
# Check if item is available on TIDAL.
2025-10-17 15:17:02 -05:00
# Note: Some albums have available=None, which should be treated as available
if hasattr ( media , "available" ) and media . available is False :
2024-04-01 15:40:58 +02:00
return False
2024-05-22 07:01:47 +02:00
# Set "Explicit" tag
2024-04-20 11:28:38 +02:00
if isinstance ( media , Track | Video | Album ):
explicit = " 🅴" if media . explicit else ""
2024-05-22 07:01:47 +02:00
# Build name and set quality
2024-04-01 15:40:58 +02:00
if isinstance ( media , Track | Video ):
2024-04-20 11:28:38 +02:00
name = f " { name_builder_artist ( media ) } - { name_builder_title ( media ) }{ explicit } "
2024-04-01 15:40:58 +02:00
elif isinstance ( media , Playlist | Artist ):
name = media . name
elif isinstance ( media , Album ):
2024-04-20 11:28:38 +02:00
name = f " { name_builder_artist ( media ) } - { media . name }{ explicit } "
2024-04-01 15:40:58 +02:00
elif isinstance ( media , Mix ):
name = media . title
2024-05-22 07:01:47 +02:00
# Determine actual quality.
2024-04-20 11:28:38 +02:00
if isinstance ( media , Track | Album ):
2025-01-12 17:42:09 +01:00
quality_highest : Quality = quality_audio_highest ( media )
2024-04-20 11:28:38 +02:00
if (
self . settings . data . quality_audio == quality_highest
or self . settings . data . quality_audio == Quality . hi_res_lossless
):
2025-01-12 17:42:09 +01:00
quality_audio = quality_highest
2024-04-20 11:28:38 +02:00
2024-04-01 15:40:58 +02:00
if name :
result = QueueDownloadItem (
name = name ,
2025-01-12 17:42:09 +01:00
quality_audio = quality_audio ,
quality_video = quality_video ,
2024-04-01 15:40:58 +02:00
type_media = type ( media ) . __name__ ,
2024-04-02 16:26:57 +02:00
status = QueueDownloadStatus . Waiting ,
2024-04-01 15:40:58 +02:00
obj = media ,
)
else :
result = False
return result
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 ))
2024-03-29 12:24:42 +01:00
self . pb_download_list . clicked . connect ( lambda : self . thread_it ( self . on_download_list_media ))
2024-04-01 12:16:06 +02:00
self . pb_reload_user_lists . clicked . connect ( lambda : self . thread_it ( self . tidal_user_lists ))
2024-04-01 12:44:41 +02:00
self . pb_queue_download_clear_all . clicked . connect ( self . on_queue_download_clear_all )
self . pb_queue_download_clear_finished . clicked . connect ( self . on_queue_download_clear_finished )
self . pb_queue_download_remove . clicked . connect ( self . on_queue_download_remove )
2025-03-03 09:31:37 +01:00
self . pb_queue_download_toggle . clicked . connect ( self . on_pb_queue_download_toggle )
2023-12-19 23:58:57 +01:00
self . l_search . returnPressed . connect (
lambda : self . search_populate_results ( self . l_search . text (), self . cb_search_type . currentData ())
)
2024-03-22 15:02:26 +01:00
self . pb_search . clicked . connect (
2023-12-19 23:58:57 +01:00
lambda : self . search_populate_results ( self . l_search . text (), self . cb_search_type . currentData ())
)
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 )
2023-12-19 23:58:57 +01:00
self . tr_lists_user . itemClicked . connect ( self . on_list_items_show )
2025-11-12 15:05:20 +01:00
self . tr_lists_user . itemExpanded . connect ( self . on_tr_lists_user_expanded )
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-16 07:09:15 +01:00
self . s_populate_tree_lists . connect ( self . on_populate_tree_lists )
2025-11-12 15:05:20 +01:00
self . s_populate_folder_children . connect ( self . on_populate_folder_children )
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
2024-04-01 12:26:10 +02:00
# Download Queue
self . tr_queue_download . itemClicked . connect ( self . on_queue_download_item_clicked )
2024-04-01 13:09:42 +02:00
self . s_queue_download_item_downloading . connect ( self . on_queue_download_item_downloading )
2024-04-01 13:28:59 +02:00
self . s_queue_download_item_finished . connect ( self . on_queue_download_item_finished )
self . s_queue_download_item_failed . connect ( self . on_queue_download_item_failed )
2024-04-20 15:37:53 +02:00
self . s_queue_download_item_skipped . connect ( self . on_queue_download_item_skipped )
2025-07-01 14:30:10 +02:00
def _init_buttons ( self ) -> None :
"""Initialize the state of the download buttons."""
2025-03-03 09:31:37 +01:00
self . pb_queue_download_run ()
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 ()
2025-11-12 15:05:20 +01:00
def on_tr_lists_user_expanded ( self , item : QtWidgets . QTreeWidgetItem ) -> None :
"""Handle expansion of folders in the user lists tree.
Args:
item (QTreeWidgetItem): The expanded tree item.
"""
# Check if it's a first-time expansion (has disabled dummy child)
if item . childCount () > 0 and item . child ( 0 ) . isDisabled ():
# Run in thread to avoid blocking UI
self . thread_it ( self . tr_lists_user_load_folder_children , item )
def tr_lists_user_load_folder_children ( self , parent_item : QtWidgets . QTreeWidgetItem ) -> None :
"""Load and display children of a folder in the user lists tree.
Args:
parent_item (QTreeWidgetItem): The parent folder item.
"""
folder : Folder | None = get_user_list_media_item ( parent_item )
if not isinstance ( folder , Folder ):
return
# Show spinner while loading
self . s_spinner_start . emit ( self . tr_lists_user )
try :
# Fetch folder contents
folders , playlists = self . _fetch_folder_contents ( folder )
# Emit signal to populate in main thread
self . s_populate_folder_children . emit ( parent_item , folders , playlists )
finally :
self . s_spinner_stop . emit ()
def on_populate_folder_children (
self , parent_item : QtWidgets . QTreeWidgetItem , folders : list [ Folder ], playlists : list [ Playlist ]
) -> None :
"""Populate folder children in the main thread (signal handler).
Args:
parent_item (QTreeWidgetItem): The parent folder item.
folders (list[Folder]): List of sub-folders.
playlists (list[Playlist]): List of playlists.
"""
# Remove dummy child
parent_item . takeChild ( 0 )
# Add sub-folders as children
for sub_folder in folders :
twi_child = QtWidgets . QTreeWidgetItem ( parent_item )
twi_child . setText ( 0 , f "📁 { sub_folder . name } " )
set_user_list_media ( twi_child , sub_folder )
info = f "( { sub_folder . total_number_of_items } items)" if sub_folder . total_number_of_items else ""
twi_child . setText ( 2 , info )
# Add dummy child for potential sub-folders
dummy = QtWidgets . QTreeWidgetItem ( twi_child )
dummy . setDisabled ( True )
# Add playlists as children
for playlist in playlists :
twi_child = QtWidgets . QTreeWidgetItem ( parent_item )
name = playlist . name if playlist . name else ""
twi_child . setText ( 0 , name )
set_user_list_media ( twi_child , playlist )
info = f "( { playlist . num_tracks + playlist . num_videos } Tracks)"
if playlist . description :
info += f " { playlist . description } "
twi_child . setText ( 2 , info )
def _fetch_folder_contents ( self , folder : Folder ) -> tuple [ list [ Folder ], list [ Playlist ]]:
"""Fetch contents (sub-folders and playlists) of a folder.
Args:
folder (Folder): The folder to fetch contents for.
Returns:
tuple[list[Folder], list[Playlist]]: Sub-folders and playlists within the folder.
"""
folder_id = folder . id if folder . id else "root"
# Fetch sub-folders with manual pagination
offset = 0
limit = 50
folders = []
while True :
batch = self . tidal . session . user . favorites . playlist_folders (
limit = limit , offset = offset , parent_folder_id = folder_id
)
if not batch :
break
folders . extend ( batch )
if len ( batch ) < limit :
break
offset += limit
# Fetch playlists in this folder using folder.items() method
offset = 0
playlists = []
while True :
batch = folder . items ( offset = offset , limit = limit )
if not batch :
break
playlists . extend ( batch )
if len ( batch ) < limit :
break
offset += limit
return folders , playlists
2025-11-12 15:20:11 +01:00
def _get_folder_playlists ( self , folder : Folder ) -> list [ Playlist ]:
"""Fetch all playlists from a folder.
Args:
folder (Folder): The folder to fetch playlists from.
Returns:
list[Playlist]: List of playlists in the folder.
"""
# Use existing method to fetch folder contents
# Since folders can't contain folders, we ignore the folders return value
_ , playlists = self . _fetch_folder_contents ( folder )
logger_gui . debug ( f "Found { len ( playlists ) } playlists in folder: { folder . name } " )
return playlists
def _get_playlist_tracks ( self , playlist : Playlist | UserPlaylist | Mix ) -> list [ Track ]:
"""Fetch all tracks from a playlist.
Args:
playlist (Playlist | UserPlaylist | Mix): The playlist to fetch tracks from.
Returns:
list[Track]: List of tracks in the playlist.
"""
playlist_name = getattr ( playlist , "name" , "unknown" )
logger_gui . debug ( f "Fetching tracks from playlist: { playlist_name } " )
media_items = items_results_all ( playlist )
# Filter for Track objects only (items_results_all may return Videos too)
tracks = [ item for item in media_items if isinstance ( item , Track )]
logger_gui . debug ( f "Found { len ( tracks ) } tracks in playlist: { playlist_name } " )
return tracks
2025-07-01 14:30:10 +02:00
def on_list_items_show ( self , item : QtWidgets . QTreeWidgetItem ) -> None :
"""Show the items in the selected playlist or mix.
Args:
item (QtWidgets.QTreeWidgetItem): The selected tree widget item.
"""
2025-06-29 20:33:22 +02:00
self . thread_it ( self . list_items_show , item )
2025-07-01 14:30:10 +02:00
def list_items_show ( self , item : QtWidgets . QTreeWidgetItem ) -> None :
2025-11-12 15:05:20 +01:00
"""Fetch and display the items in a playlist, mix, or folder.
2025-07-01 14:30:10 +02:00
Args:
2025-11-12 15:05:20 +01:00
item (QtWidgets.QTreeWidgetItem): The tree widget item representing a playlist, mix, or folder.
2025-07-01 14:30:10 +02:00
"""
2025-11-12 15:05:20 +01:00
media_list : Album | Playlist | Folder | str = get_user_list_media_item ( item )
2023-12-19 23:58:57 +01:00
# Only if clicked item is not a top level item.
if media_list :
2025-06-29 20:33:22 +02:00
# Show spinner while loading list
self . s_spinner_start . emit ( self . tr_results )
try :
2025-11-12 15:05:20 +01:00
if isinstance ( media_list , Folder ):
# Show folder contents
self . _show_folder_contents ( media_list )
elif isinstance ( media_list , str ) and media_list . startswith ( "fav_" ):
2025-06-29 20:33:22 +02:00
function_list = favorite_function_factory ( self . tidal , media_list )
self . list_items_show_result ( favorite_function = function_list )
else :
self . list_items_show_result ( media_list )
# Load cover asynchronously to avoid blocking the GUI
self . thread_it ( self . cover_show , media_list )
finally :
self . s_spinner_stop . emit ()
2023-12-19 23:58:57 +01:00
2025-11-12 15:05:20 +01:00
def _show_folder_contents ( self , folder : Folder ) -> None :
"""Display folder contents (nested playlists/folders) in results pane.
Args:
folder (Folder): The folder to display contents for.
"""
# Fetch folder contents using the shared helper method
folders , playlists = self . _fetch_folder_contents ( folder )
# Combine folders and playlists
items = folders + playlists
# Convert to ResultItems and display
result = self . search_result_to_model ( items )
self . populate_tree_results ( result )
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-06-29 20:33:22 +02:00
# Load cover asynchronously to avoid blocking the GUI
self . thread_it ( self . cover_show , media )
2024-03-29 13:10:22 +01:00
2024-04-01 12:26:10 +02:00
def on_queue_download_item_clicked ( self , item : QtWidgets . QTreeWidgetItem , column : int ) -> None :
2025-07-01 14:30:10 +02:00
"""Handle the event when a queue download item is clicked.
Args:
item (QtWidgets.QTreeWidgetItem): The clicked tree widget item.
column (int): The column index of the clicked item.
"""
2024-04-01 12:26:10 +02:00
media : Track | Video | Album | Artist | Mix | Playlist = get_queue_download_media ( item )
2025-06-29 20:33:22 +02:00
# Load cover asynchronously to avoid blocking the GUI
self . thread_it ( self . cover_show , media )
2024-04-01 12:26:10 +02:00
def cover_show ( self , media : Album | Playlist | Track | Video | Album | Artist ) -> None :
2025-07-01 14:30:10 +02:00
"""Show the cover image of the selected media item.
Args:
media (Album | Playlist | Track | Video | Album | Artist): The media item.
"""
2025-06-29 20:33:22 +02:00
cover_url : str = ""
# Show spinner in the cover label itself
parent_widget = self . l_pm_cover
2024-03-29 13:10:22 +01:00
2025-06-29 20:33:22 +02:00
# Show spinner while loading
self . s_spinner_start . emit ( parent_widget )
2025-06-29 21:53:50 +02:00
2024-03-29 13:10:22 +01:00
try :
try :
2025-06-29 20:33:22 +02:00
cover_url = media . album . image ()
except Exception :
# Only call image() if it exists
if hasattr ( media , "image" ) and callable ( getattr ( media , "image" , None )):
try :
cover_url = media . image ()
except Exception :
logger_gui . info ( f "No cover available (media ID: { getattr ( media , 'id' , 'unknown' ) } )." )
else :
2025-06-29 21:53:50 +02:00
cover_url = None
2025-06-29 20:33:22 +02:00
logger_gui . info ( f "No cover available (media ID: { getattr ( media , 'id' , 'unknown' ) } )." )
2024-03-29 13:10:22 +01:00
2025-06-29 20:33:22 +02:00
if cover_url and self . cover_url_current != cover_url :
self . cover_url_current = cover_url
data_cover : bytes = Download . cover_data ( cover_url )
pixmap : QtGui . QPixmap = QtGui . QPixmap ()
pixmap . loadFromData ( data_cover )
self . l_pm_cover . setPixmap ( pixmap )
2025-06-29 21:53:50 +02:00
elif not cover_url :
2025-06-29 20:33:22 +02:00
path_image : str = resource_path ( "tidal_dl_ng/ui/default_album_image.png" )
self . l_pm_cover . setPixmap ( QtGui . QPixmap ( path_image ))
finally :
self . s_spinner_stop . emit ()
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-07-02 08:47:59 +02:00
media_items : list [ Track | Video | Album ] = items_results_all ( media_list )
2024-05-24 07:23:10 +02:00
2025-07-01 15:52:30 +02:00
result : list [ ResultItem ] = self . 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 )
2025-07-01 14:30:10 +02:00
def on_queue_download_clear_all ( self ) -> None :
"""Clear all items from the download queue."""
2024-04-01 12:16:06 +02:00
self . on_clear_queue_download (
2024-04-02 16:26:57 +02:00
f "( { QueueDownloadStatus . Waiting } | { QueueDownloadStatus . Finished } | { QueueDownloadStatus . Failed } )"
2024-04-01 12:16:06 +02:00
)
2025-07-01 14:30:10 +02:00
def on_queue_download_clear_finished ( self ) -> None :
"""Clear finished items from the download queue."""
2024-04-02 16:26:57 +02:00
self . on_clear_queue_download ( f "[ { QueueDownloadStatus . Finished } ]" )
2024-04-01 12:16:06 +02:00
2025-07-01 14:30:10 +02:00
def on_clear_queue_download ( self , regex : str ) -> None :
"""Clear items from the download queue matching the given regex.
Args:
regex (str): Regular expression to match items.
"""
2025-07-02 08:47:59 +02:00
items : list [ QtWidgets . QTreeWidgetItem | None ] = self . tr_queue_download . findItems (
2024-04-01 12:16:06 +02:00
regex , QtCore . Qt . MatchFlag . MatchRegularExpression , column = 0
)
for item in items :
self . tr_queue_download . takeTopLevelItem ( self . tr_queue_download . indexOfTopLevelItem ( item ))
2025-07-01 14:30:10 +02:00
def on_queue_download_remove ( self ) -> None :
"""Remove selected items from the download queue."""
2025-07-02 08:47:59 +02:00
items : list [ QtWidgets . QTreeWidgetItem | None ] = self . tr_queue_download . selectedItems ()
2024-04-01 12:44:41 +02:00
if len ( items ) == 0 :
logger_gui . error ( "Please select an item from the queue first." )
else :
for item in items :
status : str = item . text ( 0 )
2024-04-02 16:26:57 +02:00
if status != QueueDownloadStatus . Downloading :
2024-04-01 12:44:41 +02:00
self . tr_queue_download . takeTopLevelItem ( self . tr_queue_download . indexOfTopLevelItem ( item ))
else :
logger_gui . info ( "Cannot remove a currently downloading item from queue." )
2025-03-03 09:31:37 +01:00
def on_pb_queue_download_toggle ( self ) -> None :
"""Toggle download status (pause / resume) accordingly.
:return: None
"""
handling_app : HandlingApp = HandlingApp ()
if handling_app . event_run . is_set ():
self . pb_queue_download_pause ()
else :
self . pb_queue_download_run ()
2025-07-01 14:30:10 +02:00
def pb_queue_download_run ( self ) -> None :
"""Start the download queue and update the button state."""
2025-03-03 09:31:37 +01:00
handling_app : HandlingApp = HandlingApp ()
handling_app . event_run . set ()
2025-10-17 11:55:39 +02:00
icon = QtGui . QIcon ( QtGui . QIcon . fromTheme ( QtGui . QIcon . ThemeIcon . MediaPlaybackPause ))
2025-03-03 09:31:37 +01:00
self . pb_queue_download_toggle . setIcon ( icon )
2025-10-17 11:55:39 +02:00
self . pb_queue_download_toggle . setStyleSheet ( "background-color: #e0a800; color: #212529" )
2025-03-03 09:31:37 +01:00
2025-07-01 14:30:10 +02:00
def pb_queue_download_pause ( self ) -> None :
"""Pause the download queue and update the button state."""
2025-03-03 09:31:37 +01:00
handling_app : HandlingApp = HandlingApp ()
handling_app . event_run . clear ()
2025-10-17 11:55:39 +02:00
icon = QtGui . QIcon ( QtGui . QIcon . fromTheme ( QtGui . QIcon . ThemeIcon . MediaPlaybackStart ))
2025-03-03 09:31:37 +01:00
self . pb_queue_download_toggle . setIcon ( icon )
2025-10-17 11:55:39 +02:00
self . pb_queue_download_toggle . setStyleSheet ( "background-color: #218838; color: #fff" )
2025-03-03 09:31:37 +01:00
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
)
2024-04-01 15:40:58 +02:00
queue_dl_item : QueueDownloadItem = self . 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 :
self . queue_download_media ( queue_dl_item )
def queue_download_media ( self , queue_dl_item : QueueDownloadItem ) -> None :
2025-07-01 14:30:10 +02:00
"""Add a media item to the download queue.
Args:
queue_dl_item (QueueDownloadItem): The item to add to the queue.
"""
2024-04-01 15:40:58 +02:00
# 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 )
2025-01-12 17:42:09 +01:00
child . setText ( 4 , queue_dl_item . quality_audio )
child . setText ( 5 , queue_dl_item . quality_video )
2024-04-01 15:40:58 +02:00
self . tr_queue_download . addTopLevelItem ( child )
2024-03-29 12:57:46 +01:00
2024-04-01 13:09:42 +02:00
def watcher_queue_download ( self ) -> None :
2025-07-01 14:30:10 +02:00
"""Monitor the download queue and process items as they become available."""
2025-03-03 09:31:37 +01:00
handling_app : HandlingApp = HandlingApp ()
while not handling_app . event_abort . is_set ():
2025-07-02 08:47:59 +02:00
items : list [ QtWidgets . QTreeWidgetItem | None ] = self . tr_queue_download . findItems (
2024-04-02 16:26:57 +02:00
QueueDownloadStatus . Waiting , QtCore . Qt . MatchFlag . MatchExactly , column = 0
2024-04-01 13:09:42 +02:00
)
2024-04-01 13:28:59 +02:00
if len ( items ) > 0 :
2024-04-20 15:37:53 +02:00
result : QueueDownloadStatus
2024-04-01 13:28:59 +02:00
item : QtWidgets . QTreeWidgetItem = items [ 0 ]
media : Track | Album | Playlist | Video | Mix | Artist = get_queue_download_media ( item )
2025-01-12 17:42:09 +01:00
quality_audio : Quality = get_queue_download_quality_audio ( item )
quality_video : QualityVideo = get_queue_download_quality_video ( item )
2024-04-01 13:28:59 +02:00
2024-04-01 15:40:58 +02:00
try :
self . s_queue_download_item_downloading . emit ( item )
2025-01-12 17:42:09 +01:00
result = self . on_queue_download ( media , quality_audio = quality_audio , quality_video = quality_video )
2024-04-20 15:37:53 +02:00
if result == QueueDownloadStatus . Finished :
self . s_queue_download_item_finished . emit ( item )
elif result == QueueDownloadStatus . Skipped :
self . s_queue_download_item_skipped . emit ( item )
2024-04-25 22:53:51 +02:00
except Exception as e :
2024-04-22 07:58:32 +02:00
logger_gui . error ( e )
2024-04-01 15:40:58 +02:00
self . s_queue_download_item_failed . emit ( item )
2024-04-01 13:28:59 +02:00
else :
time . sleep ( 2 )
2024-04-01 13:09:42 +02:00
def on_queue_download_item_downloading ( self , item : QtWidgets . QTreeWidgetItem ) -> None :
2025-07-01 14:30:10 +02:00
"""Update the status of a queue download item to 'Downloading'.
Args:
item (QtWidgets.QTreeWidgetItem): The item to update.
"""
2024-04-02 16:26:57 +02:00
self . queue_download_item_status ( item , QueueDownloadStatus . Downloading )
2024-04-01 13:09:42 +02:00
def on_queue_download_item_finished ( self , item : QtWidgets . QTreeWidgetItem ) -> None :
2025-07-01 14:30:10 +02:00
"""Update the status of a queue download item to 'Finished'.
Args:
item (QtWidgets.QTreeWidgetItem): The item to update.
"""
2024-04-02 16:26:57 +02:00
self . queue_download_item_status ( item , QueueDownloadStatus . Finished )
2024-04-01 13:09:42 +02:00
def on_queue_download_item_failed ( self , item : QtWidgets . QTreeWidgetItem ) -> None :
2025-07-01 14:30:10 +02:00
"""Update the status of a queue download item to 'Failed'.
Args:
item (QtWidgets.QTreeWidgetItem): The item to update.
"""
2024-04-02 16:26:57 +02:00
self . queue_download_item_status ( item , QueueDownloadStatus . Failed )
2024-04-01 13:09:42 +02:00
2024-04-20 15:37:53 +02:00
def on_queue_download_item_skipped ( self , item : QtWidgets . QTreeWidgetItem ) -> None :
2025-07-01 14:30:10 +02:00
"""Update the status of a queue download item to 'Skipped'.
Args:
item (QtWidgets.QTreeWidgetItem): The item to update.
"""
2024-04-20 15:37:53 +02:00
self . queue_download_item_status ( item , QueueDownloadStatus . Skipped )
2024-04-01 13:09:42 +02:00
def queue_download_item_status ( self , item : QtWidgets . QTreeWidgetItem , status : str ) -> None :
2025-07-01 14:30:10 +02:00
"""Set the status text of a queue download item.
Args:
item (QtWidgets.QTreeWidgetItem): The item to update.
status (str): The status text.
"""
2024-04-01 13:09:42 +02:00
item . setText ( 0 , status )
2024-04-21 15:58:31 +02:00
def on_queue_download (
2025-01-12 17:42:09 +01:00
self ,
media : Track | Album | Playlist | Video | Mix | Artist ,
quality_audio : Quality | None = None ,
quality_video : QualityVideo | None = None ,
2024-04-21 15:58:31 +02:00
) -> QueueDownloadStatus :
2025-07-01 14:30:10 +02:00
"""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.
"""
2024-04-20 15:37:53 +02:00
result : QueueDownloadStatus
2024-04-01 15:40:58 +02:00
items_media : [ Track | Album | Playlist | Video | Mix | Artist ]
if isinstance ( media , Artist ):
items_media : [ Album ] = items_results_all ( media )
else :
items_media = [ media ]
2023-12-19 23:58:57 +01:00
2024-04-01 13:28:59 +02:00
download_delay : bool = bool ( isinstance ( media , Track | Video ) and self . settings . data . download_delay )
2023-12-19 23:58:57 +01:00
2024-04-01 15:40:58 +02:00
for item_media in items_media :
2025-01-12 17:42:09 +01:00
result = self . download (
item_media ,
self . dl ,
delay_track = download_delay ,
quality_audio = quality_audio ,
quality_video = quality_video ,
)
2024-04-20 15:37:53 +02:00
return result
2023-12-19 23:58:57 +01:00
2024-03-22 18:18:51 +01:00
def download (
2024-04-21 15:58:31 +02:00
self ,
media : Track | Album | Playlist | Video | Mix | Artist ,
dl : Download ,
delay_track : bool = False ,
2025-01-12 17:42:09 +01:00
quality_audio : Quality | None = None ,
quality_video : QualityVideo | None = None ,
2024-04-20 15:37:53 +02:00
) -> QueueDownloadStatus :
2025-07-01 14:30:10 +02:00
"""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.
"""
2024-04-20 15:37:53 +02:00
result_dl : bool
path_file : str
result : QueueDownloadStatus
2024-01-15 21:55:51 +01:00
self . s_pb_reset . emit ()
2024-01-19 07:25:41 +01:00
self . s_statusbar_message . emit ( StatusbarMessage ( message = "Download started..." ))
2023-12-19 23:58:57 +01:00
2024-01-14 23:09:43 +01:00
file_template = get_format_template ( media , self . settings )
2023-12-19 23:58:57 +01:00
2024-01-12 11:20:10 +01:00
if isinstance ( media , Track | Video ):
2024-04-21 15:58:31 +02:00
result_dl , path_file = dl . item (
2024-04-21 22:42:19 +02:00
media = media ,
file_template = file_template ,
download_delay = delay_track ,
quality_audio = quality_audio ,
quality_video = quality_video ,
2024-04-21 15:58:31 +02:00
)
2024-01-12 11:20:10 +01:00
elif isinstance ( media , Album | Playlist | Mix ):
2024-01-12 10:21:34 +01:00
dl . items (
2024-01-19 07:34:32 +01:00
media = media ,
2023-12-19 23:58:57 +01:00
file_template = file_template ,
video_download = self . settings . data . video_download ,
download_delay = self . settings . data . download_delay ,
2024-04-21 22:42:19 +02:00
quality_audio = quality_audio ,
quality_video = quality_video ,
2023-12-19 23:58:57 +01:00
)
2024-04-20 15:37:53 +02:00
# Dummy values
result_dl = True
2024-04-20 15:38:50 +02:00
path_file = "dummy"
2024-04-20 15:37:53 +02:00
2025-06-30 10:44:51 +02:00
self . s_statusbar_message . emit ( StatusbarMessage ( message = "Download finished." , timeout = 2000 ))
2023-12-19 23:58:57 +01:00
2024-04-20 15:37:53 +02:00
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
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 )
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-07-01 15:52:30 +02:00
def closeEvent ( self , event : QtGui . QCloseEvent ) -> None :
2025-07-01 14:30:10 +02:00
"""Handle the close event of the main window.
Args:
2025-07-01 15:52:30 +02:00
event (QtGui.QCloseEvent): The close event.
2025-07-01 14:30:10 +02:00
"""
2025-09-01 14:49:24 -04:00
# 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 ()
2025-01-03 14:37:42 +01:00
self . shutdown = True
2025-03-03 09:31:37 +01:00
handling_app : HandlingApp = HandlingApp ()
handling_app . event_abort . set ()
2025-01-03 14:37:42 +01:00
event . accept ()
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
queue_dl_item : QueueDownloadItem | None = self . 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." )
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 ()