Files
tidal-dl-ng-webui/tidal_dl_ng/gui.py
2024-12-27 22:00:28 +09:00

1104 lines
43 KiB
Python

import math
import sys
import time
from collections.abc import Callable, Sequence
from requests.exceptions import HTTPError
from tidalapi.session import LinkLogin
from tidal_dl_ng import __version__, update_available
from tidal_dl_ng.dialog import DialogLogin, DialogPreferences, DialogVersion
from tidal_dl_ng.helper.gui import (
FilterHeader,
HumanProxyModel,
get_queue_download_media,
get_queue_download_quality,
get_results_media_item,
get_user_list_media_item,
set_queue_download_media,
set_user_list_media,
)
from tidal_dl_ng.helper.path import get_format_template, resource_path
from tidal_dl_ng.helper.tidal import (
favorite_function_factory,
get_tidal_media_id,
get_tidal_media_type,
instantiate_media,
items_results_all,
name_builder_artist,
name_builder_title,
quality_audio_highest,
search_results_all,
user_media_lists,
)
try:
import qdarktheme
from PySide6 import QtCore, QtGui, QtWidgets
except ImportError as e:
print(e)
print("Qt dependencies missing. Cannot start GUI. Please execute: 'pip install pyside6 pyqtdarktheme'")
sys.exit(1)
import coloredlogs.converter
from rich.progress import Progress
from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video
from tidalapi.artist import Artist
from tidalapi.session import SearchTypes
from tidal_dl_ng.config import Settings, Tidal
from tidal_dl_ng.constants import FAVORITES, QualityVideo, QueueDownloadStatus, TidalLists
from tidal_dl_ng.download import Download
from tidal_dl_ng.logger import XStream, logger_gui
from tidal_dl_ng.model.gui_data import ProgressBars, QueueDownloadItem, ResultItem, StatusbarMessage
from tidal_dl_ng.model.meta import ReleaseLatest
from tidal_dl_ng.ui.main import Ui_MainWindow
from tidal_dl_ng.ui.spinner import QtWaitingSpinner
from tidal_dl_ng.worker import Worker
# TODO: Make more use of Exceptions
class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
settings: Settings
tidal: Tidal
dl: Download
threadpool: QtCore.QThreadPool
tray: QtWidgets.QSystemTrayIcon
spinner: QtWaitingSpinner
cover_url_current: str = ""
model_tr_results: QtGui.QStandardItemModel = QtGui.QStandardItemModel()
proxy_tr_results: HumanProxyModel
s_spinner_start: QtCore.Signal = QtCore.Signal(QtWidgets.QWidget)
s_spinner_stop: QtCore.Signal = QtCore.Signal()
pb_item: QtWidgets.QProgressBar
s_item_advance: QtCore.Signal = QtCore.Signal(float)
s_item_name: QtCore.Signal = QtCore.Signal(str)
s_list_name: QtCore.Signal = QtCore.Signal(str)
pb_list: QtWidgets.QProgressBar
s_list_advance: QtCore.Signal = QtCore.Signal(float)
s_pb_reset: QtCore.Signal = QtCore.Signal()
s_populate_tree_lists: QtCore.Signal = QtCore.Signal(list)
s_statusbar_message: QtCore.Signal = QtCore.Signal(object)
s_tr_results_add_top_level_item: QtCore.Signal = QtCore.Signal(object)
s_settings_save: QtCore.Signal = QtCore.Signal()
s_pb_reload_status: QtCore.Signal = QtCore.Signal(bool)
s_update_check: QtCore.Signal = QtCore.Signal(bool)
s_update_show: QtCore.Signal = QtCore.Signal(bool, bool, object)
s_queue_download_item_downloading: QtCore.Signal = QtCore.Signal(object)
s_queue_download_item_finished: QtCore.Signal = QtCore.Signal(object)
s_queue_download_item_failed: QtCore.Signal = QtCore.Signal(object)
s_queue_download_item_skipped: QtCore.Signal = QtCore.Signal(object)
def __init__(self, tidal: Tidal | None = None):
super().__init__()
self.setupUi(self)
# self.setGeometry(50, 50, 500, 300)
self.setWindowTitle("TIDAL Downloader Next Generation!")
# Logging redirect.
XStream.stdout().messageWritten.connect(self._log_output)
# XStream.stderr().messageWritten.connect(self._log_output)
self.settings = Settings()
self._init_threads()
self._init_tree_results_model(self.model_tr_results)
self._init_tree_results(self.tr_results, self.model_tr_results)
self._init_tree_lists(self.tr_lists_user)
self._init_tree_queue(self.tr_queue_download)
self._init_info()
self._init_progressbar()
self._populate_quality(self.cb_quality_audio, Quality)
self._populate_quality(self.cb_quality_video, QualityVideo)
self._populate_search_types(self.cb_search_type, SearchTypes)
self.apply_settings(self.settings)
self._init_signals()
self.init_tidal(tidal)
logger_gui.debug("All setup.")
def init_tidal(self, tidal: Tidal = None):
result: bool = False
if tidal:
self.tidal = tidal
result = True
else:
self.tidal = Tidal(self.settings)
result = self.tidal.login_token()
if not result:
hint: str = "After you have finished the TIDAL login via web browser click the 'OK' button."
while not result:
link_login: LinkLogin = self.tidal.session.get_link_login()
d_login: DialogLogin = DialogLogin(
url_login=link_login.verification_uri_complete,
hint=hint,
expires_in=link_login.expires_in,
parent=self,
)
if d_login.return_code == 1:
try:
self.tidal.session.process_link_login(link_login, until_expiry=False)
self.tidal.login_finalize()
result = True
logger_gui.info("Login successful. Have fun!")
except (HTTPError, Exception):
hint = "Something was wrong with your redirect url. Please try again!"
logger_gui.warning("Login not successful. Try again...")
else:
# If user has pressed cancel.
sys.exit(1)
if result:
self._init_dl()
self.thread_it(self.tidal_user_lists)
def _init_threads(self):
self.threadpool = QtCore.QThreadPool()
self.thread_it(self.watcher_queue_download)
def _init_dl(self):
# Init `Download` object.
data_pb: ProgressBars = ProgressBars(
item=self.s_item_advance,
list_item=self.s_list_advance,
item_name=self.s_item_name,
list_name=self.s_list_name,
)
progress: Progress = Progress()
self.dl = Download(
session=self.tidal.session,
skip_existing=self.tidal.settings.data.skip_existing,
path_base=self.settings.data.download_base_path,
fn_logger=logger_gui,
progress_gui=data_pb,
progress=progress,
)
def _init_progressbar(self):
self.pb_list = QtWidgets.QProgressBar()
self.pb_item = QtWidgets.QProgressBar()
pbs = [self.pb_list, self.pb_item]
for pb in pbs:
pb.setRange(0, 100)
# self.pb_progress.setVisible()
self.statusbar.addPermanentWidget(pb)
def _init_info(self):
path_image: str = resource_path("tidal_dl_ng/ui/default_album_image.png")
self.l_pm_cover.setPixmap(QtGui.QPixmap(path_image))
def on_progress_reset(self):
self.pb_list.setValue(0)
self.pb_item.setValue(0)
def on_statusbar_message(self, data: StatusbarMessage):
self.statusbar.showMessage(data.message, data.timout)
def _log_output(self, text):
display_msg = coloredlogs.converter.convert(text)
cursor: QtGui.QTextCursor = self.te_debug.textCursor()
cursor.movePosition(QtGui.QTextCursor.End)
cursor.insertHtml(display_msg)
self.te_debug.setTextCursor(cursor)
self.te_debug.ensureCursorVisible()
def _populate_quality(self, ui_target: QtWidgets.QComboBox, options: type[Quality | QualityVideo]):
for item in options:
ui_target.addItem(item.name, item)
def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: SearchTypes):
for item in options:
if item:
ui_target.addItem(item.__name__, item)
self.cb_search_type.setCurrentIndex(2)
def handle_filter_activated(self):
header: FilterHeader = self.tr_results.header()
filters = []
for i in range(header.count()):
text: str = header.filter_text(i)
if text:
filters.append((i, text))
proxy_model: HumanProxyModel = self.tr_results.model()
proxy_model.filters = filters
def _init_tree_results(self, tree: QtWidgets.QTreeView, model: QtGui.QStandardItemModel) -> None:
header: FilterHeader = FilterHeader(tree)
self.proxy_tr_results: HumanProxyModel = HumanProxyModel(self)
tree.setHeader(header)
tree.setModel(model)
self.proxy_tr_results.setSourceModel(model)
tree.setModel(self.proxy_tr_results)
header.set_filter_boxes(model.columnCount())
header.filter_activated.connect(self.handle_filter_activated)
## Styling
tree.sortByColumn(0, QtCore.Qt.SortOrder.AscendingOrder)
tree.setColumnHidden(1, True)
tree.setColumnWidth(2, 150)
tree.setColumnWidth(3, 150)
tree.setColumnWidth(4, 150)
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
# Connect the contextmenu
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
tree.customContextMenuRequested.connect(self.menu_context_tree_results)
def _init_tree_results_model(self, model: QtGui.QStandardItemModel) -> None:
labels_column: [str] = ["#", "obj", "Artist", "Title", "Album", "Duration", "Quality", "Date Added"]
model.setColumnCount(len(labels_column))
model.setRowCount(0)
model.setHorizontalHeaderLabels(labels_column)
def _init_tree_queue(self, tree: QtWidgets.QTableWidget):
tree.setColumnHidden(1, True)
tree.setColumnWidth(2, 200)
header = tree.header()
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
header.setStretchLastSection(False)
def tidal_user_lists(self):
# Start loading spinner
self.s_spinner_start.emit(self.tr_lists_user)
self.s_pb_reload_status.emit(False)
user_all: [Playlist | UserPlaylist | Mix] = user_media_lists(self.tidal.session)
self.s_populate_tree_lists.emit(user_all)
def on_populate_tree_lists(self, user_lists: [Playlist | UserPlaylist | Mix]):
twi_playlists: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems(
TidalLists.Playlists, QtCore.Qt.MatchExactly, 0
)[0]
twi_mixes: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems(
TidalLists.Mixes, QtCore.Qt.MatchExactly, 0
)[0]
twi_favorites: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems(
TidalLists.Favorites, QtCore.Qt.MatchExactly, 0
)[0]
# Remove all children if present
for twi in [twi_playlists, twi_mixes]:
for i in reversed(range(twi.childCount())):
twi.removeChild(twi.child(i))
# Populate dynamic user lists
for item in user_lists:
if isinstance(item, UserPlaylist | Playlist):
twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
name: str = item.name
description: str = f" {item.description}" if item.description else ""
info: str = f"({item.num_tracks + item.num_videos} Tracks){description}"
elif isinstance(item, Mix):
twi_child = QtWidgets.QTreeWidgetItem(twi_mixes)
name: str = item.title
info: str = item.sub_title
twi_child.setText(0, name)
set_user_list_media(twi_child, item)
twi_child.setText(2, info)
# Populate static favorites
for key, favorite in FAVORITES.items():
twi_child = QtWidgets.QTreeWidgetItem(twi_favorites)
name: str = favorite["name"]
info: str = ""
twi_child.setText(0, name)
set_user_list_media(twi_child, key)
twi_child.setText(2, info)
# Stop load spinner
self.s_spinner_stop.emit()
self.s_pb_reload_status.emit(True)
def _init_tree_lists(self, tree: QtWidgets.QTreeWidget):
# Adjust Tree.
tree.setColumnWidth(0, 200)
tree.setColumnHidden(1, True)
tree.setColumnWidth(2, 300)
tree.expandAll()
# Connect the contextmenu
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
tree.customContextMenuRequested.connect(self.menu_context_tree_lists)
def on_update_check(self, on_startup: bool = True):
is_available, info = update_available()
if (on_startup and is_available) or not on_startup:
self.s_update_show.emit(True, is_available, info)
def apply_settings(self, settings: Settings):
l_cb = [
{"element": self.cb_quality_audio, "setting": settings.data.quality_audio, "default_id": 1},
{"element": self.cb_quality_video, "setting": settings.data.quality_video, "default_id": 0},
]
for item in l_cb:
idx = item["element"].findData(item["setting"])
if idx > -1:
item["element"].setCurrentIndex(idx)
else:
item["element"].setCurrentIndex(item["default_id"])
def on_spinner_start(self, parent: QtWidgets.QWidget):
self.spinner = QtWaitingSpinner(parent, True, True)
self.spinner.setColor(QtGui.QColor(255, 255, 255))
self.spinner.start()
def on_spinner_stop(self):
self.spinner.stop()
self.spinner = None
def menu_context_tree_lists(self, point: QtCore.QPoint):
# Infos about the node selected.
index = self.tr_lists_user.indexAt(point)
# Do not open menu if something went wrong or a parent node is clicked.
if not index.isValid() or not index.parent().data():
return
# We build the menu.
menu = QtWidgets.QMenu()
menu.addAction("Download Playlist", lambda: self.thread_download_list_media(point))
menu.addAction("Copy Share URL", lambda: self.thread_copy_url_share(self.tr_lists_user, point))
menu.exec(self.tr_lists_user.mapToGlobal(point))
def menu_context_tree_results(self, point: QtCore.QPoint):
# Infos about the node selected.
index = self.tr_results.indexAt(point)
# Do not open menu if something went wrong or a parent node is clicked.
if not index.isValid():
return
# We build the menu.
menu = QtWidgets.QMenu()
menu.addAction("Copy Share URL", lambda: self.thread_copy_url_share(self.tr_results, point))
menu.exec(self.tr_results.mapToGlobal(point))
def thread_download_list_media(self, point: QtCore.QPoint):
self.thread_it(self.on_download_list_media, point)
def thread_copy_url_share(self, tree_target: QtWidgets.QTreeWidget, point: QtCore.QPoint):
self.thread_it(self.on_copy_url_share, tree_target, point)
def on_copy_url_share(self, tree_target: QtWidgets.QTreeWidget | QtWidgets.QTreeView, point: QtCore.QPoint = None):
if isinstance(tree_target, QtWidgets.QTreeWidget):
item: QtWidgets.QTreeWidgetItem = tree_target.itemAt(point)
media: Album | Artist | Mix | Playlist = get_user_list_media_item(item)
else:
index: QtCore.QModelIndex = tree_target.indexAt(point)
media: Track | Video | Album | Artist | Mix | Playlist = get_results_media_item(
index, self.proxy_tr_results, self.model_tr_results
)
clipboard = QtWidgets.QApplication.clipboard()
url_share = media.share_url if hasattr(media, "share_url") else "No share URL available."
clipboard.clear()
clipboard.setText(url_share)
def on_download_list_media(self, point: QtCore.QPoint = None):
items: [QtWidgets.QTreeWidgetItem]
if point:
items = [self.tr_lists_user.itemAt(point)]
else:
items = self.tr_lists_user.selectedItems()
if len(items) == 0:
logger_gui.error("Please select a mix or playlist first.")
for item in items:
media = get_user_list_media_item(item)
queue_dl_item: QueueDownloadItem | False = self.media_to_queue_download_model(media)
if queue_dl_item:
self.queue_download_media(queue_dl_item)
def search_populate_results(self, query: str, type_media: SearchTypes):
self.model_tr_results.removeRows(0, self.model_tr_results.rowCount())
results: [ResultItem] = self.search(query, [type_media])
self.populate_tree_results(results)
def populate_tree_results(self, results: [ResultItem], parent: QtGui.QStandardItem = None):
if not parent:
self.model_tr_results.removeRows(0, self.model_tr_results.rowCount())
# Count how many digits the list length has,
count_digits: int = int(math.log10(len(results) if results else 1)) + 1
for item in results:
child: tuple = self.populate_tree_result_child(item=item, index_count_digits=count_digits)
if parent:
parent.appendRow(child)
else:
self.s_tr_results_add_top_level_item.emit(child)
def populate_tree_result_child(
self, item: [Track | Video | Mix | Album | Playlist], index_count_digits: int
) -> Sequence[QtGui.QStandardItem]:
duration: str = ""
# TODO: Duration needs to be calculated later to properly fill with zeros.
if item.duration_sec > -1:
# Format seconds to mm:ss.
m, s = divmod(item.duration_sec, 60)
duration: str = f"{m:02d}:{s:02d}"
# Since sorting happens only by string, we need to pad the index and add 1 (to avoid start at 0)
index: str = str(item.position + 1).zfill(index_count_digits)
# Populate child
child_index: QtGui.QStandardItem = QtGui.QStandardItem(index)
# TODO: Move to own method
child_obj: QtGui.QStandardItem = QtGui.QStandardItem()
child_obj.setData(item.obj, QtCore.Qt.ItemDataRole.UserRole)
# set_results_media(child, item.obj)
child_artist: QtGui.QStandardItem = QtGui.QStandardItem(item.artist)
child_title: QtGui.QStandardItem = QtGui.QStandardItem(item.title)
child_album: QtGui.QStandardItem = QtGui.QStandardItem(item.album)
child_duration: QtGui.QStandardItem = QtGui.QStandardItem(duration)
child_quality: QtGui.QStandardItem = QtGui.QStandardItem(item.quality)
child_date_added: QtGui.QStandardItem = QtGui.QStandardItem(item.date_user_added)
if isinstance(item.obj, Mix | Playlist | Album | Artist):
# Add a disabled dummy child, so expansion arrow will appear. This Child will be replaced on expansion.
child_dummy: QtGui.QStandardItem = QtGui.QStandardItem()
child_dummy.setEnabled(False)
child_index.appendRow(child_dummy)
return (
child_index,
child_obj,
child_artist,
child_title,
child_album,
child_duration,
child_quality,
child_date_added,
)
def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]):
self.model_tr_results.appendRow(item_child)
def on_settings_save(self):
self.settings.save()
self.apply_settings(self.settings)
self._init_dl()
def search(self, query: str, types_media: SearchTypes) -> [ResultItem]:
query = query.strip()
# If a direct link was searched for, skip search and create the object from the link directly.
if "http" in query:
media_type = get_tidal_media_type(query)
item_id = get_tidal_media_id(query)
try:
media = instantiate_media(self.tidal.session, media_type, item_id)
except:
logger_gui.error(f"Media not found (ID: {item_id}). Maybe it is not available anymore.")
media = None
result_search = {"direct": [media]}
else:
result_search: dict[str, [SearchTypes]] = search_results_all(
session=self.tidal.session, needle=query, types_media=types_media
)
result: [ResultItem] = []
for _media_type, l_media in result_search.items():
if isinstance(l_media, list):
result = result + self.search_result_to_model(l_media)
return result
def search_result_to_model(self, items: [*SearchTypes]) -> [ResultItem]:
result = []
for idx, item in enumerate(items):
explicit: str = ""
# Check if item is available on TIDAL.
if hasattr(item, "available") and not item.available:
continue
if isinstance(item, Track | Video | Album):
explicit = " 🅴" if item.explicit else ""
date_user_added: str = item.user_date_added.strftime("%Y-%m-%d_%H:%M") if item.user_date_added else ""
if isinstance(item, Track):
result_item: ResultItem = ResultItem(
position=idx,
artist=name_builder_artist(item),
title=f"{name_builder_title(item)}{explicit}",
album=item.album.name,
duration_sec=item.duration,
obj=item,
quality=quality_audio_highest(item),
explicit=bool(item.explicit),
date_user_added=date_user_added,
)
result.append(result_item)
elif isinstance(item, Video):
result_item: ResultItem = ResultItem(
position=idx,
artist=name_builder_artist(item),
title=f"{name_builder_title(item)}{explicit}",
album=item.album.name if item.album else "",
duration_sec=item.duration,
obj=item,
quality=item.video_quality,
explicit=bool(item.explicit),
date_user_added=date_user_added,
)
result.append(result_item)
elif isinstance(item, Playlist):
result_item: ResultItem = ResultItem(
position=idx,
artist=", ".join(artist.name for artist in item.promoted_artists) if item.promoted_artists else "",
title=item.name,
album="",
duration_sec=item.duration,
obj=item,
quality="",
explicit=False,
date_user_added=date_user_added,
)
result.append(result_item)
elif isinstance(item, Album):
result_item: ResultItem = ResultItem(
position=idx,
artist=name_builder_artist(item),
title="",
album=f"{item.name}{explicit}",
duration_sec=item.duration,
obj=item,
quality=quality_audio_highest(item),
explicit=bool(item.explicit),
date_user_added=date_user_added,
)
result.append(result_item)
elif isinstance(item, Mix):
result_item: ResultItem = ResultItem(
position=idx,
artist=item.sub_title,
title=item.title,
album="",
# TODO: Calculate total duration.
duration_sec=-1,
obj=item,
quality="",
explicit=False,
date_user_added=date_user_added,
)
result.append(result_item)
elif isinstance(item, Artist):
result_item: ResultItem = ResultItem(
position=idx,
artist=item.name,
title="",
album="",
duration_sec=-1,
obj=item,
quality="",
explicit=False,
date_user_added=date_user_added,
)
result.append(result_item)
return result
def media_to_queue_download_model(
self, media: Artist | Track | Video | Album | Playlist | Mix
) -> QueueDownloadItem | bool:
result: QueueDownloadItem | False
name: str = ""
quality: Quality | QualityVideo | str = ""
explicit: str = ""
# Check if item is available on TIDAL.
if hasattr(media, "available") and not media.available:
return False
# Set "Explicit" tag
if isinstance(media, Track | Video | Album):
explicit = " 🅴" if media.explicit else ""
# Build name and set quality
if isinstance(media, Track | Video):
name = f"{name_builder_artist(media)} - {name_builder_title(media)}{explicit}"
elif isinstance(media, Playlist | Artist):
name = media.name
quality = self.settings.data.quality_audio
elif isinstance(media, Album):
name = f"{name_builder_artist(media)} - {media.name}{explicit}"
elif isinstance(media, Mix):
name = media.title
quality = self.settings.data.quality_audio
# Determine actual quality.
if isinstance(media, Track | Album):
quality_highest: str = quality_audio_highest(media)
if (
self.settings.data.quality_audio == quality_highest
or self.settings.data.quality_audio == Quality.hi_res_lossless
):
quality = quality_highest
else:
quality = self.settings.data.quality_audio
elif isinstance(media, Video):
quality = self.settings.data.quality_video
if name:
result = QueueDownloadItem(
name=name,
quality=quality,
type_media=type(media).__name__,
status=QueueDownloadStatus.Waiting,
obj=media,
)
else:
result = False
return result
def _init_signals(self):
self.pb_download.clicked.connect(lambda: self.thread_it(self.on_download_results))
self.pb_download_list.clicked.connect(lambda: self.thread_it(self.on_download_list_media))
self.pb_reload_user_lists.clicked.connect(lambda: self.thread_it(self.tidal_user_lists))
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.l_search.returnPressed.connect(
lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData())
)
self.pb_search.clicked.connect(
lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData())
)
self.cb_quality_audio.currentIndexChanged.connect(self.on_quality_set_audio)
self.cb_quality_video.currentIndexChanged.connect(self.on_quality_set_video)
self.tr_lists_user.itemClicked.connect(self.on_list_items_show)
self.s_spinner_start[QtWidgets.QWidget].connect(self.on_spinner_start)
self.s_spinner_stop.connect(self.on_spinner_stop)
self.s_item_advance.connect(self.on_progress_item)
self.s_item_name.connect(self.on_progress_item_name)
self.s_list_name.connect(self.on_progress_list_name)
self.s_list_advance.connect(self.on_progress_list)
self.s_pb_reset.connect(self.on_progress_reset)
self.s_populate_tree_lists.connect(self.on_populate_tree_lists)
self.s_statusbar_message.connect(self.on_statusbar_message)
self.s_tr_results_add_top_level_item.connect(self.on_tr_results_add_top_level_item)
self.s_settings_save.connect(self.on_settings_save)
self.s_pb_reload_status.connect(self.button_reload_status)
self.s_update_check.connect(lambda: self.thread_it(self.on_update_check))
self.s_update_show.connect(self.on_version)
# Menubar
self.a_exit.triggered.connect(sys.exit)
self.a_version.triggered.connect(self.on_version)
self.a_preferences.triggered.connect(self.on_preferences)
self.a_logout.triggered.connect(self.on_logout)
self.a_updates_check.triggered.connect(lambda: self.on_update_check(False))
# Results
self.tr_results.expanded.connect(self.on_tr_results_expanded)
self.tr_results.clicked.connect(self.on_result_item_clicked)
# Download Queue
self.tr_queue_download.itemClicked.connect(self.on_queue_download_item_clicked)
self.s_queue_download_item_downloading.connect(self.on_queue_download_item_downloading)
self.s_queue_download_item_finished.connect(self.on_queue_download_item_finished)
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 on_logout(self):
result: bool = self.tidal.logout()
if result:
sys.exit(0)
def on_progress_list(self, value: float):
self.pb_list.setValue(int(math.ceil(value)))
def on_progress_item(self, value: float):
self.pb_item.setValue(int(math.ceil(value)))
def on_progress_item_name(self, value: str):
self.pb_item.setFormat(f"%p% {value}")
def on_progress_list_name(self, value: str):
self.pb_list.setFormat(f"%p% {value}")
def on_quality_set_audio(self, index):
self.settings.data.quality_audio = Quality(self.cb_quality_audio.itemData(index))
self.settings.save()
if self.tidal:
self.tidal.settings_apply()
def on_quality_set_video(self, index):
self.settings.data.quality_video = QualityVideo(self.cb_quality_video.itemData(index))
self.settings.save()
if self.tidal:
self.tidal.settings_apply()
def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem):
media_list: Album | Playlist | str = get_user_list_media_item(item)
# Only if clicked item is not a top level item.
if media_list:
if isinstance(media_list, str) and media_list.startswith("fav_"):
function_list = favorite_function_factory(self.tidal, media_list)
self.list_items_show_result(favorite_function=function_list)
else:
self.list_items_show_result(media_list)
self.cover_show(media_list)
def on_result_item_clicked(self, index: QtCore.QModelIndex) -> None:
media: Track | Video | Album | Artist = get_results_media_item(
index, self.proxy_tr_results, self.model_tr_results
)
self.cover_show(media)
def on_queue_download_item_clicked(self, item: QtWidgets.QTreeWidgetItem, column: int) -> None:
media: Track | Video | Album | Artist | Mix | Playlist = get_queue_download_media(item)
self.cover_show(media)
def cover_show(self, media: Album | Playlist | Track | Video | Album | Artist) -> None:
cover_url: str
try:
cover_url = media.album.image()
except:
try:
cover_url = media.image()
except:
logger_gui.info(f"No cover available (media ID: {media.id}).")
if cover_url and self.cover_url_current != cover_url:
self.cover_url_current = cover_url
data_cover: bytes = Download.cover_data(cover_url)
pixmap: QtGui.QPixmap = QtGui.QPixmap()
pixmap.loadFromData(data_cover)
self.l_pm_cover.setPixmap(pixmap)
def list_items_show_result(
self,
media_list: Album | Playlist | Mix | Artist | None = None,
point: QtCore.QPoint | None = None,
parent: QtGui.QStandardItem = None,
favorite_function: Callable = None,
) -> None:
if point:
item = self.tr_lists_user.itemAt(point)
media_list = get_user_list_media_item(item)
# Get all results
if favorite_function or isinstance(media_list, str):
if isinstance(media_list, str):
favorite_function = favorite_function_factory(self.tidal, media_list)
media_items: [Track | Video | Album] = favorite_function()
else:
media_items: [Track | Video | Album] = items_results_all(media_list)
result: [ResultItem] = self.search_result_to_model(media_items)
self.populate_tree_results(result, parent=parent)
def thread_it(self, fn: Callable, *args, **kwargs):
# Any other args, kwargs are passed to the run function
worker = Worker(fn, *args, **kwargs)
# Execute
self.threadpool.start(worker)
def on_queue_download_clear_all(self):
self.on_clear_queue_download(
f"({QueueDownloadStatus.Waiting}|{QueueDownloadStatus.Finished}|{QueueDownloadStatus.Failed})"
)
def on_queue_download_clear_finished(self):
self.on_clear_queue_download(f"[{QueueDownloadStatus.Finished}]")
def on_clear_queue_download(self, regex: str):
items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems(
regex, QtCore.Qt.MatchFlag.MatchRegularExpression, column=0
)
for item in items:
self.tr_queue_download.takeTopLevelItem(self.tr_queue_download.indexOfTopLevelItem(item))
def on_queue_download_remove(self):
items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.selectedItems()
if len(items) == 0:
logger_gui.error("Please select an item from the queue first.")
else:
for item in items:
status: str = item.text(0)
if status != QueueDownloadStatus.Downloading:
self.tr_queue_download.takeTopLevelItem(self.tr_queue_download.indexOfTopLevelItem(item))
else:
logger_gui.info("Cannot remove a currently downloading item from queue.")
# TODO: Must happen in main thread. Do not thread this.
def on_download_results(self) -> None:
items: [HumanProxyModel | None] = self.tr_results.selectionModel().selectedRows()
if len(items) == 0:
logger_gui.error("Please select a row first.")
else:
for item in items:
media: Track | Album | Playlist | Video | Artist = get_results_media_item(
item, self.proxy_tr_results, self.model_tr_results
)
queue_dl_item: QueueDownloadItem = self.media_to_queue_download_model(media)
if queue_dl_item:
self.queue_download_media(queue_dl_item)
def queue_download_media(self, queue_dl_item: QueueDownloadItem) -> None:
# Populate child
child: QtWidgets.QTreeWidgetItem = QtWidgets.QTreeWidgetItem()
child.setText(0, queue_dl_item.status)
set_queue_download_media(child, queue_dl_item.obj)
child.setText(2, queue_dl_item.name)
child.setText(3, queue_dl_item.type_media)
child.setText(4, queue_dl_item.quality)
self.tr_queue_download.addTopLevelItem(child)
def watcher_queue_download(self) -> None:
while True:
items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems(
QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0
)
if len(items) > 0:
result: QueueDownloadStatus
item: QtWidgets.QTreeWidgetItem = items[0]
media: Track | Album | Playlist | Video | Mix | Artist = get_queue_download_media(item)
tmp_quality: str = get_queue_download_quality(item)
quality: Quality | QualityVideo | None = tmp_quality if tmp_quality else None
try:
self.s_queue_download_item_downloading.emit(item)
result = self.on_queue_download(media, quality=quality)
if result == QueueDownloadStatus.Finished:
self.s_queue_download_item_finished.emit(item)
elif result == QueueDownloadStatus.Skipped:
self.s_queue_download_item_skipped.emit(item)
except Exception as e:
logger_gui.error(e)
self.s_queue_download_item_failed.emit(item)
else:
time.sleep(2)
def on_queue_download_item_downloading(self, item: QtWidgets.QTreeWidgetItem) -> None:
self.queue_download_item_status(item, QueueDownloadStatus.Downloading)
def on_queue_download_item_finished(self, item: QtWidgets.QTreeWidgetItem) -> None:
self.queue_download_item_status(item, QueueDownloadStatus.Finished)
def on_queue_download_item_failed(self, item: QtWidgets.QTreeWidgetItem) -> None:
self.queue_download_item_status(item, QueueDownloadStatus.Failed)
def on_queue_download_item_skipped(self, item: QtWidgets.QTreeWidgetItem) -> None:
self.queue_download_item_status(item, QueueDownloadStatus.Skipped)
def queue_download_item_status(self, item: QtWidgets.QTreeWidgetItem, status: str) -> None:
item.setText(0, status)
def on_queue_download(
self, media: Track | Album | Playlist | Video | Mix | Artist, quality: Quality | QualityVideo | None = None
) -> QueueDownloadStatus:
result: QueueDownloadStatus
items_media: [Track | Album | Playlist | Video | Mix | Artist]
if isinstance(media, Artist):
items_media: [Album] = items_results_all(media)
else:
items_media = [media]
download_delay: bool = bool(isinstance(media, Track | Video) and self.settings.data.download_delay)
for item_media in items_media:
result = self.download(item_media, self.dl, delay_track=download_delay, quality=quality)
return result
def download(
self,
media: Track | Album | Playlist | Video | Mix | Artist,
dl: Download,
delay_track: bool = False,
quality: Quality | QualityVideo | None = None,
) -> QueueDownloadStatus:
result_dl: bool
path_file: str
result: QueueDownloadStatus
quality_audio: Quality | None
quality_video: QualityVideo | None
self.s_pb_reset.emit()
self.s_statusbar_message.emit(StatusbarMessage(message="Download started..."))
file_template = get_format_template(media, self.settings)
if isinstance(media, Track | Video):
if isinstance(media, Track):
quality_audio = quality
quality_video = None
else:
quality_audio = None
quality_video = quality
result_dl, path_file = dl.item(
media=media,
file_template=file_template,
download_delay=delay_track,
quality_audio=quality_audio,
quality_video=quality_video,
)
elif isinstance(media, Album | Playlist | Mix):
if isinstance(media, Album):
quality_audio = quality
quality_video = None
else:
quality_audio = None
quality_video = None
dl.items(
media=media,
file_template=file_template,
video_download=self.settings.data.video_download,
download_delay=self.settings.data.download_delay,
quality_audio=quality_audio,
quality_video=quality_video,
)
# Dummy values
result_dl = True
path_file = "dummy"
self.s_statusbar_message.emit(StatusbarMessage(message="Download finished.", timout=2000))
if result_dl and path_file:
result = QueueDownloadStatus.Finished
elif not result_dl and path_file:
result = QueueDownloadStatus.Skipped
else:
result = QueueDownloadStatus.Failed
return result
def on_version(
self, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest = None
) -> None:
DialogVersion(self, update_check, update_available, update_info)
def on_preferences(self) -> None:
DialogPreferences(settings=self.settings, settings_save=self.s_settings_save, parent=self)
def on_tr_results_expanded(self, index: QtCore.QModelIndex) -> None:
# If the child is a dummy the list_item has not been expanded before
item: QtGui.QStandardItem = self.model_tr_results.itemFromIndex(self.proxy_tr_results.mapToSource(index))
load_children: bool = not item.child(0, 0).isEnabled()
if load_children:
item.removeRow(0)
media_list: [Mix | Album | Playlist | Artist] = get_results_media_item(
index, self.proxy_tr_results, self.model_tr_results
)
self.list_items_show_result(media_list=media_list, parent=item)
def button_reload_status(self, status: bool):
button_text: str = "Reloading..."
if status:
button_text = "Reload"
self.pb_reload_user_lists.setEnabled(status)
self.pb_reload_user_lists.setText(button_text)
# TODO: Comment with Google Docstrings.
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()
qdarktheme.setup_theme(additional_qss="QToolTip { border: 0px; }")
# 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
if sys.platform.startswith("win"):
import ctypes
# Make sure Pyinstaller icons are still grouped
if not sys.argv[0].endswith(".exe"):
# Arbitrary string
my_app_id: str = "exislow.tidal.dl-ng." + __version__
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_app_id)
window = MainWindow(tidal=tidal)
window.show()
# Check for updates
window.s_update_check.emit(True)
sys.exit(app.exec())
if __name__ == "__main__":
gui_activate()