diff options
Diffstat (limited to 'tidal_dl_ng')
| -rw-r--r-- | tidal_dl_ng/__init__.py | 8 | ||||
| -rw-r--r-- | tidal_dl_ng/api.py | 102 | ||||
| -rw-r--r-- | tidal_dl_ng/cli.py | 217 | ||||
| -rw-r--r-- | tidal_dl_ng/config.py | 132 | ||||
| -rw-r--r-- | tidal_dl_ng/constants.py | 18 | ||||
| -rw-r--r-- | tidal_dl_ng/download.py | 331 | ||||
| -rw-r--r-- | tidal_dl_ng/gui.py | 481 | ||||
| -rw-r--r-- | tidal_dl_ng/helper/__init__.py | 0 | ||||
| -rw-r--r-- | tidal_dl_ng/helper/decorator.py | 19 | ||||
| -rw-r--r-- | tidal_dl_ng/helper/decryption.py | 53 | ||||
| -rw-r--r-- | tidal_dl_ng/helper/exceptions.py | 6 | ||||
| -rw-r--r-- | tidal_dl_ng/helper/path.py | 177 | ||||
| -rw-r--r-- | tidal_dl_ng/helper/url.py | 15 | ||||
| -rw-r--r-- | tidal_dl_ng/helper/wrapper.py | 23 | ||||
| -rw-r--r-- | tidal_dl_ng/logger.py | 65 | ||||
| -rw-r--r-- | tidal_dl_ng/metadata.py | 163 | ||||
| -rw-r--r-- | tidal_dl_ng/model/__init__.py | 0 | ||||
| -rw-r--r-- | tidal_dl_ng/model/cfg.py | 71 | ||||
| -rw-r--r-- | tidal_dl_ng/model/gui_data.py | 32 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/__init__.py | 0 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/icon.png | bin | 0 -> 54166 bytes | |||
| -rw-r--r-- | tidal_dl_ng/ui/main.py | 456 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/main.ui | 590 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/spinner.py | 221 | ||||
| -rw-r--r-- | tidal_dl_ng/worker.py | 31 |
25 files changed, 3211 insertions, 0 deletions
diff --git a/tidal_dl_ng/__init__.py b/tidal_dl_ng/__init__.py new file mode 100644 index 0000000..a0dbac5 --- /dev/null +++ b/tidal_dl_ng/__init__.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python +import importlib.metadata + +try: + # __package__ allows for the case where __name__ is "__main__" + __version__ = importlib.metadata.version(__package__ or __name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" diff --git a/tidal_dl_ng/api.py b/tidal_dl_ng/api.py new file mode 100644 index 0000000..c68665e --- /dev/null +++ b/tidal_dl_ng/api.py @@ -0,0 +1,102 @@ +import json + +import requests + +# TODO: Implement this into `Download`: Session should randomize the usage. +__KEYS_JSON__ = """ +{ + "version": "1.0.1", + "keys": [ + { + "platform": "Fire TV", + "formats": "Normal/High/HiFi(No Master)", + "clientId": "OmDtrzFgyVVL6uW56OnFA2COiabqm", + "clientSecret": "zxen1r3pO0hgtOC7j6twMo9UAqngGrmRiWpV7QC1zJ8=", + "valid": "False", + "from": "Fokka-Engineering (https://github.com/Fokka-Engineering/libopenTIDAL/blob/655528e26e4f3ee2c426c06ea5b8440cf27abc4a/README.md#example)" + }, + { + "platform": "Fire TV", + "formats": "Master-Only(Else Error)", + "clientId": "7m7Ap0JC9j1cOM3n", + "clientSecret": "vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=", + "valid": "True", + "from": "Dniel97 (https://github.com/Dniel97/RedSea/blob/4ba02b88cee33aeb735725cb854be6c66ff372d4/config/settings.example.py#L68)" + }, + { + "platform": "Android TV", + "formats": "Normal/High/HiFi(No Master)", + "clientId": "Pzd0ExNVHkyZLiYN", + "clientSecret": "W7X6UvBaho+XOi1MUeCX6ewv2zTdSOV3Y7qC3p3675I=", + "valid": "False", + "from": "" + }, + { + "platform": "TV", + "formats": "Normal/High/HiFi/Master", + "clientId": "8SEZWa4J1NVC5U5Y", + "clientSecret": "owUYDkxddz+9FpvGX24DlxECNtFEMBxipU0lBfrbq60=", + "valid": "False", + "from": "morguldir (https://github.com/morguldir/python-tidal/commit/50f1afcd2079efb2b4cf694ef5a7d67fdf619d09)" + }, + { + "platform": "Android Auto", + "formats": "Normal/High/HiFi/Master", + "clientId": "zU4XHVVkc2tDPo4t", + "clientSecret": "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4=", + "valid": "True", + "from": "1nikolas (https://github.com/yaronzz/Tidal-Media-Downloader/pull/840)" + } + ] +} +""" +__API_KEYS__ = json.loads(__KEYS_JSON__) +__ERROR_KEY__ = ( + { + "platform": "None", + "formats": "", + "clientId": "", + "clientSecret": "", + "valid": "False", + }, +) + + +def getNum(): + return len(__API_KEYS__["keys"]) + + +def getItem(index: int): + if index < 0 or index >= len(__API_KEYS__["keys"]): + return __ERROR_KEY__ + return __API_KEYS__["keys"][index] + + +def isItemValid(index: int): + item = getItem(index) + return item["valid"] == "True" + + +def getItems(): + return __API_KEYS__["keys"] + + +def getLimitIndexs(): + array = [] + for i in range(len(__API_KEYS__["keys"])): + array.append(str(i)) + return array + + +def getVersion(): + return __API_KEYS__["version"] + + +# Load from gist +try: + respond = requests.get("https://api.github.com/gists/48d01f5a24b4b7b37f19443977c22cd6") + if respond.status_code == 200: + content = respond.json()["files"]["tidal-api-key.json"]["content"] + __API_KEYS__ = json.loads(content) +except: + pass diff --git a/tidal_dl_ng/cli.py b/tidal_dl_ng/cli.py new file mode 100644 index 0000000..c021d86 --- /dev/null +++ b/tidal_dl_ng/cli.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python +from typing import Annotated, Optional + +from pathlib import Path + +import typer +from tidal_dl_ng.config import Settings, Tidal +from tidal_dl_ng.constants import CTX_TIDAL, MediaType +from tidal_dl_ng.helper.path import path_file_settings +from tidal_dl_ng.helper.url import get_tidal_media_id, get_tidal_media_type +from tidal_dl_ng.helper.wrapper import WrapperLogger +from tidal_dl_ng.model.cfg import HelpSettings +from rich.live import Live +from rich.panel import Panel +from rich.progress import BarColumn, Console, Progress, SpinnerColumn, TextColumn +from rich.table import Table + +from tidal_dl_ng.download import Download +from tidal_dl_ng import __version__ + +app = typer.Typer() + + +def version_callback(value: bool): + if value: + print(f"{__version__}") + raise typer.Exit() + + +@app.callback() +def callback_app( + ctx: typer.Context, + version: Annotated[Optional[bool], typer.Option("--version", "-v", callback=version_callback, is_eager=True)] = None, +): + ctx.obj = {"tidal": None} + + +@app.command(name="cfg") +def settings_management( + names: list[str] = None, + editor: Annotated[ + bool, typer.Option("--editor", "-e", help="Open the settings file in your default editor.") + ] = False, +): + """ + Print or set an option. + If no arguments are given, all options will be listed. + If only one argument is given, the value will be printed for this option. + To set a value for an option simply pass the value as the second argument + + :param names: (Optional) None (list all options), one (list the value only for this option) or two arguments + (set the value for the option). + """ + if editor: + config_path: Path = Path(path_file_settings()) + + if not config_path.is_file(): + config_path.write_text('{"version": "1.0.0"}') + + config_file_str = str(config_path) + + typer.launch(config_file_str) + else: + settings = Settings() + d_settings = settings.data.to_dict() + + if names: + if names[0] not in d_settings: + print(f'Option "{names[0]}" is not valid!') + else: + if len(names) == 1: + print(f'{names[0]}: "{d_settings[names[0]]}"') + elif len(names) > 1: + settings.set_option(names[0], names[1]) + settings.save() + else: + help_settings: dict = HelpSettings().to_dict() + table = Table(title=f"Config: {path_file_settings()}") + table.add_column("Key", style="cyan", no_wrap=True) + table.add_column("Value", style="magenta") + table.add_column("Description", style="green") + + # Iterate over the attributes of the dataclass + for key, value in sorted(d_settings.items()): + table.add_row(key, str(value), help_settings[key]) + + console = Console() + console.print(table) + + +@app.command(name="login") +def login(ctx: typer.Context) -> bool: + print("Let us check, if you are already logged in... ", end="") + + settings = Settings() + tidal = Tidal(settings) + result = tidal.login(print) + ctx.obj[CTX_TIDAL] = tidal + + return result + + +@app.command(name="dl") +def download( + ctx: typer.Context, + urls_or_ids: Annotated[Optional[list[str]], typer.Argument()] = None, + list_urls: Annotated[ + Optional[Path], + typer.Option( + "--list", + "-l", + exists=True, + file_okay=True, + dir_okay=False, + writable=False, + readable=True, + resolve_path=True, + help="List with URLs to download. One per line", + ), + ] = None, +): + if not urls_or_ids: + if list_urls: + text = list_urls.read_text() + urls_or_ids = text.splitlines() + else: + print("Provide either URLs, IDs or a file containing URLs (one per line).") + + raise typer.Abort() + + ctx.invoke(login, ctx) + + dl = Download(ctx.obj[CTX_TIDAL].session, ctx.obj[CTX_TIDAL].settings.data.skip_existing) + settings: Settings = Settings() + media_type: MediaType = None + progress: Progress = Progress( + "{task.description}", + SpinnerColumn(), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + ) + progress_table = Table.grid() + fn_logger = WrapperLogger(progress.print) + + progress_table.add_row(Panel.fit(progress, title="Download Progress", border_style="green", padding=(2, 2))) + + for item in urls_or_ids: + if "http" in item: + media_name = get_tidal_media_type(item) + id_item = get_tidal_media_id(item) + else: + media_name = False + id_item = item + + if not media_name: + print(f"It seems like that you have supplied an invalid URL: {item}") + + continue + + with Live(progress_table, refresh_per_second=10): + # TODO: Fix media type + if media_name in ["track", "video"]: + if media_name == "track": + file_template = settings.data.format_track + media_type = MediaType.Track + elif media_name == "video": + file_template = settings.data.format_video + media_type = MediaType.Video + + dl.item( + id=id_item, + media_type=media_type, + path_base=settings.data.download_base_path, + file_template=file_template, + progress=progress, + fn_logger=fn_logger + ) + elif media_name in ["album", "playlist", "mix"]: + # TODO: Handle mixes. + file_template: str | bool = False + + if media_name == "album": + file_template = settings.data.format_album + media_type = MediaType.Album + elif media_name == "playlist": + file_template = settings.data.format_playlist + media_type = MediaType.Playlist + elif media_name == "mix": + file_template = settings.data.format_mix + media_type = MediaType.Mix + + dl.list( + id_media=id_item, + media_type=media_type, + path_base=settings.data.download_base_path, + file_template=file_template, + video_download=ctx.obj[CTX_TIDAL].settings.data.video_download, + progress=progress, + download_delay=settings.data.download_delay, + fn_logger=fn_logger, + ) + + progress.stop() + + return True + + +@app.command() +def gui(ctx: typer.Context): + from tidal_dl_ng.gui import gui_activate + + ctx.invoke(login, ctx) + gui_activate(ctx.obj[CTX_TIDAL]) + + +if __name__ == "__main__": + app() diff --git a/tidal_dl_ng/config.py b/tidal_dl_ng/config.py new file mode 100644 index 0000000..bdcc70d --- /dev/null +++ b/tidal_dl_ng/config.py @@ -0,0 +1,132 @@ +from typing import Any + +from collections.abc import Callable +from json import JSONDecodeError + +import tidalapi +from tidal_dl_ng.helper.decorator import SingletonMeta +from tidal_dl_ng.helper.path import path_file_settings, path_file_token +from tidal_dl_ng.model.cfg import Settings as ModelSettings +from tidal_dl_ng.model.cfg import Token as ModelToken +from requests import HTTPError + + +class BaseConfig: + data: ModelSettings | ModelToken = None + file_path: str = None + cls_model: object = None + + def save(self) -> None: + data_json = self.data.to_json() + + with open(self.file_path, encoding="utf-8", mode="w") as f: + f.write(data_json) + + def set_option(self, key: str, value: Any) -> None: + setattr(self.data, key, value) + + def read(self, path: str) -> bool: + result = False + + try: + with open(path, encoding="utf-8") as f: + settings_json = f.read() + + self.data = self.cls_model.from_json(settings_json) + result = True + except (JSONDecodeError, TypeError, FileNotFoundError): + self.data = self.cls_model() + self.save() + + return result + + +class Settings(BaseConfig, metaclass=SingletonMeta): + cls_model = ModelSettings + data = None + + def __init__(self): + self.file_path = path_file_settings() + self.read(self.file_path) + + +class Tidal(BaseConfig, metaclass=SingletonMeta): + cls_model = ModelToken + session: tidalapi.Session = None + data: ModelToken = None + token_from_storage: bool = False + settings: Settings = None + + def __init__(self, settings: Settings = None): + self.session = tidalapi.Session() + self.session.video_quality = tidalapi.VideoQuality.high + self.file_path = path_file_token() + self.token_from_storage = self.read(self.file_path) + self.login_token() + + if settings: + self.settings = settings + self.settings_apply() + + def settings_apply(self, settings: Settings = None) -> bool: + if settings: + self.settings = settings + + self.session.audio_quality = self.settings.data.quality_audio + + return True + + def login_token(self) -> bool: + result = False + + if self.token_from_storage: + try: + result = self.session.load_oauth_session( + self.data.token_type, self.data.access_token, self.data.refresh_token, self.data.expiry_time + ) + except HTTPError: + result = False + + return result + + def login_oauth_start(self, function=print) -> None: + self.session.login_oauth_simple(function) + + def login_oauth_finish(self) -> bool: + result = self.session.check_login() + + if result: + self.token_persist() + + return result + + def token_persist(self) -> None: + self.set_option("token_type", self.session.token_type) + self.set_option("access_token", self.session.access_token) + self.set_option("refresh_token", self.session.refresh_token) + self.set_option("expiry_time", self.session.expiry_time) + self.save() + + def login(self, fn_print: Callable) -> bool: + is_token = self.login_token() + result = False + + if is_token: + fn_print("Yep, looks good! You are logged in.") + + result = True + elif not is_token: + fn_print("You either do not have a token or your token is invalid.") + fn_print("No worries, we will handle this...") + self.login_oauth_start(fn_print) + + is_login = self.login_oauth_finish() + + if is_login: + fn_print("The login was successful. I have stored your credentials (token).") + + result = True + else: + fn_print("Something went wrong. Did you login using your browser correctly? May try again...") + + return result diff --git a/tidal_dl_ng/constants.py b/tidal_dl_ng/constants.py new file mode 100644 index 0000000..41564db --- /dev/null +++ b/tidal_dl_ng/constants.py @@ -0,0 +1,18 @@ +from enum import Enum + +CTX_TIDAL: str = "tidal" + + +class QualityVideo(Enum): + P360 = 360 + P480 = 480 + P720 = 720 + P1080 = 1080 + + +class MediaType(Enum): + Track = "track" + Video = "video" + Playlist = "playlist" + Album = "album" + Mix = "mix" diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py new file mode 100644 index 0000000..ba9fde0 --- /dev/null +++ b/tidal_dl_ng/download.py @@ -0,0 +1,331 @@ +import base64 +import json +import os +import random +import shutil +import tempfile +import time +from logging import Logger +from requests.exceptions import HTTPError +from uuid import uuid4 + +import ffmpeg +import m3u8 +import requests +from tidal_dl_ng.config import Settings +from tidal_dl_ng.constants import MediaType +from tidal_dl_ng.helper.decryption import decrypt_file, decrypt_security_token +from tidal_dl_ng.helper.exceptions import MediaUnknown +from tidal_dl_ng.helper.path import check_file_exists, format_path_media, path_validate +from tidal_dl_ng.helper.wrapper import WrapperLogger +from tidal_dl_ng.metadata import Metadata +from tidal_dl_ng.model.gui_data import ProgressBars +from pathvalidate import sanitize_filepath +from rich.progress import Progress +from tidalapi import Album, Playlist, Session, Track, UserPlaylist, Video, Mix + + +# TODO: Set appropriate client string and use it for video download. +# https://github.com/globocom/m3u8#using-different-http-clients +class RequestsClient: + def download(self, uri, timeout=None, headers={}, verify_ssl=True): + o = requests.get(uri, timeout=timeout, headers=headers) + + return o.text, o.url + + +class Download: + # TODO: Implement download cover 1280. + session: Session = None + skip_existing: bool = False + + def __init__(self, session: Session, skip_existing: bool = False): + self.session = session + self.skip_existing = skip_existing + + def _video(self, video: Video, path_file: str) -> str | None: + result: str | None = None + m3u8_variant: m3u8.M3U8 = m3u8.load(video.get_url()) + m3u8_playlist: m3u8.M3U8 | bool = False + settings: Settings = Settings() + resolution_best: int = 0 + + if m3u8_variant.is_variant: + for playlist in m3u8_variant.playlists: + if resolution_best < playlist.stream_info.resolution[1]: + resolution_best = playlist.stream_info.resolution[1] + m3u8_playlist = m3u8.load(playlist.uri) + + if settings.data.quality_video.value == playlist.stream_info.resolution[1]: + break + + if m3u8_playlist: + with open(path_file, "wb") as f: + for segment in m3u8_playlist.data["segments"]: + url = segment["uri"] + r = requests.get(url) + + f.write(r.content) + + result = path_file + + return result + + def item( + self, + path_base: str, + fn_logger: Logger | WrapperLogger, + id: str = None, + file_template: str = None, + media: Track | Video = None, + media_type: MediaType = None, + video_download: bool = True, + progress_gui: ProgressBars = None, + progress: Progress = None + ) -> (bool, str): + if id: + if media_type == MediaType.Track: + media = Track(self.session, id) + elif media_type == MediaType.Video: + media = Video(self.session, id) + + # If video download is not allowed + if not video_download: + return False, "" + else: + raise MediaUnknown + else: + media = media + + if file_template: + file_name_relative = format_path_media(file_template, media) + path_file = os.path.abspath(os.path.normpath(os.path.join(path_base, file_name_relative))) + else: + path_file = os.path.abspath(os.path.normpath(format_path_media(path_base, media))) + + if isinstance(media, Track): + stream = media.stream() + # TODO: Check for `manifest_mime_type'. It could be also xml. + stream_manifest = json.loads(base64.b64decode(stream.manifest).decode("utf-8")) + # TODO: Handle more than one dowload URL + stream_url = stream_manifest["urls"][0] + file_extension = self.get_file_extension(stream_url, stream_manifest["codecs"]) + elif isinstance(media, Video): + file_extension = ".ts" + + path_file = sanitize_filepath(path_file + file_extension) + # Check if path & filename longer than the OS allows. Shorten if necessary. + validation_result, path_file = path_validate(path_file, True) + + download_skip = check_file_exists(path_file) if self.skip_existing else False + + if not download_skip: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir: + tmp_path_file = os.path.join(tmp_path_dir, str(uuid4()) + file_extension) + + if isinstance(media, Track): + # TODO: Refactor to separate method. + if progress_gui is None: + progress_stdout: bool = True + else: + progress_stdout: bool = False + progress_gui.item_name.emit(media.name) + + try: + # Streaming, so we can iterate over the response. + r = requests.get(stream_url, stream=True) + r.raise_for_status() + total_size_in_bytes = int(r.headers.get("content-length", 0)) + block_size = 4096 + p_task = progress.add_task( + f"[blue]Item '{media.name[:30]}'", + total=total_size_in_bytes / block_size, + visible=progress_stdout, + ) + + while not progress.tasks[p_task].finished: + with open(tmp_path_file, "wb") as f: + for data in r.iter_content(chunk_size=block_size): + f.write(data) + progress.advance(p_task, advance=1) + + if not progress_stdout: + progress_gui.item.emit(progress.tasks[p_task].percentage) + except HTTPError: + # TODO: Handle Exception... + pass + + needs_decryption = self.is_encrypted(stream_manifest) + + if needs_decryption: + key, nonce = decrypt_security_token(stream_manifest["encryptionKey"]) + tmp_path_file_decrypted = tmp_path_file + "_decrypted" + decrypt_file(tmp_path_file, tmp_path_file_decrypted, key, nonce) + else: + tmp_path_file_decrypted = tmp_path_file + + self.metadata_write(media, tmp_path_file_decrypted) + elif isinstance(media, Video): + tmp_path_file_decrypted = self._video(media, tmp_path_file) + + # TODO: Check if is possible to write metadata to MPEG Transport Stream files. + # TODO: Make optional. + if True: + tmp_path_file_decrypted = self._video_convert(tmp_path_file_decrypted) + path_file = os.path.splitext(path_file)[0] + ".mp4" + + os.makedirs(os.path.dirname(path_file), exist_ok=True) + shutil.move(tmp_path_file_decrypted, path_file) + else: + fn_logger.debug(f"Download skipped, since file exists: '{path_file}'") + + return not download_skip, path_file + + def cover_url(self, sid: str, width: int = 320, height: int = 320): + if sid is None: + return "" + + return f"https://resources.tidal.com/images/{sid.replace('-', '/')}/{int(width)}x{int(height)}.jpg" + + def cover_data(self, sid, width: int = 320, height: int = 320): + url = self.cover_url(sid, width, height) + + try: + result: str = requests.get(url).content + except: + result: str = "" + + return result + + def metadata_write(self, track: Track, path_file: str): + result: bool = False + release_date: str = track.album.release_date.strftime("%Y-%m-%d") if track.album.release_date else "" + copy_right: str = track.copyright if track.copyright else "" + + try: + lyrics: str = track.lyrics().text if hasattr(track, "lyrics") else "" + except HTTPError: + lyrics: str = "" + + m: Metadata = Metadata( + path_file=path_file, + lyrics=lyrics, + copy_right=copy_right, + title=track.name, + artists=[artist.name for artist in track.artists], + album=track.album.name, + tracknumber=track.track_num, + date=release_date, + isrc=track.isrc, + albumartist=track.artist.name, + totaltrack=track.album.num_tracks if track.album.num_tracks else 1, + totaldisc=track.album.num_volumes if track.album.num_volumes else 1, + discnumber=track.volume_num, + url_cover=self.cover_url(track.album.cover), + ) + + m.save() + + result = True + + return result + + def list( + self, + path_base: str, + fn_logger: Logger | WrapperLogger, + id_media: str = None, + media_type: MediaType = None, + file_template: str = None, + list_media: Album | Playlist | UserPlaylist | Mix = None, + video_download: bool = False, + progress_gui: ProgressBars = None, + progress: Progress = None, + download_delay: bool = True, + ): + if not list_media: + if media_type == MediaType.Album: + list_media = Album(self.session, id_media) + elif media_type == MediaType.Playlist: + list_media = Playlist(self.session, id_media) + elif media_type == MediaType.Mix: + list_media = Mix(self.session, id_media) + else: + raise MediaUnknown + + if file_template: + file_name_relative = format_path_media(file_template, list_media) + path_file = path_base + else: + file_name_relative = file_template + path_file = format_path_media(path_base, list_media) + + # TODO: Extend with pagination support: Iterate through `items` and `tracks`until len(returned list) == 0 + if isinstance(list_media, Mix): + items = list_media.items() + list_media_name = list_media.title[:30] + elif video_download: + items = list_media.items(limit=100) + list_media_name = list_media.name[:30] + else: + items = list_media.tracks(limit=999) + list_media_name = list_media.name[:30] + + if progress_gui is None: + progress_stdout: bool = True + else: + progress_stdout: bool = False + + p_task1 = progress.add_task(f"[green]List '{list_media_name}'", total=len(items), visible=progress_stdout) + + while not progress.finished: + for media in items: + Progress() + # TODO: Handle return value of `track` method. + self.item( + path_base=path_file, + file_template=file_name_relative, + media=media, + progress_gui=progress_gui, + progress=progress, + fn_logger=fn_logger + ) + progress.advance(p_task1) + + if not progress_stdout: + progress_gui.list_item.emit(progress.tasks[p_task1].percentage) + + if download_delay: + time_sleep: float = round(random.SystemRandom().uniform(2, 5), 1) + + # TODO: Fix logging. Is not displayed in debug window. + fn_logger.debug(f"Next download will start in {time_sleep} seconds.") + time.sleep(time_sleep) + + def is_encrypted(self, manifest: dict) -> bool: + result = manifest["encryptionType"] != "NONE" + + return result + + def get_file_extension(self, stream_url: str, stream_codec: str) -> str: + result = None + + if ".flac" in stream_url: + result = ".flac" + elif ".mp4" in stream_url: + if "ac4" in stream_codec or "mha1" in stream_codec: + result = ".mp4" + elif "flac" in stream_codec: + result = ".flac" + else: + result = ".m4a" + else: + result = ".m4a" + + return result + + def _video_convert(self, path_file: str) -> str: + path_file_out = os.path.splitext(path_file)[0] + ".mp4" + result, _ = ffmpeg.input(path_file).output(path_file_out, map=0, c="copy").run() + + return path_file_out diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py new file mode 100644 index 0000000..a995c01 --- /dev/null +++ b/tidal_dl_ng/gui.py @@ -0,0 +1,481 @@ +import math +import sys +from collections.abc import Callable + +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.session import SearchTypes +from tidal_dl_ng.config import Settings, Tidal +from tidal_dl_ng.constants import QualityVideo +from tidal_dl_ng.download import Download +from tidal_dl_ng.logger import XStream, logger_cli, logger_gui +from tidal_dl_ng.model.gui_data import ProgressBars, ResultSearch +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 = None + tidal: Tidal = None + download: Download = None + threadpool: QtCore.QThreadPool = None + spinner: QtWaitingSpinner = None + spinner_start: QtCore.Signal = QtCore.Signal(QtWidgets.QWidget) + spinner_stop: QtCore.Signal = QtCore.Signal() + pb_item: QtWidgets.QProgressBar = None + s_item_advance: QtCore.Signal = QtCore.Signal(float) + s_item_name: QtCore.Signal = QtCore.Signal(str) + pb_list: QtWidgets.QProgressBar = None + s_list_advance: QtCore.Signal = QtCore.Signal(float) + + def __init__(self, tidal: Tidal | None = None): + super().__init__() + self.setupUi(self) + # self.setGeometry(50, 50, 500, 300) + self.setWindowTitle("TIDAL Downloader Next Gen!") + # TODO: Fix icons (make them visible). + my_pixmap = QtGui.QPixmap("tidal_dl_ng/ui/icon.png") + my_icon = QtGui.QIcon("tidal_dl_ng/ui/icon.png") + self.setWindowIcon(my_icon) + tray = QtWidgets.QSystemTrayIcon() + tray.setIcon(my_icon) + tray.setVisible(True) + + # Logging redirect. + XStream.stdout().messageWritten.connect(self._log_output) + # XStream.stderr().messageWritten.connect(self._log_output) + + self.settings = Settings() + self.threadpool = QtCore.QThreadPool() + + # TODO: Show GUI, create a progress bar showing the TIDAL querying progress. + self._init_tree_results(self.tr_results) + self._init_tree_lists(self.tr_lists_user) + 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.thread_it(self.init_tidal, tidal) + + logger_gui.debug("Multithreading with maximum %d threads" % self.threadpool.maxThreadCount()) + 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) + + while True: + result = self.tidal.login(logger_gui.info) + + if result: + break + + if result: + self.download = Download(self.tidal.session, self.tidal.settings.data.skip_existing) + + self.thread_it(self.populate_tree_lists, self.tidal) + + 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 progress_reset(self): + self.pb_list.setValue(0) + self.pb_item.setValue(0) + + 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 and not item.__name__ == "Artist": + ui_target.addItem(item.__name__, item) + + self.cb_search_type.setCurrentIndex(1) + + def _init_tree_results(self, tree: QtWidgets.QTableWidget): + tree.setColumnHidden(5, True) + + def populate_tree_lists(self, tidal: Tidal): + # Start loading spinner + self.spinner_start.emit(self.tr_lists_user) + self.tr_results.clear() + + # TODO: Refactor list item names to constants. + twi_playlists: QtWidgets.QTableWidgetItem = self.tr_lists_user.findItems( + "Playlists", QtCore.Qt.MatchExactly, 0 + )[0] + twi_mixes: QtWidgets.QTableWidgetItem = self.tr_lists_user.findItems("Favorites", QtCore.Qt.MatchExactly, 0)[0] + twi_favorites: QtWidgets.QTableWidgetItem = self.tr_lists_user.findItems("Mixes", QtCore.Qt.MatchExactly, 0)[0] + + user_playlists: [Playlist | UserPlaylist] = tidal.session.user.playlist_and_favorite_playlists() + user_mixes: [Mix] = tidal.session.mixes().categories[0].items + user_all: [Playlist | UserPlaylist | Mix] = user_playlists + user_mixes + + for item in user_all: + if isinstance(item, UserPlaylist): + twi_child = QtWidgets.QTreeWidgetItem(twi_playlists) + name: str = item.name + info: str = f"({item.num_tracks + item.num_videos} Tracks)" + elif isinstance(item, Playlist): + twi_child = QtWidgets.QTreeWidgetItem(twi_mixes) + name: str = item.name + info: str = f"({item.num_tracks + item.num_videos} Tracks) {item.description}" + elif isinstance(item, Mix): + twi_child = QtWidgets.QTreeWidgetItem(twi_favorites) + name: str = item.title + info: str = item.sub_title + + twi_child.setText(0, name) + twi_child.setText(1, info) + twi_child.setData(3, QtCore.Qt.ItemDataRole.UserRole, item) + + # Stop load spinner + self.spinner_stop.emit() + + def _init_tree_lists(self, tree: QtWidgets.QTreeWidget): + tree.setColumnWidth(0, 200) + tree.setColumnWidth(1, 300) + tree.setColumnHidden(2, True) + tree.expandAll() + + # Connect the contextmenu + tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + tree.customContextMenuRequested.connect(self.menu_context_tree_lists) + + def apply_settings(self, settings: Settings): + l_cb = [ + {"element": self.cb_quality_audio, "setting": settings.data.quality_audio.name, "default_id": 1}, + {"element": self.cb_quality_video, "setting": settings.data.quality_video.name, "default_id": 0}, + ] + + for item in l_cb: + idx = item["element"].findText(item["setting"]) + if idx > -1: + item["element"].setCurrentIndex(idx) + else: + item["element"].setCurrentIndex(item["default_id"]) + + @QtCore.Slot(QtWidgets.QWidget) + 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() + + @QtCore.Slot() + def on_spinner_stop(self): + self.spinner.stop() + self.spinner = None + + def menu_context_tree_lists(self, point): + # 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() + action = menu.addAction("Dowloading Playlist", lambda: self.thread_download_list_media(point)) + + menu.exec(self.tr_lists_user.mapToGlobal(point)) + + def thread_download_list_media(self, point): + self.thread_it(self.on_download_list_media, point) + self.thread_it(self.list_items_show, point=point) + + def on_download_list_media(self, point: QtCore.QPoint): + item = self.tr_lists_user.itemAt(point) + media = item.data(3, QtCore.Qt.ItemDataRole.UserRole) + + # TODO: Implement disable download button etc. + self.download_item(media, self.download) + + def search_populate_results(self, query: str, type_media: SearchTypes): + self.tr_results.clear() + + results: [ResultSearch] = self.search(query, [type_media]) + + self.populate_tree_results(results) + + def populate_tree_results(self, results: [ResultSearch]): + self.tr_results.clear() + + for item in results: + # Format seconds to mm:ss. + m, s = divmod(item.duration_sec, 60) + duration: str = f"{m:02d}:{s:02d}" + child = QtWidgets.QTreeWidgetItem() + + child.setText(0, str(item.position)) + child.setText(1, item.artist) + child.setText(2, item.title) + child.setText(3, item.album) + child.setText(4, duration) + child.setData(5, QtCore.Qt.ItemDataRole.UserRole, item.obj) + + self.tr_results.addTopLevelItem(child) + + def search(self, query: str, types_media: SearchTypes) -> [ResultSearch]: + result_search: [dict[str, *SearchTypes]] = self.tidal.session.search(query, models=types_media, limit=999) + result: [ResultSearch] = [] + + 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]) -> [ResultSearch]: + result = [] + + for idx, item in enumerate(items): + if isinstance(item, Track): + result_item: ResultSearch = ResultSearch( + position=idx, + artist=item.artist.name, + title=item.name, + album=item.album.name, + duration_sec=item.duration, + obj=item, + ) + + result.append(result_item) + elif isinstance(item, Video): + result_item: ResultSearch = ResultSearch( + position=idx, + artist=item.artist.name, + title=item.name, + album=item.album.name if item.album else "", + duration_sec=item.duration, + obj=item, + ) + + result.append(result_item) + elif isinstance(item, Playlist): + result_item: ResultSearch = ResultSearch( + position=idx, + artist=f"{item.promoted_artists[0].name}, ..." if item.promoted_artists else "", + title=item.name, + album="", + duration_sec=item.duration, + obj=item, + ) + + result.append(result_item) + elif isinstance(item, Album): + result_item: ResultSearch = ResultSearch( + position=idx, + artist=item.artist.name, + title="", + album=item.name, + duration_sec=item.duration, + obj=item, + ) + + result.append(result_item) + + return result + + def _init_signals(self): + self.b_download.clicked.connect(lambda: self.thread_it(self.on_download_results)) + self.l_search.returnPressed.connect( + lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) + ) + self.b_search.clicked.connect( + lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) + ) + self.cb_quality_audio.currentIndexChanged.connect(self.quality_set_audio) + self.cb_quality_video.currentIndexChanged.connect(self.quality_set_video) + self.tr_lists_user.itemClicked.connect(self.on_list_items_show) + self.spinner_start[QtWidgets.QWidget].connect(self.on_spinner_start) + self.spinner_stop.connect(self.on_spinner_stop) + self.s_item_advance.connect(self.progress_item) + self.s_item_name.connect(self.progress_item_name) + self.s_list_advance.connect(self.progress_list) + + def progress_list(self, value: float): + self.pb_list.setValue(int(math.ceil(value))) + + def progress_item(self, value: float): + self.pb_item.setValue(int(math.ceil(value))) + + def progress_item_name(self, value: str): + self.pb_item.setFormat(f"%p% {value}") + + def progress_list_name(self, value: str): + self.pb_list.setFormat(f"%p% {value}") + + def quality_set_audio(self, index): + self.settings.data.quality_audio = Quality(self.cb_quality_audio.itemData(index).value) + self.settings.save() + + if self.tidal: + self.tidal.settings_apply() + + def quality_set_video(self, index): + self.settings.data.quality_video = QualityVideo(self.cb_quality_video.itemData(index).value) + self.settings.save() + + if self.tidal: + self.tidal.settings_apply() + + def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem): + media_list: Album | Playlist = item.data(3, QtCore.Qt.ItemDataRole.UserRole) + + # Only if clicked item is not a top level item. + if media_list: + self.list_items_show(media_list) + + def list_items_show(self, media_list: Album | Playlist | None = None, point: QtCore.QPoint | None = None): + if point: + item = self.tr_lists_user.itemAt(point) + media_list = item.data(3, QtCore.Qt.ItemDataRole.UserRole) + + media_items = media_list.items() + result = self.search_result_to_model(media_items) + + self.populate_tree_results(result) + + 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_download_results(self): + self.b_download.setEnabled(False) + self.b_download.setText(f"Downloading...") + + items: [QtWidgets.QTreeWidgetItem] = self.tr_results.selectedItems() + + if len(items) == 0: + logger_gui.error("Please select a row first.") + else: + for item in items: + media: Track | Album | Playlist | Video = item.data(5, QtCore.Qt.ItemDataRole.UserRole) + + result_dl: bool = self.download_item(media, self.download) + + self.b_download.setText(f"Download") + self.b_download.setEnabled(True) + + def download_item(self, media: Track | Album | Playlist | Video | Mix, dl: Download) -> bool: + # TODO: Refactor this. Move this logic to `Download` class and provide a generic interface. + self.progress_reset() + self.statusbar.showMessage("Download started...") + + data_pb: ProgressBars = ProgressBars( + item=self.s_item_advance, list_item=self.s_list_advance, item_name=self.s_item_name + ) + progress: Progress = Progress() + + if isinstance(media, Track) or isinstance(media, Video): + if isinstance(media, Track): + file_template: str = self.settings.data.format_track + elif isinstance(media, Video): + file_template: str = self.settings.data.format_video + + result_download, download_path_file = dl.item( + media=media, + path_base=self.settings.data.download_base_path, + file_template=file_template, + progress_gui=data_pb, + progress=progress, + fn_logger=logger_gui + ) + + if result_download: + logger_gui.info(f"Download successful: {download_path_file}") + else: + logger_gui.info(f"Download skipped (file exists): {download_path_file}") + elif isinstance(media, Album) or isinstance(media, Playlist) or isinstance(media, Mix): + file_template: str | bool = False + + if isinstance(media, Album): + file_template: str = self.settings.data.format_album + progress_name = media.name + elif isinstance(media, Playlist): + file_template: str = self.settings.data.format_playlist + progress_name = media.name + elif isinstance(media, Mix): + file_template: str = self.settings.data.format_mix + progress_name = media.title + else: + progress_name = "List N/A" + + self.progress_list_name(progress_name) + + dl.list( + path_base=self.settings.data.download_base_path, + file_template=file_template, + list_media=media, + video_download=self.settings.data.video_download, + progress_gui=data_pb, + progress=progress, + download_delay=self.settings.data.download_delay, + fn_logger=logger_gui, + ) + + self.statusbar.showMessage("Download finished.", 2000) + progress.stop() + + # TODO: Refactor to useful return value. + return True + + def progress_reset(self): + self.pb_list.setValue(0) + self.pb_item.setValue(0) + + +# TODO: Comment with Google Docstrings. +def gui_activate(tidal: Tidal | None = None): + qdarktheme.enable_hi_dpi() + app = QtWidgets.QApplication(sys.argv) + qdarktheme.setup_theme() + + window = MainWindow(tidal) + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + gui_activate() diff --git a/tidal_dl_ng/helper/__init__.py b/tidal_dl_ng/helper/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tidal_dl_ng/helper/__init__.py diff --git a/tidal_dl_ng/helper/decorator.py b/tidal_dl_ng/helper/decorator.py new file mode 100644 index 0000000..545f73b --- /dev/null +++ b/tidal_dl_ng/helper/decorator.py @@ -0,0 +1,19 @@ +class SingletonMeta(type): + """ + The Singleton class can be implemented in different ways in Python. Some + possible methods include: base class, decorator, metaclass. We will use the + metaclass because it is best suited for this purpose. + """ + + _instances = {} + + def __call__(cls, *args, **kwargs): + """ + Possible changes to the value of the `__init__` argument do not affect + the returned instance. + """ + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + + return cls._instances[cls] diff --git a/tidal_dl_ng/helper/decryption.py b/tidal_dl_ng/helper/decryption.py new file mode 100644 index 0000000..2fa2ec0 --- /dev/null +++ b/tidal_dl_ng/helper/decryption.py @@ -0,0 +1,53 @@ +import base64 + +from Crypto.Cipher import AES +from Crypto.Util import Counter + + +def decrypt_security_token(security_token: str) -> (str, str): + """ + Decrypts security token into key and nonce pair + + security_token should match the securityToken value from the web response + """ + + # Do not change this + master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754=" + + # Decode the base64 strings to ascii strings + master_key = base64.b64decode(master_key) + security_token = base64.b64decode(security_token) + + # Get the IV from the first 16 bytes of the securityToken + iv = security_token[:16] + encrypted_st = security_token[16:] + + # Initialize decryptor + decryptor = AES.new(master_key, AES.MODE_CBC, iv) + + # Decrypt the security token + decrypted_st = decryptor.decrypt(encrypted_st) + + # Get the audio stream decryption key and nonce from the decrypted security token + key = decrypted_st[:16] + nonce = decrypted_st[16:24] + + return key, nonce + + +def decrypt_file(path_file_encrypted: str, path_file_destination: str, key: str, nonce: str) -> None: + """ + Decrypts an encrypted MQA file given the file, key and nonce + """ + + # Initialize counter and file decryptor + counter = Counter.new(64, prefix=nonce, initial_value=0) + decryptor = AES.new(key, AES.MODE_CTR, counter=counter) + + # Open and decrypt + with open(path_file_encrypted, "rb") as f_src: + audio_decrypted = decryptor.decrypt(f_src.read()) + + # Replace with decrypted file + with open(path_file_destination, "wb") as f_dst: + f_dst.write(audio_decrypted) diff --git a/tidal_dl_ng/helper/exceptions.py b/tidal_dl_ng/helper/exceptions.py new file mode 100644 index 0000000..9ef6ddc --- /dev/null +++ b/tidal_dl_ng/helper/exceptions.py @@ -0,0 +1,6 @@ +class LoginError(Exception): + pass + + +class MediaUnknown(Exception): + pass diff --git a/tidal_dl_ng/helper/path.py b/tidal_dl_ng/helper/path.py new file mode 100644 index 0000000..c5bf988 --- /dev/null +++ b/tidal_dl_ng/helper/path.py @@ -0,0 +1,177 @@ +import os +import pathlib +import platform +import re +from collections.abc import Callable +from pathlib import Path + +from pathvalidate import sanitize_filename +from tidalapi import Album, Playlist, Track, UserPlaylist, Video, Mix + + +def path_base(): + if "XDG_CONFIG_HOME" in os.environ: + return os.environ["XDG_CONFIG_HOME"] + elif "HOME" in os.environ: + return os.environ["HOME"] + elif "HOMEDRIVE" in os.environ and "HOMEPATH" in os.environ: + return os.path.join(os.environ["HOMEDRIVE"], os.environ["HOMEPATH"]) + else: + return os.path.abspath("./") + + +def path_file_log(): + return os.path.join(path_base(), ".tidal-dl-ng.log") + + +def path_file_token(): + return os.path.join(path_base(), ".tidal-dl-ng_token.json") + + +def path_file_settings(): + return os.path.join(path_base(), ".tidal-dl-ng_settings.json") + + +def format_path_media(fmt_template: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix) -> str: + result = fmt_template + + # Search track format template for placeholder. + regex = r"\{(.+?)\}" + matches = re.finditer(regex, fmt_template, re.MULTILINE) + fn_format = get_fn_format(media) + + for _matchNum, match in enumerate(matches, start=1): + template_str = match.group() + result_fmt = fn_format(match.group(1), media) + + if result_fmt: + value = sanitize_filename(result_fmt) + result = result.replace(template_str, value) + + return result + + +def format_str_track(name: str, media: Track) -> str | bool: + result: str | bool = False + + if name == "track_num": + result = str(media.track_num).rjust(2, "0") + elif name == "artist_name": + result = ", ".join(artist.name for artist in media.artists) + elif name == "track_title": + result = media.name + + return result + + +def format_str_album(name: str, media: Album) -> str | bool: + result: str | bool = False + + if name == "album_title": + result = media.name + elif name == "artist_name": + result = media.artist.name + + return result + + +def format_str_playlist(name: str, media: Playlist) -> str | bool: + result: str | bool = False + + if name == "playlist_name": + result = media.name + + return result + + +def format_str_mix(name: str, media: Mix) -> str | bool: + result: str | bool = False + + if name == "mix_name": + result = media.title + + return result + + +def format_str_video(name: str, media: Video) -> str | bool: + result: str | bool = False + + if name == "artist_name": + result = ", ".join(artist.name for artist in media.artists) + elif name == "track_title": + result = media.name + + return result + + +def get_fn_format(media: Track | Album | Playlist | UserPlaylist | Video | Mix) -> Callable: + result = None + + if isinstance(media, Track): + result = format_str_track + elif isinstance(media, Album): + result = format_str_album + elif isinstance(media, (Playlist, UserPlaylist)): + result = format_str_playlist + elif isinstance(media, Mix): + result = format_str_mix + elif isinstance(media, Video): + result = format_str_video + + return result + + +def length_max_name_file() -> int: + system = platform.system() + result: int = 255 + + try: + if system in ['Darwin', 'Linux']: + result: int = os.pathconf('/', 'PC_NAME_MAX') + except Exception as e: + pass + + return result + + +def length_max_name_path() -> int: + system = platform.system() + result: int = 255 + + try: + if system in ['Darwin', 'Linux']: + result: int = os.pathconf('/', 'PC_PATH_MAX') + except Exception as e: + pass + + return result + + +def path_validate(path_file: str, adapt: bool = False) -> (bool, str): + result: bool = False + length_max_path: int = length_max_name_path() + length_max_file: int = length_max_name_file() + path, file = os.path.split(path_file) + filename, extension = os.path.splitext(file) + + if len(path) >= length_max_path: + result = False + + if adapt: + path = Path.home() + + if len(file) >= length_max_file: + result = False + + if adapt: + file = f'{filename[:length_max_file - len(extension + "_")]}_{extension}' + + path_file = os.path.join(path, file) + + return result, path_file + + +def check_file_exists(path_file: str): + result = pathlib.Path(path_file).is_file() + + return result diff --git a/tidal_dl_ng/helper/url.py b/tidal_dl_ng/helper/url.py new file mode 100644 index 0000000..cb52238 --- /dev/null +++ b/tidal_dl_ng/helper/url.py @@ -0,0 +1,15 @@ +def get_tidal_media_id(url_or_id_media: str) -> str: + id_dirty = url_or_id_media.rsplit("/", 1)[-1] + id = id_dirty.rsplit("?", 1)[0] + + return id + + +def get_tidal_media_type(url_media: str) -> str | bool: + result = False + url_split = url_media.split("/")[-2] + + if len(url_split) > 1: + result = url_media.split("/")[-2] + + return result diff --git a/tidal_dl_ng/helper/wrapper.py b/tidal_dl_ng/helper/wrapper.py new file mode 100644 index 0000000..5b88f59 --- /dev/null +++ b/tidal_dl_ng/helper/wrapper.py @@ -0,0 +1,23 @@ +from collections.abc import Callable + + +class WrapperLogger: + fn_print: Callable = None + + def __init__(self, fn_print: Callable): + self.fn_print = fn_print + + def debug(self, value): + self.fn_print(value) + + def warning(self, value): + self.fn_print(value) + + def info(self, value): + self.fn_print(value) + + def error(self, value): + self.fn_print(value) + + def critical(self, value): + self.fn_print(value) diff --git a/tidal_dl_ng/logger.py b/tidal_dl_ng/logger.py new file mode 100644 index 0000000..83477a7 --- /dev/null +++ b/tidal_dl_ng/logger.py @@ -0,0 +1,65 @@ +import logging +import sys + +import coloredlogs +from PySide6 import QtCore + + +class XStream(QtCore.QObject): + _stdout = None + _stderr = None + messageWritten = QtCore.Signal(str) + + def flush(self): + pass + + def fileno(self): + return -1 + + def write(self, msg): + if not self.signalsBlocked(): + self.messageWritten.emit(msg) + + @staticmethod + def stdout(): + if not XStream._stdout: + XStream._stdout = XStream() + sys.stdout = XStream._stdout + return XStream._stdout + + @staticmethod + def stderr(): + if not XStream._stderr: + XStream._stderr = XStream() + sys.stderr = XStream._stderr + return XStream._stderr + + +class QtHandler(logging.Handler): + def __init__(self): + logging.Handler.__init__(self) + + def emit(self, record): + record = self.format(record) + + if record: + # originally: XStream.stdout().write("{}\n".format(record)) + XStream.stdout().write("%s\n" % record) + + +logger_gui = logging.getLogger(__name__) +handler_qt: QtHandler = QtHandler() +# log_fmt: str = "[%(asctime)s] %(levelname)s: %(message)s" +log_fmt: str = "> %(message)s" +# formatter = logging.Formatter(log_fmt) +formatter = coloredlogs.ColoredFormatter(fmt=log_fmt) +handler_qt.setFormatter(formatter) +logger_gui.addHandler(handler_qt) +logger_gui.setLevel(logging.DEBUG) + +logger_cli = logging.getLogger(__name__) +handler_stream: logging.StreamHandler = logging.StreamHandler() +formatter = coloredlogs.ColoredFormatter(fmt=log_fmt) +handler_stream.setFormatter(formatter) +logger_cli.addHandler(handler_stream) +logger_cli.setLevel(logging.DEBUG) diff --git a/tidal_dl_ng/metadata.py b/tidal_dl_ng/metadata.py new file mode 100644 index 0000000..1b53dea --- /dev/null +++ b/tidal_dl_ng/metadata.py @@ -0,0 +1,163 @@ +import pathlib + +import mutagen +import requests +from mutagen import flac, mp4 +from mutagen.id3 import APIC, TALB, TCOM, TCOP, TDRC, TIT2, TOPE, TPE1, TRCK, TSRC, USLT + + +class Metadata: + path_file: str = None + type_audio: str = None + title: str = None + album: str = None + albumartist: str = None + artists: [str] = None + copy_right: str = None + tracknumber: int = None + discnumber: int = None + totaldisc: int = None + totaltrack: int = None + date: str = None + composer: [str] = None + isrc: str = None + lyrics: str = None + path_cover: str = None + url_cover: str = None + m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC = None + + # TODO: What about videos? + def __init__( + self, + path_file: str, + type_audio: str = "", + album: str = "", + title: str = "", + artists: [str] = [""], + copy_right: str = "", + tracknumber: int = 0, + discnumber: int = 0, + totaltrack: int = 0, + totaldisc: int = 0, + composer: [str] = [""], + isrc: str = "", + albumartist: str = "", + date: str = "", + lyrics: str = "", + path_cover: str = "", + url_cover: str = "", + ): + self.path_file = path_file + + if type_audio: + self.type_audio = type_audio + else: + self.type_audio = pathlib.Path(self.path_file).suffix[1:] + + self.title = title + self.album = album + self.albumartist = albumartist + self.artists = artists + self.copy_right = copy_right + self.tracknumber = tracknumber + self.discnumber = discnumber + self.totaldisc = totaldisc + self.totaltrack = totaltrack + self.date = date + self.composer = composer + self.isrc = isrc + self.lyrics = lyrics + self.path_cover = path_cover + self.url_cover = url_cover + self.m: mutagen.MP4.MP4 | mutagen.m4a.M4A | mutagen.flac.FLAC | mutagen.mp3.MP3 = mutagen.File(self.path_file) + + def _cover(self) -> bool: + result = False + data_cover = None + + if self.url_cover: + try: + data_cover = requests.get(self.url_cover).content + except: + data_cover = "" + elif self.path_cover: + with open(self.path_cover, "rb") as f: + data_cover = f.read() + + if data_cover: + if self.type_audio == "flac": + flac_cover = flac.Picture() + flac_cover.data = data_cover + flac_cover.mime = "image/jpeg" + + self.m.clear_pictures() + self.m.add_picture(flac_cover) + elif self.type_audio == "mp3": + self.m.tags.add(APIC(encoding=3, data=data_cover)) + elif self.type_audio in ["mp4", "m4a"]: + cover_mp4 = mp4.MP4Cover(data_cover) + self.m.tags["covr"] = [cover_mp4] + + result = True + + return result + + def save(self): + if self.type_audio == "flac": + self.set_flac() + elif self.type_audio in ["mp3", "ts"]: + self.set_mp3() + elif self.type_audio in ["mp4", "m4a"]: + self.set_mp4() + + self._cover() + self.m.save() + + return True + + def set_flac(self): + if not self.m.tags: + self.m.add_tags() + + self.m.tags["title"] = self.title + self.m.tags["album"] = self.album + self.m.tags["albumartist"] = self.albumartist + self.m.tags["artist"] = ", ".join(self.artists) + self.m.tags["copyright"] = self.copy_right + self.m.tags["tracknumber"] = str(self.tracknumber) + self.m.tags["tracktotal"] = str(self.totaltrack) + self.m.tags["discnumber"] = str(self.discnumber) + self.m.tags["disctotal"] = str(self.totaldisc) + self.m.tags["date"] = self.date + self.m.tags["composer"] = ", ".join(self.composer) + self.m.tags["isrc"] = self.isrc + self.m.tags["lyrics"] = self.lyrics + + def set_mp3(self): + if not self.m.tags: + self.m.add_tags() + + self.m.tags.add(TIT2(encoding=3, text=self.title)) + self.m.tags.add(TALB(encoding=3, text=self.album)) + self.m.tags.add(TOPE(encoding=3, text=self.albumartist)) + self.m.tags.add(TPE1(encoding=3, text=", ".join(self.artists))) + self.m.tags.add(TCOP(encoding=3, text=self.copy_right)) + self.m.tags.add(TRCK(encoding=3, text=str(self.tracknumber))) + self.m.tags.add(TRCK(encoding=3, text=self.discnumber)) + self.m.tags.add(TDRC(encoding=3, text=self.date)) + self.m.tags.add(TCOM(encoding=3, text=", ".join(self.composer))) + self.m.tags.add(TSRC(encoding=3, text=self.isrc)) + self.m.tags.add(USLT(encoding=3, lang="eng", desc="desc", text=self.lyrics)) + + def set_mp4(self): + self.m.tags["\xa9nam"] = self.title + self.m.tags["\xa9alb"] = self.album + self.m.tags["aART"] = self.albumartist + self.m.tags["\xa9ART"] = ", ".join(self.artists) + self.m.tags["cprt"] = self.copy_right + self.m.tags["trkn"] = [[self.tracknumber, self.totaltrack]] + self.m.tags["disk"] = [[self.discnumber, self.totaldisc]] + # self.m.tags['\xa9gen'] = self.genre + self.m.tags["\xa9day"] = self.date + self.m.tags["\xa9wrt"] = ", ".join(self.composer) + self.m.tags["\xa9lyr"] = self.lyrics diff --git a/tidal_dl_ng/model/__init__.py b/tidal_dl_ng/model/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tidal_dl_ng/model/__init__.py diff --git a/tidal_dl_ng/model/cfg.py b/tidal_dl_ng/model/cfg.py new file mode 100644 index 0000000..e885074 --- /dev/null +++ b/tidal_dl_ng/model/cfg.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass + +from tidal_dl_ng.constants import QualityVideo +from dataclasses_json import dataclass_json +from tidalapi import Quality + + +@dataclass_json +@dataclass +class Settings: + skip_existing: bool = False + album_cover_save: bool = True + lyrics_save: bool = False + # TODO: Implement API KEY selection. + api_key_index: bool = 0 + album_info_save: bool = False + video_download: bool = True + multi_thread: bool = False + download_delay: bool = True + download_base_path: str = "./download/" + quality_audio: Quality = Quality.low_320k + quality_video: QualityVideo = QualityVideo.P480 + use_playlist_folder: bool = True + format_album: str = "Albums/{artist_name} - {title}/{track_num}. {artist_name} - {track_title}" + format_playlist: str = "Playlists/{playlist_name}/{artist_name} - {track_title}" + format_mix: str = "Mix/{mix_name}/{artist_name} - {track_title}" + format_track: str = "Tracks/{artist_name} - {track_title}" + format_video: str = "Videos/{artist_name} - {track_title}" + video_convert_mp4: bool = True + + +@dataclass_json +@dataclass +class HelpSettings: + skip_existing: str = "Do not download, if file already exists." + album_cover_save: str = "Safe cover to album folder." + lyrics_save: str = "Safe lyrics to audio file." + api_key_index: str = "Set the device API KEY." + album_info_save: str = "Save album info to track?" + video_download: str = "Allow download of videos." + multi_thread: str = "Download several tracks in parallel." + download_delay: str = "Activate randomized download delay to mimic human behaviour." + download_base_path: str = "Where to store the downloaded media." + quality_audio: str = ( + 'Desired audio download quality: "LOW" (96kbps), "HIGH" (320kbps), ' + '"LOSSLESS" (16 Bit, 44,1 kHz), ' + '"HI_RES" (MQA 24 Bit, 96 kHz), "HI_RES_LOSSLESS" (up to 24 Bit, 192 kHz)' + ) + quality_video: str = 'Desired video download quality: "360", "480", "720", "1080"' + use_playlist_folder: str = ( + "Should items of playlist be downloaded to a separate playlist folder or to the tracks folder?" + ) + # TODO: Describe possible variables. + format_album: str = "Where to download albums and how to name the items." + format_playlist: str = "Where to download playlists and how to name the items." + format_mix: str = "Where to download mixes and how to name the items." + format_track: str = "Where to download tracks and how to name the items." + format_video: str = "Where to download videos and how to name the items." + video_convert_mp4: str = ( + "Videos are downloaded as MPEG Transport Stream (TS) files. With this option each video " + "will be converted to MP4. FFMPEG must be installed and added to your 'PATH' variable." + ) + + +@dataclass_json +@dataclass +class Token: + token_type: str | None = None + access_token: str | None = None + refresh_token: str | None = None + expiry_time: float = 0.0 diff --git a/tidal_dl_ng/model/gui_data.py b/tidal_dl_ng/model/gui_data.py new file mode 100644 index 0000000..0830011 --- /dev/null +++ b/tidal_dl_ng/model/gui_data.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass + +try: + from PySide6 import QtCore + + @dataclass + class ProgressBars: + item: QtCore.Signal + item_name: QtCore.Signal + list_item: QtCore.Signal +except ModuleNotFoundError: + class ProgressBars: + pass + + +# TODO: Remove if not used? +@dataclass +class UserList: + name: str + count: str + obj: object + + +@dataclass +class ResultSearch: + position: int + artist: str + title: str + album: str + duration_sec: int + obj: object + diff --git a/tidal_dl_ng/ui/__init__.py b/tidal_dl_ng/ui/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tidal_dl_ng/ui/__init__.py diff --git a/tidal_dl_ng/ui/icon.png b/tidal_dl_ng/ui/icon.png Binary files differnew file mode 100644 index 0000000..8bc9e0d --- /dev/null +++ b/tidal_dl_ng/ui/icon.png diff --git a/tidal_dl_ng/ui/main.py b/tidal_dl_ng/ui/main.py new file mode 100644 index 0000000..c7d86a4 --- /dev/null +++ b/tidal_dl_ng/ui/main.py @@ -0,0 +1,456 @@ +################################################################################ +## Form generated from reading UI file 'main.ui' +## +## Created by: Qt User Interface Compiler version 6.6.1 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import QCoreApplication, QLocale, QMetaObject, QRect, QSize, Qt +from PySide6.QtGui import QAction +from PySide6.QtWidgets import ( + QAbstractItemView, + QComboBox, + QHBoxLayout, + QLabel, + QLayout, + QLineEdit, + QMenu, + QMenuBar, + QPlainTextEdit, + QPushButton, + QSizePolicy, + QStatusBar, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + + +class Ui_MainWindow: + def setupUi(self, MainWindow): + if not MainWindow.objectName(): + MainWindow.setObjectName("MainWindow") + MainWindow.resize(900, 700) + self.a_options = QAction(MainWindow) + self.a_options.setObjectName("a_options") + self.a_options.setEnabled(False) + self.a_options.setText("Options") + self.a_options.setIconText("Options") + # if QT_CONFIG(tooltip) + self.a_options.setToolTip("Options") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.a_options.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.a_options.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + self.w_central = QWidget(MainWindow) + self.w_central.setObjectName("w_central") + self.w_central.setEnabled(True) + sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(100) + sizePolicy.setVerticalStretch(100) + sizePolicy.setHeightForWidth(self.w_central.sizePolicy().hasHeightForWidth()) + self.w_central.setSizePolicy(sizePolicy) + # if QT_CONFIG(tooltip) + self.w_central.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.w_central.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.w_central.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.w_central.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.w_central.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.horizontalLayout = QHBoxLayout(self.w_central) + self.horizontalLayout.setObjectName("horizontalLayout") + self.lh_main = QHBoxLayout() + self.lh_main.setObjectName("lh_main") + self.lh_main.setSizeConstraint(QLayout.SetNoConstraint) + self.tr_lists_user = QTreeWidget(self.w_central) + __qtreewidgetitem = QTreeWidgetItem() + __qtreewidgetitem.setText(1, "Info") + __qtreewidgetitem.setText(0, "Playlist") + self.tr_lists_user.setHeaderItem(__qtreewidgetitem) + __qtreewidgetitem1 = QTreeWidgetItem(self.tr_lists_user) + __qtreewidgetitem1.setFlags(Qt.ItemIsEnabled) + __qtreewidgetitem2 = QTreeWidgetItem(self.tr_lists_user) + __qtreewidgetitem2.setFlags(Qt.ItemIsEnabled) + __qtreewidgetitem3 = QTreeWidgetItem(self.tr_lists_user) + __qtreewidgetitem3.setFlags(Qt.ItemIsEnabled) + self.tr_lists_user.setObjectName("tr_lists_user") + # if QT_CONFIG(tooltip) + self.tr_lists_user.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.tr_lists_user.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.tr_lists_user.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.tr_lists_user.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.tr_lists_user.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.tr_lists_user.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.tr_lists_user.setProperty("showDropIndicator", False) + self.tr_lists_user.setIndentation(10) + self.tr_lists_user.setUniformRowHeights(True) + self.tr_lists_user.setSortingEnabled(True) + self.tr_lists_user.header().setCascadingSectionResizes(True) + self.tr_lists_user.header().setHighlightSections(True) + self.tr_lists_user.header().setProperty("showSortIndicator", True) + + self.lh_main.addWidget(self.tr_lists_user) + + self.lv_search_result = QVBoxLayout() + # ifndef Q_OS_MAC + self.lv_search_result.setSpacing(-1) + # endif + self.lv_search_result.setObjectName("lv_search_result") + self.lh_search = QHBoxLayout() + self.lh_search.setObjectName("lh_search") + self.l_search = QLineEdit(self.w_central) + self.l_search.setObjectName("l_search") + self.l_search.setAcceptDrops(False) + # if QT_CONFIG(tooltip) + self.l_search.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.l_search.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.l_search.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.l_search.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.l_search.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.l_search.setLocale(QLocale(QLocale.English, QLocale.UnitedStates)) + self.l_search.setText("") + self.l_search.setPlaceholderText("Type and press ENTER to search...") + self.l_search.setClearButtonEnabled(True) + + self.lh_search.addWidget(self.l_search) + + self.cb_search_type = QComboBox(self.w_central) + self.cb_search_type.setObjectName("cb_search_type") + # if QT_CONFIG(tooltip) + self.cb_search_type.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.cb_search_type.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.cb_search_type.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.cb_search_type.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.cb_search_type.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.cb_search_type.setCurrentText("") + self.cb_search_type.setPlaceholderText("") + + self.lh_search.addWidget(self.cb_search_type) + + self.b_search = QPushButton(self.w_central) + self.b_search.setObjectName("b_search") + # if QT_CONFIG(statustip) + self.b_search.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.b_search.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.b_search.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.b_search.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.b_search.setText("Search") + # if QT_CONFIG(shortcut) + self.b_search.setShortcut("") + # endif // QT_CONFIG(shortcut) + + self.lh_search.addWidget(self.b_search) + + self.lv_search_result.addLayout(self.lh_search) + + self.tr_results = QTreeWidget(self.w_central) + self.tr_results.setObjectName("tr_results") + self.tr_results.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.tr_results.setProperty("showDropIndicator", False) + self.tr_results.setDragDropOverwriteMode(False) + self.tr_results.setAlternatingRowColors(False) + self.tr_results.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.tr_results.setIndentation(10) + self.tr_results.setSortingEnabled(True) + self.tr_results.header().setProperty("showSortIndicator", True) + self.tr_results.header().setStretchLastSection(False) + + self.lv_search_result.addWidget(self.tr_results) + + self.lh_download = QHBoxLayout() + self.lh_download.setObjectName("lh_download") + self.l_quality_audio = QLabel(self.w_central) + self.l_quality_audio.setObjectName("l_quality_audio") + # if QT_CONFIG(tooltip) + self.l_quality_audio.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.l_quality_audio.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.l_quality_audio.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.l_quality_audio.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.l_quality_audio.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.l_quality_audio.setText("Audio") + self.l_quality_audio.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter) + + self.lh_download.addWidget(self.l_quality_audio) + + self.cb_quality_audio = QComboBox(self.w_central) + self.cb_quality_audio.setObjectName("cb_quality_audio") + # if QT_CONFIG(tooltip) + self.cb_quality_audio.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.cb_quality_audio.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.cb_quality_audio.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.cb_quality_audio.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.cb_quality_audio.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.cb_quality_audio.setCurrentText("") + self.cb_quality_audio.setPlaceholderText("") + self.cb_quality_audio.setFrame(True) + + self.lh_download.addWidget(self.cb_quality_audio) + + self.l_quality_video = QLabel(self.w_central) + self.l_quality_video.setObjectName("l_quality_video") + # if QT_CONFIG(tooltip) + self.l_quality_video.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.l_quality_video.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.l_quality_video.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.l_quality_video.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.l_quality_video.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.l_quality_video.setText("Video") + self.l_quality_video.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter) + + self.lh_download.addWidget(self.l_quality_video) + + self.cb_quality_video = QComboBox(self.w_central) + self.cb_quality_video.setObjectName("cb_quality_video") + # if QT_CONFIG(tooltip) + self.cb_quality_video.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.cb_quality_video.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.cb_quality_video.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.cb_quality_video.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.cb_quality_video.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.cb_quality_video.setCurrentText("") + self.cb_quality_video.setPlaceholderText("") + + self.lh_download.addWidget(self.cb_quality_video) + + self.b_download = QPushButton(self.w_central) + self.b_download.setObjectName("b_download") + # if QT_CONFIG(tooltip) + self.b_download.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.b_download.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.b_download.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.b_download.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.b_download.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.b_download.setText("Download") + # if QT_CONFIG(shortcut) + self.b_download.setShortcut("") + # endif // QT_CONFIG(shortcut) + + self.lh_download.addWidget(self.b_download) + + self.lh_download.setStretch(0, 5) + self.lh_download.setStretch(2, 5) + self.lh_download.setStretch(4, 15) + + self.lv_search_result.addLayout(self.lh_download) + + self.te_debug = QPlainTextEdit(self.w_central) + self.te_debug.setObjectName("te_debug") + self.te_debug.setEnabled(True) + sizePolicy1 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum) + sizePolicy1.setHorizontalStretch(0) + sizePolicy1.setVerticalStretch(0) + sizePolicy1.setHeightForWidth(self.te_debug.sizePolicy().hasHeightForWidth()) + self.te_debug.setSizePolicy(sizePolicy1) + self.te_debug.setMaximumSize(QSize(16777215, 16777215)) + self.te_debug.setAcceptDrops(False) + # if QT_CONFIG(tooltip) + self.te_debug.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.te_debug.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.te_debug.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.te_debug.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.te_debug.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.te_debug.setUndoRedoEnabled(False) + self.te_debug.setReadOnly(True) + + self.lv_search_result.addWidget(self.te_debug) + + self.lh_main.addLayout(self.lv_search_result) + + self.lh_main.setStretch(0, 40) + self.lh_main.setStretch(1, 60) + + self.horizontalLayout.addLayout(self.lh_main) + + MainWindow.setCentralWidget(self.w_central) + self.menubar = QMenuBar(MainWindow) + self.menubar.setObjectName("menubar") + self.menubar.setGeometry(QRect(0, 0, 900, 24)) + # if QT_CONFIG(tooltip) + self.menubar.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.menubar.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.menubar.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.menubar.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.menubar.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.m_file = QMenu(self.menubar) + self.m_file.setObjectName("m_file") + # if QT_CONFIG(tooltip) + self.m_file.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.m_file.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.m_file.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.m_file.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.m_file.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + MainWindow.setMenuBar(self.menubar) + self.statusbar = QStatusBar(MainWindow) + self.statusbar.setObjectName("statusbar") + # if QT_CONFIG(tooltip) + self.statusbar.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.statusbar.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.statusbar.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.statusbar.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.statusbar.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.statusbar.setLayoutDirection(Qt.LeftToRight) + MainWindow.setStatusBar(self.statusbar) + + self.menubar.addAction(self.m_file.menuAction()) + self.m_file.addAction(self.a_options) + + self.retranslateUi(MainWindow) + + QMetaObject.connectSlotsByName(MainWindow) + + # setupUi + + def retranslateUi(self, MainWindow): + MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", "MainWindow", None)) + ___qtreewidgetitem = self.tr_lists_user.headerItem() + ___qtreewidgetitem.setText(2, QCoreApplication.translate("MainWindow", "obj", None)) + + __sortingEnabled = self.tr_lists_user.isSortingEnabled() + self.tr_lists_user.setSortingEnabled(False) + ___qtreewidgetitem1 = self.tr_lists_user.topLevelItem(0) + ___qtreewidgetitem1.setText(0, QCoreApplication.translate("MainWindow", "Playlists", None)) + ___qtreewidgetitem2 = self.tr_lists_user.topLevelItem(1) + ___qtreewidgetitem2.setText(0, QCoreApplication.translate("MainWindow", "Mixes", None)) + ___qtreewidgetitem3 = self.tr_lists_user.topLevelItem(2) + ___qtreewidgetitem3.setText(0, QCoreApplication.translate("MainWindow", "Favorites", None)) + self.tr_lists_user.setSortingEnabled(__sortingEnabled) + + ___qtreewidgetitem4 = self.tr_results.headerItem() + ___qtreewidgetitem4.setText(5, QCoreApplication.translate("MainWindow", "obj", None)) + ___qtreewidgetitem4.setText(4, QCoreApplication.translate("MainWindow", "Duration", None)) + ___qtreewidgetitem4.setText(3, QCoreApplication.translate("MainWindow", "Album", None)) + ___qtreewidgetitem4.setText(2, QCoreApplication.translate("MainWindow", "Title", None)) + ___qtreewidgetitem4.setText(1, QCoreApplication.translate("MainWindow", "Artist", None)) + ___qtreewidgetitem4.setText(0, QCoreApplication.translate("MainWindow", "#", None)) + self.te_debug.setPlaceholderText(QCoreApplication.translate("MainWindow", "Logs...", None)) + self.m_file.setTitle(QCoreApplication.translate("MainWindow", "File", None)) + + # retranslateUi diff --git a/tidal_dl_ng/ui/main.ui b/tidal_dl_ng/ui/main.ui new file mode 100644 index 0000000..e4584d1 --- /dev/null +++ b/tidal_dl_ng/ui/main.ui @@ -0,0 +1,590 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>MainWindow</class> + <widget class="QMainWindow" name="MainWindow"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>900</width> + <height>700</height> + </rect> + </property> + <property name="windowTitle"> + <string>MainWindow</string> + </property> + <widget class="QWidget" name="w_central"> + <property name="enabled"> + <bool>true</bool> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Expanding"> + <horstretch>100</horstretch> + <verstretch>100</verstretch> + </sizepolicy> + </property> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <layout class="QHBoxLayout" name="lh_main" stretch="40,60"> + <property name="sizeConstraint"> + <enum>QLayout::SetNoConstraint</enum> + </property> + <item> + <widget class="QTreeWidget" name="tr_lists_user"> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <property name="editTriggers"> + <set>QAbstractItemView::NoEditTriggers</set> + </property> + <property name="showDropIndicator" stdset="0"> + <bool>false</bool> + </property> + <property name="indentation"> + <number>10</number> + </property> + <property name="uniformRowHeights"> + <bool>true</bool> + </property> + <property name="sortingEnabled"> + <bool>true</bool> + </property> + <attribute name="headerCascadingSectionResizes"> + <bool>true</bool> + </attribute> + <attribute name="headerHighlightSections"> + <bool>true</bool> + </attribute> + <attribute name="headerShowSortIndicator" stdset="0"> + <bool>true</bool> + </attribute> + <column> + <property name="text"> + <string notr="true">Playlist</string> + </property> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + </column> + <column> + <property name="text"> + <string notr="true">Info</string> + </property> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + </column> + <column> + <property name="text"> + <string>obj</string> + </property> + </column> + <item> + <property name="text"> + <string>Playlists</string> + </property> + <property name="text"> + <string/> + </property> + <property name="text"> + <string/> + </property> + <property name="flags"> + <set>ItemIsEnabled</set> + </property> + </item> + <item> + <property name="text"> + <string>Mixes</string> + </property> + <property name="text"> + <string/> + </property> + <property name="text"> + <string/> + </property> + <property name="flags"> + <set>ItemIsEnabled</set> + </property> + </item> + <item> + <property name="text"> + <string>Favorites</string> + </property> + <property name="text"> + <string/> + </property> + <property name="text"> + <string/> + </property> + <property name="flags"> + <set>ItemIsEnabled</set> + </property> + </item> + </widget> + </item> + <item> + <layout class="QVBoxLayout" name="lv_search_result" stretch="0,0,0,0"> + <property name="spacing"> + <number>-1</number> + </property> + <item> + <layout class="QHBoxLayout" name="lh_search"> + <item> + <widget class="QLineEdit" name="l_search"> + <property name="acceptDrops"> + <bool>false</bool> + </property> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <property name="locale"> + <locale language="English" country="UnitedStates"/> + </property> + <property name="text"> + <string notr="true"/> + </property> + <property name="placeholderText"> + <string notr="true">Type and press ENTER to search...</string> + </property> + <property name="clearButtonEnabled"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="cb_search_type"> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <property name="currentText"> + <string notr="true"/> + </property> + <property name="placeholderText"> + <string notr="true"/> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="b_search"> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <property name="text"> + <string notr="true">Search</string> + </property> + <property name="shortcut"> + <string notr="true"/> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QTreeWidget" name="tr_results"> + <property name="editTriggers"> + <set>QAbstractItemView::NoEditTriggers</set> + </property> + <property name="showDropIndicator" stdset="0"> + <bool>false</bool> + </property> + <property name="dragDropOverwriteMode"> + <bool>false</bool> + </property> + <property name="alternatingRowColors"> + <bool>false</bool> + </property> + <property name="selectionMode"> + <enum>QAbstractItemView::ExtendedSelection</enum> + </property> + <property name="indentation"> + <number>10</number> + </property> + <property name="sortingEnabled"> + <bool>true</bool> + </property> + <attribute name="headerShowSortIndicator" stdset="0"> + <bool>true</bool> + </attribute> + <attribute name="headerStretchLastSection"> + <bool>false</bool> + </attribute> + <column> + <property name="text"> + <string>#</string> + </property> + </column> + <column> + <property name="text"> + <string>Artist</string> + </property> + </column> + <column> + <property name="text"> + <string>Title</string> + </property> + </column> + <column> + <property name="text"> + <string>Album</string> + </property> + </column> + <column> + <property name="text"> + <string>Duration</string> + </property> + </column> + <column> + <property name="text"> + <string>obj</string> + </property> + </column> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="lh_download" stretch="5,0,5,0,15"> + <item> + <widget class="QLabel" name="l_quality_audio"> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <property name="text"> + <string notr="true">Audio</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="cb_quality_audio"> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <property name="currentText"> + <string notr="true"/> + </property> + <property name="placeholderText"> + <string notr="true"/> + </property> + <property name="frame"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="l_quality_video"> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <property name="text"> + <string notr="true">Video</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="cb_quality_video"> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <property name="currentText"> + <string notr="true"/> + </property> + <property name="placeholderText"> + <string notr="true"/> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="b_download"> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <property name="text"> + <string notr="true">Download</string> + </property> + <property name="shortcut"> + <string notr="true"/> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QPlainTextEdit" name="te_debug"> + <property name="enabled"> + <bool>true</bool> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Maximum"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>16777215</height> + </size> + </property> + <property name="acceptDrops"> + <bool>false</bool> + </property> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <property name="undoRedoEnabled"> + <bool>false</bool> + </property> + <property name="readOnly"> + <bool>true</bool> + </property> + <property name="placeholderText"> + <string>Logs...</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + </layout> + </widget> + <widget class="QMenuBar" name="menubar"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>900</width> + <height>24</height> + </rect> + </property> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <widget class="QMenu" name="m_file"> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <property name="title"> + <string>File</string> + </property> + <addaction name="a_options"/> + </widget> + <addaction name="m_file"/> + </widget> + <widget class="QStatusBar" name="statusbar"> + <property name="toolTip"> + <string notr="true"/> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + <property name="accessibleName"> + <string notr="true"/> + </property> + <property name="accessibleDescription"> + <string notr="true"/> + </property> + <property name="layoutDirection"> + <enum>Qt::LeftToRight</enum> + </property> + </widget> + <action name="a_options"> + <property name="enabled"> + <bool>false</bool> + </property> + <property name="text"> + <string notr="true">Options</string> + </property> + <property name="iconText"> + <string notr="true">Options</string> + </property> + <property name="toolTip"> + <string notr="true">Options</string> + </property> + <property name="statusTip"> + <string notr="true"/> + </property> + <property name="whatsThis"> + <string notr="true"/> + </property> + </action> + </widget> + <resources/> + <connections/> +</ui> diff --git a/tidal_dl_ng/ui/spinner.py b/tidal_dl_ng/ui/spinner.py new file mode 100644 index 0000000..124b7f4 --- /dev/null +++ b/tidal_dl_ng/ui/spinner.py @@ -0,0 +1,221 @@ +""" +The MIT License (MIT) + +Copyright (c) 2012-2014 Alexander Turkin +Copyright (c) 2014 William Hallatt +Copyright (c) 2015 Jacob Dawid +Copyright (c) 2016 Luca Weiss + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import math + +from PySide6.QtCore import QRect, Qt, QTimer +from PySide6.QtGui import QColor, QPainter +from PySide6.QtWidgets import QWidget + + +# Taken from https://github.com/COOLMSF/QtWaitingSpinnerForPyQt6 and adapted for PySide6. +class QtWaitingSpinner(QWidget): + def __init__( + self, parent, centerOnParent=True, disableParentWhenSpinning=False, modality=Qt.WindowModality.NonModal + ): + super().__init__(parent) + + self._centerOnParent = centerOnParent + self._disableParentWhenSpinning = disableParentWhenSpinning + + # WAS IN initialize() + self._color = QColor(Qt.GlobalColor.black) + self._roundness = 100.0 + self._minimumTrailOpacity = 3.14159265358979323846 + self._trailFadePercentage = 80.0 + self._revolutionsPerSecond = 1.57079632679489661923 + self._numberOfLines = 20 + self._lineLength = 10 + self._lineWidth = 2 + self._innerRadius = 10 + self._currentCounter = 0 + self._isSpinning = False + + self._timer = QTimer(self) + self._timer.timeout.connect(self.rotate) + self.updateSize() + self.updateTimer() + self.hide() + # END initialize() + + self.setWindowModality(modality) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + def paintEvent(self, QPaintEvent): + self.updatePosition() + painter = QPainter(self) + painter.fillRect(self.rect(), Qt.GlobalColor.transparent) + # Can't found in Qt6 + # painter.setRenderHint(QPainter.Antialiasing, True) + + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + + painter.setPen(Qt.PenStyle.NoPen) + for i in range(0, self._numberOfLines): + painter.save() + painter.translate(self._innerRadius + self._lineLength, self._innerRadius + self._lineLength) + rotateAngle = float(360 * i) / float(self._numberOfLines) + painter.rotate(rotateAngle) + painter.translate(self._innerRadius, 0) + distance = self.lineCountDistanceFromPrimary(i, self._currentCounter, self._numberOfLines) + color = self.currentLineColor( + distance, self._numberOfLines, self._trailFadePercentage, self._minimumTrailOpacity, self._color + ) + painter.setBrush(color) + rect = QRect(0, int(-self._lineWidth / 2), int(self._lineLength), int(self._lineWidth)) + painter.drawRoundedRect(rect, self._roundness, self._roundness, Qt.SizeMode.RelativeSize) + painter.restore() + + def start(self): + self.updatePosition() + self._isSpinning = True + self.show() + + if self.parentWidget and self._disableParentWhenSpinning: + self.parentWidget().setEnabled(False) + + if not self._timer.isActive(): + self._timer.start() + self._currentCounter = 0 + + def stop(self): + self._isSpinning = False + self.hide() + + if self.parentWidget() and self._disableParentWhenSpinning: + self.parentWidget().setEnabled(True) + + if self._timer.isActive(): + self._timer.stop() + self._currentCounter = 0 + + def setNumberOfLines(self, lines): + self._numberOfLines = lines + self._currentCounter = 0 + self.updateTimer() + + def setLineLength(self, length): + self._lineLength = length + self.updateSize() + + def setLineWidth(self, width): + self._lineWidth = width + self.updateSize() + + def setInnerRadius(self, radius): + self._innerRadius = radius + self.updateSize() + + def color(self): + return self._color + + def roundness(self): + return self._roundness + + def minimumTrailOpacity(self): + return self._minimumTrailOpacity + + def trailFadePercentage(self): + return self._trailFadePercentage + + def revolutionsPersSecond(self): + return self._revolutionsPerSecond + + def numberOfLines(self): + return self._numberOfLines + + def lineLength(self): + return self._lineLength + + def lineWidth(self): + return self._lineWidth + + def innerRadius(self): + return self._innerRadius + + def isSpinning(self): + return self._isSpinning + + def setRoundness(self, roundness): + self._roundness = max(0.0, min(100.0, roundness)) + + def setColor(self, color=Qt.GlobalColor.black): + self._color = QColor(color) + + def setRevolutionsPerSecond(self, revolutionsPerSecond): + self._revolutionsPerSecond = revolutionsPerSecond + self.updateTimer() + + def setTrailFadePercentage(self, trail): + self._trailFadePercentage = trail + + def setMinimumTrailOpacity(self, minimumTrailOpacity): + self._minimumTrailOpacity = minimumTrailOpacity + + def rotate(self): + self._currentCounter += 1 + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + self.update() + + def updateSize(self): + size = int((self._innerRadius + self._lineLength) * 2) + self.setFixedSize(size, size) + + def updateTimer(self): + self._timer.setInterval(int(1000 / (self._numberOfLines * self._revolutionsPerSecond))) + + def updatePosition(self): + if self.parentWidget() and self._centerOnParent: + self.move( + int(self.parentWidget().width() / 2 - self.width() / 2), + int(self.parentWidget().height() / 2 - self.height() / 2), + ) + + def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines): + distance = primary - current + if distance < 0: + distance += totalNrOfLines + return distance + + def currentLineColor(self, countDistance, totalNrOfLines, trailFadePerc, minOpacity, colorinput): + color = QColor(colorinput) + if countDistance == 0: + return color + minAlphaF = minOpacity / 100.0 + distanceThreshold = int(math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0)) + if countDistance > distanceThreshold: + color.setAlphaF(minAlphaF) + else: + alphaDiff = color.alphaF() - minAlphaF + gradient = alphaDiff / float(distanceThreshold + 1) + resultAlpha = color.alphaF() - gradient * countDistance + # If alpha is out of bounds, clip it. + resultAlpha = min(1.0, max(0.0, resultAlpha)) + color.setAlphaF(resultAlpha) + return color diff --git a/tidal_dl_ng/worker.py b/tidal_dl_ng/worker.py new file mode 100644 index 0000000..84d5ac3 --- /dev/null +++ b/tidal_dl_ng/worker.py @@ -0,0 +1,31 @@ +from PySide6 import QtCore + + +# Taken from https://www.pythonguis.com/tutorials/multithreading-pyside6-applications-qthreadpool/ +class Worker(QtCore.QRunnable): + """ + Worker thread + + Inherits from QRunnable to handler worker thread setup, signals and wrap-up. + + :param callback: The function callback to run on this worker thread. Supplied args and + kwargs will be passed through to the runner. + :type callback: function + :param args: Arguments to pass to the callback function + :param kwargs: Keywords to pass to the callback function + + """ + + def __init__(self, fn, *args, **kwargs): + super().__init__() + # Store constructor arguments (re-used for processing) + self.fn = fn + self.args = args + self.kwargs = kwargs + + @QtCore.Slot() # QtCore.Slot + def run(self): + """ + Initialise the runner function with passed args, kwargs. + """ + self.fn(*self.args, **self.kwargs) |
