diff options
Diffstat (limited to 'tidal_dl_ng/gui.py')
| -rw-r--r-- | tidal_dl_ng/gui.py | 481 |
1 files changed, 481 insertions, 0 deletions
diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py new file mode 100644 index 0000000..a995c01 --- /dev/null +++ b/tidal_dl_ng/gui.py @@ -0,0 +1,481 @@ +import math +import sys +from collections.abc import Callable + +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.session import SearchTypes +from tidal_dl_ng.config import Settings, Tidal +from tidal_dl_ng.constants import QualityVideo +from tidal_dl_ng.download import Download +from tidal_dl_ng.logger import XStream, logger_cli, logger_gui +from tidal_dl_ng.model.gui_data import ProgressBars, ResultSearch +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 = None + tidal: Tidal = None + download: Download = None + threadpool: QtCore.QThreadPool = None + spinner: QtWaitingSpinner = None + spinner_start: QtCore.Signal = QtCore.Signal(QtWidgets.QWidget) + spinner_stop: QtCore.Signal = QtCore.Signal() + pb_item: QtWidgets.QProgressBar = None + s_item_advance: QtCore.Signal = QtCore.Signal(float) + s_item_name: QtCore.Signal = QtCore.Signal(str) + pb_list: QtWidgets.QProgressBar = None + s_list_advance: QtCore.Signal = QtCore.Signal(float) + + def __init__(self, tidal: Tidal | None = None): + super().__init__() + self.setupUi(self) + # self.setGeometry(50, 50, 500, 300) + self.setWindowTitle("TIDAL Downloader Next Gen!") + # TODO: Fix icons (make them visible). + my_pixmap = QtGui.QPixmap("tidal_dl_ng/ui/icon.png") + my_icon = QtGui.QIcon("tidal_dl_ng/ui/icon.png") + self.setWindowIcon(my_icon) + tray = QtWidgets.QSystemTrayIcon() + tray.setIcon(my_icon) + tray.setVisible(True) + + # Logging redirect. + XStream.stdout().messageWritten.connect(self._log_output) + # XStream.stderr().messageWritten.connect(self._log_output) + + self.settings = Settings() + self.threadpool = QtCore.QThreadPool() + + # TODO: Show GUI, create a progress bar showing the TIDAL querying progress. + self._init_tree_results(self.tr_results) + self._init_tree_lists(self.tr_lists_user) + 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.thread_it(self.init_tidal, tidal) + + logger_gui.debug("Multithreading with maximum %d threads" % self.threadpool.maxThreadCount()) + 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) + + while True: + result = self.tidal.login(logger_gui.info) + + if result: + break + + if result: + self.download = Download(self.tidal.session, self.tidal.settings.data.skip_existing) + + self.thread_it(self.populate_tree_lists, self.tidal) + + 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 progress_reset(self): + self.pb_list.setValue(0) + self.pb_item.setValue(0) + + 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 and not item.__name__ == "Artist": + ui_target.addItem(item.__name__, item) + + self.cb_search_type.setCurrentIndex(1) + + def _init_tree_results(self, tree: QtWidgets.QTableWidget): + tree.setColumnHidden(5, True) + + def populate_tree_lists(self, tidal: Tidal): + # Start loading spinner + self.spinner_start.emit(self.tr_lists_user) + self.tr_results.clear() + + # TODO: Refactor list item names to constants. + twi_playlists: QtWidgets.QTableWidgetItem = self.tr_lists_user.findItems( + "Playlists", QtCore.Qt.MatchExactly, 0 + )[0] + twi_mixes: QtWidgets.QTableWidgetItem = self.tr_lists_user.findItems("Favorites", QtCore.Qt.MatchExactly, 0)[0] + twi_favorites: QtWidgets.QTableWidgetItem = self.tr_lists_user.findItems("Mixes", QtCore.Qt.MatchExactly, 0)[0] + + user_playlists: [Playlist | UserPlaylist] = tidal.session.user.playlist_and_favorite_playlists() + user_mixes: [Mix] = tidal.session.mixes().categories[0].items + user_all: [Playlist | UserPlaylist | Mix] = user_playlists + user_mixes + + for item in user_all: + if isinstance(item, UserPlaylist): + twi_child = QtWidgets.QTreeWidgetItem(twi_playlists) + name: str = item.name + info: str = f"({item.num_tracks + item.num_videos} Tracks)" + elif isinstance(item, Playlist): + twi_child = QtWidgets.QTreeWidgetItem(twi_mixes) + name: str = item.name + info: str = f"({item.num_tracks + item.num_videos} Tracks) {item.description}" + elif isinstance(item, Mix): + twi_child = QtWidgets.QTreeWidgetItem(twi_favorites) + name: str = item.title + info: str = item.sub_title + + twi_child.setText(0, name) + twi_child.setText(1, info) + twi_child.setData(3, QtCore.Qt.ItemDataRole.UserRole, item) + + # Stop load spinner + self.spinner_stop.emit() + + def _init_tree_lists(self, tree: QtWidgets.QTreeWidget): + tree.setColumnWidth(0, 200) + tree.setColumnWidth(1, 300) + tree.setColumnHidden(2, True) + tree.expandAll() + + # Connect the contextmenu + tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + tree.customContextMenuRequested.connect(self.menu_context_tree_lists) + + def apply_settings(self, settings: Settings): + l_cb = [ + {"element": self.cb_quality_audio, "setting": settings.data.quality_audio.name, "default_id": 1}, + {"element": self.cb_quality_video, "setting": settings.data.quality_video.name, "default_id": 0}, + ] + + for item in l_cb: + idx = item["element"].findText(item["setting"]) + if idx > -1: + item["element"].setCurrentIndex(idx) + else: + item["element"].setCurrentIndex(item["default_id"]) + + @QtCore.Slot(QtWidgets.QWidget) + 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() + + @QtCore.Slot() + def on_spinner_stop(self): + self.spinner.stop() + self.spinner = None + + def menu_context_tree_lists(self, point): + # 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() + action = menu.addAction("Dowloading Playlist", lambda: self.thread_download_list_media(point)) + + menu.exec(self.tr_lists_user.mapToGlobal(point)) + + def thread_download_list_media(self, point): + self.thread_it(self.on_download_list_media, point) + self.thread_it(self.list_items_show, point=point) + + def on_download_list_media(self, point: QtCore.QPoint): + item = self.tr_lists_user.itemAt(point) + media = item.data(3, QtCore.Qt.ItemDataRole.UserRole) + + # TODO: Implement disable download button etc. + self.download_item(media, self.download) + + def search_populate_results(self, query: str, type_media: SearchTypes): + self.tr_results.clear() + + results: [ResultSearch] = self.search(query, [type_media]) + + self.populate_tree_results(results) + + def populate_tree_results(self, results: [ResultSearch]): + self.tr_results.clear() + + for item in results: + # Format seconds to mm:ss. + m, s = divmod(item.duration_sec, 60) + duration: str = f"{m:02d}:{s:02d}" + child = QtWidgets.QTreeWidgetItem() + + child.setText(0, str(item.position)) + child.setText(1, item.artist) + child.setText(2, item.title) + child.setText(3, item.album) + child.setText(4, duration) + child.setData(5, QtCore.Qt.ItemDataRole.UserRole, item.obj) + + self.tr_results.addTopLevelItem(child) + + def search(self, query: str, types_media: SearchTypes) -> [ResultSearch]: + result_search: [dict[str, *SearchTypes]] = self.tidal.session.search(query, models=types_media, limit=999) + result: [ResultSearch] = [] + + 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]) -> [ResultSearch]: + result = [] + + for idx, item in enumerate(items): + if isinstance(item, Track): + result_item: ResultSearch = ResultSearch( + position=idx, + artist=item.artist.name, + title=item.name, + album=item.album.name, + duration_sec=item.duration, + obj=item, + ) + + result.append(result_item) + elif isinstance(item, Video): + result_item: ResultSearch = ResultSearch( + position=idx, + artist=item.artist.name, + title=item.name, + album=item.album.name if item.album else "", + duration_sec=item.duration, + obj=item, + ) + + result.append(result_item) + elif isinstance(item, Playlist): + result_item: ResultSearch = ResultSearch( + position=idx, + artist=f"{item.promoted_artists[0].name}, ..." if item.promoted_artists else "", + title=item.name, + album="", + duration_sec=item.duration, + obj=item, + ) + + result.append(result_item) + elif isinstance(item, Album): + result_item: ResultSearch = ResultSearch( + position=idx, + artist=item.artist.name, + title="", + album=item.name, + duration_sec=item.duration, + obj=item, + ) + + result.append(result_item) + + return result + + def _init_signals(self): + self.b_download.clicked.connect(lambda: self.thread_it(self.on_download_results)) + self.l_search.returnPressed.connect( + lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) + ) + self.b_search.clicked.connect( + lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) + ) + self.cb_quality_audio.currentIndexChanged.connect(self.quality_set_audio) + self.cb_quality_video.currentIndexChanged.connect(self.quality_set_video) + self.tr_lists_user.itemClicked.connect(self.on_list_items_show) + self.spinner_start[QtWidgets.QWidget].connect(self.on_spinner_start) + self.spinner_stop.connect(self.on_spinner_stop) + self.s_item_advance.connect(self.progress_item) + self.s_item_name.connect(self.progress_item_name) + self.s_list_advance.connect(self.progress_list) + + def progress_list(self, value: float): + self.pb_list.setValue(int(math.ceil(value))) + + def progress_item(self, value: float): + self.pb_item.setValue(int(math.ceil(value))) + + def progress_item_name(self, value: str): + self.pb_item.setFormat(f"%p% {value}") + + def progress_list_name(self, value: str): + self.pb_list.setFormat(f"%p% {value}") + + def quality_set_audio(self, index): + self.settings.data.quality_audio = Quality(self.cb_quality_audio.itemData(index).value) + self.settings.save() + + if self.tidal: + self.tidal.settings_apply() + + def quality_set_video(self, index): + self.settings.data.quality_video = QualityVideo(self.cb_quality_video.itemData(index).value) + self.settings.save() + + if self.tidal: + self.tidal.settings_apply() + + def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem): + media_list: Album | Playlist = item.data(3, QtCore.Qt.ItemDataRole.UserRole) + + # Only if clicked item is not a top level item. + if media_list: + self.list_items_show(media_list) + + def list_items_show(self, media_list: Album | Playlist | None = None, point: QtCore.QPoint | None = None): + if point: + item = self.tr_lists_user.itemAt(point) + media_list = item.data(3, QtCore.Qt.ItemDataRole.UserRole) + + media_items = media_list.items() + result = self.search_result_to_model(media_items) + + self.populate_tree_results(result) + + 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_download_results(self): + self.b_download.setEnabled(False) + self.b_download.setText(f"Downloading...") + + items: [QtWidgets.QTreeWidgetItem] = self.tr_results.selectedItems() + + if len(items) == 0: + logger_gui.error("Please select a row first.") + else: + for item in items: + media: Track | Album | Playlist | Video = item.data(5, QtCore.Qt.ItemDataRole.UserRole) + + result_dl: bool = self.download_item(media, self.download) + + self.b_download.setText(f"Download") + self.b_download.setEnabled(True) + + def download_item(self, media: Track | Album | Playlist | Video | Mix, dl: Download) -> bool: + # TODO: Refactor this. Move this logic to `Download` class and provide a generic interface. + self.progress_reset() + self.statusbar.showMessage("Download started...") + + data_pb: ProgressBars = ProgressBars( + item=self.s_item_advance, list_item=self.s_list_advance, item_name=self.s_item_name + ) + progress: Progress = Progress() + + if isinstance(media, Track) or isinstance(media, Video): + if isinstance(media, Track): + file_template: str = self.settings.data.format_track + elif isinstance(media, Video): + file_template: str = self.settings.data.format_video + + result_download, download_path_file = dl.item( + media=media, + path_base=self.settings.data.download_base_path, + file_template=file_template, + progress_gui=data_pb, + progress=progress, + fn_logger=logger_gui + ) + + if result_download: + logger_gui.info(f"Download successful: {download_path_file}") + else: + logger_gui.info(f"Download skipped (file exists): {download_path_file}") + elif isinstance(media, Album) or isinstance(media, Playlist) or isinstance(media, Mix): + file_template: str | bool = False + + if isinstance(media, Album): + file_template: str = self.settings.data.format_album + progress_name = media.name + elif isinstance(media, Playlist): + file_template: str = self.settings.data.format_playlist + progress_name = media.name + elif isinstance(media, Mix): + file_template: str = self.settings.data.format_mix + progress_name = media.title + else: + progress_name = "List N/A" + + self.progress_list_name(progress_name) + + dl.list( + path_base=self.settings.data.download_base_path, + file_template=file_template, + list_media=media, + video_download=self.settings.data.video_download, + progress_gui=data_pb, + progress=progress, + download_delay=self.settings.data.download_delay, + fn_logger=logger_gui, + ) + + self.statusbar.showMessage("Download finished.", 2000) + progress.stop() + + # TODO: Refactor to useful return value. + return True + + def progress_reset(self): + self.pb_list.setValue(0) + self.pb_item.setValue(0) + + +# TODO: Comment with Google Docstrings. +def gui_activate(tidal: Tidal | None = None): + qdarktheme.enable_hi_dpi() + app = QtWidgets.QApplication(sys.argv) + qdarktheme.setup_theme() + + window = MainWindow(tidal) + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + gui_activate() |
