✨ Downloads can be now paused / resumed. Fixes #149 & 324

👷 Shutdown is handled more gracefully.
This commit is contained in:
Robert Honz
2025-03-03 09:31:37 +01:00
parent 2aff7c920e
commit 0684994850
7 changed files with 171 additions and 19 deletions
+1 -1
View File
@@ -174,7 +174,7 @@ profile = "black"
multi_line_output = 3 multi_line_output = 3
indent = 4 indent = 4
color_output = true 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] [tool.mypy]
# https://mypy.readthedocs.io/en/latest/config_file.html#using-a-pyproject-toml-file # 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 #!/usr/bin/env python
import signal
import sys
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Annotated, Optional from typing import Annotated, Optional
import typer import typer
from config import HandlingApp
from rich.console import Group from rich.console import Group
from rich.live import Live from rich.live import Live
from rich.progress import ( from rich.progress import (
@@ -64,6 +67,7 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo
# Create initial objects. # Create initial objects.
settings: Settings = Settings() settings: Settings = Settings()
handling_app: HandlingApp = HandlingApp()
progress: Progress = Progress( progress: Progress = Progress(
TextColumn("[progress.description]{task.description}"), TextColumn("[progress.description]{task.description}"),
SpinnerColumn(), SpinnerColumn(),
@@ -92,8 +96,9 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo
fn_logger=fn_logger, fn_logger=fn_logger,
progress=progress, progress=progress,
progress_overall=progress_overall, progress_overall=progress_overall,
event_abort=handling_app.event_abort,
event_run=handling_app.event_run,
) )
progress_table = Table.grid() progress_table = Table.grid()
# Style Progress display. # Style Progress display.
@@ -112,6 +117,10 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo
for item in urls: for item in urls:
media_type: MediaType | bool = False 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. # Extract media name and id from link.
if "http" in item: if "http" in item:
media_type = get_tidal_media_type(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) item_ids.append(item_id)
for item_id in item_ids: for item_id in item_ids:
# Exit loop if abort signal is set.
if handling_app.event_abort.is_set():
return False
dl.items( dl.items(
media_id=item_id, media_id=item_id,
media_type=media_type, media_type=media_type,
@@ -379,5 +392,21 @@ def gui(ctx: typer.Context):
gui_activate(ctx.obj[CTX_TIDAL]) 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__": if __name__ == "__main__":
# Catch CTRL+C
signal.signal(signal.SIGINT, handle_sigint_term)
signal.signal(signal.SIGTERM, handle_sigint_term)
app() app()
+9
View File
@@ -4,6 +4,7 @@ import shutil
from collections.abc import Callable from collections.abc import Callable
from json import JSONDecodeError from json import JSONDecodeError
from pathlib import Path from pathlib import Path
from threading import Event
from typing import Any from typing import Any
import tidalapi import tidalapi
@@ -189,3 +190,11 @@ class Tidal(BaseConfig, metaclass=SingletonMeta):
del self.session del self.session
return True 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 import time
from collections.abc import Callable from collections.abc import Callable
from concurrent import futures from concurrent import futures
from threading import Event
from uuid import uuid4 from uuid import uuid4
import m3u8 import m3u8
@@ -74,6 +75,8 @@ class Download:
progress_gui: ProgressBars progress_gui: ProgressBars
progress: Progress progress: Progress
progress_overall: Progress progress_overall: Progress
event_abort: Event
event_run: Event
def __init__( def __init__(
self, self,
@@ -84,6 +87,8 @@ class Download:
progress_gui: ProgressBars = None, progress_gui: ProgressBars = None,
progress: Progress = None, progress: Progress = None,
progress_overall: Progress = None, progress_overall: Progress = None,
event_abort: Event = None,
event_run: Event = None,
): ):
self.settings = Settings() self.settings = Settings()
self.session = session self.session = session
@@ -93,6 +98,8 @@ class Download:
self.progress = progress self.progress = progress
self.progress_overall = progress_overall self.progress_overall = progress_overall
self.path_base = path_base self.path_base = path_base
self.event_abort = event_abort
self.event_run = event_run
if not self.settings.data.path_binary_ffmpeg and ( if not self.settings.data.path_binary_ffmpeg and (
self.settings.data.video_convert_mp4 or self.settings.data.extract_flac 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 max_workers=self.settings.data.downloads_simultaneous_per_track_max
) as executor: ) as executor:
# Dispatch all download tasks to worker threads # 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) executor.submit(self._download_segment, url, path_base, block_size, p_task, progress_to_stdout)
for url in urls for url in urls
] ]
# Report results as they become available # Report results as they become available
for future in futures.as_completed(l_futures): for future in futures.as_completed(l_futures):
# Retrieve result # Retrieve result
@@ -192,6 +200,14 @@ class Download:
result_segments = False result_segments = False
self.fn_logger.error(f"Something went wrong while downloading {media_name}. File is corrupt!") 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 tmp_path_file_decrypted: pathlib.Path = path_file
# Only if no error happened while downloading. # 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 id_segment: int = int(filename_stem) if filename_stem.isdecimal() else 0
error: HTTPError | None = None 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 # Retry download on failed segments, with an exponential delay between retries
with requests.Session() as s: with requests.Session() as s:
retries = Retry(total=5, backoff_factor=1) # , status_forcelist=[ 502, 503, 504 ]) 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. # 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. # 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( time_sleep: float = round(
random.SystemRandom().uniform( random.SystemRandom().uniform(
self.settings.data.download_delay_sec_min, self.settings.data.download_delay_sec_max self.settings.data.download_delay_sec_min, self.settings.data.download_delay_sec_max
@@ -726,7 +751,7 @@ class Download:
while not progress.finished: while not progress.finished:
with futures.ThreadPoolExecutor(max_workers=self.settings.data.downloads_concurrent_max) as executor: with futures.ThreadPoolExecutor(max_workers=self.settings.data.downloads_concurrent_max) as executor:
# Dispatch all download tasks to worker threads # Dispatch all download tasks to worker threads
l_futures: [any] = [ l_futures: [futures.Future] = [
executor.submit( executor.submit(
self.item, self.item,
media=item_media, media=item_media,
@@ -738,6 +763,7 @@ class Download:
) )
for item_media in items for item_media in items
] ]
# Report results as they become available # Report results as they become available
for future in futures.as_completed(l_futures): for future in futures.as_completed(l_futures):
# Retrieve result # Retrieve result
@@ -752,6 +778,15 @@ class Download:
if not progress_stdout: if not progress_stdout:
self.progress_gui.list_item.emit(progress.tasks[p_task1].percentage) 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 # Create playlist file
if self.settings.data.playlist_create: if self.settings.data.playlist_create:
self.playlist_populate(set(result_dirs), list_media_name, is_album) self.playlist_populate(set(result_dirs), list_media_name, is_album)
+50 -2
View File
@@ -49,6 +49,7 @@ import sys
import time import time
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from config import HandlingApp
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from tidalapi.session import LinkLogin 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._populate_search_types(self.cb_search_type, SearchTypes)
self.apply_settings(self.settings) self.apply_settings(self.settings)
self._init_signals() self._init_signals()
self._init_buttons()
self.init_tidal(tidal) self.init_tidal(tidal)
logger_gui.debug("All setup.") logger_gui.debug("All setup.")
@@ -218,6 +220,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
list_name=self.s_list_name, list_name=self.s_list_name,
) )
progress: Progress = Progress() progress: Progress = Progress()
handling_app: HandlingApp = HandlingApp()
self.dl = Download( self.dl = Download(
session=self.tidal.session, session=self.tidal.session,
skip_existing=self.tidal.settings.data.skip_existing, skip_existing=self.tidal.settings.data.skip_existing,
@@ -225,6 +228,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
fn_logger=logger_gui, fn_logger=logger_gui,
progress_gui=data_pb, progress_gui=data_pb,
progress=progress, progress=progress,
event_abort=handling_app.event_abort,
event_run=handling_app.event_run,
) )
def _init_progressbar(self): 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_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_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_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( self.l_search.returnPressed.connect(
lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) 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) self.s_update_show.connect(self.on_version)
# Menubar # 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_version.triggered.connect(self.on_version)
self.a_preferences.triggered.connect(self.on_preferences) self.a_preferences.triggered.connect(self.on_preferences)
self.a_logout.triggered.connect(self.on_logout) 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_failed.connect(self.on_queue_download_item_failed)
self.s_queue_download_item_skipped.connect(self.on_queue_download_item_skipped) 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): def on_logout(self):
result: bool = self.tidal.logout() result: bool = self.tidal.logout()
@@ -929,6 +938,36 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
else: else:
logger_gui.info("Cannot remove a currently downloading item from queue.") 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. # TODO: Must happen in main thread. Do not thread this.
def on_download_results(self) -> None: def on_download_results(self) -> None:
items: [HumanProxyModel | None] = self.tr_results.selectionModel().selectedRows() items: [HumanProxyModel | None] = self.tr_results.selectionModel().selectedRows()
@@ -958,7 +997,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
self.tr_queue_download.addTopLevelItem(child) self.tr_queue_download.addTopLevelItem(child)
def watcher_queue_download(self) -> None: 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( items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems(
QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0 QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0
) )
@@ -1107,6 +1148,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
def closeEvent(self, event): def closeEvent(self, event):
self.shutdown = True self.shutdown = True
handling_app: HandlingApp = HandlingApp()
handling_app.event_abort.set()
event.accept() event.accept()
@@ -1114,7 +1158,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
def gui_activate(tidal: Tidal | None = None): def gui_activate(tidal: Tidal | None = None):
# Set dark theme and create QT app. # Set dark theme and create QT app.
qdarktheme.enable_hi_dpi() qdarktheme.enable_hi_dpi()
app = QtWidgets.QApplication(sys.argv) app = QtWidgets.QApplication(sys.argv)
# Fix for Windows: Tooltips have bright font color # Fix for Windows: Tooltips have bright font color
# https://github.com/5yutan5/PyQtDarkTheme/issues/239 # https://github.com/5yutan5/PyQtDarkTheme/issues/239
# qdarktheme.setup_theme() # qdarktheme.setup_theme()
@@ -1123,6 +1169,7 @@ def gui_activate(tidal: Tidal | None = None):
# Create icon object and apply it to app window. # Create icon object and apply it to app window.
pixmap: QtGui.QPixmap = QtGui.QPixmap("tidal_dl_ng/ui/icon.png") pixmap: QtGui.QPixmap = QtGui.QPixmap("tidal_dl_ng/ui/icon.png")
icon: QtGui.QIcon = QtGui.QIcon(pixmap) icon: QtGui.QIcon = QtGui.QIcon(pixmap)
app.setWindowIcon(icon) app.setWindowIcon(icon)
# This bit gets the taskbar icon working properly in Windows # 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) ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_app_id)
window = MainWindow(tidal=tidal) window = MainWindow(tidal=tidal)
window.show() window.show()
# Check for updates # Check for updates
window.s_update_check.emit(True) 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.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 ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QComboBox, QComboBox,
@@ -455,11 +455,24 @@ class Ui_MainWindow:
self.lv_queue_download.addWidget(self.tr_queue_download) 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 = QPushButton(self.w_central)
self.pb_queue_download_remove.setObjectName("pb_queue_download_remove") self.pb_queue_download_remove.setObjectName("pb_queue_download_remove")
self.pb_queue_download_remove.setEnabled(True) 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 = QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3") self.horizontalLayout_3.setObjectName("horizontalLayout_3")
@@ -557,7 +570,7 @@ class Ui_MainWindow:
def retranslateUi(self, MainWindow): def retranslateUi(self, MainWindow):
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", "MainWindow", None)) MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", "MainWindow", None))
self.a_version.setText(QCoreApplication.translate("MainWindow", "Version", 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_logout.setText(QCoreApplication.translate("MainWindow", "Logout", None))
self.a_updates_check.setText(QCoreApplication.translate("MainWindow", "Check for Updates", None)) self.a_updates_check.setText(QCoreApplication.translate("MainWindow", "Check for Updates", None))
___qtreewidgetitem = self.tr_lists_user.headerItem() ___qtreewidgetitem = self.tr_lists_user.headerItem()
@@ -585,6 +598,7 @@ class Ui_MainWindow:
___qtreewidgetitem4.setText(2, QCoreApplication.translate("MainWindow", "Name", None)) ___qtreewidgetitem4.setText(2, QCoreApplication.translate("MainWindow", "Name", None))
___qtreewidgetitem4.setText(1, QCoreApplication.translate("MainWindow", "obj", None)) ___qtreewidgetitem4.setText(1, QCoreApplication.translate("MainWindow", "obj", None))
self.pb_queue_download_remove.setText(QCoreApplication.translate("MainWindow", "Remove", 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_finished.setText(QCoreApplication.translate("MainWindow", "Clear Finished", None))
self.pb_queue_download_clear_all.setText(QCoreApplication.translate("MainWindow", "Clear All", None)) self.pb_queue_download_clear_all.setText(QCoreApplication.translate("MainWindow", "Clear All", None))
self.m_file.setTitle(QCoreApplication.translate("MainWindow", "File", None)) self.m_file.setTitle(QCoreApplication.translate("MainWindow", "File", None))
+18 -1
View File
@@ -649,6 +649,8 @@
</column> </column>
</widget> </widget>
</item> </item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item> <item>
<widget class="QPushButton" name="pb_queue_download_remove"> <widget class="QPushButton" name="pb_queue_download_remove">
<property name="enabled"> <property name="enabled">
@@ -657,8 +659,23 @@
<property name="text"> <property name="text">
<string>Remove</string> <string>Remove</string>
</property> </property>
<property name="icon">
<iconset theme="QIcon::ThemeIcon::EditClear"/>
</property>
</widget> </widget>
</item> </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> <item>
<layout class="QHBoxLayout" name="horizontalLayout_3"> <layout class="QHBoxLayout" name="horizontalLayout_3">
<item> <item>
@@ -787,7 +804,7 @@
</action> </action>
<action name="a_exit"> <action name="a_exit">
<property name="text"> <property name="text">
<string>Exit</string> <string>Quit TIDAL-Downloader-NG</string>
</property> </property>
</action> </action>
<action name="a_logout"> <action name="a_logout">