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