Merge pull request #380 from exislow/149-feature-add-pauseresume-button-for-interrupting-the-download

✨ Downloads can be now paused / resumed. Fixes #149 & 324
This commit is contained in:
exislow
2025-03-03 09:32:20 +01:00
committed by GitHub
7 changed files with 171 additions and 19 deletions
+1 -1
View File
@@ -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
+30 -1
View File
@@ -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()
+9
View File
@@ -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()
+38 -3
View File
@@ -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)
+50 -2
View File
@@ -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)
+17 -3
View File
@@ -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))
+18 -1
View File
@@ -649,6 +649,8 @@
</column>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<widget class="QPushButton" name="pb_queue_download_remove">
<property name="enabled">
@@ -657,8 +659,23 @@
<property name="text">
<string>Remove</string>
</property>
<property name="icon">
<iconset theme="QIcon::ThemeIcon::EditClear"/>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_queue_download_toggle">
<property name="text">
<string>Queue</string>
</property>
<property name="icon">
<iconset theme="QIcon::ThemeIcon::MediaPlaybackStart"/>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
@@ -787,7 +804,7 @@
</action>
<action name="a_exit">
<property name="text">
<string>Exit</string>
<string>Quit TIDAL-Downloader-NG</string>
</property>
</action>
<action name="a_logout">