From 0684994850439bccb8c2a8f20df708a826d99654 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Mon, 3 Mar 2025 09:31:37 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Downloads=20can=20be=20now=20paused?= =?UTF-8?q?=20/=20resumed.=20Fixes=20#149=20&=20324=20=F0=9F=91=B7=20Shutd?= =?UTF-8?q?own=20is=20handled=20more=20gracefully.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- tidal_dl_ng/cli.py | 31 +++++++++++++++++++++++- tidal_dl_ng/config.py | 9 +++++++ tidal_dl_ng/download.py | 41 +++++++++++++++++++++++++++++--- tidal_dl_ng/gui.py | 52 +++++++++++++++++++++++++++++++++++++++-- tidal_dl_ng/ui/main.py | 20 +++++++++++++--- tidal_dl_ng/ui/main.ui | 35 ++++++++++++++++++++------- 7 files changed, 171 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c231720..0b12e10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,7 +174,7 @@ profile = "black" multi_line_output = 3 indent = 4 color_output = true -known_third_party = ["Crypto", "PySide6", "coloredlogs", "dataclasses_json", "ffmpeg", "m3u8", "mutagen", "pathvalidate", "requests", "rich", "tidalapi", "toml", "typer"] +known_third_party = ["Crypto", "PySide6", "coloredlogs", "config", "dataclasses_json", "ffmpeg", "m3u8", "mutagen", "pathvalidate", "requests", "rich", "tidalapi", "toml", "typer"] [tool.mypy] # https://mypy.readthedocs.io/en/latest/config_file.html#using-a-pyproject-toml-file diff --git a/tidal_dl_ng/cli.py b/tidal_dl_ng/cli.py index e951fc5..9239977 100644 --- a/tidal_dl_ng/cli.py +++ b/tidal_dl_ng/cli.py @@ -1,9 +1,12 @@ #!/usr/bin/env python +import signal +import sys from collections.abc import Callable from pathlib import Path from typing import Annotated, Optional import typer +from config import HandlingApp from rich.console import Group from rich.live import Live from rich.progress import ( @@ -64,6 +67,7 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo # Create initial objects. settings: Settings = Settings() + handling_app: HandlingApp = HandlingApp() progress: Progress = Progress( TextColumn("[progress.description]{task.description}"), SpinnerColumn(), @@ -92,8 +96,9 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo fn_logger=fn_logger, progress=progress, progress_overall=progress_overall, + event_abort=handling_app.event_abort, + event_run=handling_app.event_run, ) - progress_table = Table.grid() # Style Progress display. @@ -112,6 +117,10 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo for item in urls: media_type: MediaType | bool = False + # Exit loop if abort signal is set. + if handling_app.event_abort.is_set(): + return False + # Extract media name and id from link. if "http" in item: media_type = get_tidal_media_type(item) @@ -143,6 +152,10 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo item_ids.append(item_id) for item_id in item_ids: + # Exit loop if abort signal is set. + if handling_app.event_abort.is_set(): + return False + dl.items( media_id=item_id, media_type=media_type, @@ -379,5 +392,21 @@ def gui(ctx: typer.Context): gui_activate(ctx.obj[CTX_TIDAL]) +def handle_sigint_term(signum, frame): + """Set app abort event, so threads can check it and shutdown. + + :param signum: + :param frame: + :return: + """ + handling_app: HandlingApp = HandlingApp() + + handling_app.event_abort.set() + + if __name__ == "__main__": + # Catch CTRL+C + signal.signal(signal.SIGINT, handle_sigint_term) + signal.signal(signal.SIGTERM, handle_sigint_term) + app() diff --git a/tidal_dl_ng/config.py b/tidal_dl_ng/config.py index c6fdaee..345bebd 100644 --- a/tidal_dl_ng/config.py +++ b/tidal_dl_ng/config.py @@ -4,6 +4,7 @@ import shutil from collections.abc import Callable from json import JSONDecodeError from pathlib import Path +from threading import Event from typing import Any import tidalapi @@ -189,3 +190,11 @@ class Tidal(BaseConfig, metaclass=SingletonMeta): del self.session return True + + +class HandlingApp(metaclass=SingletonMeta): + event_abort: Event = Event() + event_run: Event = Event() + + def __init__(self): + self.event_run.set() diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py index c147ce9..fae7b41 100644 --- a/tidal_dl_ng/download.py +++ b/tidal_dl_ng/download.py @@ -6,6 +6,7 @@ import tempfile import time from collections.abc import Callable from concurrent import futures +from threading import Event from uuid import uuid4 import m3u8 @@ -74,6 +75,8 @@ class Download: progress_gui: ProgressBars progress: Progress progress_overall: Progress + event_abort: Event + event_run: Event def __init__( self, @@ -84,6 +87,8 @@ class Download: progress_gui: ProgressBars = None, progress: Progress = None, progress_overall: Progress = None, + event_abort: Event = None, + event_run: Event = None, ): self.settings = Settings() self.session = session @@ -93,6 +98,8 @@ class Download: self.progress = progress self.progress_overall = progress_overall self.path_base = path_base + self.event_abort = event_abort + self.event_run = event_run if not self.settings.data.path_binary_ffmpeg and ( self.settings.data.video_convert_mp4 or self.settings.data.extract_flac @@ -172,10 +179,11 @@ class Download: max_workers=self.settings.data.downloads_simultaneous_per_track_max ) as executor: # Dispatch all download tasks to worker threads - l_futures: [any] = [ + l_futures: [futures.Future] = [ executor.submit(self._download_segment, url, path_base, block_size, p_task, progress_to_stdout) for url in urls ] + # Report results as they become available for future in futures.as_completed(l_futures): # Retrieve result @@ -192,6 +200,14 @@ class Download: result_segments = False self.fn_logger.error(f"Something went wrong while downloading {media_name}. File is corrupt!") + # If app is terminated (CTRL+C) + if self.event_abort.is_set(): + # Cancel all not yet started tasks + for f in l_futures: + f.cancel() + + return False, path_file + tmp_path_file_decrypted: pathlib.Path = path_file # Only if no error happened while downloading. @@ -241,6 +257,15 @@ class Download: id_segment: int = int(filename_stem) if filename_stem.isdecimal() else 0 error: HTTPError | None = None + # If app is terminated (CTRL+C) + if self.event_abort.is_set(): + return DownloadSegmentResult( + result=False, url=url, path_segment=path_segment, id_segment=id_segment, error=error + ) + + if not self.event_run.is_set(): + self.event_run.wait() + # Retry download on failed segments, with an exponential delay between retries with requests.Session() as s: retries = Retry(total=5, backoff_factor=1) # , status_forcelist=[ 502, 503, 504 ]) @@ -478,7 +503,7 @@ class Download: # Whether a file was downloaded or skipped and the download delay is enabled, wait until the next download. # Only use this, if you have a list of several Track items. - if download_delay and not skip_file: + if (download_delay and not skip_file) and not self.event_abort.is_set(): time_sleep: float = round( random.SystemRandom().uniform( self.settings.data.download_delay_sec_min, self.settings.data.download_delay_sec_max @@ -726,7 +751,7 @@ class Download: while not progress.finished: with futures.ThreadPoolExecutor(max_workers=self.settings.data.downloads_concurrent_max) as executor: # Dispatch all download tasks to worker threads - l_futures: [any] = [ + l_futures: [futures.Future] = [ executor.submit( self.item, media=item_media, @@ -738,6 +763,7 @@ class Download: ) for item_media in items ] + # Report results as they become available for future in futures.as_completed(l_futures): # Retrieve result @@ -752,6 +778,15 @@ class Download: if not progress_stdout: self.progress_gui.list_item.emit(progress.tasks[p_task1].percentage) + # If app is terminated (CTRL+C) + if self.event_abort.is_set(): + # Cancel all not yet started tasks + for f in l_futures: + f.cancel() + + # End method here. + return + # Create playlist file if self.settings.data.playlist_create: self.playlist_populate(set(result_dirs), list_media_name, is_album) diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py index 425719a..2e81c41 100644 --- a/tidal_dl_ng/gui.py +++ b/tidal_dl_ng/gui.py @@ -49,6 +49,7 @@ import sys import time from collections.abc import Callable, Sequence +from config import HandlingApp from requests.exceptions import HTTPError from tidalapi.session import LinkLogin @@ -161,6 +162,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._populate_search_types(self.cb_search_type, SearchTypes) self.apply_settings(self.settings) self._init_signals() + self._init_buttons() self.init_tidal(tidal) logger_gui.debug("All setup.") @@ -218,6 +220,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): list_name=self.s_list_name, ) progress: Progress = Progress() + handling_app: HandlingApp = HandlingApp() self.dl = Download( session=self.tidal.session, skip_existing=self.tidal.settings.data.skip_existing, @@ -225,6 +228,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): fn_logger=logger_gui, progress_gui=data_pb, progress=progress, + event_abort=handling_app.event_abort, + event_run=handling_app.event_run, ) def _init_progressbar(self): @@ -751,6 +756,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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.pb_queue_download_toggle.clicked.connect(self.on_pb_queue_download_toggle) self.l_search.returnPressed.connect( lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) ) @@ -776,7 +782,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.s_update_show.connect(self.on_version) # Menubar - self.a_exit.triggered.connect(sys.exit) + self.a_exit.triggered.connect(self.close) self.a_version.triggered.connect(self.on_version) self.a_preferences.triggered.connect(self.on_preferences) self.a_logout.triggered.connect(self.on_logout) @@ -793,6 +799,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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 _init_buttons(self): + self.pb_queue_download_run() + def on_logout(self): result: bool = self.tidal.logout() @@ -929,6 +938,36 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): else: logger_gui.info("Cannot remove a currently downloading item from queue.") + 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() + + def pb_queue_download_run(self): + handling_app: HandlingApp = HandlingApp() + + handling_app.event_run.set() + + icon = QtGui.QIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.ThemeIcon.MediaPlaybackStart)) + self.pb_queue_download_toggle.setIcon(icon) + self.pb_queue_download_toggle.setStyleSheet("background-color: #218838; color: #fff") + + def pb_queue_download_pause(self): + handling_app: HandlingApp = HandlingApp() + + handling_app.event_run.clear() + + icon = QtGui.QIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.ThemeIcon.MediaPlaybackPause)) + self.pb_queue_download_toggle.setIcon(icon) + self.pb_queue_download_toggle.setStyleSheet("background-color: #e0a800; color: #212529") + # TODO: Must happen in main thread. Do not thread this. def on_download_results(self) -> None: items: [HumanProxyModel | None] = self.tr_results.selectionModel().selectedRows() @@ -958,7 +997,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.tr_queue_download.addTopLevelItem(child) def watcher_queue_download(self) -> None: - while not self.shutdown: + handling_app: HandlingApp = HandlingApp() + + while not handling_app.event_abort.is_set(): items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0 ) @@ -1107,6 +1148,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def closeEvent(self, event): self.shutdown = True + handling_app: HandlingApp = HandlingApp() + handling_app.event_abort.set() + event.accept() @@ -1114,7 +1158,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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() @@ -1123,6 +1169,7 @@ def gui_activate(tidal: Tidal | None = None): # 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 @@ -1136,6 +1183,7 @@ def gui_activate(tidal: Tidal | None = None): ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_app_id) window = MainWindow(tidal=tidal) + window.show() # Check for updates window.s_update_check.emit(True) diff --git a/tidal_dl_ng/ui/main.py b/tidal_dl_ng/ui/main.py index 4c68126..432f7b7 100644 --- a/tidal_dl_ng/ui/main.py +++ b/tidal_dl_ng/ui/main.py @@ -7,7 +7,7 @@ ################################################################################ from PySide6.QtCore import QCoreApplication, QLocale, QMetaObject, QRect, QSize, Qt -from PySide6.QtGui import QAction, QFont, QPixmap +from PySide6.QtGui import QAction, QFont, QIcon, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, @@ -455,11 +455,24 @@ class Ui_MainWindow: self.lv_queue_download.addWidget(self.tr_queue_download) + self.horizontalLayout_4 = QHBoxLayout() + self.horizontalLayout_4.setObjectName("horizontalLayout_4") self.pb_queue_download_remove = QPushButton(self.w_central) self.pb_queue_download_remove.setObjectName("pb_queue_download_remove") self.pb_queue_download_remove.setEnabled(True) + icon = QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditClear)) + self.pb_queue_download_remove.setIcon(icon) - self.lv_queue_download.addWidget(self.pb_queue_download_remove) + self.horizontalLayout_4.addWidget(self.pb_queue_download_remove) + + self.pb_queue_download_toggle = QPushButton(self.w_central) + self.pb_queue_download_toggle.setObjectName("pb_queue_download_toggle") + icon1 = QIcon(QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackStart)) + self.pb_queue_download_toggle.setIcon(icon1) + + self.horizontalLayout_4.addWidget(self.pb_queue_download_toggle) + + self.lv_queue_download.addLayout(self.horizontalLayout_4) self.horizontalLayout_3 = QHBoxLayout() self.horizontalLayout_3.setObjectName("horizontalLayout_3") @@ -557,7 +570,7 @@ class Ui_MainWindow: def retranslateUi(self, MainWindow): MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", "MainWindow", None)) self.a_version.setText(QCoreApplication.translate("MainWindow", "Version", None)) - self.a_exit.setText(QCoreApplication.translate("MainWindow", "Exit", None)) + self.a_exit.setText(QCoreApplication.translate("MainWindow", "Quit TIDAL-Downloader-NG", None)) self.a_logout.setText(QCoreApplication.translate("MainWindow", "Logout", None)) self.a_updates_check.setText(QCoreApplication.translate("MainWindow", "Check for Updates", None)) ___qtreewidgetitem = self.tr_lists_user.headerItem() @@ -585,6 +598,7 @@ class Ui_MainWindow: ___qtreewidgetitem4.setText(2, QCoreApplication.translate("MainWindow", "Name", None)) ___qtreewidgetitem4.setText(1, QCoreApplication.translate("MainWindow", "obj", None)) self.pb_queue_download_remove.setText(QCoreApplication.translate("MainWindow", "Remove", None)) + self.pb_queue_download_toggle.setText(QCoreApplication.translate("MainWindow", "Queue", None)) self.pb_queue_download_clear_finished.setText(QCoreApplication.translate("MainWindow", "Clear Finished", None)) self.pb_queue_download_clear_all.setText(QCoreApplication.translate("MainWindow", "Clear All", None)) self.m_file.setTitle(QCoreApplication.translate("MainWindow", "File", None)) diff --git a/tidal_dl_ng/ui/main.ui b/tidal_dl_ng/ui/main.ui index 47f17a4..49c1a45 100644 --- a/tidal_dl_ng/ui/main.ui +++ b/tidal_dl_ng/ui/main.ui @@ -650,14 +650,31 @@ - - - true - - - Remove - - + + + + + true + + + Remove + + + + + + + + + + Queue + + + + + + + @@ -787,7 +804,7 @@ - Exit + Quit TIDAL-Downloader-NG