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:
+1
-1
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user