import math import sys import time from collections.abc import Callable, Sequence from requests.exceptions import HTTPError from tidalapi.session import LinkLogin from tidal_dl_ng import __version__, update_available from tidal_dl_ng.dialog import DialogLogin, DialogPreferences, DialogVersion from tidal_dl_ng.helper.gui import ( FilterHeader, HumanProxyModel, get_queue_download_media, get_queue_download_quality, get_results_media_item, get_user_list_media_item, set_queue_download_media, set_user_list_media, ) from tidal_dl_ng.helper.path import get_format_template, resource_path from tidal_dl_ng.helper.tidal import ( favorite_function_factory, get_tidal_media_id, get_tidal_media_type, instantiate_media, items_results_all, name_builder_artist, name_builder_title, quality_audio_highest, search_results_all, user_media_lists, ) try: import qdarktheme from PySide6 import QtCore, QtGui, QtWidgets except ImportError as e: print(e) print("Qt dependencies missing. Cannot start GUI. Please execute: 'pip install pyside6 pyqtdarktheme'") sys.exit(1) import coloredlogs.converter from rich.progress import Progress from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video from tidalapi.artist import Artist from tidalapi.session import SearchTypes from tidal_dl_ng.config import Settings, Tidal from tidal_dl_ng.constants import FAVORITES, QualityVideo, QueueDownloadStatus, TidalLists from tidal_dl_ng.download import Download from tidal_dl_ng.logger import XStream, logger_gui from tidal_dl_ng.model.gui_data import ProgressBars, QueueDownloadItem, ResultItem, StatusbarMessage from tidal_dl_ng.model.meta import ReleaseLatest from tidal_dl_ng.ui.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): settings: Settings tidal: Tidal dl: Download threadpool: QtCore.QThreadPool tray: QtWidgets.QSystemTrayIcon spinner: QtWaitingSpinner cover_url_current: str = "" model_tr_results: QtGui.QStandardItemModel = QtGui.QStandardItemModel() proxy_tr_results: HumanProxyModel s_spinner_start: QtCore.Signal = QtCore.Signal(QtWidgets.QWidget) s_spinner_stop: QtCore.Signal = QtCore.Signal() pb_item: QtWidgets.QProgressBar s_item_advance: QtCore.Signal = QtCore.Signal(float) s_item_name: QtCore.Signal = QtCore.Signal(str) s_list_name: QtCore.Signal = QtCore.Signal(str) pb_list: QtWidgets.QProgressBar s_list_advance: QtCore.Signal = QtCore.Signal(float) s_pb_reset: QtCore.Signal = QtCore.Signal() s_populate_tree_lists: QtCore.Signal = QtCore.Signal(list) s_statusbar_message: QtCore.Signal = QtCore.Signal(object) s_tr_results_add_top_level_item: QtCore.Signal = QtCore.Signal(object) s_settings_save: QtCore.Signal = QtCore.Signal() s_pb_reload_status: QtCore.Signal = QtCore.Signal(bool) s_update_check: QtCore.Signal = QtCore.Signal(bool) s_update_show: QtCore.Signal = QtCore.Signal(bool, bool, object) s_queue_download_item_downloading: QtCore.Signal = QtCore.Signal(object) s_queue_download_item_finished: QtCore.Signal = QtCore.Signal(object) s_queue_download_item_failed: QtCore.Signal = QtCore.Signal(object) s_queue_download_item_skipped: QtCore.Signal = QtCore.Signal(object) def __init__(self, tidal: Tidal | None = None): super().__init__() self.setupUi(self) # self.setGeometry(50, 50, 500, 300) self.setWindowTitle("TIDAL Downloader Next Generation!") # Logging redirect. XStream.stdout().messageWritten.connect(self._log_output) # XStream.stderr().messageWritten.connect(self._log_output) self.settings = Settings() self._init_threads() self._init_tree_results_model(self.model_tr_results) self._init_tree_results(self.tr_results, self.model_tr_results) self._init_tree_lists(self.tr_lists_user) self._init_tree_queue(self.tr_queue_download) self._init_info() self._init_progressbar() self._populate_quality(self.cb_quality_audio, Quality) self._populate_quality(self.cb_quality_video, QualityVideo) self._populate_search_types(self.cb_search_type, SearchTypes) self.apply_settings(self.settings) self._init_signals() self.init_tidal(tidal) logger_gui.debug("All setup.") def init_tidal(self, tidal: Tidal = None): result: bool = False if tidal: self.tidal = tidal result = True else: self.tidal = Tidal(self.settings) result = self.tidal.login_token() if not result: hint: str = "After you have finished the TIDAL login via web browser click the 'OK' button." while not result: link_login: LinkLogin = self.tidal.session.get_link_login() d_login: DialogLogin = DialogLogin( url_login=link_login.verification_uri_complete, hint=hint, expires_in=link_login.expires_in, parent=self, ) if d_login.return_code == 1: try: self.tidal.session.process_link_login(link_login, until_expiry=False) self.tidal.login_finalize() result = True logger_gui.info("Login successful. Have fun!") except (HTTPError, Exception): hint = "Something was wrong with your redirect url. Please try again!" logger_gui.warning("Login not successful. Try again...") else: # If user has pressed cancel. sys.exit(1) if result: self._init_dl() self.thread_it(self.tidal_user_lists) def _init_threads(self): self.threadpool = QtCore.QThreadPool() self.thread_it(self.watcher_queue_download) def _init_dl(self): # 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() self.dl = Download( session=self.tidal.session, 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, ) def _init_progressbar(self): self.pb_list = QtWidgets.QProgressBar() self.pb_item = QtWidgets.QProgressBar() pbs = [self.pb_list, self.pb_item] for pb in pbs: pb.setRange(0, 100) # self.pb_progress.setVisible() self.statusbar.addPermanentWidget(pb) def _init_info(self): path_image: str = resource_path("tidal_dl_ng/ui/default_album_image.png") self.l_pm_cover.setPixmap(QtGui.QPixmap(path_image)) def on_progress_reset(self): self.pb_list.setValue(0) self.pb_item.setValue(0) def on_statusbar_message(self, data: StatusbarMessage): self.statusbar.showMessage(data.message, data.timout) def _log_output(self, text): display_msg = coloredlogs.converter.convert(text) cursor: QtGui.QTextCursor = self.te_debug.textCursor() cursor.movePosition(QtGui.QTextCursor.End) cursor.insertHtml(display_msg) self.te_debug.setTextCursor(cursor) self.te_debug.ensureCursorVisible() def _populate_quality(self, ui_target: QtWidgets.QComboBox, options: type[Quality | QualityVideo]): for item in options: ui_target.addItem(item.name, item) def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: SearchTypes): for item in options: if item: ui_target.addItem(item.__name__, item) self.cb_search_type.setCurrentIndex(2) def handle_filter_activated(self): header: FilterHeader = self.tr_results.header() filters = [] for i in range(header.count()): text: str = header.filter_text(i) if text: filters.append((i, text)) proxy_model: HumanProxyModel = self.tr_results.model() proxy_model.filters = filters def _init_tree_results(self, tree: QtWidgets.QTreeView, model: QtGui.QStandardItemModel) -> None: header: FilterHeader = FilterHeader(tree) self.proxy_tr_results: HumanProxyModel = HumanProxyModel(self) tree.setHeader(header) tree.setModel(model) self.proxy_tr_results.setSourceModel(model) tree.setModel(self.proxy_tr_results) header.set_filter_boxes(model.columnCount()) header.filter_activated.connect(self.handle_filter_activated) ## Styling tree.sortByColumn(0, QtCore.Qt.SortOrder.AscendingOrder) tree.setColumnHidden(1, True) tree.setColumnWidth(2, 150) tree.setColumnWidth(3, 150) tree.setColumnWidth(4, 150) header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # Connect the contextmenu tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) tree.customContextMenuRequested.connect(self.menu_context_tree_results) def _init_tree_results_model(self, model: QtGui.QStandardItemModel) -> None: labels_column: [str] = ["#", "obj", "Artist", "Title", "Album", "Duration", "Quality", "Date Added"] model.setColumnCount(len(labels_column)) model.setRowCount(0) model.setHorizontalHeaderLabels(labels_column) def _init_tree_queue(self, tree: QtWidgets.QTableWidget): tree.setColumnHidden(1, True) tree.setColumnWidth(2, 200) header = tree.header() header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) header.setStretchLastSection(False) def tidal_user_lists(self): # Start loading spinner self.s_spinner_start.emit(self.tr_lists_user) self.s_pb_reload_status.emit(False) user_all: [Playlist | UserPlaylist | Mix] = user_media_lists(self.tidal.session) self.s_populate_tree_lists.emit(user_all) def on_populate_tree_lists(self, user_lists: [Playlist | UserPlaylist | Mix]): twi_playlists: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( TidalLists.Playlists, QtCore.Qt.MatchExactly, 0 )[0] twi_mixes: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( TidalLists.Mixes, QtCore.Qt.MatchExactly, 0 )[0] twi_favorites: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( TidalLists.Favorites, QtCore.Qt.MatchExactly, 0 )[0] # Remove all children if present for twi in [twi_playlists, twi_mixes]: for i in reversed(range(twi.childCount())): twi.removeChild(twi.child(i)) # Populate dynamic user lists for item in user_lists: if isinstance(item, UserPlaylist | Playlist): twi_child = QtWidgets.QTreeWidgetItem(twi_playlists) name: str = item.name description: str = f" {item.description}" if item.description else "" info: str = f"({item.num_tracks + item.num_videos} Tracks){description}" elif isinstance(item, Mix): twi_child = QtWidgets.QTreeWidgetItem(twi_mixes) name: str = item.title info: str = item.sub_title twi_child.setText(0, name) set_user_list_media(twi_child, item) twi_child.setText(2, info) # Populate static favorites for key, favorite in FAVORITES.items(): twi_child = QtWidgets.QTreeWidgetItem(twi_favorites) name: str = favorite["name"] info: str = "" twi_child.setText(0, name) set_user_list_media(twi_child, key) twi_child.setText(2, info) # Stop load spinner self.s_spinner_stop.emit() self.s_pb_reload_status.emit(True) def _init_tree_lists(self, tree: QtWidgets.QTreeWidget): # Adjust Tree. tree.setColumnWidth(0, 200) tree.setColumnHidden(1, True) tree.setColumnWidth(2, 300) tree.expandAll() # Connect the contextmenu tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) tree.customContextMenuRequested.connect(self.menu_context_tree_lists) def on_update_check(self, on_startup: bool = True): is_available, info = update_available() if (on_startup and is_available) or not on_startup: self.s_update_show.emit(True, is_available, info) def apply_settings(self, settings: Settings): l_cb = [ {"element": self.cb_quality_audio, "setting": settings.data.quality_audio, "default_id": 1}, {"element": self.cb_quality_video, "setting": settings.data.quality_video, "default_id": 0}, ] for item in l_cb: idx = item["element"].findData(item["setting"]) if idx > -1: item["element"].setCurrentIndex(idx) else: item["element"].setCurrentIndex(item["default_id"]) def on_spinner_start(self, parent: QtWidgets.QWidget): self.spinner = QtWaitingSpinner(parent, True, True) self.spinner.setColor(QtGui.QColor(255, 255, 255)) self.spinner.start() def on_spinner_stop(self): self.spinner.stop() self.spinner = None def menu_context_tree_lists(self, point: QtCore.QPoint): # 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 # We build the menu. menu = QtWidgets.QMenu() menu.addAction("Download Playlist", lambda: self.thread_download_list_media(point)) menu.addAction("Copy Share URL", lambda: self.thread_copy_url_share(self.tr_lists_user, point)) menu.exec(self.tr_lists_user.mapToGlobal(point)) def menu_context_tree_results(self, point: QtCore.QPoint): # 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 # We build the menu. menu = QtWidgets.QMenu() menu.addAction("Copy Share URL", lambda: self.thread_copy_url_share(self.tr_results, point)) menu.exec(self.tr_results.mapToGlobal(point)) def thread_download_list_media(self, point: QtCore.QPoint): self.thread_it(self.on_download_list_media, point) def thread_copy_url_share(self, tree_target: QtWidgets.QTreeWidget, point: QtCore.QPoint): self.thread_it(self.on_copy_url_share, tree_target, point) def on_copy_url_share(self, tree_target: QtWidgets.QTreeWidget | QtWidgets.QTreeView, point: QtCore.QPoint = None): if isinstance(tree_target, QtWidgets.QTreeWidget): item: QtWidgets.QTreeWidgetItem = tree_target.itemAt(point) media: Album | Artist | Mix | Playlist = get_user_list_media_item(item) else: index: QtCore.QModelIndex = tree_target.indexAt(point) media: Track | Video | Album | Artist | Mix | Playlist = get_results_media_item( index, self.proxy_tr_results, self.model_tr_results ) clipboard = QtWidgets.QApplication.clipboard() url_share = media.share_url if hasattr(media, "share_url") else "No share URL available." clipboard.clear() clipboard.setText(url_share) def on_download_list_media(self, point: QtCore.QPoint = None): items: [QtWidgets.QTreeWidgetItem] if point: items = [self.tr_lists_user.itemAt(point)] else: items = self.tr_lists_user.selectedItems() if len(items) == 0: logger_gui.error("Please select a mix or playlist first.") for item in items: media = get_user_list_media_item(item) queue_dl_item: QueueDownloadItem | False = self.media_to_queue_download_model(media) if queue_dl_item: self.queue_download_media(queue_dl_item) def search_populate_results(self, query: str, type_media: SearchTypes): self.model_tr_results.removeRows(0, self.model_tr_results.rowCount()) results: [ResultItem] = self.search(query, [type_media]) self.populate_tree_results(results) def populate_tree_results(self, results: [ResultItem], parent: QtGui.QStandardItem = None): if not parent: self.model_tr_results.removeRows(0, self.model_tr_results.rowCount()) # Count how many digits the list length has, count_digits: int = int(math.log10(len(results) if results else 1)) + 1 for item in results: child: tuple = self.populate_tree_result_child(item=item, index_count_digits=count_digits) if parent: parent.appendRow(child) else: self.s_tr_results_add_top_level_item.emit(child) def populate_tree_result_child( self, item: [Track | Video | Mix | Album | Playlist], index_count_digits: int ) -> Sequence[QtGui.QStandardItem]: duration: str = "" # TODO: Duration needs to be calculated later to properly fill with zeros. if item.duration_sec > -1: # Format seconds to mm:ss. m, s = divmod(item.duration_sec, 60) duration: str = f"{m:02d}:{s:02d}" # Since sorting happens only by string, we need to pad the index and add 1 (to avoid start at 0) index: str = str(item.position + 1).zfill(index_count_digits) # Populate child child_index: QtGui.QStandardItem = QtGui.QStandardItem(index) # TODO: Move to own method child_obj: QtGui.QStandardItem = QtGui.QStandardItem() child_obj.setData(item.obj, QtCore.Qt.ItemDataRole.UserRole) # set_results_media(child, item.obj) child_artist: QtGui.QStandardItem = QtGui.QStandardItem(item.artist) child_title: QtGui.QStandardItem = QtGui.QStandardItem(item.title) child_album: QtGui.QStandardItem = QtGui.QStandardItem(item.album) child_duration: QtGui.QStandardItem = QtGui.QStandardItem(duration) child_quality: QtGui.QStandardItem = QtGui.QStandardItem(item.quality) child_date_added: QtGui.QStandardItem = QtGui.QStandardItem(item.date_user_added) if isinstance(item.obj, Mix | Playlist | Album | Artist): # Add a disabled dummy child, so expansion arrow will appear. This Child will be replaced on expansion. child_dummy: QtGui.QStandardItem = QtGui.QStandardItem() child_dummy.setEnabled(False) child_index.appendRow(child_dummy) return ( child_index, child_obj, child_artist, child_title, child_album, child_duration, child_quality, child_date_added, ) def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]): self.model_tr_results.appendRow(item_child) def on_settings_save(self): self.settings.save() self.apply_settings(self.settings) self._init_dl() def search(self, query: str, types_media: SearchTypes) -> [ResultItem]: query = query.strip() # If a direct link was searched for, skip search and create the object from the link directly. if "http" in query: media_type = get_tidal_media_type(query) item_id = get_tidal_media_id(query) try: media = instantiate_media(self.tidal.session, media_type, item_id) except: logger_gui.error(f"Media not found (ID: {item_id}). Maybe it is not available anymore.") media = None result_search = {"direct": [media]} else: result_search: dict[str, [SearchTypes]] = search_results_all( session=self.tidal.session, needle=query, types_media=types_media ) result: [ResultItem] = [] for _media_type, l_media in result_search.items(): if isinstance(l_media, list): result = result + self.search_result_to_model(l_media) return result def search_result_to_model(self, items: [*SearchTypes]) -> [ResultItem]: result = [] for idx, item in enumerate(items): explicit: str = "" # Check if item is available on TIDAL. if hasattr(item, "available") and not item.available: continue if isinstance(item, Track | Video | Album): explicit = " 🅴" if item.explicit else "" date_user_added: str = item.user_date_added.strftime("%Y-%m-%d_%H:%M") if item.user_date_added else "" if isinstance(item, Track): result_item: ResultItem = 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, quality=quality_audio_highest(item), explicit=bool(item.explicit), date_user_added=date_user_added, ) result.append(result_item) elif isinstance(item, Video): result_item: ResultItem = 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, ) result.append(result_item) elif isinstance(item, Playlist): result_item: ResultItem = 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, ) result.append(result_item) elif isinstance(item, Album): result_item: ResultItem = 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, ) result.append(result_item) elif isinstance(item, Mix): result_item: ResultItem = ResultItem( position=idx, artist=item.sub_title, title=item.title, album="", # TODO: Calculate total duration. duration_sec=-1, obj=item, quality="", explicit=False, date_user_added=date_user_added, ) result.append(result_item) elif isinstance(item, Artist): result_item: ResultItem = ResultItem( position=idx, artist=item.name, title="", album="", duration_sec=-1, obj=item, quality="", explicit=False, date_user_added=date_user_added, ) result.append(result_item) return result def media_to_queue_download_model( self, media: Artist | Track | Video | Album | Playlist | Mix ) -> QueueDownloadItem | bool: result: QueueDownloadItem | False name: str = "" quality: Quality | QualityVideo | str = "" explicit: str = "" # Check if item is available on TIDAL. if hasattr(media, "available") and not media.available: return False # Set "Explicit" tag if isinstance(media, Track | Video | Album): explicit = " 🅴" if media.explicit else "" # Build name and set quality if isinstance(media, Track | Video): name = f"{name_builder_artist(media)} - {name_builder_title(media)}{explicit}" elif isinstance(media, Playlist | Artist): name = media.name quality = self.settings.data.quality_audio elif isinstance(media, Album): name = f"{name_builder_artist(media)} - {media.name}{explicit}" elif isinstance(media, Mix): name = media.title quality = self.settings.data.quality_audio # Determine actual quality. if isinstance(media, Track | Album): quality_highest: str = quality_audio_highest(media) if ( self.settings.data.quality_audio == quality_highest or self.settings.data.quality_audio == Quality.hi_res_lossless ): quality = quality_highest else: quality = self.settings.data.quality_audio elif isinstance(media, Video): quality = self.settings.data.quality_video if name: result = QueueDownloadItem( name=name, quality=quality, type_media=type(media).__name__, status=QueueDownloadStatus.Waiting, obj=media, ) else: result = False return result def _init_signals(self): self.pb_download.clicked.connect(lambda: self.thread_it(self.on_download_results)) self.pb_download_list.clicked.connect(lambda: self.thread_it(self.on_download_list_media)) self.pb_reload_user_lists.clicked.connect(lambda: self.thread_it(self.tidal_user_lists)) 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) self.l_search.returnPressed.connect( lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) ) self.pb_search.clicked.connect( lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) ) self.cb_quality_audio.currentIndexChanged.connect(self.on_quality_set_audio) self.cb_quality_video.currentIndexChanged.connect(self.on_quality_set_video) self.tr_lists_user.itemClicked.connect(self.on_list_items_show) self.s_spinner_start[QtWidgets.QWidget].connect(self.on_spinner_start) self.s_spinner_stop.connect(self.on_spinner_stop) self.s_item_advance.connect(self.on_progress_item) self.s_item_name.connect(self.on_progress_item_name) self.s_list_name.connect(self.on_progress_list_name) self.s_list_advance.connect(self.on_progress_list) self.s_pb_reset.connect(self.on_progress_reset) self.s_populate_tree_lists.connect(self.on_populate_tree_lists) self.s_statusbar_message.connect(self.on_statusbar_message) self.s_tr_results_add_top_level_item.connect(self.on_tr_results_add_top_level_item) self.s_settings_save.connect(self.on_settings_save) self.s_pb_reload_status.connect(self.button_reload_status) self.s_update_check.connect(lambda: self.thread_it(self.on_update_check)) self.s_update_show.connect(self.on_version) # Menubar self.a_exit.triggered.connect(sys.exit) self.a_version.triggered.connect(self.on_version) self.a_preferences.triggered.connect(self.on_preferences) self.a_logout.triggered.connect(self.on_logout) self.a_updates_check.triggered.connect(lambda: self.on_update_check(False)) # Results self.tr_results.expanded.connect(self.on_tr_results_expanded) self.tr_results.clicked.connect(self.on_result_item_clicked) # Download Queue self.tr_queue_download.itemClicked.connect(self.on_queue_download_item_clicked) self.s_queue_download_item_downloading.connect(self.on_queue_download_item_downloading) 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) self.s_queue_download_item_skipped.connect(self.on_queue_download_item_skipped) def on_logout(self): result: bool = self.tidal.logout() if result: sys.exit(0) def on_progress_list(self, value: float): self.pb_list.setValue(int(math.ceil(value))) def on_progress_item(self, value: float): self.pb_item.setValue(int(math.ceil(value))) def on_progress_item_name(self, value: str): self.pb_item.setFormat(f"%p% {value}") def on_progress_list_name(self, value: str): self.pb_list.setFormat(f"%p% {value}") def on_quality_set_audio(self, index): self.settings.data.quality_audio = Quality(self.cb_quality_audio.itemData(index)) self.settings.save() if self.tidal: self.tidal.settings_apply() def on_quality_set_video(self, index): self.settings.data.quality_video = QualityVideo(self.cb_quality_video.itemData(index)) self.settings.save() if self.tidal: self.tidal.settings_apply() def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem): media_list: Album | Playlist | str = get_user_list_media_item(item) # Only if clicked item is not a top level item. if media_list: if isinstance(media_list, str) and media_list.startswith("fav_"): 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) self.cover_show(media_list) def on_result_item_clicked(self, index: QtCore.QModelIndex) -> None: media: Track | Video | Album | Artist = get_results_media_item( index, self.proxy_tr_results, self.model_tr_results ) self.cover_show(media) def on_queue_download_item_clicked(self, item: QtWidgets.QTreeWidgetItem, column: int) -> None: media: Track | Video | Album | Artist | Mix | Playlist = get_queue_download_media(item) self.cover_show(media) def cover_show(self, media: Album | Playlist | Track | Video | Album | Artist) -> None: cover_url: str try: cover_url = media.album.image() except: try: cover_url = media.image() except: logger_gui.info(f"No cover available (media ID: {media.id}).") 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) def list_items_show_result( self, media_list: Album | Playlist | Mix | Artist | None = None, point: QtCore.QPoint | None = None, parent: QtGui.QStandardItem = None, favorite_function: Callable = None, ) -> None: if point: item = self.tr_lists_user.itemAt(point) media_list = get_user_list_media_item(item) # Get all results if favorite_function or isinstance(media_list, str): if isinstance(media_list, str): favorite_function = favorite_function_factory(self.tidal, media_list) media_items: [Track | Video | Album] = favorite_function() else: media_items: [Track | Video | Album] = items_results_all(media_list) result: [ResultItem] = self.search_result_to_model(media_items) self.populate_tree_results(result, parent=parent) def thread_it(self, fn: Callable, *args, **kwargs): # Any other args, kwargs are passed to the run function worker = Worker(fn, *args, **kwargs) # Execute self.threadpool.start(worker) def on_queue_download_clear_all(self): self.on_clear_queue_download( f"({QueueDownloadStatus.Waiting}|{QueueDownloadStatus.Finished}|{QueueDownloadStatus.Failed})" ) def on_queue_download_clear_finished(self): self.on_clear_queue_download(f"[{QueueDownloadStatus.Finished}]") def on_clear_queue_download(self, regex: str): items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( regex, QtCore.Qt.MatchFlag.MatchRegularExpression, column=0 ) for item in items: self.tr_queue_download.takeTopLevelItem(self.tr_queue_download.indexOfTopLevelItem(item)) def on_queue_download_remove(self): items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.selectedItems() 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) if status != QueueDownloadStatus.Downloading: self.tr_queue_download.takeTopLevelItem(self.tr_queue_download.indexOfTopLevelItem(item)) else: logger_gui.info("Cannot remove a currently downloading item from queue.") # TODO: Must happen in main thread. Do not thread this. def on_download_results(self) -> None: items: [HumanProxyModel | None] = self.tr_results.selectionModel().selectedRows() if len(items) == 0: logger_gui.error("Please select a row first.") else: for item in items: media: Track | Album | Playlist | Video | Artist = get_results_media_item( item, self.proxy_tr_results, self.model_tr_results ) queue_dl_item: QueueDownloadItem = self.media_to_queue_download_model(media) if queue_dl_item: self.queue_download_media(queue_dl_item) def queue_download_media(self, queue_dl_item: QueueDownloadItem) -> None: # Populate child child: QtWidgets.QTreeWidgetItem = QtWidgets.QTreeWidgetItem() child.setText(0, queue_dl_item.status) set_queue_download_media(child, queue_dl_item.obj) child.setText(2, queue_dl_item.name) child.setText(3, queue_dl_item.type_media) child.setText(4, queue_dl_item.quality) self.tr_queue_download.addTopLevelItem(child) def watcher_queue_download(self) -> None: while True: items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0 ) if len(items) > 0: result: QueueDownloadStatus item: QtWidgets.QTreeWidgetItem = items[0] media: Track | Album | Playlist | Video | Mix | Artist = get_queue_download_media(item) tmp_quality: str = get_queue_download_quality(item) quality: Quality | QualityVideo | None = tmp_quality if tmp_quality else None try: self.s_queue_download_item_downloading.emit(item) result = self.on_queue_download(media, quality=quality) if result == QueueDownloadStatus.Finished: self.s_queue_download_item_finished.emit(item) elif result == QueueDownloadStatus.Skipped: self.s_queue_download_item_skipped.emit(item) except Exception as e: logger_gui.error(e) self.s_queue_download_item_failed.emit(item) else: time.sleep(2) def on_queue_download_item_downloading(self, item: QtWidgets.QTreeWidgetItem) -> None: self.queue_download_item_status(item, QueueDownloadStatus.Downloading) def on_queue_download_item_finished(self, item: QtWidgets.QTreeWidgetItem) -> None: self.queue_download_item_status(item, QueueDownloadStatus.Finished) def on_queue_download_item_failed(self, item: QtWidgets.QTreeWidgetItem) -> None: self.queue_download_item_status(item, QueueDownloadStatus.Failed) def on_queue_download_item_skipped(self, item: QtWidgets.QTreeWidgetItem) -> None: self.queue_download_item_status(item, QueueDownloadStatus.Skipped) def queue_download_item_status(self, item: QtWidgets.QTreeWidgetItem, status: str) -> None: item.setText(0, status) def on_queue_download( self, media: Track | Album | Playlist | Video | Mix | Artist, quality: Quality | QualityVideo | None = None ) -> QueueDownloadStatus: result: QueueDownloadStatus items_media: [Track | Album | Playlist | Video | Mix | Artist] if isinstance(media, Artist): items_media: [Album] = items_results_all(media) else: items_media = [media] download_delay: bool = bool(isinstance(media, Track | Video) and self.settings.data.download_delay) for item_media in items_media: result = self.download(item_media, self.dl, delay_track=download_delay, quality=quality) return result def download( self, media: Track | Album | Playlist | Video | Mix | Artist, dl: Download, delay_track: bool = False, quality: Quality | QualityVideo | None = None, ) -> QueueDownloadStatus: result_dl: bool path_file: str result: QueueDownloadStatus quality_audio: Quality | None quality_video: QualityVideo | None self.s_pb_reset.emit() self.s_statusbar_message.emit(StatusbarMessage(message="Download started...")) file_template = get_format_template(media, self.settings) if isinstance(media, Track | Video): if isinstance(media, Track): quality_audio = quality quality_video = None else: quality_audio = None quality_video = quality result_dl, path_file = dl.item( media=media, file_template=file_template, download_delay=delay_track, quality_audio=quality_audio, quality_video=quality_video, ) elif isinstance(media, Album | Playlist | Mix): if isinstance(media, Album): quality_audio = quality quality_video = None else: quality_audio = None quality_video = None dl.items( media=media, file_template=file_template, video_download=self.settings.data.video_download, download_delay=self.settings.data.download_delay, quality_audio=quality_audio, quality_video=quality_video, ) # Dummy values result_dl = True path_file = "dummy" self.s_statusbar_message.emit(StatusbarMessage(message="Download finished.", timout=2000)) if result_dl and path_file: result = QueueDownloadStatus.Finished elif not result_dl and path_file: result = QueueDownloadStatus.Skipped else: result = QueueDownloadStatus.Failed return result def on_version( self, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest = None ) -> None: DialogVersion(self, update_check, update_available, update_info) def on_preferences(self) -> None: DialogPreferences(settings=self.settings, settings_save=self.s_settings_save, parent=self) def on_tr_results_expanded(self, index: QtCore.QModelIndex) -> None: # If the child is a dummy the list_item has not been expanded before item: QtGui.QStandardItem = self.model_tr_results.itemFromIndex(self.proxy_tr_results.mapToSource(index)) load_children: bool = not item.child(0, 0).isEnabled() if load_children: item.removeRow(0) media_list: [Mix | Album | Playlist | Artist] = get_results_media_item( index, self.proxy_tr_results, self.model_tr_results ) self.list_items_show_result(media_list=media_list, parent=item) def button_reload_status(self, status: bool): button_text: str = "Reloading..." if status: button_text = "Reload" self.pb_reload_user_lists.setEnabled(status) self.pb_reload_user_lists.setText(button_text) # TODO: Comment with Google Docstrings. def gui_activate(tidal: Tidal | None = None): # Set dark theme and create QT app. qdarktheme.enable_hi_dpi() app = QtWidgets.QApplication(sys.argv) # Fix for Windows: Tooltips have bright font color # https://github.com/5yutan5/PyQtDarkTheme/issues/239 # qdarktheme.setup_theme() qdarktheme.setup_theme(additional_qss="QToolTip { border: 0px; }") # Create icon object and apply it to app window. pixmap: QtGui.QPixmap = QtGui.QPixmap("tidal_dl_ng/ui/icon.png") icon: QtGui.QIcon = QtGui.QIcon(pixmap) app.setWindowIcon(icon) # This bit gets the taskbar icon working properly in Windows if sys.platform.startswith("win"): import ctypes # Make sure Pyinstaller icons are still grouped if not sys.argv[0].endswith(".exe"): # Arbitrary string my_app_id: str = "exislow.tidal.dl-ng." + __version__ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_app_id) window = MainWindow(tidal=tidal) window.show() # Check for updates window.s_update_check.emit(True) sys.exit(app.exec()) if __name__ == "__main__": gui_activate()