commit 2353324570a50cea94aa3d84efd537a92388a2e7 Author: Dongho Kim Date: Fri Dec 27 22:00:28 2024 +0900 test diff --git a/main.py b/main.py new file mode 100644 index 0000000..48eadb3 --- /dev/null +++ b/main.py @@ -0,0 +1,52 @@ +from tidal_dl_ng.config import Settings, Tidal +from tidal_dl_ng.constants import CTX_TIDAL, MediaType +from tidal_dl_ng.download import Download +from tidal_dl_ng.helper.path import get_format_template, path_file_settings +from tidal_dl_ng.helper.tidal import ( + all_artist_album_ids, + get_tidal_media_id, + get_tidal_media_type, + instantiate_media, +) +from tidal_dl_ng.helper.wrapper import LoggerWrapped +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 pathlib import Path +from typing import Annotated, Optional +from fastapi import FastAPI, HTTPException, Depends, Request +from fastapi.templating import Jinja2Templates +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from typing import List, Optional +import uvicorn + +# Initialize FastAPI app +app = FastAPI( + title="FastAPI Template", description="A simple FastAPI template with basic CRUD operations", version="1.0.0" +) + +app.mount("/static", StaticFiles(directory="static"), name="static") + +# Initialize Jinja2 templates +templates = Jinja2Templates(directory="templates") + + +# Main Landing Page +@app.get("/") +async def main(request: Request): + return templates.TemplateResponse("pages/index.html", {"request": request, "title": "Home Page"}) + + +# Health check endpoint +@app.get("/health") +async def health_check(): + return {"status": "healthy"} + + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ad2c0f3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,55 @@ +annotated-types==0.7.0 +anyio==4.7.0 +certifi==2024.12.14 +charset-normalizer==3.4.1 +click==8.1.8 +dataclasses-json==0.6.7 +dnspython==2.7.0 +email_validator==2.2.0 +fastapi==0.115.6 +fastapi-cli==0.0.7 +ffmpeg-python==0.2.0 +future==1.0.0 +h11==0.14.0 +httpcore==1.0.7 +httptools==0.6.4 +httpx==0.28.1 +idna==3.10 +isodate==0.7.2 +Jinja2==3.1.5 +m3u8==6.0.0 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +marshmallow==3.23.2 +mdurl==0.1.2 +mpegdash==0.4.0 +mutagen==1.47.0 +mypy-extensions==1.0.0 +packaging==24.2 +pathvalidate==3.2.1 +pycryptodome==3.21.0 +pydantic==2.10.4 +pydantic_core==2.27.2 +Pygments==2.18.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +python-multipart==0.0.20 +PyYAML==6.0.2 +ratelimit==2.2.1 +requests==2.32.3 +rich==13.9.4 +rich-toolkit==0.12.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +starlette==0.41.3 +tidalapi==0.8.2 +toml==0.10.2 +typer==0.15.1 +typing-inspect==0.9.0 +typing_extensions==4.12.2 +urllib3==2.3.0 +uvicorn==0.34.0 +uvloop==0.21.0 +watchfiles==1.0.3 +websockets==14.1 diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..93fe594 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,43 @@ +{# templates/base.html #} + + + + + + {% block title %}{{ title }}{% endblock %} - My Site + + {# CSS Block #} + {% block css %} + + {% endblock %} + + + {# Header Block #} + {% block header %} +
+ +
+ {% endblock %} + + {# Main Content Block #} +
+ {% block content %} + {% endblock %} +
+ + {# Footer Block #} + {% block footer %} + + {% endblock %} + + {# JavaScript Block #} + {% block javascript %} + + {% endblock %} + + \ No newline at end of file diff --git a/templates/pages/index.html b/templates/pages/index.html new file mode 100644 index 0000000..7375b00 --- /dev/null +++ b/templates/pages/index.html @@ -0,0 +1,20 @@ +{# templates/pages/home.html #} +{% extends "base.html" %} + +{% block content %} +
+

Welcome to the Home Page

+

This is the home page content.

+
+{% endblock %} + +{% block css %} +{{ super() }} + +{% endblock %} \ No newline at end of file diff --git a/tidal_dl_ng/__init__.py b/tidal_dl_ng/__init__.py new file mode 100644 index 0000000..5fb660b --- /dev/null +++ b/tidal_dl_ng/__init__.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python +import importlib.metadata +import sys +from pathlib import Path +from urllib.parse import urlparse + +import requests +import toml + +from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC +from tidal_dl_ng.model.meta import ProjectInformation, ReleaseLatest + + +def metadata_project() -> ProjectInformation: + result: ProjectInformation + file_path: Path = Path(__file__) + tmp_result: dict = {} + + paths: [Path] = [ + file_path.parent, + file_path.parent.parent, + file_path.parent.parent.parent, + ] + + for pyproject_toml_dir in paths: + pyproject_toml_file: Path = pyproject_toml_dir / "pyproject.toml" + + if pyproject_toml_file.exists() and pyproject_toml_file.is_file(): + tmp_result = toml.load(pyproject_toml_file) + + break + + if tmp_result: + result = ProjectInformation( + version=tmp_result["tool"]["poetry"]["version"], repository_url=tmp_result["tool"]["poetry"]["repository"] + ) + else: + try: + meta_info = importlib.metadata.metadata(name_package()) + result = ProjectInformation(version=meta_info["Version"], repository_url=meta_info["Home-page"]) + except: + result = ProjectInformation(version="0.0.0", repository_url="https://anerroroccur.ed/sorry/for/that") + + return result + + +def version_app() -> str: + metadata: ProjectInformation = metadata_project() + version: str = metadata.version + + return version + + +def repository_url() -> str: + metadata: ProjectInformation = metadata_project() + url_repo: str = metadata.repository_url + + return url_repo + + +def repository_path() -> str: + url_repo: str = repository_url() + url_path: str = urlparse(url_repo).path + + return url_path + + +def latest_version_information() -> ReleaseLatest: + release_info: ReleaseLatest + repo_path: str = repository_path() + url: str = f"https://api.github.com/repos{repo_path}/releases/latest" + + try: + response = requests.get(url, timeout=REQUESTS_TIMEOUT_SEC) + release_info: str = response.json() + + release_info = ReleaseLatest( + version=release_info["tag_name"], url=release_info["html_url"], release_info=release_info["body"] + ) + except: + release_info = ReleaseLatest( + version="v0.0.0", + url=url, + release_info=f"Something went wrong calling {url}. Check your internet connection.", + ) + + return release_info + + +def name_package() -> str: + package_name: str = __package__ or __name__ + + return package_name + + +def is_dev_env() -> bool: + package_name: str = name_package() + result: bool = False + + # Check if package is running from source code == dev mode + # If package is not running in PyInstaller environment. + if not getattr(sys, "frozen", False) and not hasattr(sys, "_MEIPASS"): + try: + importlib.metadata.version(package_name) + except: + # If package is not installed + result = True + + return result + + +def name_app() -> str: + app_name: str = name_package() + is_dev: bool = is_dev_env() + + if is_dev: + app_name = app_name + "-dev" + + return app_name + + +__name_display__ = name_app() +__version__ = version_app() + + +def update_available() -> (bool, ReleaseLatest): + latest_info: ReleaseLatest = latest_version_information() + result: bool = False + version_current: str = "v" + __version__ + + if version_current != latest_info.version and version_current != "v0.0.0": + result = True + + return result, latest_info diff --git a/tidal_dl_ng/api.py b/tidal_dl_ng/api.py new file mode 100644 index 0000000..5923159 --- /dev/null +++ b/tidal_dl_ng/api.py @@ -0,0 +1,114 @@ +import json + +import requests + +# See also +# https://github.com/yaronzz/Tidal-Media-Downloader/commit/1d5b8cd8f65fd1def45d6406778248249d6dfbdf +# https://github.com/yaronzz/Tidal-Media-Downloader/pull/840 +# https://github.com/nathom/streamrip/tree/main/streamrip +# https://github.com/arnesongit/plugin.audio.tidal2/blob/e9429d601d0c303d775d05a19a66415b57479d87/resources/lib/tidal2/tidalapi/__init__.py#L86 + +# 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", + }, +) + +from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC + + +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", timeout=REQUESTS_TIMEOUT_SEC + ) + if respond.status_code == 200: + content = respond.json()["files"]["tidal-api-key.json"]["content"] + __API_KEYS__ = json.loads(content) +except Exception as e: + # TODO: Implement proper logging. + print(e) + pass diff --git a/tidal_dl_ng/cli.py b/tidal_dl_ng/cli.py new file mode 100644 index 0000000..5abda11 --- /dev/null +++ b/tidal_dl_ng/cli.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python +from pathlib import Path +from typing import Annotated, Optional + +import typer +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 import __version__ +from tidal_dl_ng.config import Settings, Tidal +from tidal_dl_ng.constants import CTX_TIDAL, MediaType +from tidal_dl_ng.download import Download +from tidal_dl_ng.helper.path import get_format_template, path_file_settings +from tidal_dl_ng.helper.tidal import ( + all_artist_album_ids, + get_tidal_media_id, + get_tidal_media_type, + instantiate_media, +) +from tidal_dl_ng.helper.wrapper import LoggerWrapped +from tidal_dl_ng.model.cfg import HelpSettings + +app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]}, add_completion=False) + + +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: Annotated[Optional[list[str]], typer.Argument()] = 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(fn_print=print) + ctx.obj[CTX_TIDAL] = tidal + + return result + + +@app.command(name="logout") +def logout() -> bool: + settings = Settings() + tidal = Tidal(settings) + result = tidal.logout() + + if result: + print("You have been successfully logged out.") + + return result + + +@app.command(name="dl") +def download( + ctx: typer.Context, + urls: Annotated[Optional[list[str]], typer.Argument()] = None, + file_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: + # Read the text file provided. + if file_urls: + text = file_urls.read_text() + urls = text.splitlines() + else: + print("Provide either URLs, IDs or a file containing URLs (one per line).") + + raise typer.Abort() + + # Call login method to validate the token. + ctx.invoke(login, ctx) + + # Create initial objects. + settings: Settings = Settings() + progress: Progress = Progress( + "{task.description}", + SpinnerColumn(), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + ) + fn_logger = LoggerWrapped(progress.print) + dl = Download( + session=ctx.obj[CTX_TIDAL].session, + skip_existing=ctx.obj[CTX_TIDAL].settings.data.skip_existing, + path_base=settings.data.download_base_path, + fn_logger=fn_logger, + progress=progress, + ) + progress_table = Table.grid() + + # Style Progress display. + progress_table.add_row(Panel.fit(progress, title="Download Progress", border_style="green", padding=(2, 2))) + + urls_pos_last = len(urls) - 1 + + for item in urls: + media_type: MediaType | bool = False + + # Extract media name and id from link. + if "http" in item: + media_type = get_tidal_media_type(item) + item_id = get_tidal_media_id(item) + file_template = get_format_template(media_type, settings) + + # If url is invalid skip to next url in list. + if not media_type: + print(f"It seems like that you have supplied an invalid URL: {item}") + + continue + + # Create Live display for Progress. + with Live(progress_table, refresh_per_second=10): + # Download media. + if media_type in [MediaType.TRACK, MediaType.VIDEO]: + download_delay: bool = bool(settings.data.download_delay and urls.index(item) < urls_pos_last) + + dl.item( + media_id=item_id, media_type=media_type, file_template=file_template, download_delay=download_delay + ) + elif media_type in [MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX, MediaType.ARTIST]: + item_ids: [int] = [] + + if media_type == MediaType.ARTIST: + media = instantiate_media(ctx.obj[CTX_TIDAL].session, media_type, item_id) + media_type = MediaType.ALBUM + item_ids = item_ids + all_artist_album_ids(media) + else: + item_ids.append(item_id) + + for item_id in item_ids: + dl.items( + media_id=item_id, + media_type=media_type, + file_template=file_template, + video_download=ctx.obj[CTX_TIDAL].settings.data.video_download, + download_delay=settings.data.download_delay, + ) + + # Stop Progress display. + 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..c6fdaee --- /dev/null +++ b/tidal_dl_ng/config.py @@ -0,0 +1,191 @@ +import json +import os +import shutil +from collections.abc import Callable +from json import JSONDecodeError +from pathlib import Path +from typing import Any + +import tidalapi +from requests import HTTPError + +from tidal_dl_ng.helper.decorator import SingletonMeta +from tidal_dl_ng.helper.path import path_config_base, 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 + + +class BaseConfig: + data: ModelSettings | ModelToken + file_path: str + cls_model: ModelSettings | ModelToken + path_base: str = path_config_base() + + def save(self, config_to_compare: str = None) -> None: + data_json = self.data.to_json() + + # If old and current config is equal, skip the write operation. + if config_to_compare == data_json: + return + + # Try to create the base folder. + os.makedirs(self.path_base, exist_ok=True) + + with open(self.file_path, encoding="utf-8", mode="w") as f: + # Save it in a pretty format + obj_json_config = json.loads(data_json) + json.dump(obj_json_config, f, indent=4) + + def set_option(self, key: str, value: Any) -> None: + value_old: Any = getattr(self.data, key) + + if type(value_old) == bool: # noqa: E721 + value = True if value.lower() in ("true", "1", "yes", "y") else False # noqa: SIM210 + elif type(value_old) == int and type(value) != int: # noqa: E721 + value = int(value) + + setattr(self.data, key, value) + + def read(self, path: str) -> bool: + result: bool = False + settings_json: str = "" + + 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, ValueError) as e: + if isinstance(e, ValueError): + path_bak = path + ".bak" + + # First check if a backup file already exists. If yes, remove it. + if os.path.exists(path_bak): + os.remove(path_bak) + + # Move the invalid config file to the backup location. + shutil.move(path, path_bak) + # TODO: Implement better global logger. + print( + "Something is wrong with your config. Maybe it is not compatible anymore due to a new app version." + f" You can find a backup of your old config here: '{path_bak}'. A new default config was created." + ) + + self.data = self.cls_model() + + # Call save in case of we need to update the saved config, due to changes in code. + self.save(settings_json) + + return result + + +class Settings(BaseConfig, metaclass=SingletonMeta): + def __init__(self): + self.cls_model = ModelSettings + self.file_path = path_file_settings() + self.read(self.file_path) + + +class Tidal(BaseConfig, metaclass=SingletonMeta): + session: tidalapi.Session + token_from_storage: bool = False + settings: Settings + is_pkce: bool + + def __init__(self, settings: Settings = None): + self.cls_model = ModelToken + tidal_config: tidalapi.Config = tidalapi.Config(item_limit=10000) + self.session = tidalapi.Session(tidal_config) + # self.session.config.client_id = "km8T1xS355y7dd3H" + # self.session.config.client_secret = "vcmeGW1OuZ0fWYMCSZ6vNvSLJlT3XEpW0ambgYt5ZuI=" + self.file_path = path_file_token() + self.token_from_storage = self.read(self.file_path) + + 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 + self.session.video_quality = tidalapi.VideoQuality.high + + return True + + def login_token(self, do_pkce: bool = False) -> bool: + result = False + self.is_pkce = do_pkce + + 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, + is_pkce=do_pkce, + ) + except (HTTPError, JSONDecodeError): + result = False + # Remove token file. Probably corrupt or invalid. + if os.path.exists(self.file_path): + os.remove(self.file_path) + + print( + "Either there is something wrong with your credentials / account or some server problems on TIDALs " + "side. Anyway... Try to login again by re-starting this app." + ) + + return result + + def login_finalize(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...") + # Login method: Device linking + self.session.login_oauth_simple(fn_print) + # Login method: PKCE authorization (was necessary for HI_RES_LOSSLESS streaming earlier) + # self.session.login_pkce(fn_print) + + is_login = self.login_finalize() + + 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 + + def logout(self): + Path(self.file_path).unlink(missing_ok=True) + self.token_from_storage = False + del self.session + + return True diff --git a/tidal_dl_ng/constants.py b/tidal_dl_ng/constants.py new file mode 100644 index 0000000..7da2711 --- /dev/null +++ b/tidal_dl_ng/constants.py @@ -0,0 +1,60 @@ +from enum import StrEnum + +CTX_TIDAL: str = "tidal" +REQUESTS_TIMEOUT_SEC: int = 45 +EXTENSION_LYRICS: str = ".lrc" +UNIQUIFY_THRESHOLD: int = 99 +FILENAME_SANITIZE_PLACEHOLDER: str = "_" +COVER_NAME: str = "cover.jpg" +BLOCK_SIZE: int = 4096 +BLOCKS: int = 1024 +CHUNK_SIZE: int = BLOCK_SIZE * BLOCKS +PLAYLIST_EXTENSION: str = ".m3u" +PLAYLIST_PREFIX: str = "_" + + +class QualityVideo(StrEnum): + P360: str = "360" + P480: str = "480" + P720: str = "720" + P1080: str = "1080" + + +class MediaType(StrEnum): + TRACK: str = "track" + VIDEO: str = "video" + PLAYLIST: str = "playlist" + ALBUM: str = "album" + MIX: str = "mix" + ARTIST: str = "artist" + + +class CoverDimensions(StrEnum): + Px80: str = "80" + Px160: str = "160" + Px320: str = "320" + Px640: str = "640" + Px1280: str = "1280" + + +class TidalLists(StrEnum): + Playlists: str = "Playlists" + Favorites: str = "Favorites" + Mixes: str = "Mixes" + + +class QueueDownloadStatus(StrEnum): + Waiting: str = "⏳️" + Downloading: str = "▶️" + Finished: str = "✅" + Failed: str = "❌" + Skipped: str = "↪️" + + +FAVORITES: {} = { + "fav_videos": {"name": "Videos", "function_name": "videos"}, + "fav_tracks": {"name": "Tracks", "function_name": "tracks"}, + "fav_mixes": {"name": "Mixes & Radio", "function_name": "mixes"}, + "fav_artists": {"name": "Artists", "function_name": "artists"}, + "fav_albums": {"name": "Albums", "function_name": "albums"}, +} diff --git a/tidal_dl_ng/dialog.py b/tidal_dl_ng/dialog.py new file mode 100644 index 0000000..b17c151 --- /dev/null +++ b/tidal_dl_ng/dialog.py @@ -0,0 +1,307 @@ +import datetime +import os.path +import shutil +import webbrowser +from enum import Enum, StrEnum +from pathlib import Path + +from PySide6 import QtCore, QtGui, QtWidgets +from tidalapi import Quality as QualityAudio + +from tidal_dl_ng import __version__ +from tidal_dl_ng.config import Settings +from tidal_dl_ng.constants import CoverDimensions, QualityVideo +from tidal_dl_ng.model.cfg import HelpSettings +from tidal_dl_ng.model.cfg import Settings as ModelSettings +from tidal_dl_ng.model.meta import ReleaseLatest +from tidal_dl_ng.ui.dialog_login import Ui_DialogLogin +from tidal_dl_ng.ui.dialog_settings import Ui_DialogSettings +from tidal_dl_ng.ui.dialog_version import Ui_DialogVersion + + +class DialogVersion(QtWidgets.QDialog): + """Version dialog.""" + + ui: Ui_DialogVersion + + def __init__( + self, parent=None, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest = None + ): + super().__init__(parent) + + # Create an instance of the GUI + self.ui = Ui_DialogVersion() + + # Run the .setupUi() method to show the GUI + self.ui.setupUi(self) + # Set the version. + self.ui.l_version.setText("v" + __version__) + + if not update_check: + self.update_info_hide() + self.error_hide() + else: + self.update_info(update_available, update_info) + + # Show + self.exec() + + def update_info(self, update_available: bool, update_info: ReleaseLatest): + if not update_available and update_info.version == "v0.0.0": + self.update_info_hide() + self.ui.l_error_details.setText( + "Cannot retrieve update information. Maybe something is wrong with your internet connection." + ) + else: + self.error_hide() + + if not update_available: + self.ui.l_h_version_new.setText("Latest available version:") + self.changelog_hide() + else: + self.ui.l_changelog_details.setText(update_info.release_info) + self.ui.pb_download.clicked.connect(lambda: webbrowser.open(update_info.url)) + + self.ui.l_version_new.setText(update_info.version) + + def error_hide(self): + self.ui.l_error.setHidden(True) + self.ui.l_error_details.setHidden(True) + + def update_info_hide(self): + self.ui.l_h_version_new.setHidden(True) + self.ui.l_version_new.setHidden(True) + self.changelog_hide() + + def changelog_hide(self): + self.ui.l_changelog.setHidden(True) + self.ui.l_changelog_details.setHidden(True) + self.ui.pb_download.setHidden(True) + + +class DialogLogin(QtWidgets.QDialog): + """Version dialog.""" + + ui: Ui_DialogLogin + url_redirect: str + + def __init__(self, url_login: str, hint: str, expires_in: int, parent=None): + super().__init__(parent) + + datetime_current: datetime.datetime = datetime.datetime.now() + datetime_expires: datetime.datetime = datetime_current + datetime.timedelta(0, expires_in) + + # Create an instance of the GUI + self.ui = Ui_DialogLogin() + + # Run the .setupUi() method to show the GUI + self.ui.setupUi(self) + # Set data. + self.ui.tb_url_login.setText(f'https://{url_login}') + self.ui.l_hint.setText(hint) + self.ui.l_expires_date_time.setText(datetime_expires.strftime("%Y-%m-%d %H:%M")) + # Show + self.return_code = self.exec() + + +class DialogPreferences(QtWidgets.QDialog): + """Preferences dialog.""" + + ui: Ui_DialogSettings + settings: Settings + data: ModelSettings + s_settings_save: QtCore.Signal + icon: QtGui.QIcon + help_settings: HelpSettings + parameters_checkboxes: [str] + parameters_combo: [(str, StrEnum)] + parameters_line_edit: [str] + parameters_spin_box: [str] + prefix_checkbox: str = "cb_" + prefix_label: str = "l_" + prefix_icon: str = "icon_" + prefix_line_edit: str = "le_" + prefix_combo: str = "c_" + prefix_spin_box: str = "sb_" + + def __init__(self, settings: Settings, settings_save: QtCore.Signal, parent=None): + super().__init__(parent) + + self.settings = settings + self.data = settings.data + self.s_settings_save = settings_save + self.help_settings = HelpSettings() + pixmapi: QtWidgets.QStyle.StandardPixmap = QtWidgets.QStyle.SP_MessageBoxQuestion + self.icon = self.style().standardIcon(pixmapi) + + self._init_checkboxes() + self._init_comboboxes() + self._init_line_edit() + self._init_spin_box() + + # Create an instance of the GUI + self.ui = Ui_DialogSettings() + + # Run the .setupUi() method to show the GUI + self.ui.setupUi(self) + # Set data. + self.gui_populate() + # Post setup + + self.exec() + + def _init_line_edit(self): + self.parameters_line_edit = [ + "download_base_path", + "format_album", + "format_playlist", + "format_mix", + "format_track", + "format_video", + "path_binary_ffmpeg", + ] + + def _init_spin_box(self): + self.parameters_spin_box = ["album_track_num_pad_min", "downloads_concurrent_max"] + + def _init_comboboxes(self): + self.parameters_combo = [ + ("quality_audio", QualityAudio), + ("quality_video", QualityVideo), + ("metadata_cover_dimension", CoverDimensions), + ] + + def _init_checkboxes(self): + self.parameters_checkboxes = [ + "lyrics_embed", + "lyrics_file", + "video_download", + "download_delay", + "video_convert_mp4", + "extract_flac", + "metadata_cover_embed", + "cover_album_file", + "skip_existing", + "symlink_to_track", + "playlist_create", + ] + + def gui_populate(self): + self.populate_checkboxes() + self.populate_combo() + self.populate_line_edit() + self.populate_spin_box() + + def dialog_chose_file( + self, + obj_line_edit: QtWidgets.QLineEdit, + file_mode: QtWidgets.QFileDialog | QtWidgets.QFileDialog.FileMode = QtWidgets.QFileDialog.Directory, + path_default: str = None, + ): + # If a path is set, use it otherwise the users home directory. + path_settings: str = os.path.expanduser(obj_line_edit.text()) if obj_line_edit.text() else "" + # Check if obj_line_edit is empty but path_default can be usd instead + path_settings = ( + path_settings if path_settings else os.path.expanduser(path_default) if path_default else path_settings + ) + dir_current: str = path_settings if path_settings and os.path.exists(path_settings) else str(Path.home()) + dialog: QtWidgets.QFileDialog = QtWidgets.QFileDialog() + + # Set to directory mode only but show files. + dialog.setFileMode(file_mode) + dialog.setViewMode(QtWidgets.QFileDialog.Detail) + dialog.setOption(QtWidgets.QFileDialog.ShowDirsOnly, False) + dialog.setOption(QtWidgets.QFileDialog.DontResolveSymlinks, True) + + # There is a bug in the PyQt implementation, which hides files in Directory mode. + # Thus, we need to use the PyQt dialog instead of the native dialog. + if os.name == "nt" and file_mode == QtWidgets.QFileDialog.Directory: + dialog.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True) + + dialog.setDirectory(dir_current) + + # Execute dialog and set path is something is choosen. + if dialog.exec(): + dir_name: str = dialog.selectedFiles()[0] + path: Path = Path(dir_name) + obj_line_edit.setText(str(path)) + + def populate_line_edit(self): + for pn in self.parameters_line_edit: + label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn) + label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn) + line_edit: QtWidgets.QLineEdit = getattr(self.ui, self.prefix_line_edit + pn) + + label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16)))) + label_icon.setToolTip(getattr(self.help_settings, pn)) + label.setText(pn) + line_edit.setText(str(getattr(self.data, pn))) + + # Base Path File Dialog + self.ui.pb_download_base_path.clicked.connect(lambda x: self.dialog_chose_file(self.ui.le_download_base_path)) + self.ui.pb_path_binary_ffmpeg.clicked.connect( + lambda x: self.dialog_chose_file( + self.ui.le_path_binary_ffmpeg, + file_mode=QtWidgets.QFileDialog.FileMode.ExistingFiles, + path_default=shutil.which("ffmpeg"), + ) + ) + + def populate_combo(self): + for p in self.parameters_combo: + pn: str = p[0] + values: Enum = p[1] + label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn) + label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn) + combo: QtWidgets.QComboBox = getattr(self.ui, self.prefix_combo + pn) + setting_current = getattr(self.data, pn) + + label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16)))) + label_icon.setToolTip(getattr(self.help_settings, pn)) + label.setText(pn) + + for index, v in enumerate(values): + combo.addItem(v.name, v) + + if v == setting_current: + combo.setCurrentIndex(index) + + def populate_checkboxes(self): + for pn in self.parameters_checkboxes: + checkbox: QtWidgets.QCheckBox = getattr(self.ui, self.prefix_checkbox + pn) + + checkbox.setText(pn) + checkbox.setToolTip(getattr(self.help_settings, pn)) + checkbox.setIcon(self.icon) + checkbox.setChecked(getattr(self.data, pn)) + + def populate_spin_box(self): + for pn in self.parameters_spin_box: + label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn) + label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn) + spin_box: QtWidgets.QSpinBox = getattr(self.ui, self.prefix_spin_box + pn) + + label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16)))) + label_icon.setToolTip(getattr(self.help_settings, pn)) + label.setText(pn) + spin_box.setValue(getattr(self.data, pn)) + + def accept(self): + # Get settings. + self.to_settings() + self.done(1) + + def to_settings(self): + for item in self.parameters_checkboxes: + setattr(self.settings.data, item, getattr(self.ui, self.prefix_checkbox + item).isChecked()) + + for item in self.parameters_line_edit: + setattr(self.settings.data, item, getattr(self.ui, self.prefix_line_edit + item).text()) + + for item in self.parameters_combo: + setattr(self.settings.data, item[0], getattr(self.ui, self.prefix_combo + item[0]).currentData()) + + for item in self.parameters_spin_box: + setattr(self.settings.data, item, getattr(self.ui, self.prefix_spin_box + item).value()) + + self.s_settings_save.emit() diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py new file mode 100644 index 0000000..0807a1a --- /dev/null +++ b/tidal_dl_ng/download.py @@ -0,0 +1,805 @@ +import os +import pathlib +import random +import shutil +import tempfile +import time +from collections.abc import Callable +from concurrent import futures +from uuid import uuid4 + +import ffmpeg +import m3u8 +import requests +from requests.adapters import HTTPAdapter, Retry +from requests.exceptions import HTTPError +from rich.progress import Progress, TaskID +from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video +from tidalapi.exceptions import TooManyRequests +from tidalapi.media import AudioExtensions, Codec, Quality, Stream, StreamManifest, VideoExtensions + +from tidal_dl_ng.config import Settings +from tidal_dl_ng.constants import ( + CHUNK_SIZE, + COVER_NAME, + EXTENSION_LYRICS, + PLAYLIST_EXTENSION, + PLAYLIST_PREFIX, + REQUESTS_TIMEOUT_SEC, + MediaType, + QualityVideo, +) +from tidal_dl_ng.helper.decryption import decrypt_file, decrypt_security_token +from tidal_dl_ng.helper.exceptions import MediaMissing +from tidal_dl_ng.helper.path import check_file_exists, format_path_media, path_file_sanitize, url_to_filename +from tidal_dl_ng.helper.tidal import ( + instantiate_media, + items_results_all, + name_builder_album_artist, + name_builder_artist, + name_builder_item, + name_builder_title, +) +from tidal_dl_ng.metadata import Metadata +from tidal_dl_ng.model.downloader import DownloadSegmentResult +from tidal_dl_ng.model.gui_data import ProgressBars + + +# 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: str, timeout: int = REQUESTS_TIMEOUT_SEC, headers: dict | None = None, verify_ssl: bool = True + ): + if not headers: + headers = {} + + o = requests.get(uri, timeout=timeout, headers=headers) + + return o.text, o.url + + +# TODO: Use pathlib.Path everywhere +class Download: + settings: Settings + session: Session + skip_existing: bool = False + fn_logger: Callable + progress_gui: ProgressBars + progress: Progress + + def __init__( + self, + session: Session, + path_base: str, + fn_logger: Callable, + skip_existing: bool = False, + progress_gui: ProgressBars = None, + progress: Progress = None, + ): + self.settings = Settings() + self.session = session + self.skip_existing = skip_existing + self.fn_logger = fn_logger + self.progress_gui = progress_gui + self.progress = progress + self.path_base = path_base + + if not self.settings.data.path_binary_ffmpeg and ( + self.settings.data.video_convert_mp4 or self.settings.data.extract_flac + ): + self.settings.data.video_convert_mp4 = False + self.settings.data.extract_flac = False + + self.fn_logger.error( + "FFmpeg path is not set. Videos can be downloaded but will not be processed. FLAC cannot be " + "extracted from MP4 containers. Make sure FFmpeg is installed. The path to the FFmpeg binary must " + "be set in (`path_binary_ffmpeg`)." + ) + + def _download( + self, + media: Track | Video, + path_file: pathlib.Path, + stream_manifest: StreamManifest | None = None, + ) -> (bool, pathlib.Path): + media_name: str = name_builder_item(media) + urls: [str] + path_base: pathlib.Path = path_file.parent + result_segments: bool = True + dl_segment_results: [DownloadSegmentResult] = [] + result_merge: bool = False + + # Get urls for media. + try: + if isinstance(media, Track): + urls = stream_manifest.get_urls() + elif isinstance(media, Video): + m3u8_variant: m3u8.M3U8 = m3u8.load(media.get_url()) + # Find the desired video resolution or the next best one. + m3u8_playlist, codecs = self._extract_video_stream(m3u8_variant, int(self.settings.data.quality_video)) + # Populate urls. + urls = m3u8_playlist.files + except Exception: + return False, path_file + + # Set the correct progress output channel. + if self.progress_gui is None: + progress_to_stdout: bool = True + else: + progress_to_stdout: bool = False + # Send signal to GUI with media name + self.progress_gui.item_name.emit(media_name[:30]) + + # Compute total iterations for progress + urls_count: int = len(urls) + + if urls_count > 1: + progress_total: int = urls_count + block_size: int | None = None + elif urls_count == 1: + # Get file size and compute progress steps + r = requests.head(urls[0], timeout=REQUESTS_TIMEOUT_SEC) + total_size_in_bytes: int = int(r.headers.get("content-length", 0)) + block_size: int | None = 1048576 + progress_total: float = total_size_in_bytes / block_size + else: + raise ValueError + + # Create progress Task + p_task: TaskID = self.progress.add_task( + f"[blue]Item '{media_name[:30]}'", + total=progress_total, + visible=progress_to_stdout, + ) + + # Download segments until progress is finished. + # TODO: Compute download speed (https://github.com/Textualize/rich/blob/master/examples/downloader.py) + while not self.progress.tasks[p_task].finished: + with futures.ThreadPoolExecutor( + max_workers=self.settings.data.downloads_simultaneous_per_track_max + ) as executor: + # Dispatch all download tasks to worker threads + l_futures: [any] = [ + executor.submit(self._download_segment, url, path_base, block_size, p_task, progress_to_stdout) + for url in urls + ] + # Report results as they become available + for future in futures.as_completed(l_futures): + # Retrieve result + result_dl_segment: DownloadSegmentResult = future.result() + + dl_segment_results.append(result_dl_segment) + + # check for a link that was skipped + if not result_dl_segment.result and (result_dl_segment.url is not urls[-1]): + # Sometimes it happens, if a track is very short (< 8 seconds or so), that the last URL in `urls` is + # invalid (HTTP Error 500) and not necessary. File won't be corrupt. + # If this is NOT the case, but any other URL has resulted in an error, + # mark the whole thing as corrupt. + result_segments = False + self.fn_logger.error(f"Something went wrong while downloading {media_name}. File is corrupt!") + + tmp_path_file_decrypted: pathlib.Path = path_file + + # Only if no error happened while downloading. + if result_segments: + # Bring list into right order, so segments can be easily merged. + dl_segment_results.sort(key=lambda x: x.id_segment) + result_merge: bool = self._segments_merge(path_file, dl_segment_results) + + if not result_merge: + self.fn_logger.error(f"Something went wrong while writing to {media_name}. File is corrupt!") + elif result_merge and isinstance(media, Track) and stream_manifest.is_encrypted: + key, nonce = decrypt_security_token(stream_manifest.encryption_key) + tmp_path_file_decrypted = path_file.with_suffix(".decrypted") + decrypt_file(path_file, tmp_path_file_decrypted, key, nonce) + + return result_merge, tmp_path_file_decrypted + + def _segments_merge(self, path_file, dl_segment_results) -> bool: + result: bool + + # Copy the content of all segments into one file. + try: + with path_file.open("wb") as f_target: + for dl_segment_result in dl_segment_results: + with dl_segment_result.path_segment.open("rb") as f_segment: + # Read and write junks, which gives better HDD write performance + while segment := f_segment.read(CHUNK_SIZE): + f_target.write(segment) + + # Delete segment from HDD + dl_segment_result.path_segment.unlink() + + result = True + except Exception: + result = False + + return result + + def _download_segment( + self, url: str, path_base: pathlib.Path, block_size: int | None, p_task: TaskID, progress_to_stdout: bool + ) -> DownloadSegmentResult: + result: bool = False + path_segment: pathlib.Path = path_base / url_to_filename(url) + # Calculate the segment ID based on the file name within the URL. + filename_stem: str = str(path_segment.stem).split("_")[-1] + # CAUTION: This is a workaround, so BTS (LOW quality) track will work. They usually have only ONE link. + id_segment: int = int(filename_stem) if filename_stem.isdecimal() else 0 + error: HTTPError | None = None + + # Retry download on failed segments, with an exponential delay between retries + s = requests.Session() + retries = Retry(total=5, backoff_factor=1) # , status_forcelist=[ 502, 503, 504 ]) + + s.mount("https://", HTTPAdapter(max_retries=retries)) + + try: + # Create the request object with stream=True, so the content won't be loaded into memory at once. + r = s.get(url, stream=True, timeout=REQUESTS_TIMEOUT_SEC) + + r.raise_for_status() + + # Write the content to disk. If `chunk_size` is set to `None` the whole file will be written at once. + with path_segment.open("wb") as f: + for data in r.iter_content(chunk_size=block_size): + f.write(data) + # Advance progress bar. + self.progress.advance(p_task) + + result = True + except Exception: + self.progress.advance(p_task) + + # To send the progress to the GUI, we need to emit the percentage. + if not progress_to_stdout: + self.progress_gui.item.emit(self.progress.tasks[p_task].percentage) + + return DownloadSegmentResult( + result=result, url=url, path_segment=path_segment, id_segment=id_segment, error=error + ) + + def extension_guess(self, quality_audio: Quality, is_video: bool) -> AudioExtensions | VideoExtensions: + result: AudioExtensions | VideoExtensions + + if is_video: + result = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS + else: + result = ( + AudioExtensions.FLAC + if self.settings.data.extract_flac and quality_audio in (Quality.hi_res_lossless, Quality.high_lossless) + else AudioExtensions.M4A + ) + + return result + + def item( + self, + file_template: str, + media: Track | Video = None, + media_id: str = None, + media_type: MediaType = None, + video_download: bool = True, + download_delay: bool = False, + quality_audio: Quality | None = None, + quality_video: QualityVideo | None = None, + is_parent_album: bool = False, + ) -> (bool, pathlib.Path): + try: + if media_id and media_type: + # If no media instance is provided, we need to create the media instance. + media = instantiate_media(self.session, media_type, media_id) + elif isinstance(media, Track): # Check if media is available not deactivated / removed from TIDAL. + if not media.available: + self.fn_logger.info( + f"This track is not available for listening anymore on TIDAL. Skipping: {name_builder_item(media)}" + ) + + return False, "" + else: + # Re-create media instance with full album information + media = self.session.track(media.id, with_album=True) + elif not media: + raise MediaMissing + except: + return False, "" + + # If video download is not allowed end here + if not video_download and isinstance(media, Video): + self.fn_logger.info( + f"Video downloads are deactivated (see settings). Skipping video: {name_builder_item(media)}" + ) + + return False, "" + + # Create file name and path + file_extension_dummy: str = self.extension_guess(quality_audio, isinstance(media, Video)) + file_name_relative: str = format_path_media(file_template, media, self.settings.data.album_track_num_pad_min) + path_media_dst: pathlib.Path = ( + pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension_dummy) + ).absolute() + + # Sanitize final path_file to fit into OS boundaries. + path_media_dst = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True)) + + # Compute if and how downloads need to be skipped. + skip_download: bool = False + + if self.skip_existing: + skip_file: bool = check_file_exists(path_media_dst, extension_ignore=False) + + if self.settings.data.symlink_to_track and not isinstance(media, Video): + # Compute symlink tracks path, sanitize and check if file exists + file_name_track_dir_relative: str = format_path_media(self.settings.data.format_track, media) + path_media_track_dir: pathlib.Path = ( + pathlib.Path(self.path_base).expanduser() / (file_name_track_dir_relative + file_extension_dummy) + ).absolute() + path_media_track_dir = pathlib.Path(path_file_sanitize(str(path_media_track_dir), adapt=True)) + file_exists_track_dir: bool = check_file_exists(path_media_track_dir, extension_ignore=False) + file_exists_playlist_dir: bool = ( + not file_exists_track_dir and skip_file and not path_media_dst.is_symlink() + ) + skip_download = file_exists_playlist_dir or file_exists_track_dir + + # If + if skip_file and file_exists_playlist_dir: + skip_file = False + else: + skip_file: bool = False + + if not skip_file: + # If a quality is explicitly set, change it and remember the previously set quality. + quality_audio_old: Quality = self.adjust_quality_audio(quality_audio) if quality_audio else quality_audio + quality_video_old: QualityVideo = ( + self.adjust_quality_video(quality_video) if quality_video else quality_video + ) + do_flac_extract = False + # Get extension. + file_extension: str + stream_manifest: StreamManifest | None = None + + if isinstance(media, Track): + try: + media_stream: Stream = media.get_stream() + stream_manifest = media_stream.get_stream_manifest() + except TooManyRequests: + self.fn_logger.exception( + f"Too many requests against TIDAL backend. Skipping '{name_builder_item(media)}'. " + f"Consider to activate delay between downloads." + ) + + return False, "" + except Exception: + self.fn_logger.exception(f"Something went wrong. Skipping '{name_builder_item(media)}'.") + + return False, "" + + file_extension = stream_manifest.file_extension + + if self.settings.data.extract_flac and ( + stream_manifest.codecs.upper() == Codec.FLAC and file_extension != AudioExtensions.FLAC + ): + file_extension = AudioExtensions.FLAC + do_flac_extract = True + elif isinstance(media, Video): + file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS + + # Compute file name, sanitize once again and create destination directory + path_media_dst = path_media_dst.with_suffix(file_extension) + path_media_dst = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True)) + os.makedirs(path_media_dst.parent, exist_ok=True) + + if not skip_download: + # Create a temp directory and file. + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir: + tmp_path_file: pathlib.Path = pathlib.Path(tmp_path_dir) / str(uuid4()) + + # Create empty file + tmp_path_file.touch() + + # Download media. + result_download, tmp_path_file = self._download( + media=media, stream_manifest=stream_manifest, path_file=tmp_path_file + ) + + if result_download: + # Convert video from TS to MP4 + if isinstance(media, Video) and self.settings.data.video_convert_mp4: + # Convert `*.ts` file to `*.mp4` using ffmpeg + tmp_path_file = self._video_convert(tmp_path_file) + + # Extract FLAC from MP4 container using ffmpeg + if isinstance(media, Track) and self.settings.data.extract_flac and do_flac_extract: + tmp_path_file = self._extract_flac(tmp_path_file) + + tmp_path_lyrics: pathlib.Path | None = None + tmp_path_cover: pathlib.Path | None = None + + # Write metadata to file. + if not isinstance(media, Video): + result_metadata, tmp_path_lyrics, tmp_path_cover = self.metadata_write( + media, tmp_path_file, is_parent_album, media_stream + ) + + # Move lyrics file + if self.settings.data.lyrics_file and not isinstance(media, Video) and tmp_path_lyrics: + self._move_lyrics(tmp_path_lyrics, path_media_dst) + + # Move cover file + # TODO: Cover is downloaded with every track of the album. Needs refactoring, so cover is only + # downloaded for an album once. + if self.settings.data.cover_album_file and tmp_path_cover: + self._move_cover(tmp_path_cover, path_media_dst) + + self.fn_logger.info(f"Downloaded item '{name_builder_item(media)}'.") + + # Move final file to the configured destination directory. + shutil.move(tmp_path_file, path_media_dst) + + # If files needs to be symlinked, do postprocessing here. + if self.settings.data.symlink_to_track and not isinstance(media, Video): + path_media_track_dir: pathlib.Path = self.media_move_and_symlink(media, path_media_dst, file_extension) + + if quality_audio: + # Set quality back to the global user value + self.adjust_quality_audio(quality_audio_old) + + if quality_video: + # Set quality back to the global user value + self.adjust_quality_video(quality_video_old) + else: + self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'") + + status_download: bool = not skip_file + + # Whether a file was downloaded or skipped and the download delay is enabled, wait until the next download. + # Only use this, if you have a list of several Track items. + if download_delay and not skip_file: + time_sleep: float = round( + random.SystemRandom().uniform( + self.settings.data.download_delay_sec_min, self.settings.data.download_delay_sec_max + ), + 1, + ) + + self.fn_logger.debug(f"Next download will start in {time_sleep} seconds.") + time.sleep(time_sleep) + + return status_download, path_media_dst + + def media_move_and_symlink( + self, media: Track | Video, path_media_src: pathlib.Path, file_extension: str + ) -> pathlib.Path: + # Compute tracks path, sanitize and ensure path exists + file_name_relative: str = format_path_media(self.settings.data.format_track, media) + path_media_dst: pathlib.Path = ( + pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension) + ).absolute() + path_media_dst = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True)) + + os.makedirs(path_media_dst.parent, exist_ok=True) + + # Move item and symlink it + if path_media_dst != path_media_src: + if self.skip_existing: + skip_file: bool = check_file_exists(path_media_dst, extension_ignore=False) + skip_symlink: bool = path_media_src.is_symlink() + else: + skip_file: bool = False + skip_symlink: bool = False + + if not skip_file: + self.fn_logger.debug(f"Move: {path_media_src} -> {path_media_dst}") + shutil.move(path_media_src, path_media_dst) + + if not skip_symlink: + self.fn_logger.debug(f"Symlink: {path_media_src} -> {path_media_dst}") + path_media_src.unlink(missing_ok=True) + path_media_src.symlink_to(path_media_dst) + + return path_media_dst + + def adjust_quality_audio(self, quality) -> Quality: + # Save original quality settings + quality_old: Quality = self.session.audio_quality + self.session.audio_quality = quality + + return quality_old + + def adjust_quality_video(self, quality) -> QualityVideo: + quality_old: QualityVideo = self.settings.data.quality_video + + self.settings.data.quality_video = quality + + return quality_old + + def _move_file(self, path_file_source: pathlib.Path, path_file_destination: str | pathlib.Path) -> bool: + result: bool + + # Check if the file was downloaded + if path_file_source and path_file_source.is_file(): + # Move it. + shutil.move(path_file_source, path_file_destination) + + result = True + else: + result = False + + return result + + def _move_lyrics(self, path_lyrics: pathlib.Path, file_media_dst: pathlib.Path) -> bool: + # Build tmp lyrics filename + path_file_lyrics: pathlib.Path = file_media_dst.with_suffix(EXTENSION_LYRICS) + result: bool = self._move_file(path_lyrics, path_file_lyrics) + + return result + + def _move_cover(self, path_cover: pathlib.Path, file_media_dst: pathlib.Path) -> bool: + # Build tmp lyrics filename + path_file_cover: pathlib.Path = file_media_dst.parent / COVER_NAME + result: bool = self._move_file(path_cover, path_file_cover) + + return result + + def lyrics_to_file(self, dir_destination: pathlib.Path, lyrics: str) -> str: + return self.write_to_tmp_file(dir_destination, mode="x", content=lyrics) + + def cover_to_file(self, dir_destination: pathlib.Path, image: bytes) -> str: + return self.write_to_tmp_file(dir_destination, mode="xb", content=image) + + def write_to_tmp_file(self, dir_destination: pathlib.Path, mode: str, content: str | bytes) -> str: + result: str = dir_destination / str(uuid4()) + encoding: str | None = "utf-8" if isinstance(content, str) else None + + try: + with open(result, mode=mode, encoding=encoding) as f: + f.write(content) + except: + result = "" + + return result + + @staticmethod + def cover_data(url: str = None, path_file: str = None) -> str | bytes: + result: str | bytes = "" + + if url: + try: + result = requests.get(url, timeout=REQUESTS_TIMEOUT_SEC).content + except Exception as e: + # TODO: Implement propper logging. + print(e) + elif path_file: + try: + with open(path_file, "rb") as f: + result = f.read() + except OSError as e: + # TODO: Implement propper logging. + print(e) + + return result + + def metadata_write( + self, track: Track, path_media: pathlib.Path, is_parent_album: bool, media_stream: Stream + ) -> (bool, pathlib.Path | None, pathlib.Path | None): + result: bool = False + path_lyrics: pathlib.Path | None = None + path_cover: pathlib.Path | None = None + release_date: str = ( + track.album.available_release_date.strftime("%Y-%m-%d") + if track.album.available_release_date + else track.album.release_date.strftime("%Y-%m-%d") if track.album.release_date else "" + ) + copy_right: str = track.copyright if hasattr(track, "copyright") and track.copyright else "" + isrc: str = track.isrc if hasattr(track, "isrc") and track.isrc else "" + lyrics: str = "" + cover_data: bytes = None + + if self.settings.data.lyrics_embed or self.settings.data.lyrics_file: + # Try to retrieve lyrics. + try: + lyrics_obj = track.lyrics() + + if lyrics_obj.subtitles: + lyrics = lyrics_obj.subtitles + elif lyrics_obj.text: + lyrics = lyrics_obj.text + except: + lyrics = "" + # TODO: Implement proper logging. + print(f"Could not retrieve lyrics for `{name_builder_item(track)}`.") + + if lyrics and self.settings.data.lyrics_file: + path_lyrics = self.lyrics_to_file(path_media.parent, lyrics) + + if self.settings.data.metadata_cover_embed or (self.settings.data.cover_album_file and is_parent_album): + url_cover = track.album.image(int(self.settings.data.metadata_cover_dimension)) + cover_data = self.cover_data(url=url_cover) + + if cover_data and self.settings.data.cover_album_file and is_parent_album: + path_cover = self.cover_to_file(path_media.parent, cover_data) + + # `None` values are not allowed. + m: Metadata = Metadata( + path_file=path_media, + lyrics=lyrics, + copy_right=copy_right, + title=name_builder_title(track), + artists=name_builder_artist(track), + album=track.album.name if track.album else "", + tracknumber=track.track_num, + date=release_date, + isrc=isrc, + albumartist=name_builder_album_artist(track), + totaltrack=track.album.num_tracks if track.album and track.album.num_tracks else 1, + totaldisc=track.album.num_volumes if track.album and track.album.num_volumes else 1, + discnumber=track.volume_num if track.volume_num else 1, + cover_data=cover_data if self.settings.data.metadata_cover_embed else None, + album_replay_gain=media_stream.album_replay_gain, + album_peak_amplitude=media_stream.album_peak_amplitude, + track_replay_gain=media_stream.track_replay_gain, + track_peak_amplitude=media_stream.track_peak_amplitude, + ) + + m.save() + + result = True + + return result, path_lyrics, path_cover + + def items( + self, + file_template: str, + media: Album | Playlist | UserPlaylist | Mix = None, + media_id: str = None, + media_type: MediaType = None, + video_download: bool = False, + download_delay: bool = True, + quality_audio: Quality | None = None, + quality_video: QualityVideo | None = None, + ): + # If no media instance is provided, we need to create the media instance. + if media_id and media_type: + media = instantiate_media(self.session, media_type, media_id) + elif not media: + raise MediaMissing + + # Create file name and path + file_name_relative: str = format_path_media(file_template, media, self.settings.data.album_track_num_pad_min) + + # Get the name of the list and check, if videos should be included. + list_media_name: str = name_builder_title(media) + list_media_name_short: str = list_media_name[:30] + + # Get all items of the list. + items = items_results_all(media, videos_include=video_download) + + # Determine where to redirect the progress information. + if self.progress_gui is None: + progress_stdout: bool = True + else: + progress_stdout: bool = False + self.progress_gui.list_name.emit(list_media_name_short[:30]) + + # Create the list progress task. + p_task1: TaskID = self.progress.add_task( + f"[green]List '{list_media_name_short}'", total=len(items), visible=progress_stdout + ) + + is_album: bool = isinstance(media, Album) + result_dirs: [pathlib.Path] = [] + + # Iterate through list items + while not self.progress.finished: + with futures.ThreadPoolExecutor(max_workers=self.settings.data.downloads_concurrent_max) as executor: + # Dispatch all download tasks to worker threads + l_futures: [any] = [ + executor.submit( + self.item, + media=item_media, + file_template=file_name_relative, + quality_audio=quality_audio, + quality_video=quality_video, + download_delay=download_delay, + is_parent_album=is_album, + ) + for item_media in items + ] + # Report results as they become available + for future in futures.as_completed(l_futures): + # Retrieve result + status, result_path_file = future.result() + + if status: + result_dirs.append(result_path_file.parent) + + # Advance progress bar. + self.progress.advance(p_task1) + + if not progress_stdout: + self.progress_gui.list_item.emit(self.progress.tasks[p_task1].percentage) + + # Create playlist file + if self.settings.data.playlist_create: + self.playlist_populate(set(result_dirs), list_media_name, is_album) + + self.fn_logger.info(f"Finished list '{list_media_name}'.") + + def playlist_populate(self, dirs_scoped: [pathlib.Path], name_list: str, is_album: bool) -> [pathlib.Path]: + result: [pathlib.Path] = [] + + # For each dir, which contains tracks + for dir_scoped in dirs_scoped: + # Sanitize final playlist name to fit into OS boundaries. + path_playlist = dir_scoped / (PLAYLIST_PREFIX + name_list + PLAYLIST_EXTENSION) + path_playlist = pathlib.Path(path_file_sanitize(path_playlist, adapt=True)) + + self.fn_logger.debug(f"Playlist: Creating {path_playlist}") + + # Get all tracks in the directory + path_tracks: [pathlib.Path] = [] + + for extension_audio in AudioExtensions: + path_tracks = path_tracks + list(dir_scoped.glob(f"*{extension_audio!s}")) + + # If it is not an album sort by modification time + if not is_album: + path_tracks.sort(key=lambda x: os.path.getmtime(x)) + + # Write data to m3u file + with path_playlist.open(mode="w", encoding="utf-8") as f: + for path_track in path_tracks: + # If it's a symlink write the relative file path to the actual track into the playlist file + if path_track.is_symlink(): + media_file_target = path_track.resolve().relative_to(path_track.parent, walk_up=True) + + f.write(str(media_file_target) + os.linesep) + + result.append(path_playlist) + + return result + + def _video_convert(self, path_file: pathlib.Path) -> pathlib.Path: + path_file_out: pathlib.Path = path_file.with_suffix(AudioExtensions.MP4) + result, _ = ( + ffmpeg.input(path_file) + .output(str(path_file_out), map=0, c="copy", loglevel="quiet") + .run(cmd=self.settings.data.path_binary_ffmpeg) + ) + + return path_file_out + + def _extract_flac(self, path_media_src: pathlib.Path) -> pathlib.Path: + path_media_out = path_media_src.with_suffix(AudioExtensions.FLAC) + result, _ = ( + ffmpeg.input(path_media_src) + .output( + str(path_media_out), + map=0, + movflags="use_metadata_tags", + acodec="copy", + map_metadata="0:g", + loglevel="quiet", + ) + .run(cmd=self.settings.data.path_binary_ffmpeg) + ) + + return path_media_out + + def _extract_video_stream(self, m3u8_variant: m3u8.M3U8, quality: int) -> (m3u8.M3U8 | bool, str): + m3u8_playlist: m3u8.M3U8 | bool = False + resolution_best: int = 0 + mime_type: str = "" + + 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) + mime_type = playlist.stream_info.codecs + + if quality == playlist.stream_info.resolution[1]: + break + + return m3u8_playlist, mime_type diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py new file mode 100644 index 0000000..fffb7d1 --- /dev/null +++ b/tidal_dl_ng/gui.py @@ -0,0 +1,1103 @@ +import math +import sys +import time +from collections.abc import Callable, Sequence + +from requests.exceptions import HTTPError +from tidalapi.session import LinkLogin + +from tidal_dl_ng import __version__, update_available +from tidal_dl_ng.dialog import DialogLogin, DialogPreferences, DialogVersion +from tidal_dl_ng.helper.gui import ( + FilterHeader, + HumanProxyModel, + get_queue_download_media, + get_queue_download_quality, + get_results_media_item, + get_user_list_media_item, + set_queue_download_media, + set_user_list_media, +) +from tidal_dl_ng.helper.path import get_format_template, resource_path +from tidal_dl_ng.helper.tidal import ( + favorite_function_factory, + get_tidal_media_id, + get_tidal_media_type, + instantiate_media, + items_results_all, + name_builder_artist, + name_builder_title, + quality_audio_highest, + search_results_all, + user_media_lists, +) + +try: + import qdarktheme + from PySide6 import QtCore, QtGui, QtWidgets +except ImportError as e: + print(e) + print("Qt dependencies missing. Cannot start GUI. Please execute: 'pip install pyside6 pyqtdarktheme'") + sys.exit(1) + +import coloredlogs.converter +from rich.progress import Progress +from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video +from tidalapi.artist import Artist +from tidalapi.session import SearchTypes + +from tidal_dl_ng.config import Settings, Tidal +from tidal_dl_ng.constants import FAVORITES, QualityVideo, QueueDownloadStatus, TidalLists +from tidal_dl_ng.download import Download +from tidal_dl_ng.logger import XStream, logger_gui +from tidal_dl_ng.model.gui_data import ProgressBars, QueueDownloadItem, ResultItem, StatusbarMessage +from tidal_dl_ng.model.meta import ReleaseLatest +from tidal_dl_ng.ui.main import Ui_MainWindow +from tidal_dl_ng.ui.spinner import QtWaitingSpinner +from tidal_dl_ng.worker import Worker + + +# TODO: Make more use of Exceptions +class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): + settings: Settings + tidal: Tidal + dl: Download + threadpool: QtCore.QThreadPool + tray: QtWidgets.QSystemTrayIcon + spinner: QtWaitingSpinner + cover_url_current: str = "" + model_tr_results: QtGui.QStandardItemModel = QtGui.QStandardItemModel() + proxy_tr_results: HumanProxyModel + s_spinner_start: QtCore.Signal = QtCore.Signal(QtWidgets.QWidget) + s_spinner_stop: QtCore.Signal = QtCore.Signal() + pb_item: QtWidgets.QProgressBar + s_item_advance: QtCore.Signal = QtCore.Signal(float) + s_item_name: QtCore.Signal = QtCore.Signal(str) + s_list_name: QtCore.Signal = QtCore.Signal(str) + pb_list: QtWidgets.QProgressBar + s_list_advance: QtCore.Signal = QtCore.Signal(float) + s_pb_reset: QtCore.Signal = QtCore.Signal() + s_populate_tree_lists: QtCore.Signal = QtCore.Signal(list) + s_statusbar_message: QtCore.Signal = QtCore.Signal(object) + s_tr_results_add_top_level_item: QtCore.Signal = QtCore.Signal(object) + s_settings_save: QtCore.Signal = QtCore.Signal() + s_pb_reload_status: QtCore.Signal = QtCore.Signal(bool) + s_update_check: QtCore.Signal = QtCore.Signal(bool) + s_update_show: QtCore.Signal = QtCore.Signal(bool, bool, object) + s_queue_download_item_downloading: QtCore.Signal = QtCore.Signal(object) + s_queue_download_item_finished: QtCore.Signal = QtCore.Signal(object) + s_queue_download_item_failed: QtCore.Signal = QtCore.Signal(object) + s_queue_download_item_skipped: QtCore.Signal = QtCore.Signal(object) + + def __init__(self, tidal: Tidal | None = None): + super().__init__() + self.setupUi(self) + # self.setGeometry(50, 50, 500, 300) + self.setWindowTitle("TIDAL Downloader Next Generation!") + + # Logging redirect. + XStream.stdout().messageWritten.connect(self._log_output) + # XStream.stderr().messageWritten.connect(self._log_output) + + self.settings = Settings() + + self._init_threads() + self._init_tree_results_model(self.model_tr_results) + self._init_tree_results(self.tr_results, self.model_tr_results) + self._init_tree_lists(self.tr_lists_user) + self._init_tree_queue(self.tr_queue_download) + self._init_info() + self._init_progressbar() + self._populate_quality(self.cb_quality_audio, Quality) + self._populate_quality(self.cb_quality_video, QualityVideo) + self._populate_search_types(self.cb_search_type, SearchTypes) + self.apply_settings(self.settings) + self._init_signals() + self.init_tidal(tidal) + + logger_gui.debug("All setup.") + + def init_tidal(self, tidal: Tidal = None): + result: bool = False + + if tidal: + self.tidal = tidal + result = True + else: + self.tidal = Tidal(self.settings) + result = self.tidal.login_token() + + if not result: + hint: str = "After you have finished the TIDAL login via web browser click the 'OK' button." + + while not result: + link_login: LinkLogin = self.tidal.session.get_link_login() + d_login: DialogLogin = DialogLogin( + url_login=link_login.verification_uri_complete, + hint=hint, + expires_in=link_login.expires_in, + parent=self, + ) + + if d_login.return_code == 1: + try: + self.tidal.session.process_link_login(link_login, until_expiry=False) + self.tidal.login_finalize() + + result = True + logger_gui.info("Login successful. Have fun!") + except (HTTPError, Exception): + hint = "Something was wrong with your redirect url. Please try again!" + logger_gui.warning("Login not successful. Try again...") + else: + # If user has pressed cancel. + sys.exit(1) + + if result: + self._init_dl() + self.thread_it(self.tidal_user_lists) + + def _init_threads(self): + self.threadpool = QtCore.QThreadPool() + self.thread_it(self.watcher_queue_download) + + def _init_dl(self): + # Init `Download` object. + data_pb: ProgressBars = ProgressBars( + item=self.s_item_advance, + list_item=self.s_list_advance, + item_name=self.s_item_name, + list_name=self.s_list_name, + ) + progress: Progress = Progress() + self.dl = Download( + session=self.tidal.session, + skip_existing=self.tidal.settings.data.skip_existing, + path_base=self.settings.data.download_base_path, + fn_logger=logger_gui, + progress_gui=data_pb, + progress=progress, + ) + + def _init_progressbar(self): + self.pb_list = QtWidgets.QProgressBar() + self.pb_item = QtWidgets.QProgressBar() + pbs = [self.pb_list, self.pb_item] + + for pb in pbs: + pb.setRange(0, 100) + # self.pb_progress.setVisible() + self.statusbar.addPermanentWidget(pb) + + def _init_info(self): + path_image: str = resource_path("tidal_dl_ng/ui/default_album_image.png") + + self.l_pm_cover.setPixmap(QtGui.QPixmap(path_image)) + + def on_progress_reset(self): + self.pb_list.setValue(0) + self.pb_item.setValue(0) + + def on_statusbar_message(self, data: StatusbarMessage): + self.statusbar.showMessage(data.message, data.timout) + + def _log_output(self, text): + display_msg = coloredlogs.converter.convert(text) + + cursor: QtGui.QTextCursor = self.te_debug.textCursor() + cursor.movePosition(QtGui.QTextCursor.End) + cursor.insertHtml(display_msg) + + self.te_debug.setTextCursor(cursor) + self.te_debug.ensureCursorVisible() + + def _populate_quality(self, ui_target: QtWidgets.QComboBox, options: type[Quality | QualityVideo]): + for item in options: + ui_target.addItem(item.name, item) + + def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: SearchTypes): + for item in options: + if item: + ui_target.addItem(item.__name__, item) + + self.cb_search_type.setCurrentIndex(2) + + def handle_filter_activated(self): + header: FilterHeader = self.tr_results.header() + filters = [] + + for i in range(header.count()): + text: str = header.filter_text(i) + + if text: + filters.append((i, text)) + + proxy_model: HumanProxyModel = self.tr_results.model() + proxy_model.filters = filters + + def _init_tree_results(self, tree: QtWidgets.QTreeView, model: QtGui.QStandardItemModel) -> None: + header: FilterHeader = FilterHeader(tree) + self.proxy_tr_results: HumanProxyModel = HumanProxyModel(self) + + tree.setHeader(header) + tree.setModel(model) + self.proxy_tr_results.setSourceModel(model) + tree.setModel(self.proxy_tr_results) + header.set_filter_boxes(model.columnCount()) + header.filter_activated.connect(self.handle_filter_activated) + ## Styling + tree.sortByColumn(0, QtCore.Qt.SortOrder.AscendingOrder) + tree.setColumnHidden(1, True) + tree.setColumnWidth(2, 150) + tree.setColumnWidth(3, 150) + tree.setColumnWidth(4, 150) + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) + # Connect the contextmenu + tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + tree.customContextMenuRequested.connect(self.menu_context_tree_results) + + def _init_tree_results_model(self, model: QtGui.QStandardItemModel) -> None: + labels_column: [str] = ["#", "obj", "Artist", "Title", "Album", "Duration", "Quality", "Date Added"] + + model.setColumnCount(len(labels_column)) + model.setRowCount(0) + model.setHorizontalHeaderLabels(labels_column) + + def _init_tree_queue(self, tree: QtWidgets.QTableWidget): + tree.setColumnHidden(1, True) + tree.setColumnWidth(2, 200) + + header = tree.header() + + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) + header.setStretchLastSection(False) + + def tidal_user_lists(self): + # Start loading spinner + self.s_spinner_start.emit(self.tr_lists_user) + self.s_pb_reload_status.emit(False) + + user_all: [Playlist | UserPlaylist | Mix] = user_media_lists(self.tidal.session) + + self.s_populate_tree_lists.emit(user_all) + + def on_populate_tree_lists(self, user_lists: [Playlist | UserPlaylist | Mix]): + twi_playlists: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( + TidalLists.Playlists, QtCore.Qt.MatchExactly, 0 + )[0] + twi_mixes: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( + TidalLists.Mixes, QtCore.Qt.MatchExactly, 0 + )[0] + twi_favorites: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( + TidalLists.Favorites, QtCore.Qt.MatchExactly, 0 + )[0] + + # Remove all children if present + for twi in [twi_playlists, twi_mixes]: + for i in reversed(range(twi.childCount())): + twi.removeChild(twi.child(i)) + + # Populate dynamic user lists + for item in user_lists: + if isinstance(item, UserPlaylist | Playlist): + twi_child = QtWidgets.QTreeWidgetItem(twi_playlists) + name: str = item.name + description: str = f" {item.description}" if item.description else "" + info: str = f"({item.num_tracks + item.num_videos} Tracks){description}" + elif isinstance(item, Mix): + twi_child = QtWidgets.QTreeWidgetItem(twi_mixes) + name: str = item.title + info: str = item.sub_title + + twi_child.setText(0, name) + set_user_list_media(twi_child, item) + twi_child.setText(2, info) + + # Populate static favorites + for key, favorite in FAVORITES.items(): + twi_child = QtWidgets.QTreeWidgetItem(twi_favorites) + name: str = favorite["name"] + info: str = "" + + twi_child.setText(0, name) + set_user_list_media(twi_child, key) + twi_child.setText(2, info) + + # Stop load spinner + self.s_spinner_stop.emit() + self.s_pb_reload_status.emit(True) + + def _init_tree_lists(self, tree: QtWidgets.QTreeWidget): + # Adjust Tree. + tree.setColumnWidth(0, 200) + tree.setColumnHidden(1, True) + tree.setColumnWidth(2, 300) + tree.expandAll() + + # Connect the contextmenu + tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + tree.customContextMenuRequested.connect(self.menu_context_tree_lists) + + def on_update_check(self, on_startup: bool = True): + is_available, info = update_available() + + if (on_startup and is_available) or not on_startup: + self.s_update_show.emit(True, is_available, info) + + def apply_settings(self, settings: Settings): + l_cb = [ + {"element": self.cb_quality_audio, "setting": settings.data.quality_audio, "default_id": 1}, + {"element": self.cb_quality_video, "setting": settings.data.quality_video, "default_id": 0}, + ] + + for item in l_cb: + idx = item["element"].findData(item["setting"]) + + if idx > -1: + item["element"].setCurrentIndex(idx) + else: + item["element"].setCurrentIndex(item["default_id"]) + + def on_spinner_start(self, parent: QtWidgets.QWidget): + self.spinner = QtWaitingSpinner(parent, True, True) + self.spinner.setColor(QtGui.QColor(255, 255, 255)) + self.spinner.start() + + def on_spinner_stop(self): + self.spinner.stop() + self.spinner = None + + def menu_context_tree_lists(self, point: QtCore.QPoint): + # Infos about the node selected. + index = self.tr_lists_user.indexAt(point) + + # Do not open menu if something went wrong or a parent node is clicked. + if not index.isValid() or not index.parent().data(): + return + + # We build the menu. + menu = QtWidgets.QMenu() + menu.addAction("Download Playlist", lambda: self.thread_download_list_media(point)) + menu.addAction("Copy Share URL", lambda: self.thread_copy_url_share(self.tr_lists_user, point)) + + menu.exec(self.tr_lists_user.mapToGlobal(point)) + + def menu_context_tree_results(self, point: QtCore.QPoint): + # Infos about the node selected. + index = self.tr_results.indexAt(point) + + # Do not open menu if something went wrong or a parent node is clicked. + if not index.isValid(): + return + + # We build the menu. + menu = QtWidgets.QMenu() + menu.addAction("Copy Share URL", lambda: self.thread_copy_url_share(self.tr_results, point)) + + menu.exec(self.tr_results.mapToGlobal(point)) + + def thread_download_list_media(self, point: QtCore.QPoint): + self.thread_it(self.on_download_list_media, point) + + def thread_copy_url_share(self, tree_target: QtWidgets.QTreeWidget, point: QtCore.QPoint): + self.thread_it(self.on_copy_url_share, tree_target, point) + + def on_copy_url_share(self, tree_target: QtWidgets.QTreeWidget | QtWidgets.QTreeView, point: QtCore.QPoint = None): + if isinstance(tree_target, QtWidgets.QTreeWidget): + + item: QtWidgets.QTreeWidgetItem = tree_target.itemAt(point) + media: Album | Artist | Mix | Playlist = get_user_list_media_item(item) + else: + index: QtCore.QModelIndex = tree_target.indexAt(point) + media: Track | Video | Album | Artist | Mix | Playlist = get_results_media_item( + index, self.proxy_tr_results, self.model_tr_results + ) + + clipboard = QtWidgets.QApplication.clipboard() + url_share = media.share_url if hasattr(media, "share_url") else "No share URL available." + + clipboard.clear() + clipboard.setText(url_share) + + def on_download_list_media(self, point: QtCore.QPoint = None): + items: [QtWidgets.QTreeWidgetItem] + + if point: + items = [self.tr_lists_user.itemAt(point)] + else: + items = self.tr_lists_user.selectedItems() + + if len(items) == 0: + logger_gui.error("Please select a mix or playlist first.") + + for item in items: + media = get_user_list_media_item(item) + queue_dl_item: QueueDownloadItem | False = self.media_to_queue_download_model(media) + + if queue_dl_item: + self.queue_download_media(queue_dl_item) + + def search_populate_results(self, query: str, type_media: SearchTypes): + self.model_tr_results.removeRows(0, self.model_tr_results.rowCount()) + + results: [ResultItem] = self.search(query, [type_media]) + + self.populate_tree_results(results) + + def populate_tree_results(self, results: [ResultItem], parent: QtGui.QStandardItem = None): + if not parent: + self.model_tr_results.removeRows(0, self.model_tr_results.rowCount()) + + # Count how many digits the list length has, + count_digits: int = int(math.log10(len(results) if results else 1)) + 1 + + for item in results: + child: tuple = self.populate_tree_result_child(item=item, index_count_digits=count_digits) + + if parent: + parent.appendRow(child) + else: + self.s_tr_results_add_top_level_item.emit(child) + + def populate_tree_result_child( + self, item: [Track | Video | Mix | Album | Playlist], index_count_digits: int + ) -> Sequence[QtGui.QStandardItem]: + duration: str = "" + + # TODO: Duration needs to be calculated later to properly fill with zeros. + if item.duration_sec > -1: + # Format seconds to mm:ss. + m, s = divmod(item.duration_sec, 60) + duration: str = f"{m:02d}:{s:02d}" + + # Since sorting happens only by string, we need to pad the index and add 1 (to avoid start at 0) + index: str = str(item.position + 1).zfill(index_count_digits) + + # Populate child + child_index: QtGui.QStandardItem = QtGui.QStandardItem(index) + # TODO: Move to own method + child_obj: QtGui.QStandardItem = QtGui.QStandardItem() + + child_obj.setData(item.obj, QtCore.Qt.ItemDataRole.UserRole) + # set_results_media(child, item.obj) + + child_artist: QtGui.QStandardItem = QtGui.QStandardItem(item.artist) + child_title: QtGui.QStandardItem = QtGui.QStandardItem(item.title) + child_album: QtGui.QStandardItem = QtGui.QStandardItem(item.album) + child_duration: QtGui.QStandardItem = QtGui.QStandardItem(duration) + child_quality: QtGui.QStandardItem = QtGui.QStandardItem(item.quality) + child_date_added: QtGui.QStandardItem = QtGui.QStandardItem(item.date_user_added) + + if isinstance(item.obj, Mix | Playlist | Album | Artist): + # Add a disabled dummy child, so expansion arrow will appear. This Child will be replaced on expansion. + child_dummy: QtGui.QStandardItem = QtGui.QStandardItem() + + child_dummy.setEnabled(False) + child_index.appendRow(child_dummy) + + return ( + child_index, + child_obj, + child_artist, + child_title, + child_album, + child_duration, + child_quality, + child_date_added, + ) + + def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]): + self.model_tr_results.appendRow(item_child) + + def on_settings_save(self): + self.settings.save() + self.apply_settings(self.settings) + self._init_dl() + + def search(self, query: str, types_media: SearchTypes) -> [ResultItem]: + query = query.strip() + + # If a direct link was searched for, skip search and create the object from the link directly. + if "http" in query: + media_type = get_tidal_media_type(query) + item_id = get_tidal_media_id(query) + + try: + media = instantiate_media(self.tidal.session, media_type, item_id) + except: + logger_gui.error(f"Media not found (ID: {item_id}). Maybe it is not available anymore.") + + media = None + + result_search = {"direct": [media]} + else: + result_search: dict[str, [SearchTypes]] = search_results_all( + session=self.tidal.session, needle=query, types_media=types_media + ) + + result: [ResultItem] = [] + + for _media_type, l_media in result_search.items(): + if isinstance(l_media, list): + result = result + self.search_result_to_model(l_media) + + return result + + def search_result_to_model(self, items: [*SearchTypes]) -> [ResultItem]: + result = [] + + for idx, item in enumerate(items): + explicit: str = "" + # Check if item is available on TIDAL. + if hasattr(item, "available") and not item.available: + continue + + if isinstance(item, Track | Video | Album): + explicit = " 🅴" if item.explicit else "" + + date_user_added: str = item.user_date_added.strftime("%Y-%m-%d_%H:%M") if item.user_date_added else "" + + if isinstance(item, Track): + result_item: ResultItem = ResultItem( + position=idx, + artist=name_builder_artist(item), + title=f"{name_builder_title(item)}{explicit}", + album=item.album.name, + duration_sec=item.duration, + obj=item, + quality=quality_audio_highest(item), + explicit=bool(item.explicit), + date_user_added=date_user_added, + ) + + result.append(result_item) + elif isinstance(item, Video): + result_item: ResultItem = ResultItem( + position=idx, + artist=name_builder_artist(item), + title=f"{name_builder_title(item)}{explicit}", + album=item.album.name if item.album else "", + duration_sec=item.duration, + obj=item, + quality=item.video_quality, + explicit=bool(item.explicit), + date_user_added=date_user_added, + ) + + result.append(result_item) + elif isinstance(item, Playlist): + result_item: ResultItem = ResultItem( + position=idx, + artist=", ".join(artist.name for artist in item.promoted_artists) if item.promoted_artists else "", + title=item.name, + album="", + duration_sec=item.duration, + obj=item, + quality="", + explicit=False, + date_user_added=date_user_added, + ) + + result.append(result_item) + elif isinstance(item, Album): + result_item: ResultItem = ResultItem( + position=idx, + artist=name_builder_artist(item), + title="", + album=f"{item.name}{explicit}", + duration_sec=item.duration, + obj=item, + quality=quality_audio_highest(item), + explicit=bool(item.explicit), + date_user_added=date_user_added, + ) + + result.append(result_item) + elif isinstance(item, Mix): + result_item: ResultItem = ResultItem( + position=idx, + artist=item.sub_title, + title=item.title, + album="", + # TODO: Calculate total duration. + duration_sec=-1, + obj=item, + quality="", + explicit=False, + date_user_added=date_user_added, + ) + + result.append(result_item) + elif isinstance(item, Artist): + result_item: ResultItem = ResultItem( + position=idx, + artist=item.name, + title="", + album="", + duration_sec=-1, + obj=item, + quality="", + explicit=False, + date_user_added=date_user_added, + ) + + result.append(result_item) + + return result + + def media_to_queue_download_model( + self, media: Artist | Track | Video | Album | Playlist | Mix + ) -> QueueDownloadItem | bool: + result: QueueDownloadItem | False + name: str = "" + quality: Quality | QualityVideo | str = "" + explicit: str = "" + + # Check if item is available on TIDAL. + if hasattr(media, "available") and not media.available: + return False + + # Set "Explicit" tag + if isinstance(media, Track | Video | Album): + explicit = " 🅴" if media.explicit else "" + + # Build name and set quality + if isinstance(media, Track | Video): + name = f"{name_builder_artist(media)} - {name_builder_title(media)}{explicit}" + elif isinstance(media, Playlist | Artist): + name = media.name + quality = self.settings.data.quality_audio + elif isinstance(media, Album): + name = f"{name_builder_artist(media)} - {media.name}{explicit}" + elif isinstance(media, Mix): + name = media.title + quality = self.settings.data.quality_audio + + # Determine actual quality. + if isinstance(media, Track | Album): + quality_highest: str = quality_audio_highest(media) + + if ( + self.settings.data.quality_audio == quality_highest + or self.settings.data.quality_audio == Quality.hi_res_lossless + ): + quality = quality_highest + else: + quality = self.settings.data.quality_audio + elif isinstance(media, Video): + quality = self.settings.data.quality_video + + if name: + result = QueueDownloadItem( + name=name, + quality=quality, + type_media=type(media).__name__, + status=QueueDownloadStatus.Waiting, + obj=media, + ) + else: + result = False + + return result + + def _init_signals(self): + self.pb_download.clicked.connect(lambda: self.thread_it(self.on_download_results)) + self.pb_download_list.clicked.connect(lambda: self.thread_it(self.on_download_list_media)) + self.pb_reload_user_lists.clicked.connect(lambda: self.thread_it(self.tidal_user_lists)) + self.pb_queue_download_clear_all.clicked.connect(self.on_queue_download_clear_all) + self.pb_queue_download_clear_finished.clicked.connect(self.on_queue_download_clear_finished) + self.pb_queue_download_remove.clicked.connect(self.on_queue_download_remove) + self.l_search.returnPressed.connect( + lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) + ) + self.pb_search.clicked.connect( + lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) + ) + self.cb_quality_audio.currentIndexChanged.connect(self.on_quality_set_audio) + self.cb_quality_video.currentIndexChanged.connect(self.on_quality_set_video) + self.tr_lists_user.itemClicked.connect(self.on_list_items_show) + self.s_spinner_start[QtWidgets.QWidget].connect(self.on_spinner_start) + self.s_spinner_stop.connect(self.on_spinner_stop) + self.s_item_advance.connect(self.on_progress_item) + self.s_item_name.connect(self.on_progress_item_name) + self.s_list_name.connect(self.on_progress_list_name) + self.s_list_advance.connect(self.on_progress_list) + self.s_pb_reset.connect(self.on_progress_reset) + self.s_populate_tree_lists.connect(self.on_populate_tree_lists) + self.s_statusbar_message.connect(self.on_statusbar_message) + self.s_tr_results_add_top_level_item.connect(self.on_tr_results_add_top_level_item) + self.s_settings_save.connect(self.on_settings_save) + self.s_pb_reload_status.connect(self.button_reload_status) + self.s_update_check.connect(lambda: self.thread_it(self.on_update_check)) + self.s_update_show.connect(self.on_version) + + # Menubar + self.a_exit.triggered.connect(sys.exit) + self.a_version.triggered.connect(self.on_version) + self.a_preferences.triggered.connect(self.on_preferences) + self.a_logout.triggered.connect(self.on_logout) + self.a_updates_check.triggered.connect(lambda: self.on_update_check(False)) + + # Results + self.tr_results.expanded.connect(self.on_tr_results_expanded) + self.tr_results.clicked.connect(self.on_result_item_clicked) + + # Download Queue + self.tr_queue_download.itemClicked.connect(self.on_queue_download_item_clicked) + self.s_queue_download_item_downloading.connect(self.on_queue_download_item_downloading) + self.s_queue_download_item_finished.connect(self.on_queue_download_item_finished) + self.s_queue_download_item_failed.connect(self.on_queue_download_item_failed) + self.s_queue_download_item_skipped.connect(self.on_queue_download_item_skipped) + + def on_logout(self): + result: bool = self.tidal.logout() + + if result: + sys.exit(0) + + def on_progress_list(self, value: float): + self.pb_list.setValue(int(math.ceil(value))) + + def on_progress_item(self, value: float): + self.pb_item.setValue(int(math.ceil(value))) + + def on_progress_item_name(self, value: str): + self.pb_item.setFormat(f"%p% {value}") + + def on_progress_list_name(self, value: str): + self.pb_list.setFormat(f"%p% {value}") + + def on_quality_set_audio(self, index): + self.settings.data.quality_audio = Quality(self.cb_quality_audio.itemData(index)) + self.settings.save() + + if self.tidal: + self.tidal.settings_apply() + + def on_quality_set_video(self, index): + self.settings.data.quality_video = QualityVideo(self.cb_quality_video.itemData(index)) + self.settings.save() + + if self.tidal: + self.tidal.settings_apply() + + def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem): + media_list: Album | Playlist | str = get_user_list_media_item(item) + + # Only if clicked item is not a top level item. + if media_list: + if isinstance(media_list, str) and media_list.startswith("fav_"): + function_list = favorite_function_factory(self.tidal, media_list) + + self.list_items_show_result(favorite_function=function_list) + else: + self.list_items_show_result(media_list) + self.cover_show(media_list) + + def on_result_item_clicked(self, index: QtCore.QModelIndex) -> None: + media: Track | Video | Album | Artist = get_results_media_item( + index, self.proxy_tr_results, self.model_tr_results + ) + + self.cover_show(media) + + def on_queue_download_item_clicked(self, item: QtWidgets.QTreeWidgetItem, column: int) -> None: + media: Track | Video | Album | Artist | Mix | Playlist = get_queue_download_media(item) + + self.cover_show(media) + + def cover_show(self, media: Album | Playlist | Track | Video | Album | Artist) -> None: + cover_url: str + + try: + cover_url = media.album.image() + except: + try: + cover_url = media.image() + except: + logger_gui.info(f"No cover available (media ID: {media.id}).") + + if cover_url and self.cover_url_current != cover_url: + self.cover_url_current = cover_url + data_cover: bytes = Download.cover_data(cover_url) + pixmap: QtGui.QPixmap = QtGui.QPixmap() + pixmap.loadFromData(data_cover) + self.l_pm_cover.setPixmap(pixmap) + + def list_items_show_result( + self, + media_list: Album | Playlist | Mix | Artist | None = None, + point: QtCore.QPoint | None = None, + parent: QtGui.QStandardItem = None, + favorite_function: Callable = None, + ) -> None: + if point: + item = self.tr_lists_user.itemAt(point) + media_list = get_user_list_media_item(item) + + # Get all results + if favorite_function or isinstance(media_list, str): + if isinstance(media_list, str): + favorite_function = favorite_function_factory(self.tidal, media_list) + + media_items: [Track | Video | Album] = favorite_function() + else: + media_items: [Track | Video | Album] = items_results_all(media_list) + + result: [ResultItem] = self.search_result_to_model(media_items) + + self.populate_tree_results(result, parent=parent) + + def thread_it(self, fn: Callable, *args, **kwargs): + # Any other args, kwargs are passed to the run function + worker = Worker(fn, *args, **kwargs) + + # Execute + self.threadpool.start(worker) + + def on_queue_download_clear_all(self): + self.on_clear_queue_download( + f"({QueueDownloadStatus.Waiting}|{QueueDownloadStatus.Finished}|{QueueDownloadStatus.Failed})" + ) + + def on_queue_download_clear_finished(self): + self.on_clear_queue_download(f"[{QueueDownloadStatus.Finished}]") + + def on_clear_queue_download(self, regex: str): + items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( + regex, QtCore.Qt.MatchFlag.MatchRegularExpression, column=0 + ) + + for item in items: + self.tr_queue_download.takeTopLevelItem(self.tr_queue_download.indexOfTopLevelItem(item)) + + def on_queue_download_remove(self): + items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.selectedItems() + + if len(items) == 0: + logger_gui.error("Please select an item from the queue first.") + else: + for item in items: + status: str = item.text(0) + + if status != QueueDownloadStatus.Downloading: + self.tr_queue_download.takeTopLevelItem(self.tr_queue_download.indexOfTopLevelItem(item)) + else: + logger_gui.info("Cannot remove a currently downloading item from queue.") + + # TODO: Must happen in main thread. Do not thread this. + def on_download_results(self) -> None: + items: [HumanProxyModel | None] = self.tr_results.selectionModel().selectedRows() + + if len(items) == 0: + logger_gui.error("Please select a row first.") + else: + for item in items: + media: Track | Album | Playlist | Video | Artist = get_results_media_item( + item, self.proxy_tr_results, self.model_tr_results + ) + queue_dl_item: QueueDownloadItem = self.media_to_queue_download_model(media) + + if queue_dl_item: + self.queue_download_media(queue_dl_item) + + def queue_download_media(self, queue_dl_item: QueueDownloadItem) -> None: + # Populate child + child: QtWidgets.QTreeWidgetItem = QtWidgets.QTreeWidgetItem() + + child.setText(0, queue_dl_item.status) + set_queue_download_media(child, queue_dl_item.obj) + child.setText(2, queue_dl_item.name) + child.setText(3, queue_dl_item.type_media) + child.setText(4, queue_dl_item.quality) + self.tr_queue_download.addTopLevelItem(child) + + def watcher_queue_download(self) -> None: + while True: + items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( + QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0 + ) + + if len(items) > 0: + result: QueueDownloadStatus + item: QtWidgets.QTreeWidgetItem = items[0] + media: Track | Album | Playlist | Video | Mix | Artist = get_queue_download_media(item) + tmp_quality: str = get_queue_download_quality(item) + quality: Quality | QualityVideo | None = tmp_quality if tmp_quality else None + + try: + self.s_queue_download_item_downloading.emit(item) + result = self.on_queue_download(media, quality=quality) + + if result == QueueDownloadStatus.Finished: + self.s_queue_download_item_finished.emit(item) + elif result == QueueDownloadStatus.Skipped: + self.s_queue_download_item_skipped.emit(item) + except Exception as e: + logger_gui.error(e) + self.s_queue_download_item_failed.emit(item) + else: + time.sleep(2) + + def on_queue_download_item_downloading(self, item: QtWidgets.QTreeWidgetItem) -> None: + self.queue_download_item_status(item, QueueDownloadStatus.Downloading) + + def on_queue_download_item_finished(self, item: QtWidgets.QTreeWidgetItem) -> None: + self.queue_download_item_status(item, QueueDownloadStatus.Finished) + + def on_queue_download_item_failed(self, item: QtWidgets.QTreeWidgetItem) -> None: + self.queue_download_item_status(item, QueueDownloadStatus.Failed) + + def on_queue_download_item_skipped(self, item: QtWidgets.QTreeWidgetItem) -> None: + self.queue_download_item_status(item, QueueDownloadStatus.Skipped) + + def queue_download_item_status(self, item: QtWidgets.QTreeWidgetItem, status: str) -> None: + item.setText(0, status) + + def on_queue_download( + self, media: Track | Album | Playlist | Video | Mix | Artist, quality: Quality | QualityVideo | None = None + ) -> QueueDownloadStatus: + result: QueueDownloadStatus + items_media: [Track | Album | Playlist | Video | Mix | Artist] + + if isinstance(media, Artist): + items_media: [Album] = items_results_all(media) + else: + items_media = [media] + + download_delay: bool = bool(isinstance(media, Track | Video) and self.settings.data.download_delay) + + for item_media in items_media: + result = self.download(item_media, self.dl, delay_track=download_delay, quality=quality) + + return result + + def download( + self, + media: Track | Album | Playlist | Video | Mix | Artist, + dl: Download, + delay_track: bool = False, + quality: Quality | QualityVideo | None = None, + ) -> QueueDownloadStatus: + result_dl: bool + path_file: str + result: QueueDownloadStatus + quality_audio: Quality | None + quality_video: QualityVideo | None + self.s_pb_reset.emit() + self.s_statusbar_message.emit(StatusbarMessage(message="Download started...")) + + file_template = get_format_template(media, self.settings) + + if isinstance(media, Track | Video): + if isinstance(media, Track): + quality_audio = quality + quality_video = None + else: + quality_audio = None + quality_video = quality + + result_dl, path_file = dl.item( + media=media, + file_template=file_template, + download_delay=delay_track, + quality_audio=quality_audio, + quality_video=quality_video, + ) + elif isinstance(media, Album | Playlist | Mix): + if isinstance(media, Album): + quality_audio = quality + quality_video = None + else: + quality_audio = None + quality_video = None + + dl.items( + media=media, + file_template=file_template, + video_download=self.settings.data.video_download, + download_delay=self.settings.data.download_delay, + quality_audio=quality_audio, + quality_video=quality_video, + ) + + # Dummy values + result_dl = True + path_file = "dummy" + + self.s_statusbar_message.emit(StatusbarMessage(message="Download finished.", timout=2000)) + + if result_dl and path_file: + result = QueueDownloadStatus.Finished + elif not result_dl and path_file: + result = QueueDownloadStatus.Skipped + else: + result = QueueDownloadStatus.Failed + + return result + + def on_version( + self, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest = None + ) -> None: + DialogVersion(self, update_check, update_available, update_info) + + def on_preferences(self) -> None: + DialogPreferences(settings=self.settings, settings_save=self.s_settings_save, parent=self) + + def on_tr_results_expanded(self, index: QtCore.QModelIndex) -> None: + # If the child is a dummy the list_item has not been expanded before + item: QtGui.QStandardItem = self.model_tr_results.itemFromIndex(self.proxy_tr_results.mapToSource(index)) + load_children: bool = not item.child(0, 0).isEnabled() + + if load_children: + item.removeRow(0) + media_list: [Mix | Album | Playlist | Artist] = get_results_media_item( + index, self.proxy_tr_results, self.model_tr_results + ) + + self.list_items_show_result(media_list=media_list, parent=item) + + def button_reload_status(self, status: bool): + button_text: str = "Reloading..." + if status: + button_text = "Reload" + + self.pb_reload_user_lists.setEnabled(status) + self.pb_reload_user_lists.setText(button_text) + + +# TODO: Comment with Google Docstrings. +def gui_activate(tidal: Tidal | None = None): + # Set dark theme and create QT app. + qdarktheme.enable_hi_dpi() + app = QtWidgets.QApplication(sys.argv) + # Fix for Windows: Tooltips have bright font color + # https://github.com/5yutan5/PyQtDarkTheme/issues/239 + # qdarktheme.setup_theme() + qdarktheme.setup_theme(additional_qss="QToolTip { border: 0px; }") + + # Create icon object and apply it to app window. + pixmap: QtGui.QPixmap = QtGui.QPixmap("tidal_dl_ng/ui/icon.png") + icon: QtGui.QIcon = QtGui.QIcon(pixmap) + app.setWindowIcon(icon) + + # This bit gets the taskbar icon working properly in Windows + if sys.platform.startswith("win"): + import ctypes + + # Make sure Pyinstaller icons are still grouped + if not sys.argv[0].endswith(".exe"): + # Arbitrary string + my_app_id: str = "exislow.tidal.dl-ng." + __version__ + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_app_id) + + window = MainWindow(tidal=tidal) + window.show() + # Check for updates + window.s_update_check.emit(True) + + sys.exit(app.exec()) + + +if __name__ == "__main__": + gui_activate() diff --git a/tidal_dl_ng/helper/__init__.py b/tidal_dl_ng/helper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tidal_dl_ng/helper/__pycache__/__init__.cpython-311.pyc b/tidal_dl_ng/helper/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..beadca6 Binary files /dev/null and b/tidal_dl_ng/helper/__pycache__/__init__.cpython-311.pyc differ diff --git a/tidal_dl_ng/helper/__pycache__/decorator.cpython-311.pyc b/tidal_dl_ng/helper/__pycache__/decorator.cpython-311.pyc new file mode 100644 index 0000000..6ee5a8e Binary files /dev/null and b/tidal_dl_ng/helper/__pycache__/decorator.cpython-311.pyc differ diff --git a/tidal_dl_ng/helper/__pycache__/decryption.cpython-311.pyc b/tidal_dl_ng/helper/__pycache__/decryption.cpython-311.pyc new file mode 100644 index 0000000..90ed0b3 Binary files /dev/null and b/tidal_dl_ng/helper/__pycache__/decryption.cpython-311.pyc differ diff --git a/tidal_dl_ng/helper/__pycache__/exceptions.cpython-311.pyc b/tidal_dl_ng/helper/__pycache__/exceptions.cpython-311.pyc new file mode 100644 index 0000000..fddfc9d Binary files /dev/null and b/tidal_dl_ng/helper/__pycache__/exceptions.cpython-311.pyc differ diff --git a/tidal_dl_ng/helper/__pycache__/path.cpython-311.pyc b/tidal_dl_ng/helper/__pycache__/path.cpython-311.pyc new file mode 100644 index 0000000..7b74368 Binary files /dev/null and b/tidal_dl_ng/helper/__pycache__/path.cpython-311.pyc differ diff --git a/tidal_dl_ng/helper/__pycache__/tidal.cpython-311.pyc b/tidal_dl_ng/helper/__pycache__/tidal.cpython-311.pyc new file mode 100644 index 0000000..851992d Binary files /dev/null and b/tidal_dl_ng/helper/__pycache__/tidal.cpython-311.pyc differ diff --git a/tidal_dl_ng/helper/__pycache__/wrapper.cpython-311.pyc b/tidal_dl_ng/helper/__pycache__/wrapper.cpython-311.pyc new file mode 100644 index 0000000..5319e5b Binary files /dev/null and b/tidal_dl_ng/helper/__pycache__/wrapper.cpython-311.pyc differ diff --git a/tidal_dl_ng/helper/decorator.py b/tidal_dl_ng/helper/decorator.py new file mode 100644 index 0000000..ba0f417 --- /dev/null +++ b/tidal_dl_ng/helper/decorator.py @@ -0,0 +1,22 @@ +from typing import ClassVar + + +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: ClassVar[dict] = {} + + 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..17ea19b --- /dev/null +++ b/tidal_dl_ng/helper/decryption.py @@ -0,0 +1,55 @@ +import base64 +import pathlib + +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: pathlib.Path, path_file_destination: pathlib.Path, key: str, nonce: str) -> None: + """ + Decrypts an encrypted MQA file given the file, key and nonce. + TODO: Is it really only necessary for MQA of for all other formats, too? + """ + + # 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 path_file_encrypted.open("rb") as f_src: + audio_decrypted = decryptor.decrypt(f_src.read()) + + # Replace with decrypted file + with path_file_destination.open("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..be1bdee --- /dev/null +++ b/tidal_dl_ng/helper/exceptions.py @@ -0,0 +1,14 @@ +class LoginError(Exception): + pass + + +class MediaUnknown(Exception): + pass + + +class UnknownManifestFormat(Exception): + pass + + +class MediaMissing(Exception): + pass diff --git a/tidal_dl_ng/helper/gui.py b/tidal_dl_ng/helper/gui.py new file mode 100644 index 0000000..187f6a8 --- /dev/null +++ b/tidal_dl_ng/helper/gui.py @@ -0,0 +1,201 @@ +import re + +from PySide6 import QtCore, QtGui, QtWidgets +from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video +from tidalapi.artist import Artist +from tidalapi.media import Quality + + +def get_table_data( + item: QtWidgets.QTreeWidgetItem, column: int +) -> Track | Video | Album | Artist | Mix | Playlist | UserPlaylist: + result: Track | Video | Album | Artist = item.data(column, QtCore.Qt.ItemDataRole.UserRole) + + return result + + +def get_table_text(item: QtWidgets.QTreeWidgetItem, column: int) -> str: + result: str = item.text(column) + + return result + + +def get_results_media_item( + index: QtCore.QModelIndex, proxy: QtCore.QSortFilterProxyModel, model: QtGui.QStandardItemModel +) -> Track | Video | Album | Artist | Playlist | Mix: + # Switch column to "obj" column and map proxy data to our model. + item: QtGui.QStandardItem = model.itemFromIndex(proxy.mapToSource(index.siblingAtColumn(1))) + result: Track | Video | Album | Artist = item.data(QtCore.Qt.ItemDataRole.UserRole) + + return result + + +def get_user_list_media_item(item: QtWidgets.QTreeWidgetItem) -> Mix | Playlist | UserPlaylist: + result: Mix | Playlist | UserPlaylist = get_table_data(item, 1) + + return result + + +def get_queue_download_media( + item: QtWidgets.QTreeWidgetItem, +) -> Mix | Playlist | UserPlaylist | Track | Video | Album | Artist: + result: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist = get_table_data(item, 1) + + return result + + +def get_queue_download_quality( + item: QtWidgets.QTreeWidgetItem, +) -> Quality: + result: Quality = get_table_text(item, 4) + + return result + + +def set_table_data( + item: QtWidgets.QTreeWidgetItem, data: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist, column: int +): + item.setData(column, QtCore.Qt.ItemDataRole.UserRole, data) + + +def set_results_media(item: QtWidgets.QTreeWidgetItem, media: Track | Video | Album | Artist): + set_table_data(item, media, 1) + + +def set_user_list_media( + item: QtWidgets.QTreeWidgetItem, media: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist +): + set_table_data(item, media, 1) + + +def set_queue_download_media( + item: QtWidgets.QTreeWidgetItem, media: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist +): + set_table_data(item, media, 1) + + +class FilterHeader(QtWidgets.QHeaderView): + filter_activated = QtCore.Signal() + + def __init__(self, parent): + super().__init__(QtCore.Qt.Horizontal, parent) + self._editors = [] + self._padding = 4 + self.setCascadingSectionResizes(True) + self.setSectionResizeMode(QtWidgets.QHeaderView.Interactive) + self.setStretchLastSection(True) + self.setDefaultAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) + self.setSortIndicatorShown(False) + self.setSectionsMovable(True) + self.sectionResized.connect(self.adjust_positions) + parent.horizontalScrollBar().valueChanged.connect(self.adjust_positions) + + def set_filter_boxes(self, count): + while self._editors: + editor = self._editors.pop() + editor.deleteLater() + + for _ in range(count): + editor = QtWidgets.QLineEdit(self.parent()) + editor.setPlaceholderText("Filter") + editor.setClearButtonEnabled(True) + editor.returnPressed.connect(self.filter_activated.emit) + self._editors.append(editor) + + self.adjust_positions() + + def sizeHint(self): + size = super().sizeHint() + + if self._editors: + height = self._editors[0].sizeHint().height() + + size.setHeight(size.height() + height + self._padding) + + return size + + def updateGeometries(self): + if self._editors: + height = self._editors[0].sizeHint().height() + + self.setViewportMargins(0, 0, 0, height + self._padding) + else: + self.setViewportMargins(0, 0, 0, 0) + + super().updateGeometries() + self.adjust_positions() + + def adjust_positions(self): + for index, editor in enumerate(self._editors): + height = editor.sizeHint().height() + + editor.move(self.sectionPosition(index) - self.offset() + 2, height + (self._padding // 2)) + editor.resize(self.sectionSize(index), height) + + def filter_text(self, index) -> str: + if 0 <= index < len(self._editors): + return self._editors[index].text() + + return "" + + def set_filter_text(self, index, text): + if 0 <= index < len(self._editors): + self._editors[index].setText(text) + + def clear_filters(self): + for editor in self._editors: + editor.clear() + + +class HumanProxyModel(QtCore.QSortFilterProxyModel): + def _human_key(self, key): + parts = re.split(r"(\d*\.\d+|\d+)", key) + + return tuple((e.swapcase() if i % 2 == 0 else float(e)) for i, e in enumerate(parts)) + + def lessThan(self, source_left, source_right): + data_left = source_left.data() + data_right = source_right.data() + + if isinstance(data_left, str) and isinstance(data_right, str): + return self._human_key(data_left) < self._human_key(data_right) + + return super().lessThan(source_left, source_right) + + @property + def filters(self): + if not hasattr(self, "_filters"): + self._filters = [] + + return self._filters + + @filters.setter + def filters(self, filters): + self._filters = filters + + self.invalidateFilter() + + def filterAcceptsRow(self, source_row: int, source_parent: QtCore.QModelIndex) -> bool: + model = self.sourceModel() + source_index = model.index(source_row, 0, source_parent) + result: [bool] = [] + + # Show top level children + for child_row in range(model.rowCount(source_index)): + if self.filterAcceptsRow(child_row, source_index): + return True + + # Filter for actual needle + for i, text in self.filters: + if 0 <= i < self.columnCount(): + ix = self.sourceModel().index(source_row, i, source_parent) + data = ix.data() + + # Append results to list to enable an AND operator for filtering. + result.append(bool(re.search(rf"{text}", data, re.MULTILINE | re.IGNORECASE)) if data else False) + + # If no filter set, just set the result to True. + if not result: + result.append(True) + + return all(result) diff --git a/tidal_dl_ng/helper/path.py b/tidal_dl_ng/helper/path.py new file mode 100644 index 0000000..a275cca --- /dev/null +++ b/tidal_dl_ng/helper/path.py @@ -0,0 +1,324 @@ +import math +import os +import pathlib +import posixpath +import re +import sys +from urllib.parse import unquote, urlsplit + +from pathvalidate import sanitize_filename, sanitize_filepath +from pathvalidate.error import ValidationError +from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video +from tidalapi.media import AudioExtensions + +from tidal_dl_ng import __name_display__ +from tidal_dl_ng.constants import FILENAME_SANITIZE_PLACEHOLDER, UNIQUIFY_THRESHOLD, MediaType +from tidal_dl_ng.helper.tidal import name_builder_album_artist, name_builder_artist, name_builder_title + + +def path_home() -> str: + 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_config_base() -> str: + # https://wiki.archlinux.org/title/XDG_Base_Directory + # X11 workaround: If user specified config path is set, do not point to "~/.config" + path_user_custom: str = os.environ.get("XDG_CONFIG_HOME", "") + path_config: str = ".config" if not path_user_custom else "" + path_base: str = os.path.join(path_home(), path_config, __name_display__) + + return path_base + + +def path_file_log() -> str: + return os.path.join(path_config_base(), "app.log") + + +def path_file_token() -> str: + return os.path.join(path_config_base(), "token.json") + + +def path_file_settings() -> str: + return os.path.join(path_config_base(), "settings.json") + + +def format_path_media( + fmt_template: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, album_track_num_pad_min: int = 0 +) -> str: + result = fmt_template + + # Search track format template for placeholder. + regex = r"\{(.+?)\}" + matches = re.finditer(regex, fmt_template, re.MULTILINE) + + for _matchNum, match in enumerate(matches, start=1): + template_str = match.group() + result_fmt = format_str_media(match.group(1), media, album_track_num_pad_min) + + if result_fmt != match.group(1): + value = sanitize_filename(result_fmt) + result = result.replace(template_str, value) + + return result + + +def format_str_media( + name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, album_track_num_pad_min: int = 0 +) -> str: + result: str = name + + try: + match name: + case "artist_name": + if isinstance(media, Track | Video): + if hasattr(media, "artists"): + result = name_builder_artist(media) + elif hasattr(media, "artist"): + result = media.artist.name + case "album_artist": + result = name_builder_album_artist(media) + case "track_title": + if isinstance(media, Track | Video): + result = name_builder_title(media) + case "mix_name": + if isinstance(media, Mix): + result = media.title + case "playlist_name": + if isinstance(media, Playlist | UserPlaylist): + result = media.name + case "album_title": + if isinstance(media, Album): + result = media.name + elif isinstance(media, Track): + result = media.album.name + case "album_track_num": + if isinstance(media, Track | Video): + num_tracks: int = media.album.num_tracks if hasattr(media, "album") else 1 + count_digits: int = int(math.log10(num_tracks)) + 1 + count_digits_computed: int = ( + count_digits if count_digits > album_track_num_pad_min else album_track_num_pad_min + ) + result = str(media.track_num).zfill(count_digits_computed) + case "album_num_tracks": + if isinstance(media, Track | Video): + result = str(media.album.num_tracks if hasattr(media, "album") else 1) + case "track_id": + if isinstance(media, Track | Video): + result = str(media.id) + case "playlist_id": + if isinstance(media, Playlist): + result = str(media.id) + case "album_id": + if isinstance(media, Album): + result = str(media.id) + elif isinstance(media, Track): + result = str(media.album.id) + case "track_duration_seconds": + if isinstance(media, Track | Video): + result = str(media.duration) + case "track_duration_minutes": + if isinstance(media, Track | Video): + m, s = divmod(media.duration, 60) + result = f"{m:01d}:{s:02d}" + case "album_duration_seconds": + if isinstance(media, Album): + result = str(media.duration) + case "album_duration_minutes": + if isinstance(media, Album): + m, s = divmod(media.duration, 60) + result = f"{m:01d}:{s:02d}" + case "playlist_duration_seconds": + if isinstance(media, Album): + result = str(media.duration) + case "playlist_duration_minutes": + if isinstance(media, Album): + m, s = divmod(media.duration, 60) + result = f"{m:01d}:{s:02d}" + case "album_year": + if isinstance(media, Album): + result = str(media.year) + elif isinstance(media, Track): + result = str(media.album.year) + case "video_quality": + if isinstance(media, Video): + result = media.video_quality + case "track_quality": + if isinstance(media, Track): + result = ", ".join(tag for tag in media.media_metadata_tags) + case "track_explicit": + if isinstance(media, Track | Video): + result = " (Explicit)" if media.explicit else "" + case "album_explicit": + if isinstance(media, Album): + result = " (Explicit)" if media.explicit else "" + case "album_num_volumes": + if isinstance(media, Album): + result = str(media.num_volumes) + case "track_volume_num": + if isinstance(media, Track | Video): + result = str(media.volume_num) + case "track_volume_num_optional": + if isinstance(media, Track | Video): + num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1 + result = "" if num_volumes == 1 else str(media.volume_num) + case "track_volume_num_optional_CD": + if isinstance(media, Track | Video): + num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1 + result = "" if num_volumes == 1 else f"CD{media.volume_num!s}" + except Exception as e: + # TODO: Implement better exception logging. + print(e) + + pass + + return result + + +def get_format_template( + media: Track | Album | Playlist | UserPlaylist | Video | Mix | MediaType, settings +) -> str | bool: + result = False + + if isinstance(media, Track) or media == MediaType.TRACK: + result = settings.data.format_track + elif isinstance(media, Album) or media == MediaType.ALBUM or media == MediaType.ARTIST: + result = settings.data.format_album + elif isinstance(media, Playlist | UserPlaylist) or media == MediaType.PLAYLIST: + result = settings.data.format_playlist + elif isinstance(media, Mix) or media == MediaType.MIX: + result = settings.data.format_mix + elif isinstance(media, Video) or media == MediaType.VIDEO: + result = settings.data.format_video + + return result + + +def path_file_sanitize(path_file: str, adapt: bool = False, uniquify: bool = False) -> (bool, str): + # Split into path and filename + pathname, filename = os.path.split(path_file) + file_extension: str = pathlib.Path(path_file).suffix + + # Sanitize path + try: + pathname_sanitized: str = sanitize_filepath( + pathname, replacement_text=" ", validate_after_sanitize=True, platform="auto" + ) + except ValidationError: + # If adaption of path is allowed in case of an error set path to HOME. + if adapt: + pathname_sanitized: str = str(pathlib.Path.home()) + else: + raise + + # Sanitize filename + try: + filename_sanitized: str = sanitize_filename( + filename, replacement_text=" ", validate_after_sanitize=True, platform="auto" + ) + + # Check if the file extension was removed by shortening the filename length + if not filename_sanitized.endswith(file_extension): + # Add the original file extension + file_suffix: str = FILENAME_SANITIZE_PLACEHOLDER + file_extension + filename_sanitized = filename_sanitized[: -len(file_suffix)] + file_suffix + except ValidationError as e: + # TODO: Implement proper exception handling and logging. + # Hacky stuff, since the sanitizing function does not shorten the filename somehow (bug?) + # TODO: Remove after pathvalidate update. + # If filename too long + if e.description.startswith("[PV1101]"): + byte_ct: int = len(filename.encode("utf-8")) - 255 + filename_sanitized = ( + filename[: -byte_ct - len(FILENAME_SANITIZE_PLACEHOLDER) - len(file_extension)] + + FILENAME_SANITIZE_PLACEHOLDER + + file_extension + ) + else: + print(e) + + # Join path and filename + result: str = os.path.join(pathname_sanitized, filename_sanitized) + + # Uniquify + if uniquify: + unique_suffix: str = file_unique_suffix(result) + + if unique_suffix: + file_suffix = unique_suffix + file_extension + # For most OS filename has a character limit of 255. + filename_sanitized = ( + filename_sanitized[: -len(file_suffix)] + file_suffix + if len(filename_sanitized + unique_suffix) > 255 + else filename_sanitized[: -len(file_extension)] + file_suffix + ) + + # Join path and filename + result = os.path.join(pathname_sanitized, filename_sanitized) + + return result + + +def file_unique_suffix(path_file: str, seperator: str = "_") -> str: + threshold_zfill: int = len(str(UNIQUIFY_THRESHOLD)) + count: int = 0 + path_file_tmp: str = path_file + unique_suffix: str = "" + + while check_file_exists(path_file_tmp) and count < UNIQUIFY_THRESHOLD: + count += 1 + unique_suffix = seperator + str(count).zfill(threshold_zfill) + filename, file_extension = os.path.splitext(path_file_tmp) + path_file_tmp = filename + unique_suffix + file_extension + + return unique_suffix + + +def check_file_exists(path_file: pathlib.Path, extension_ignore: bool = False) -> bool: + if extension_ignore: + path_file_stem: str = pathlib.Path(path_file).stem + path_parent: pathlib.Path = pathlib.Path(path_file).parent + path_files: [str] = [] + + for extension in AudioExtensions: + path_files.append(str(path_parent.joinpath(path_file_stem + extension))) + else: + path_files: [str] = [path_file] + + result = bool(sum([[True] if os.path.isfile(_file) else [] for _file in path_files], [])) + + return result + + +def resource_path(relative_path): + try: + base_path = sys._MEIPASS + except Exception: + base_path = os.path.abspath(".") + + return os.path.join(base_path, relative_path) + + +def url_to_filename(url: str) -> str: + """Return basename corresponding to url. + >>> print(url_to_filename('http://example.com/path/to/file%C3%80?opt=1')) + fileÀ + >>> print(url_to_filename('http://example.com/slash%2fname')) # '/' in name + Taken from https://gist.github.com/zed/c2168b9c52b032b5fb7d + Traceback (most recent call last): + ... + ValueError + """ + urlpath: str = urlsplit(url).path + basename: str = posixpath.basename(unquote(urlpath)) + + if os.path.basename(basename) != basename or unquote(posixpath.basename(urlpath)) != basename: + raise ValueError # reject '%2f' or 'dir%5Cbasename.ext' on Windows + + return basename diff --git a/tidal_dl_ng/helper/tidal.py b/tidal_dl_ng/helper/tidal.py new file mode 100644 index 0000000..e568cc6 --- /dev/null +++ b/tidal_dl_ng/helper/tidal.py @@ -0,0 +1,205 @@ +from collections.abc import Callable + +from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video +from tidalapi.artist import Artist, Role +from tidalapi.media import MediaMetadataTags, Quality +from tidalapi.session import SearchTypes +from tidalapi.user import LoggedInUser + +from tidal_dl_ng.constants import FAVORITES, MediaType +from tidal_dl_ng.helper.exceptions import MediaUnknown + + +def name_builder_artist(media: Track | Video | Album) -> str: + return ", ".join(artist.name for artist in media.artists) + + +def name_builder_album_artist(media: Track | Album) -> str: + artists_tmp: [str] = [] + artists: [Artist] = media.album.artists if isinstance(media, Track) else media.artists + + for artist in artists: + if Role.main in artist.roles: + artists_tmp.append(artist.name) + + return ", ".join(artists_tmp) + + +def name_builder_title(media: Track | Video | Mix | Playlist | Album | Video) -> str: + result: str = ( + media.title if isinstance(media, Mix) else media.full_name if hasattr(media, "full_name") else media.name + ) + + return result + + +def name_builder_item(media: Track) -> str: + return f"{name_builder_artist(media)} - {name_builder_title(media)}" + + +def get_tidal_media_id(url_or_id_media: str) -> str: + id_dirty = url_or_id_media.rsplit("/", 1)[-1] + id_media = id_dirty.rsplit("?", 1)[0] + + return id_media + + +def get_tidal_media_type(url_media: str) -> MediaType | bool: + result: MediaType | bool = False + url_split = url_media.split("/")[-2] + + if len(url_split) > 1: + media_name = url_media.split("/")[-2] + + if media_name == "track": + result = MediaType.TRACK + elif media_name == "video": + result = MediaType.VIDEO + elif media_name == "album": + result = MediaType.ALBUM + elif media_name == "playlist": + result = MediaType.PLAYLIST + elif media_name == "mix": + result = MediaType.MIX + elif media_name == "artist": + result = MediaType.ARTIST + + return result + + +def search_results_all(session: Session, needle: str, types_media: SearchTypes = None) -> dict[str, [SearchTypes]]: + limit: int = 300 + offset: int = 0 + done: bool = False + result: dict[str, [SearchTypes]] = {} + + while not done: + tmp_result: dict[str, [SearchTypes]] = session.search( + query=needle, models=types_media, limit=limit, offset=offset + ) + tmp_done: bool = True + + for key, value in tmp_result.items(): + # Append pagination results, if there are any + if offset == 0: + result = tmp_result + tmp_done = False + elif bool(value): + result[key] += value + tmp_done = False + + # Next page + offset += limit + done = tmp_done + + return result + + +def items_results_all( + media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True +) -> [Track | Video | Album]: + result: [Track | Video | Album] = [] + + if isinstance(media_list, Mix): + result = media_list.items() + else: + func_get_items_media: [Callable] = [] + + if isinstance(media_list, Playlist | Album): + if videos_include: + func_get_items_media.append(media_list.items) + else: + func_get_items_media.append(media_list.tracks) + else: + func_get_items_media.append(media_list.get_albums) + func_get_items_media.append(media_list.get_ep_singles) + + result = paginate_results(func_get_items_media) + + return result + + +def all_artist_album_ids(media_artist: Artist) -> [int | None]: + result: [int] = [] + func_get_items_media: [Callable] = [media_artist.get_albums, media_artist.get_ep_singles] + albums: [Album] = paginate_results(func_get_items_media) + + for album in albums: + result.append(album.id) + + return result + + +def paginate_results(func_get_items_media: [Callable]) -> [Track | Video | Album | Playlist | UserPlaylist]: + result: [Track | Video | Album] = [] + + for func_media in func_get_items_media: + limit: int = 100 + offset: int = 0 + done: bool = False + + if func_media.__func__ == LoggedInUser.playlist_and_favorite_playlists: + limit: int = 50 + + while not done: + tmp_result: [Track | Video | Album | Playlist | UserPlaylist] = func_media(limit=limit, offset=offset) + + if bool(tmp_result): + result += tmp_result + # Get the next page in the next iteration. + offset += limit + else: + done = True + + return result + + +def user_media_lists(session: Session) -> [Playlist | UserPlaylist | Mix]: + user_playlists: [Playlist | UserPlaylist] = paginate_results([session.user.playlist_and_favorite_playlists]) + user_mixes: [Mix] = session.mixes().categories[0].items + result: [Playlist | UserPlaylist | Mix] = user_playlists + user_mixes + + return result + + +def instantiate_media( + session: Session, + media_type: type[MediaType.TRACK, MediaType.VIDEO, MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX], + id_media: str, +) -> Track | Video | Album | Playlist | Mix | Artist: + if media_type == MediaType.TRACK: + media = session.track(id_media, with_album=True) + elif media_type == MediaType.VIDEO: + media = session.video(id_media) + elif media_type == MediaType.ALBUM: + media = session.album(id_media) + elif media_type == MediaType.PLAYLIST: + media = session.playlist(id_media) + elif media_type == MediaType.MIX: + media = session.mix(id_media) + elif media_type == MediaType.ARTIST: + media = session.artist(id_media) + else: + raise MediaUnknown + + return media + + +def quality_audio_highest(media: Track | Album) -> Quality: + quality: Quality + + if MediaMetadataTags.hi_res_lossless in media.media_metadata_tags: + quality = Quality.hi_res_lossless + elif MediaMetadataTags.lossless in media.media_metadata_tags: + quality = Quality.high_lossless + else: + quality = media.audio_quality + + return quality + + +def favorite_function_factory(tidal, favorite_item: str): + function_name: str = FAVORITES[favorite_item]["function_name"] + function_list: Callable = getattr(tidal.session.user.favorites, function_name) + + return function_list diff --git a/tidal_dl_ng/helper/wrapper.py b/tidal_dl_ng/helper/wrapper.py new file mode 100644 index 0000000..cd8e6ba --- /dev/null +++ b/tidal_dl_ng/helper/wrapper.py @@ -0,0 +1,26 @@ +from collections.abc import Callable + + +class LoggerWrapped: + 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) + + def exception(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..62d96cd --- /dev/null +++ b/tidal_dl_ng/metadata.py @@ -0,0 +1,164 @@ +import mutagen +from mutagen import flac, id3, mp4 +from mutagen.id3 import APIC, TALB, TCOM, TCOP, TDRC, TIT2, TOPE, TPE1, TRCK, TSRC, TXXX, USLT + + +class Metadata: + path_file: str + title: str + album: str + albumartist: str + artists: [str] + copy_right: str + tracknumber: int + discnumber: int + totaldisc: int + totaltrack: int + date: str + composer: [str] + isrc: str + lyrics: str + path_cover: str + cover_data: bytes + album_replay_gain: float + album_peak_amplitude: float + track_replay_gain: float + track_peak_amplitude: float + m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC + + def __init__( + self, + path_file: str, + album: str = "", + title: str = "", + artists: str = "", + copy_right: str = "", + tracknumber: int = 0, + discnumber: int = 0, + totaltrack: int = 0, + totaldisc: int = 0, + composer: list[str] | None = None, + isrc: str = "", + albumartist: str = "", + date: str = "", + lyrics: str = "", + cover_data: bytes = None, + album_replay_gain: float = 1.0, + album_peak_amplitude: float = 1.0, + track_replay_gain: float = 1.0, + track_peak_amplitude: float = 1.0, + ): + self.path_file = path_file + 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.cover_data = cover_data + self.album_replay_gain = album_replay_gain + self.album_peak_amplitude = album_peak_amplitude + self.track_replay_gain = track_replay_gain + self.track_peak_amplitude = track_peak_amplitude + self.m: mutagen.mp4.MP4 | mutagen.flac.FLAC | mutagen.mp3.MP3 = mutagen.File(self.path_file) + + def _cover(self) -> bool: + result: bool = False + + if self.cover_data: + if isinstance(self.m, mutagen.flac.FLAC): + flac_cover = flac.Picture() + flac_cover.type = id3.PictureType.COVER_FRONT + flac_cover.data = self.cover_data + flac_cover.mime = "image/jpeg" + + self.m.clear_pictures() + self.m.add_picture(flac_cover) + elif isinstance(self.m, mutagen.mp3.MP3): + self.m.tags.add(APIC(encoding=3, data=self.cover_data)) + elif isinstance(self.m, mutagen.mp4.MP4): + cover_mp4 = mp4.MP4Cover(self.cover_data) + self.m.tags["covr"] = [cover_mp4] + + result = True + + return result + + def save(self): + if not self.m.tags: + self.m.add_tags() + + if isinstance(self.m, mutagen.flac.FLAC): + self.set_flac() + elif isinstance(self.m, mutagen.mp3.MP3): + self.set_mp3() + elif isinstance(self.m, mutagen.mp4.MP4): + self.set_mp4() + + self._cover() + self.m.save() + + return True + + def set_flac(self): + self.m.tags["TITLE"] = self.title + self.m.tags["ALBUM"] = self.album + self.m.tags["ALBUMARTIST"] = self.albumartist + self.m.tags["ARTIST"] = 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) if self.composer else "" + self.m.tags["ISRC"] = self.isrc + self.m.tags["LYRICS"] = self.lyrics + self.m.tags["REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain) + self.m.tags["REPLAYGAIN_ALBUM_PEAK"] = str(self.album_peak_amplitude) + self.m.tags["REPLAYGAIN_TRACK_GAIN"] = str(self.track_replay_gain) + self.m.tags["REPLAYGAIN_TRACK_PEAK"] = str(self.track_peak_amplitude) + + def set_mp3(self): + # ID3 Frame (tags) overview: https://exiftool.org/TagNames/ID3.html / https://id3.org/id3v2.3.0 + # Mapping overview: https://docs.mp3tag.de/mapping/ + 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=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) if self.composer else "")) + self.m.tags.add(TSRC(encoding=3, text=self.isrc)) + self.m.tags.add(USLT(encoding=3, lang="eng", desc="desc", text=self.lyrics)) + self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_ALBUM_GAIN", text=str(self.album_replay_gain))) + self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_ALBUM_PEAK", text=str(self.album_peak_amplitude))) + self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_TRACK_GAIN", text=str(self.track_replay_gain))) + self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_TRACK_PEAK", text=str(self.track_peak_amplitude))) + + 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"] = 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) if self.composer else "" + self.m.tags["\xa9lyr"] = self.lyrics + self.m.tags["isrc"] = self.isrc + self.m.tags["----:com.apple.iTunes:REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain).encode("utf-8") + self.m.tags["----:com.apple.iTunes:REPLAYGAIN_ALBUM_PEAK"] = str(self.album_peak_amplitude).encode("utf-8") + self.m.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN"] = str(self.track_replay_gain).encode("utf-8") + self.m.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK"] = str(self.track_peak_amplitude).encode("utf-8") diff --git a/tidal_dl_ng/model/__init__.py b/tidal_dl_ng/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tidal_dl_ng/model/__pycache__/__init__.cpython-311.pyc b/tidal_dl_ng/model/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..be06b61 Binary files /dev/null and b/tidal_dl_ng/model/__pycache__/__init__.cpython-311.pyc differ diff --git a/tidal_dl_ng/model/__pycache__/cfg.cpython-311.pyc b/tidal_dl_ng/model/__pycache__/cfg.cpython-311.pyc new file mode 100644 index 0000000..425803b Binary files /dev/null and b/tidal_dl_ng/model/__pycache__/cfg.cpython-311.pyc differ diff --git a/tidal_dl_ng/model/__pycache__/downloader.cpython-311.pyc b/tidal_dl_ng/model/__pycache__/downloader.cpython-311.pyc new file mode 100644 index 0000000..c7af787 Binary files /dev/null and b/tidal_dl_ng/model/__pycache__/downloader.cpython-311.pyc differ diff --git a/tidal_dl_ng/model/__pycache__/gui_data.cpython-311.pyc b/tidal_dl_ng/model/__pycache__/gui_data.cpython-311.pyc new file mode 100644 index 0000000..aa9ed39 Binary files /dev/null and b/tidal_dl_ng/model/__pycache__/gui_data.cpython-311.pyc differ diff --git a/tidal_dl_ng/model/__pycache__/meta.cpython-311.pyc b/tidal_dl_ng/model/__pycache__/meta.cpython-311.pyc new file mode 100644 index 0000000..91cc19e Binary files /dev/null and b/tidal_dl_ng/model/__pycache__/meta.cpython-311.pyc differ diff --git a/tidal_dl_ng/model/cfg.py b/tidal_dl_ng/model/cfg.py new file mode 100644 index 0000000..557371a --- /dev/null +++ b/tidal_dl_ng/model/cfg.py @@ -0,0 +1,108 @@ +from dataclasses import dataclass + +from dataclasses_json import dataclass_json +from tidalapi import Quality + +from tidal_dl_ng.constants import CoverDimensions, QualityVideo + + +@dataclass_json +@dataclass +class Settings: + skip_existing: bool = True + lyrics_embed: bool = False + lyrics_file: bool = False + # TODO: Implement API KEY selection. + # api_key_index: bool = 0 + # TODO: Implement album info download to separate file. + # album_info_save: bool = False + video_download: bool = True + # TODO: Implement multi threading for downloads. + # multi_thread: bool = False + download_delay: bool = True + download_base_path: str = "~/download" + quality_audio: Quality = Quality.low_320k + quality_video: QualityVideo = QualityVideo.P480 + format_album: str = ( + "Albums/{album_artist} - {album_title}{album_explicit}/{track_volume_num_optional}" + "{album_track_num}. {artist_name} - {track_title}{album_explicit}" + ) + 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}{track_explicit}" + format_video: str = "Videos/{artist_name} - {track_title}{track_explicit}" + video_convert_mp4: bool = True + path_binary_ffmpeg: str = "" + metadata_cover_dimension: CoverDimensions = CoverDimensions.Px320 + metadata_cover_embed: bool = True + cover_album_file: bool = True + extract_flac: bool = True + downloads_simultaneous_per_track_max: int = 20 + download_delay_sec_min: float = 3.0 + download_delay_sec_max: float = 5.0 + album_track_num_pad_min: int = 1 + downloads_concurrent_max: int = 3 + symlink_to_track: bool = False + playlist_create: bool = False + + +@dataclass_json +@dataclass +class HelpSettings: + skip_existing: str = "Skip download if file already exists." + album_cover_save: str = "Safe cover to album folder." + lyrics_embed: str = "Embed lyrics in audio file, if lyrics are available." + lyrics_file: str = "Save lyrics to separate *.lrc file, if lyrics are available." + 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_LOSSLESS" (up to 24 Bit, 192 kHz)' + ) + quality_video: str = 'Desired video download quality: "360", "480", "720", "1080"' + # 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." + ) + path_binary_ffmpeg: str = ( + "Path to FFmpeg binary file (executable). Only necessary if FFmpeg not set in $PATH. Mandatory for Windows: " + "The directory of `ffmpeg.exe`must be set in %PATH%." + ) + metadata_cover_dimension: str = ( + "The dimensions of the cover image embedded into the track. Possible values: 320x320, 640x640x 1280x1280." + ) + metadata_cover_embed: str = "Embed album cover into file." + cover_album_file: str = "Save cover to 'cover.jpg', if an album is downloaded." + extract_flac: str = "Extract FLAC audio tracks from MP4 containers and save them as `*.flac` (uses FFmpeg)." + downloads_simultaneous_per_track_max: str = "Maximum number of simultaneous chunk downloads per track." + download_delay_sec_min: str = "Lower boundary for the calculation of the download delay in seconds." + download_delay_sec_max: str = "Upper boundary for the calculation of the download delay in seconds." + album_track_num_pad_min: str = ( + "Minimum length of the album track count, will be padded with zeroes (0). To disable padding set this to 1." + ) + downloads_concurrent_max: str = "Maximum concurrent number of downloads (threads)." + symlink_to_track: str = ( + "If enabled the tracks of albums, playlists and mixes will be downloaded to the track directory but symlinked " + "accordingly." + ) + playlist_create: str = "Creates a '_playlist.m3u8' file for downloaded albums, playlists and mixes." + + +@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/downloader.py b/tidal_dl_ng/model/downloader.py new file mode 100644 index 0000000..3f6151f --- /dev/null +++ b/tidal_dl_ng/model/downloader.py @@ -0,0 +1,13 @@ +import pathlib +from dataclasses import dataclass + +from requests import HTTPError + + +@dataclass +class DownloadSegmentResult: + result: bool + url: str + path_segment: pathlib.Path + id_segment: int + error: HTTPError | None = None diff --git a/tidal_dl_ng/model/gui_data.py b/tidal_dl_ng/model/gui_data.py new file mode 100644 index 0000000..186ed98 --- /dev/null +++ b/tidal_dl_ng/model/gui_data.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass + +from tidalapi.media import Quality + +try: + from PySide6 import QtCore + + @dataclass + class ProgressBars: + item: QtCore.Signal + item_name: QtCore.Signal + list_item: QtCore.Signal + list_name: QtCore.Signal + +except ModuleNotFoundError: + + class ProgressBars: + pass + + +@dataclass +class ResultItem: + position: int + artist: str + title: str + album: str + duration_sec: int + obj: object + quality: str + explicit: bool + date_user_added: str + + +@dataclass +class StatusbarMessage: + message: str + timout: int = 0 + + +@dataclass +class QueueDownloadItem: + status: str + name: str + type_media: str + quality: Quality + obj: object diff --git a/tidal_dl_ng/model/meta.py b/tidal_dl_ng/model/meta.py new file mode 100644 index 0000000..1fd4abd --- /dev/null +++ b/tidal_dl_ng/model/meta.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + + +@dataclass +class ReleaseLatest: + version: str + url: str + release_info: str + + +@dataclass +class ProjectInformation: + version: str + repository_url: str diff --git a/tidal_dl_ng/ui/__init__.py b/tidal_dl_ng/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tidal_dl_ng/ui/default_album_image.png b/tidal_dl_ng/ui/default_album_image.png new file mode 100644 index 0000000..0ab51f1 Binary files /dev/null and b/tidal_dl_ng/ui/default_album_image.png differ diff --git a/tidal_dl_ng/ui/dialog_login.py b/tidal_dl_ng/ui/dialog_login.py new file mode 100644 index 0000000..59a78da --- /dev/null +++ b/tidal_dl_ng/ui/dialog_login.py @@ -0,0 +1,119 @@ +################################################################################ +## Form generated from reading UI file 'dialog_login.ui' +## +## Created by: Qt User Interface Compiler version 6.8.0 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import QCoreApplication, QMetaObject, QRect, Qt +from PySide6.QtGui import QFont +from PySide6.QtWidgets import QDialogButtonBox, QHBoxLayout, QLabel, QSizePolicy, QTextBrowser, QVBoxLayout, QWidget + + +class Ui_DialogLogin: + def setupUi(self, DialogLogin): + if not DialogLogin.objectName(): + DialogLogin.setObjectName("DialogLogin") + DialogLogin.resize(451, 400) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(DialogLogin.sizePolicy().hasHeightForWidth()) + DialogLogin.setSizePolicy(sizePolicy) + self.bb_dialog = QDialogButtonBox(DialogLogin) + self.bb_dialog.setObjectName("bb_dialog") + self.bb_dialog.setGeometry(QRect(20, 350, 411, 32)) + sizePolicy.setHeightForWidth(self.bb_dialog.sizePolicy().hasHeightForWidth()) + self.bb_dialog.setSizePolicy(sizePolicy) + self.bb_dialog.setLayoutDirection(Qt.LayoutDirection.LeftToRight) + self.bb_dialog.setStyleSheet("") + self.bb_dialog.setOrientation(Qt.Orientation.Horizontal) + self.bb_dialog.setStandardButtons(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok) + self.verticalLayoutWidget = QWidget(DialogLogin) + self.verticalLayoutWidget.setObjectName("verticalLayoutWidget") + self.verticalLayoutWidget.setGeometry(QRect(20, 20, 411, 325)) + self.lv_main = QVBoxLayout(self.verticalLayoutWidget) + self.lv_main.setObjectName("lv_main") + self.lv_main.setContentsMargins(0, 0, 0, 0) + self.l_header = QLabel(self.verticalLayoutWidget) + self.l_header.setObjectName("l_header") + sizePolicy.setHeightForWidth(self.l_header.sizePolicy().hasHeightForWidth()) + self.l_header.setSizePolicy(sizePolicy) + font = QFont() + font.setPointSize(23) + font.setBold(True) + self.l_header.setFont(font) + + self.lv_main.addWidget(self.l_header) + + self.l_description = QLabel(self.verticalLayoutWidget) + self.l_description.setObjectName("l_description") + sizePolicy.setHeightForWidth(self.l_description.sizePolicy().hasHeightForWidth()) + self.l_description.setSizePolicy(sizePolicy) + font1 = QFont() + font1.setItalic(True) + self.l_description.setFont(font1) + self.l_description.setWordWrap(True) + + self.lv_main.addWidget(self.l_description) + + self.tb_url_login = QTextBrowser(self.verticalLayoutWidget) + self.tb_url_login.setObjectName("tb_url_login") + self.tb_url_login.setOpenExternalLinks(True) + + self.lv_main.addWidget(self.tb_url_login) + + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.l_expires_description = QLabel(self.verticalLayoutWidget) + self.l_expires_description.setObjectName("l_expires_description") + + self.horizontalLayout.addWidget(self.l_expires_description) + + self.l_expires_date_time = QLabel(self.verticalLayoutWidget) + self.l_expires_date_time.setObjectName("l_expires_date_time") + font2 = QFont() + font2.setBold(True) + self.l_expires_date_time.setFont(font2) + self.l_expires_date_time.setAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTrailing | Qt.AlignmentFlag.AlignVCenter + ) + + self.horizontalLayout.addWidget(self.l_expires_date_time) + + self.lv_main.addLayout(self.horizontalLayout) + + self.l_hint = QLabel(self.verticalLayoutWidget) + self.l_hint.setObjectName("l_hint") + sizePolicy.setHeightForWidth(self.l_hint.sizePolicy().hasHeightForWidth()) + self.l_hint.setSizePolicy(sizePolicy) + self.l_hint.setFont(font1) + self.l_hint.setWordWrap(True) + + self.lv_main.addWidget(self.l_hint) + + self.retranslateUi(DialogLogin) + self.bb_dialog.accepted.connect(DialogLogin.accept) + self.bb_dialog.rejected.connect(DialogLogin.reject) + + QMetaObject.connectSlotsByName(DialogLogin) + + # setupUi + + def retranslateUi(self, DialogLogin): + DialogLogin.setWindowTitle(QCoreApplication.translate("DialogLogin", "Dialog", None)) + self.l_header.setText(QCoreApplication.translate("DialogLogin", "TIDAL Login (as Device)", None)) + self.l_description.setText( + QCoreApplication.translate( + "DialogLogin", + "Click the link below and login with your TIDAL credentials. TIDAL will ask you, if you like to add this app as a new device. You need to confirm this.", + None, + ) + ) + self.tb_url_login.setPlaceholderText(QCoreApplication.translate("DialogLogin", "Copy this login URL...", None)) + self.l_expires_description.setText(QCoreApplication.translate("DialogLogin", "This link expires at:", None)) + self.l_expires_date_time.setText(QCoreApplication.translate("DialogLogin", "COMPUTING", None)) + self.l_hint.setText(QCoreApplication.translate("DialogLogin", "Waiting...", None)) + + # retranslateUi diff --git a/tidal_dl_ng/ui/dialog_login.ui b/tidal_dl_ng/ui/dialog_login.ui new file mode 100644 index 0000000..2ff401f --- /dev/null +++ b/tidal_dl_ng/ui/dialog_login.ui @@ -0,0 +1,195 @@ + + + DialogLogin + + + + 0 + 0 + 451 + 400 + + + + + 0 + 0 + + + + Dialog + + + + + 20 + 350 + 411 + 32 + + + + + 0 + 0 + + + + Qt::LayoutDirection::LeftToRight + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + 20 + 20 + 411 + 325 + + + + + + + + 0 + 0 + + + + + 23 + true + + + + TIDAL Login (as Device) + + + + + + + + 0 + 0 + + + + + true + + + + Click the link below and login with your TIDAL credentials. TIDAL will ask you, if you like to add this app as a new device. You need to confirm this. + + + true + + + + + + + Copy this login URL... + + + true + + + + + + + + + This link expires at: + + + + + + + + true + + + + COMPUTING + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + + 0 + 0 + + + + + true + + + + Waiting... + + + true + + + + + + + + + + bb_dialog + accepted() + DialogLogin + accept() + + + 248 + 254 + + + 157 + 274 + + + + + bb_dialog + rejected() + DialogLogin + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/tidal_dl_ng/ui/dialog_settings.py b/tidal_dl_ng/ui/dialog_settings.py new file mode 100644 index 0000000..3098817 --- /dev/null +++ b/tidal_dl_ng/ui/dialog_settings.py @@ -0,0 +1,631 @@ +################################################################################ +## Form generated from reading UI file 'dialog_settings.ui' +## +## Created by: Qt User Interface Compiler version 6.8.1 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import QCoreApplication, QMetaObject, Qt +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialogButtonBox, + QGroupBox, + QHBoxLayout, + QLabel, + QLayout, + QLineEdit, + QPushButton, + QSizePolicy, + QSpinBox, + QVBoxLayout, +) + + +class Ui_DialogSettings: + def setupUi(self, DialogSettings): + if not DialogSettings.objectName(): + DialogSettings.setObjectName("DialogSettings") + DialogSettings.resize(640, 800) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(100) + sizePolicy.setVerticalStretch(100) + sizePolicy.setHeightForWidth(DialogSettings.sizePolicy().hasHeightForWidth()) + DialogSettings.setSizePolicy(sizePolicy) + DialogSettings.setSizeGripEnabled(True) + self.lv_dialog_settings = QVBoxLayout(DialogSettings) + self.lv_dialog_settings.setObjectName("lv_dialog_settings") + self.lv_dialog_settings.setContentsMargins(0, 0, 0, 0) + self.lv_main = QVBoxLayout() + self.lv_main.setObjectName("lv_main") + self.lv_main.setContentsMargins(12, 12, 12, 12) + self.gb_flags = QGroupBox(DialogSettings) + self.gb_flags.setObjectName("gb_flags") + sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + sizePolicy1.setHorizontalStretch(100) + sizePolicy1.setVerticalStretch(100) + sizePolicy1.setHeightForWidth(self.gb_flags.sizePolicy().hasHeightForWidth()) + self.gb_flags.setSizePolicy(sizePolicy1) + self.gb_flags.setFlat(False) + self.gb_flags.setCheckable(False) + self.lv_flags = QVBoxLayout(self.gb_flags) + self.lv_flags.setObjectName("lv_flags") + self.lh_flags_1 = QHBoxLayout() + self.lh_flags_1.setObjectName("lh_flags_1") + self.lv_flag_video_download = QVBoxLayout() + self.lv_flag_video_download.setObjectName("lv_flag_video_download") + self.cb_video_download = QCheckBox(self.gb_flags) + self.cb_video_download.setObjectName("cb_video_download") + sizePolicy1.setHeightForWidth(self.cb_video_download.sizePolicy().hasHeightForWidth()) + self.cb_video_download.setSizePolicy(sizePolicy1) + + self.lv_flag_video_download.addWidget(self.cb_video_download) + + self.lh_flags_1.addLayout(self.lv_flag_video_download) + + self.lv_flag_video_convert = QVBoxLayout() + self.lv_flag_video_convert.setObjectName("lv_flag_video_convert") + self.cb_video_convert_mp4 = QCheckBox(self.gb_flags) + self.cb_video_convert_mp4.setObjectName("cb_video_convert_mp4") + sizePolicy1.setHeightForWidth(self.cb_video_convert_mp4.sizePolicy().hasHeightForWidth()) + self.cb_video_convert_mp4.setSizePolicy(sizePolicy1) + + self.lv_flag_video_convert.addWidget(self.cb_video_convert_mp4) + + self.lh_flags_1.addLayout(self.lv_flag_video_convert) + + self.lv_flags.addLayout(self.lh_flags_1) + + self.lh_flags_2 = QHBoxLayout() + self.lh_flags_2.setObjectName("lh_flags_2") + self.lv_flag_lyrics_embed = QVBoxLayout() + self.lv_flag_lyrics_embed.setObjectName("lv_flag_lyrics_embed") + self.cb_lyrics_embed = QCheckBox(self.gb_flags) + self.cb_lyrics_embed.setObjectName("cb_lyrics_embed") + sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + sizePolicy2.setHorizontalStretch(0) + sizePolicy2.setVerticalStretch(0) + sizePolicy2.setHeightForWidth(self.cb_lyrics_embed.sizePolicy().hasHeightForWidth()) + self.cb_lyrics_embed.setSizePolicy(sizePolicy2) + + self.lv_flag_lyrics_embed.addWidget(self.cb_lyrics_embed) + + self.lh_flags_2.addLayout(self.lv_flag_lyrics_embed) + + self.lv_flag_lyrics_file = QVBoxLayout() + self.lv_flag_lyrics_file.setObjectName("lv_flag_lyrics_file") + self.cb_lyrics_file = QCheckBox(self.gb_flags) + self.cb_lyrics_file.setObjectName("cb_lyrics_file") + sizePolicy1.setHeightForWidth(self.cb_lyrics_file.sizePolicy().hasHeightForWidth()) + self.cb_lyrics_file.setSizePolicy(sizePolicy1) + + self.lv_flag_lyrics_file.addWidget(self.cb_lyrics_file) + + self.lh_flags_2.addLayout(self.lv_flag_lyrics_file) + + self.lv_flags.addLayout(self.lh_flags_2) + + self.lh_flag_3 = QHBoxLayout() + self.lh_flag_3.setObjectName("lh_flag_3") + self.lv_flag_download_delay = QVBoxLayout() + self.lv_flag_download_delay.setObjectName("lv_flag_download_delay") + self.cb_download_delay = QCheckBox(self.gb_flags) + self.cb_download_delay.setObjectName("cb_download_delay") + sizePolicy1.setHeightForWidth(self.cb_download_delay.sizePolicy().hasHeightForWidth()) + self.cb_download_delay.setSizePolicy(sizePolicy1) + + self.lv_flag_download_delay.addWidget(self.cb_download_delay) + + self.lh_flag_3.addLayout(self.lv_flag_download_delay) + + self.lv_flag_extract_flac = QVBoxLayout() + self.lv_flag_extract_flac.setObjectName("lv_flag_extract_flac") + self.cb_extract_flac = QCheckBox(self.gb_flags) + self.cb_extract_flac.setObjectName("cb_extract_flac") + sizePolicy2.setHeightForWidth(self.cb_extract_flac.sizePolicy().hasHeightForWidth()) + self.cb_extract_flac.setSizePolicy(sizePolicy2) + + self.lv_flag_extract_flac.addWidget(self.cb_extract_flac) + + self.lh_flag_3.addLayout(self.lv_flag_extract_flac) + + self.lv_flags.addLayout(self.lh_flag_3) + + self.lh_flags_4 = QHBoxLayout() + self.lh_flags_4.setObjectName("lh_flags_4") + self.lv_flag_metadata_cover_embed = QVBoxLayout() + self.lv_flag_metadata_cover_embed.setObjectName("lv_flag_metadata_cover_embed") + self.cb_metadata_cover_embed = QCheckBox(self.gb_flags) + self.cb_metadata_cover_embed.setObjectName("cb_metadata_cover_embed") + + self.lv_flag_metadata_cover_embed.addWidget(self.cb_metadata_cover_embed) + + self.lh_flags_4.addLayout(self.lv_flag_metadata_cover_embed) + + self.lv_flag_cover_album_file = QVBoxLayout() + self.lv_flag_cover_album_file.setObjectName("lv_flag_cover_album_file") + self.cb_cover_album_file = QCheckBox(self.gb_flags) + self.cb_cover_album_file.setObjectName("cb_cover_album_file") + + self.lv_flag_cover_album_file.addWidget(self.cb_cover_album_file) + + self.lh_flags_4.addLayout(self.lv_flag_cover_album_file) + + self.lv_flags.addLayout(self.lh_flags_4) + + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.lv_flag_skip_existing = QVBoxLayout() + self.lv_flag_skip_existing.setObjectName("lv_flag_skip_existing") + self.cb_skip_existing = QCheckBox(self.gb_flags) + self.cb_skip_existing.setObjectName("cb_skip_existing") + + self.lv_flag_skip_existing.addWidget(self.cb_skip_existing) + + self.horizontalLayout.addLayout(self.lv_flag_skip_existing) + + self.lv_symlink_to_track = QVBoxLayout() + self.lv_symlink_to_track.setObjectName("lv_symlink_to_track") + self.cb_symlink_to_track = QCheckBox(self.gb_flags) + self.cb_symlink_to_track.setObjectName("cb_symlink_to_track") + + self.lv_symlink_to_track.addWidget(self.cb_symlink_to_track) + + self.horizontalLayout.addLayout(self.lv_symlink_to_track) + + self.lv_flags.addLayout(self.horizontalLayout) + + self.horizontalLayout_12 = QHBoxLayout() + self.horizontalLayout_12.setObjectName("horizontalLayout_12") + self.lv_playlist_create = QVBoxLayout() + self.lv_playlist_create.setObjectName("lv_playlist_create") + self.cb_playlist_create = QCheckBox(self.gb_flags) + self.cb_playlist_create.setObjectName("cb_playlist_create") + + self.lv_playlist_create.addWidget(self.cb_playlist_create) + + self.horizontalLayout_12.addLayout(self.lv_playlist_create) + + self.verticalLayout_4 = QVBoxLayout() + self.verticalLayout_4.setObjectName("verticalLayout_4") + + self.horizontalLayout_12.addLayout(self.verticalLayout_4) + + self.lv_flags.addLayout(self.horizontalLayout_12) + + self.lv_main.addWidget(self.gb_flags) + + self.gb_choices = QGroupBox(DialogSettings) + self.gb_choices.setObjectName("gb_choices") + sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + sizePolicy3.setHorizontalStretch(0) + sizePolicy3.setVerticalStretch(0) + sizePolicy3.setHeightForWidth(self.gb_choices.sizePolicy().hasHeightForWidth()) + self.gb_choices.setSizePolicy(sizePolicy3) + self.lv_choices = QVBoxLayout(self.gb_choices) + self.lv_choices.setObjectName("lv_choices") + self.lh_choices_quality_audio = QHBoxLayout() + self.lh_choices_quality_audio.setObjectName("lh_choices_quality_audio") + self.l_icon_quality_audio = QLabel(self.gb_choices) + self.l_icon_quality_audio.setObjectName("l_icon_quality_audio") + sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + sizePolicy4.setHorizontalStretch(0) + sizePolicy4.setVerticalStretch(0) + sizePolicy4.setHeightForWidth(self.l_icon_quality_audio.sizePolicy().hasHeightForWidth()) + self.l_icon_quality_audio.setSizePolicy(sizePolicy4) + + self.lh_choices_quality_audio.addWidget(self.l_icon_quality_audio) + + self.l_quality_audio = QLabel(self.gb_choices) + self.l_quality_audio.setObjectName("l_quality_audio") + sizePolicy4.setHeightForWidth(self.l_quality_audio.sizePolicy().hasHeightForWidth()) + self.l_quality_audio.setSizePolicy(sizePolicy4) + + self.lh_choices_quality_audio.addWidget(self.l_quality_audio) + + self.c_quality_audio = QComboBox(self.gb_choices) + self.c_quality_audio.setObjectName("c_quality_audio") + + self.lh_choices_quality_audio.addWidget(self.c_quality_audio) + + self.lh_choices_quality_audio.setStretch(2, 50) + + self.lv_choices.addLayout(self.lh_choices_quality_audio) + + self.lh_choices_quality_video = QHBoxLayout() + self.lh_choices_quality_video.setObjectName("lh_choices_quality_video") + self.l_icon_quality_video = QLabel(self.gb_choices) + self.l_icon_quality_video.setObjectName("l_icon_quality_video") + sizePolicy4.setHeightForWidth(self.l_icon_quality_video.sizePolicy().hasHeightForWidth()) + self.l_icon_quality_video.setSizePolicy(sizePolicy4) + + self.lh_choices_quality_video.addWidget(self.l_icon_quality_video) + + self.l_quality_video = QLabel(self.gb_choices) + self.l_quality_video.setObjectName("l_quality_video") + sizePolicy4.setHeightForWidth(self.l_quality_video.sizePolicy().hasHeightForWidth()) + self.l_quality_video.setSizePolicy(sizePolicy4) + + self.lh_choices_quality_video.addWidget(self.l_quality_video) + + self.c_quality_video = QComboBox(self.gb_choices) + self.c_quality_video.setObjectName("c_quality_video") + + self.lh_choices_quality_video.addWidget(self.c_quality_video) + + self.lh_choices_quality_video.setStretch(2, 50) + + self.lv_choices.addLayout(self.lh_choices_quality_video) + + self.lh_choices_cover_dimension = QHBoxLayout() + self.lh_choices_cover_dimension.setObjectName("lh_choices_cover_dimension") + self.lh_choices_cover_dimension.setSizeConstraint(QLayout.SizeConstraint.SetDefaultConstraint) + self.l_icon_metadata_cover_dimension = QLabel(self.gb_choices) + self.l_icon_metadata_cover_dimension.setObjectName("l_icon_metadata_cover_dimension") + sizePolicy4.setHeightForWidth(self.l_icon_metadata_cover_dimension.sizePolicy().hasHeightForWidth()) + self.l_icon_metadata_cover_dimension.setSizePolicy(sizePolicy4) + + self.lh_choices_cover_dimension.addWidget(self.l_icon_metadata_cover_dimension) + + self.l_metadata_cover_dimension = QLabel(self.gb_choices) + self.l_metadata_cover_dimension.setObjectName("l_metadata_cover_dimension") + sizePolicy4.setHeightForWidth(self.l_metadata_cover_dimension.sizePolicy().hasHeightForWidth()) + self.l_metadata_cover_dimension.setSizePolicy(sizePolicy4) + + self.lh_choices_cover_dimension.addWidget(self.l_metadata_cover_dimension) + + self.c_metadata_cover_dimension = QComboBox(self.gb_choices) + self.c_metadata_cover_dimension.setObjectName("c_metadata_cover_dimension") + sizePolicy5 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + sizePolicy5.setHorizontalStretch(10) + sizePolicy5.setVerticalStretch(0) + sizePolicy5.setHeightForWidth(self.c_metadata_cover_dimension.sizePolicy().hasHeightForWidth()) + self.c_metadata_cover_dimension.setSizePolicy(sizePolicy5) + + self.lh_choices_cover_dimension.addWidget(self.c_metadata_cover_dimension) + + self.lh_choices_cover_dimension.setStretch(2, 50) + + self.lv_choices.addLayout(self.lh_choices_cover_dimension) + + self.lv_main.addWidget(self.gb_choices) + + self.gb_numbers = QGroupBox(DialogSettings) + self.gb_numbers.setObjectName("gb_numbers") + self.verticalLayout_8 = QVBoxLayout(self.gb_numbers) + self.verticalLayout_8.setObjectName("verticalLayout_8") + self.horizontalLayout_9 = QHBoxLayout() + self.horizontalLayout_9.setObjectName("horizontalLayout_9") + self.l_album_track_num_pad_min = QLabel(self.gb_numbers) + self.l_album_track_num_pad_min.setObjectName("l_album_track_num_pad_min") + + self.horizontalLayout_9.addWidget(self.l_album_track_num_pad_min) + + self.l_icon_album_track_num_pad_min = QLabel(self.gb_numbers) + self.l_icon_album_track_num_pad_min.setObjectName("l_icon_album_track_num_pad_min") + + self.horizontalLayout_9.addWidget(self.l_icon_album_track_num_pad_min) + + self.sb_album_track_num_pad_min = QSpinBox(self.gb_numbers) + self.sb_album_track_num_pad_min.setObjectName("sb_album_track_num_pad_min") + self.sb_album_track_num_pad_min.setMaximum(4) + + self.horizontalLayout_9.addWidget(self.sb_album_track_num_pad_min) + + self.verticalLayout_8.addLayout(self.horizontalLayout_9) + + self.horizontalLayout_11 = QHBoxLayout() + self.horizontalLayout_11.setObjectName("horizontalLayout_11") + self.l_downloads_concurrent_max = QLabel(self.gb_numbers) + self.l_downloads_concurrent_max.setObjectName("l_downloads_concurrent_max") + + self.horizontalLayout_11.addWidget(self.l_downloads_concurrent_max) + + self.l_icon_downloads_concurrent_max = QLabel(self.gb_numbers) + self.l_icon_downloads_concurrent_max.setObjectName("l_icon_downloads_concurrent_max") + + self.horizontalLayout_11.addWidget(self.l_icon_downloads_concurrent_max) + + self.sb_downloads_concurrent_max = QSpinBox(self.gb_numbers) + self.sb_downloads_concurrent_max.setObjectName("sb_downloads_concurrent_max") + self.sb_downloads_concurrent_max.setMinimum(1) + self.sb_downloads_concurrent_max.setMaximum(5) + + self.horizontalLayout_11.addWidget(self.sb_downloads_concurrent_max) + + self.verticalLayout_8.addLayout(self.horizontalLayout_11) + + self.lv_main.addWidget(self.gb_numbers) + + self.gb_path = QGroupBox(DialogSettings) + self.gb_path.setObjectName("gb_path") + self.horizontalLayout_2 = QHBoxLayout(self.gb_path) + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.verticalLayout_2 = QVBoxLayout() + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.lh_path_base = QHBoxLayout() + self.lh_path_base.setObjectName("lh_path_base") + self.l_icon_download_base_path = QLabel(self.gb_path) + self.l_icon_download_base_path.setObjectName("l_icon_download_base_path") + sizePolicy4.setHeightForWidth(self.l_icon_download_base_path.sizePolicy().hasHeightForWidth()) + self.l_icon_download_base_path.setSizePolicy(sizePolicy4) + + self.lh_path_base.addWidget(self.l_icon_download_base_path) + + self.l_download_base_path = QLabel(self.gb_path) + self.l_download_base_path.setObjectName("l_download_base_path") + sizePolicy3.setHeightForWidth(self.l_download_base_path.sizePolicy().hasHeightForWidth()) + self.l_download_base_path.setSizePolicy(sizePolicy3) + + self.lh_path_base.addWidget(self.l_download_base_path) + + self.verticalLayout_2.addLayout(self.lh_path_base) + + self.lh_path_fmt_track = QHBoxLayout() + self.lh_path_fmt_track.setObjectName("lh_path_fmt_track") + self.l_icon_format_track = QLabel(self.gb_path) + self.l_icon_format_track.setObjectName("l_icon_format_track") + sizePolicy4.setHeightForWidth(self.l_icon_format_track.sizePolicy().hasHeightForWidth()) + self.l_icon_format_track.setSizePolicy(sizePolicy4) + + self.lh_path_fmt_track.addWidget(self.l_icon_format_track) + + self.l_format_track = QLabel(self.gb_path) + self.l_format_track.setObjectName("l_format_track") + sizePolicy3.setHeightForWidth(self.l_format_track.sizePolicy().hasHeightForWidth()) + self.l_format_track.setSizePolicy(sizePolicy3) + + self.lh_path_fmt_track.addWidget(self.l_format_track) + + self.verticalLayout_2.addLayout(self.lh_path_fmt_track) + + self.lh_path_fmt_video = QHBoxLayout() + self.lh_path_fmt_video.setObjectName("lh_path_fmt_video") + self.l_icon_format_video = QLabel(self.gb_path) + self.l_icon_format_video.setObjectName("l_icon_format_video") + sizePolicy4.setHeightForWidth(self.l_icon_format_video.sizePolicy().hasHeightForWidth()) + self.l_icon_format_video.setSizePolicy(sizePolicy4) + + self.lh_path_fmt_video.addWidget(self.l_icon_format_video) + + self.l_format_video = QLabel(self.gb_path) + self.l_format_video.setObjectName("l_format_video") + sizePolicy3.setHeightForWidth(self.l_format_video.sizePolicy().hasHeightForWidth()) + self.l_format_video.setSizePolicy(sizePolicy3) + + self.lh_path_fmt_video.addWidget(self.l_format_video) + + self.verticalLayout_2.addLayout(self.lh_path_fmt_video) + + self.lh_path_fmt_album = QHBoxLayout() + self.lh_path_fmt_album.setObjectName("lh_path_fmt_album") + self.l_icon_format_album = QLabel(self.gb_path) + self.l_icon_format_album.setObjectName("l_icon_format_album") + sizePolicy6 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + sizePolicy6.setHorizontalStretch(0) + sizePolicy6.setVerticalStretch(0) + sizePolicy6.setHeightForWidth(self.l_icon_format_album.sizePolicy().hasHeightForWidth()) + self.l_icon_format_album.setSizePolicy(sizePolicy6) + + self.lh_path_fmt_album.addWidget(self.l_icon_format_album) + + self.l_format_album = QLabel(self.gb_path) + self.l_format_album.setObjectName("l_format_album") + sizePolicy3.setHeightForWidth(self.l_format_album.sizePolicy().hasHeightForWidth()) + self.l_format_album.setSizePolicy(sizePolicy3) + + self.lh_path_fmt_album.addWidget(self.l_format_album) + + self.verticalLayout_2.addLayout(self.lh_path_fmt_album) + + self.lh_fpath_mt_playlist = QHBoxLayout() + self.lh_fpath_mt_playlist.setObjectName("lh_fpath_mt_playlist") + self.l_icon_format_playlist = QLabel(self.gb_path) + self.l_icon_format_playlist.setObjectName("l_icon_format_playlist") + sizePolicy4.setHeightForWidth(self.l_icon_format_playlist.sizePolicy().hasHeightForWidth()) + self.l_icon_format_playlist.setSizePolicy(sizePolicy4) + + self.lh_fpath_mt_playlist.addWidget(self.l_icon_format_playlist) + + self.l_format_playlist = QLabel(self.gb_path) + self.l_format_playlist.setObjectName("l_format_playlist") + sizePolicy3.setHeightForWidth(self.l_format_playlist.sizePolicy().hasHeightForWidth()) + self.l_format_playlist.setSizePolicy(sizePolicy3) + + self.lh_fpath_mt_playlist.addWidget(self.l_format_playlist) + + self.verticalLayout_2.addLayout(self.lh_fpath_mt_playlist) + + self.lh_path_fmt_mix = QHBoxLayout() + self.lh_path_fmt_mix.setObjectName("lh_path_fmt_mix") + self.l_icon_format_mix = QLabel(self.gb_path) + self.l_icon_format_mix.setObjectName("l_icon_format_mix") + sizePolicy6.setHeightForWidth(self.l_icon_format_mix.sizePolicy().hasHeightForWidth()) + self.l_icon_format_mix.setSizePolicy(sizePolicy6) + + self.lh_path_fmt_mix.addWidget(self.l_icon_format_mix) + + self.l_format_mix = QLabel(self.gb_path) + self.l_format_mix.setObjectName("l_format_mix") + sizePolicy3.setHeightForWidth(self.l_format_mix.sizePolicy().hasHeightForWidth()) + self.l_format_mix.setSizePolicy(sizePolicy3) + + self.lh_path_fmt_mix.addWidget(self.l_format_mix) + + self.verticalLayout_2.addLayout(self.lh_path_fmt_mix) + + self.lh_path_binary_ffmpeg = QHBoxLayout() + self.lh_path_binary_ffmpeg.setObjectName("lh_path_binary_ffmpeg") + self.l_icon_path_binary_ffmpeg = QLabel(self.gb_path) + self.l_icon_path_binary_ffmpeg.setObjectName("l_icon_path_binary_ffmpeg") + sizePolicy4.setHeightForWidth(self.l_icon_path_binary_ffmpeg.sizePolicy().hasHeightForWidth()) + self.l_icon_path_binary_ffmpeg.setSizePolicy(sizePolicy4) + + self.lh_path_binary_ffmpeg.addWidget(self.l_icon_path_binary_ffmpeg) + + self.l_path_binary_ffmpeg = QLabel(self.gb_path) + self.l_path_binary_ffmpeg.setObjectName("l_path_binary_ffmpeg") + sizePolicy3.setHeightForWidth(self.l_path_binary_ffmpeg.sizePolicy().hasHeightForWidth()) + self.l_path_binary_ffmpeg.setSizePolicy(sizePolicy3) + + self.lh_path_binary_ffmpeg.addWidget(self.l_path_binary_ffmpeg) + + self.verticalLayout_2.addLayout(self.lh_path_binary_ffmpeg) + + self.horizontalLayout_2.addLayout(self.verticalLayout_2) + + self.verticalLayout = QVBoxLayout() + self.verticalLayout.setObjectName("verticalLayout") + self.horizontalLayout_10 = QHBoxLayout() + self.horizontalLayout_10.setObjectName("horizontalLayout_10") + self.le_download_base_path = QLineEdit(self.gb_path) + self.le_download_base_path.setObjectName("le_download_base_path") + sizePolicy2.setHeightForWidth(self.le_download_base_path.sizePolicy().hasHeightForWidth()) + self.le_download_base_path.setSizePolicy(sizePolicy2) + self.le_download_base_path.setDragEnabled(True) + + self.horizontalLayout_10.addWidget(self.le_download_base_path) + + self.pb_download_base_path = QPushButton(self.gb_path) + self.pb_download_base_path.setObjectName("pb_download_base_path") + + self.horizontalLayout_10.addWidget(self.pb_download_base_path) + + self.verticalLayout.addLayout(self.horizontalLayout_10) + + self.horizontalLayout_7 = QHBoxLayout() + self.horizontalLayout_7.setObjectName("horizontalLayout_7") + self.le_format_track = QLineEdit(self.gb_path) + self.le_format_track.setObjectName("le_format_track") + + self.horizontalLayout_7.addWidget(self.le_format_track) + + self.verticalLayout.addLayout(self.horizontalLayout_7) + + self.horizontalLayout_5 = QHBoxLayout() + self.horizontalLayout_5.setObjectName("horizontalLayout_5") + self.le_format_video = QLineEdit(self.gb_path) + self.le_format_video.setObjectName("le_format_video") + + self.horizontalLayout_5.addWidget(self.le_format_video) + + self.verticalLayout.addLayout(self.horizontalLayout_5) + + self.horizontalLayout_6 = QHBoxLayout() + self.horizontalLayout_6.setObjectName("horizontalLayout_6") + self.le_format_album = QLineEdit(self.gb_path) + self.le_format_album.setObjectName("le_format_album") + + self.horizontalLayout_6.addWidget(self.le_format_album) + + self.verticalLayout.addLayout(self.horizontalLayout_6) + + self.horizontalLayout_4 = QHBoxLayout() + self.horizontalLayout_4.setObjectName("horizontalLayout_4") + self.le_format_playlist = QLineEdit(self.gb_path) + self.le_format_playlist.setObjectName("le_format_playlist") + sizePolicy2.setHeightForWidth(self.le_format_playlist.sizePolicy().hasHeightForWidth()) + self.le_format_playlist.setSizePolicy(sizePolicy2) + + self.horizontalLayout_4.addWidget(self.le_format_playlist) + + self.verticalLayout.addLayout(self.horizontalLayout_4) + + self.horizontalLayout_8 = QHBoxLayout() + self.horizontalLayout_8.setObjectName("horizontalLayout_8") + self.le_format_mix = QLineEdit(self.gb_path) + self.le_format_mix.setObjectName("le_format_mix") + + self.horizontalLayout_8.addWidget(self.le_format_mix) + + self.verticalLayout.addLayout(self.horizontalLayout_8) + + self.horizontalLayout_3 = QHBoxLayout() + self.horizontalLayout_3.setObjectName("horizontalLayout_3") + self.le_path_binary_ffmpeg = QLineEdit(self.gb_path) + self.le_path_binary_ffmpeg.setObjectName("le_path_binary_ffmpeg") + sizePolicy2.setHeightForWidth(self.le_path_binary_ffmpeg.sizePolicy().hasHeightForWidth()) + self.le_path_binary_ffmpeg.setSizePolicy(sizePolicy2) + self.le_path_binary_ffmpeg.setDragEnabled(True) + + self.horizontalLayout_3.addWidget(self.le_path_binary_ffmpeg) + + self.pb_path_binary_ffmpeg = QPushButton(self.gb_path) + self.pb_path_binary_ffmpeg.setObjectName("pb_path_binary_ffmpeg") + + self.horizontalLayout_3.addWidget(self.pb_path_binary_ffmpeg) + + self.verticalLayout.addLayout(self.horizontalLayout_3) + + self.horizontalLayout_2.addLayout(self.verticalLayout) + + self.horizontalLayout_2.setStretch(1, 50) + + self.lv_main.addWidget(self.gb_path) + + self.bb_dialog = QDialogButtonBox(DialogSettings) + self.bb_dialog.setObjectName("bb_dialog") + self.bb_dialog.setOrientation(Qt.Orientation.Horizontal) + self.bb_dialog.setStandardButtons(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok) + + self.lv_main.addWidget(self.bb_dialog) + + self.lv_dialog_settings.addLayout(self.lv_main) + + self.retranslateUi(DialogSettings) + self.bb_dialog.accepted.connect(DialogSettings.accept) + self.bb_dialog.rejected.connect(DialogSettings.reject) + + QMetaObject.connectSlotsByName(DialogSettings) + + # setupUi + + def retranslateUi(self, DialogSettings): + DialogSettings.setWindowTitle(QCoreApplication.translate("DialogSettings", "Preferences", None)) + self.gb_flags.setTitle(QCoreApplication.translate("DialogSettings", "Flags", None)) + self.cb_video_download.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_video_convert_mp4.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + # if QT_CONFIG(whatsthis) + self.cb_lyrics_embed.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + self.cb_lyrics_embed.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_lyrics_file.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_download_delay.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_extract_flac.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_metadata_cover_embed.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_cover_album_file.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_skip_existing.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_symlink_to_track.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_playlist_create.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.gb_choices.setTitle(QCoreApplication.translate("DialogSettings", "Choices", None)) + self.l_icon_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_quality_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_quality_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_metadata_cover_dimension.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_metadata_cover_dimension.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.gb_numbers.setTitle(QCoreApplication.translate("DialogSettings", "Numbers", None)) + self.l_album_track_num_pad_min.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_album_track_num_pad_min.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_downloads_concurrent_max.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_downloads_concurrent_max.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.gb_path.setTitle(QCoreApplication.translate("DialogSettings", "Path", None)) + self.l_icon_download_base_path.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_download_base_path.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_format_track.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_format_track.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_format_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_format_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_format_album.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_format_album.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_format_playlist.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_format_playlist.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_format_mix.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_format_mix.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_path_binary_ffmpeg.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_path_binary_ffmpeg.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.pb_download_base_path.setText(QCoreApplication.translate("DialogSettings", "...", None)) + self.pb_path_binary_ffmpeg.setText(QCoreApplication.translate("DialogSettings", "...", None)) + + # retranslateUi diff --git a/tidal_dl_ng/ui/dialog_settings.ui b/tidal_dl_ng/ui/dialog_settings.ui new file mode 100644 index 0000000..5287e38 --- /dev/null +++ b/tidal_dl_ng/ui/dialog_settings.ui @@ -0,0 +1,812 @@ + + + DialogSettings + + + + 0 + 0 + 640 + 800 + + + + + 100 + 100 + + + + Preferences + + + true + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 12 + + + 12 + + + 12 + + + 12 + + + + + + 100 + 100 + + + + Flags + + + false + + + false + + + + + + + + + + + 100 + 100 + + + + CheckBox + + + + + + + + + + + + 100 + 100 + + + + CheckBox + + + + + + + + + + + + + + + + 0 + 0 + + + + + + + CheckBox + + + + + + + + + + + + 100 + 100 + + + + CheckBox + + + + + + + + + + + + + + + + 100 + 100 + + + + CheckBox + + + + + + + + + + + + 0 + 0 + + + + CheckBox + + + + + + + + + + + + + + + CheckBox + + + + + + + + + + + CheckBox + + + + + + + + + + + + + + + CheckBox + + + + + + + + + + + CheckBox + + + + + + + + + + + + + + + CheckBox + + + + + + + + + + + + + + + + + + 0 + 0 + + + + Choices + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + QLayout::SizeConstraint::SetDefaultConstraint + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 10 + 0 + + + + + + + + + + + + + Numbers + + + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + 4 + + + + + + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + 1 + + + 5 + + + + + + + + + + + + Path + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + + + + + 0 + 0 + + + + true + + + + + + + ... + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + true + + + + + + + ... + + + + + + + + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + + + + bb_dialog + accepted() + DialogSettings + accept() + + + 319 + 661 + + + 319 + 340 + + + + + bb_dialog + rejected() + DialogSettings + reject() + + + 319 + 661 + + + 319 + 340 + + + + + diff --git a/tidal_dl_ng/ui/dialog_version.py b/tidal_dl_ng/ui/dialog_version.py new file mode 100644 index 0000000..b8cf294 --- /dev/null +++ b/tidal_dl_ng/ui/dialog_version.py @@ -0,0 +1,161 @@ +################################################################################ +## Form generated from reading UI file 'dialog_version.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, QMetaObject, QSize, Qt +from PySide6.QtGui import QFont +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSizePolicy, QSpacerItem, QVBoxLayout + + +class Ui_DialogVersion: + def setupUi(self, DialogVersion): + if not DialogVersion.objectName(): + DialogVersion.setObjectName("DialogVersion") + DialogVersion.resize(436, 235) + sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(DialogVersion.sizePolicy().hasHeightForWidth()) + DialogVersion.setSizePolicy(sizePolicy) + DialogVersion.setMaximumSize(QSize(436, 235)) + self.verticalLayout = QVBoxLayout(DialogVersion) + self.verticalLayout.setObjectName("verticalLayout") + self.l_name_app = QLabel(DialogVersion) + self.l_name_app.setObjectName("l_name_app") + sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + sizePolicy1.setHorizontalStretch(0) + sizePolicy1.setVerticalStretch(0) + sizePolicy1.setHeightForWidth(self.l_name_app.sizePolicy().hasHeightForWidth()) + self.l_name_app.setSizePolicy(sizePolicy1) + font = QFont() + font.setBold(True) + self.l_name_app.setFont(font) + self.l_name_app.setAlignment(Qt.AlignCenter) + self.l_name_app.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.verticalLayout.addWidget(self.l_name_app) + + self.lv_version = QVBoxLayout() + self.lv_version.setObjectName("lv_version") + self.lh_version = QHBoxLayout() + self.lh_version.setObjectName("lh_version") + self.l_h_version = QLabel(DialogVersion) + self.l_h_version.setObjectName("l_h_version") + self.l_h_version.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lh_version.addWidget(self.l_h_version) + + self.l_version = QLabel(DialogVersion) + self.l_version.setObjectName("l_version") + self.l_version.setFont(font) + self.l_version.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lh_version.addWidget(self.l_version) + + self.lv_version.addLayout(self.lh_version) + + self.verticalLayout.addLayout(self.lv_version) + + self.lv_update = QVBoxLayout() + self.lv_update.setObjectName("lv_update") + self.l_error = QLabel(DialogVersion) + self.l_error.setObjectName("l_error") + self.l_error.setFont(font) + self.l_error.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lv_update.addWidget(self.l_error) + + self.l_error_details = QLabel(DialogVersion) + self.l_error_details.setObjectName("l_error_details") + self.l_error_details.setFont(font) + self.l_error_details.setAlignment(Qt.AlignCenter) + self.l_error_details.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lv_update.addWidget(self.l_error_details) + + self.lh_update_version = QHBoxLayout() + self.lh_update_version.setObjectName("lh_update_version") + self.l_h_version_new = QLabel(DialogVersion) + self.l_h_version_new.setObjectName("l_h_version_new") + self.l_h_version_new.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lh_update_version.addWidget(self.l_h_version_new) + + self.l_version_new = QLabel(DialogVersion) + self.l_version_new.setObjectName("l_version_new") + self.l_version_new.setFont(font) + self.l_version_new.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lh_update_version.addWidget(self.l_version_new) + + self.lv_update.addLayout(self.lh_update_version) + + self.l_changelog = QLabel(DialogVersion) + self.l_changelog.setObjectName("l_changelog") + self.l_changelog.setFont(font) + self.l_changelog.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lv_update.addWidget(self.l_changelog) + + self.l_changelog_details = QLabel(DialogVersion) + self.l_changelog_details.setObjectName("l_changelog_details") + self.l_changelog_details.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lv_update.addWidget(self.l_changelog_details) + + self.lv_download = QHBoxLayout() + self.lv_download.setObjectName("lv_download") + self.lv_download.setContentsMargins(-1, 20, -1, -1) + self.pb_download = QPushButton(DialogVersion) + self.pb_download.setObjectName("pb_download") + self.pb_download.setFlat(False) + + self.lv_download.addWidget(self.pb_download) + + self.sh_download = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + + self.lv_download.addItem(self.sh_download) + + self.lv_update.addLayout(self.lv_download) + + self.verticalLayout.addLayout(self.lv_update) + + self.l_url_github = QLabel(DialogVersion) + self.l_url_github.setObjectName("l_url_github") + self.l_url_github.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter) + self.l_url_github.setOpenExternalLinks(True) + self.l_url_github.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.verticalLayout.addWidget(self.l_url_github) + + self.retranslateUi(DialogVersion) + + QMetaObject.connectSlotsByName(DialogVersion) + + # setupUi + + def retranslateUi(self, DialogVersion): + DialogVersion.setWindowTitle(QCoreApplication.translate("DialogVersion", "Version", None)) + self.l_name_app.setText(QCoreApplication.translate("DialogVersion", "TIDAL Downloader Next Generation!", None)) + self.l_h_version.setText(QCoreApplication.translate("DialogVersion", "Installed Version:", None)) + self.l_version.setText(QCoreApplication.translate("DialogVersion", "v1.2.3", None)) + self.l_error.setText(QCoreApplication.translate("DialogVersion", "ERROR", None)) + self.l_error_details.setText(QCoreApplication.translate("DialogVersion", "", None)) + self.l_h_version_new.setText(QCoreApplication.translate("DialogVersion", "New Version Available:", None)) + self.l_version_new.setText(QCoreApplication.translate("DialogVersion", "v0.0.0", None)) + self.l_changelog.setText(QCoreApplication.translate("DialogVersion", "Changelog", None)) + self.l_changelog_details.setText(QCoreApplication.translate("DialogVersion", "", None)) + self.pb_download.setText(QCoreApplication.translate("DialogVersion", "Download", None)) + self.l_url_github.setText( + QCoreApplication.translate( + "DialogVersion", + 'https://github.com/exislow/tidal-dl-ng/', + None, + ) + ) + + # retranslateUi diff --git a/tidal_dl_ng/ui/dialog_version.ui b/tidal_dl_ng/ui/dialog_version.ui new file mode 100644 index 0000000..0cea9bd --- /dev/null +++ b/tidal_dl_ng/ui/dialog_version.ui @@ -0,0 +1,227 @@ + + + DialogVersion + + + + 0 + 0 + 436 + 235 + + + + + 0 + 0 + + + + + 436 + 235 + + + + Version + + + + + + + 0 + 0 + + + + + true + + + + TIDAL Downloader Next Generation! + + + Qt::AlignCenter + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + + + Installed Version: + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + true + + + + v1.2.3 + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + + + + + + true + + + + ERROR + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + true + + + + <ERROR> + + + Qt::AlignCenter + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + New Version Available: + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + true + + + + v0.0.0 + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + + true + + + + Changelog + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + <CHANGELOG> + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + 20 + + + + + Download + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + <a href="https://github.com/exislow/tidal-dl-ng/">https://github.com/exislow/tidal-dl-ng/</a> + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + diff --git a/tidal_dl_ng/ui/dummy_register.py b/tidal_dl_ng/ui/dummy_register.py new file mode 100644 index 0000000..dfffdc2 --- /dev/null +++ b/tidal_dl_ng/ui/dummy_register.py @@ -0,0 +1,33 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + +from .dummy_wiggly import WigglyWidget + +# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin + + +TOOLTIP = "A cool wiggly widget (Python)" +DOM_XML = """ + + + + + 0 + 0 + 400 + 200 + + + + Hello, world + + + +""" + +if __name__ == "__main__": + QPyDesignerCustomWidgetCollection.registerCustomWidget( + WigglyWidget, module="wigglywidget", tool_tip=TOOLTIP, xml=DOM_XML + ) diff --git a/tidal_dl_ng/ui/dummy_wiggly.py b/tidal_dl_ng/ui/dummy_wiggly.py new file mode 100644 index 0000000..e6eb3d3 --- /dev/null +++ b/tidal_dl_ng/ui/dummy_wiggly.py @@ -0,0 +1,68 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from PySide6.QtCore import Property, QBasicTimer +from PySide6.QtGui import QColor, QFontMetrics, QPainter, QPalette +from PySide6.QtWidgets import QWidget + + +class WigglyWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._step = 0 + self._text = "" + self.setBackgroundRole(QPalette.Midlight) + self.setAutoFillBackground(True) + + new_font = self.font() + new_font.setPointSize(new_font.pointSize() + 20) + self.setFont(new_font) + self._timer = QBasicTimer() + + def isRunning(self): + return self._timer.isActive() + + def setRunning(self, r): + if r == self.isRunning(): + return + if r: + self._timer.start(60, self) + else: + self._timer.stop() + + def paintEvent(self, event): + if not self._text: + return + + sineTable = [0, 38, 71, 92, 100, 92, 71, 38, 0, -38, -71, -92, -100, -92, -71, -38] + + metrics = QFontMetrics(self.font()) + x = (self.width() - metrics.horizontalAdvance(self.text)) / 2 + y = (self.height() + metrics.ascent() - metrics.descent()) / 2 + color = QColor() + + with QPainter(self) as painter: + for i in range(len(self.text)): + index = (self._step + i) % 16 + color.setHsv((15 - index) * 16, 255, 191) + painter.setPen(color) + dy = (sineTable[index] * metrics.height()) / 400 + c = self._text[i] + painter.drawText(x, y - dy, str(c)) + x += metrics.horizontalAdvance(c) + + def timerEvent(self, event): + if event.timerId() == self._timer.timerId(): + self._step += 1 + self.update() + else: + QWidget.timerEvent(event) + + def text(self): + return self._text + + def setText(self, text): + self._text = text + + running = Property(bool, isRunning, setRunning) + text = Property(str, text, setText) diff --git a/tidal_dl_ng/ui/icon.icns b/tidal_dl_ng/ui/icon.icns new file mode 100644 index 0000000..e41a4f3 Binary files /dev/null and b/tidal_dl_ng/ui/icon.icns differ diff --git a/tidal_dl_ng/ui/icon.ico b/tidal_dl_ng/ui/icon.ico new file mode 100644 index 0000000..177f3dc Binary files /dev/null and b/tidal_dl_ng/ui/icon.ico differ diff --git a/tidal_dl_ng/ui/icon.png b/tidal_dl_ng/ui/icon.png new file mode 100644 index 0000000..8bc9e0d Binary files /dev/null and b/tidal_dl_ng/ui/icon.png differ diff --git a/tidal_dl_ng/ui/main.py b/tidal_dl_ng/ui/main.py new file mode 100644 index 0000000..9c1525d --- /dev/null +++ b/tidal_dl_ng/ui/main.py @@ -0,0 +1,588 @@ +################################################################################ +## Form generated from reading UI file 'main.ui' +## +## Created by: Qt User Interface Compiler version 6.7.0 +## +## 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, QFont, QPixmap +from PySide6.QtWidgets import ( + QAbstractItemView, + QComboBox, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QMenu, + QMenuBar, + QPlainTextEdit, + QPushButton, + QSizePolicy, + QStatusBar, + QTreeView, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + + +class Ui_MainWindow: + def setupUi(self, MainWindow): + if not MainWindow.objectName(): + MainWindow.setObjectName("MainWindow") + MainWindow.resize(1200, 800) + self.a_preferences = QAction(MainWindow) + self.a_preferences.setObjectName("a_preferences") + self.a_preferences.setEnabled(True) + self.a_preferences.setText("Preferences...") + self.a_preferences.setIconText("Preferences...") + # if QT_CONFIG(tooltip) + self.a_preferences.setToolTip("Preferences...") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.a_preferences.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.a_preferences.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + self.a_version = QAction(MainWindow) + self.a_version.setObjectName("a_version") + self.a_exit = QAction(MainWindow) + self.a_exit.setObjectName("a_exit") + self.a_logout = QAction(MainWindow) + self.a_logout.setObjectName("a_logout") + self.a_updates_check = QAction(MainWindow) + self.a_updates_check.setObjectName("a_updates_check") + self.w_central = QWidget(MainWindow) + self.w_central.setObjectName("w_central") + self.w_central.setEnabled(True) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.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.lv_list_user = QVBoxLayout() + self.lv_list_user.setObjectName("lv_list_user") + self.tr_lists_user = QTreeWidget(self.w_central) + __qtreewidgetitem = QTreeWidgetItem() + __qtreewidgetitem.setText(2, "Info") + __qtreewidgetitem.setText(0, "Name") + 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.setSelectionMode(QAbstractItemView.ExtendedSelection) + 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.lv_list_user.addWidget(self.tr_lists_user) + + self.lv_list_control = QHBoxLayout() + self.lv_list_control.setObjectName("lv_list_control") + self.pb_reload_user_lists = QPushButton(self.w_central) + self.pb_reload_user_lists.setObjectName("pb_reload_user_lists") + sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + sizePolicy1.setHorizontalStretch(0) + sizePolicy1.setVerticalStretch(0) + sizePolicy1.setHeightForWidth(self.pb_reload_user_lists.sizePolicy().hasHeightForWidth()) + self.pb_reload_user_lists.setSizePolicy(sizePolicy1) + + self.lv_list_control.addWidget(self.pb_reload_user_lists) + + self.pb_download_list = QPushButton(self.w_central) + self.pb_download_list.setObjectName("pb_download_list") + sizePolicy1.setHeightForWidth(self.pb_download_list.sizePolicy().hasHeightForWidth()) + self.pb_download_list.setSizePolicy(sizePolicy1) + + self.lv_list_control.addWidget(self.pb_download_list) + + self.lv_list_user.addLayout(self.lv_list_control) + + self.horizontalLayout.addLayout(self.lv_list_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") + self.cb_search_type.setMinimumSize(QSize(100, 0)) + # 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.setSizeAdjustPolicy(QComboBox.AdjustToContentsOnFirstShow) + self.cb_search_type.setPlaceholderText("") + + self.lh_search.addWidget(self.cb_search_type) + + self.pb_search = QPushButton(self.w_central) + self.pb_search.setObjectName("pb_search") + # if QT_CONFIG(statustip) + self.pb_search.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.pb_search.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.pb_search.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.pb_search.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.pb_search.setText("Search") + # if QT_CONFIG(shortcut) + self.pb_search.setShortcut("") + # endif // QT_CONFIG(shortcut) + + self.lh_search.addWidget(self.pb_search) + + self.lv_search_result.addLayout(self.lh_search) + + self.tr_results = QTreeView(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.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") + self.cb_quality_audio.setMinimumSize(QSize(140, 0)) + # 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.setSizeAdjustPolicy(QComboBox.AdjustToContentsOnFirstShow) + 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") + self.cb_quality_video.setMinimumSize(QSize(100, 0)) + # 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.setSizeAdjustPolicy(QComboBox.AdjustToContentsOnFirstShow) + self.cb_quality_video.setPlaceholderText("") + + self.lh_download.addWidget(self.cb_quality_video) + + self.pb_download = QPushButton(self.w_central) + self.pb_download.setObjectName("pb_download") + # if QT_CONFIG(tooltip) + self.pb_download.setToolTip("") + # endif // QT_CONFIG(tooltip) + # if QT_CONFIG(statustip) + self.pb_download.setStatusTip("") + # endif // QT_CONFIG(statustip) + # if QT_CONFIG(whatsthis) + self.pb_download.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + # if QT_CONFIG(accessibility) + self.pb_download.setAccessibleName("") + # endif // QT_CONFIG(accessibility) + # if QT_CONFIG(accessibility) + self.pb_download.setAccessibleDescription("") + # endif // QT_CONFIG(accessibility) + self.pb_download.setText("Download") + # if QT_CONFIG(shortcut) + self.pb_download.setShortcut("") + # endif // QT_CONFIG(shortcut) + + self.lh_download.addWidget(self.pb_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) + sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) + sizePolicy2.setHorizontalStretch(0) + sizePolicy2.setVerticalStretch(0) + sizePolicy2.setHeightForWidth(self.te_debug.sizePolicy().hasHeightForWidth()) + self.te_debug.setSizePolicy(sizePolicy2) + 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.horizontalLayout.addLayout(self.lv_search_result) + + self.lv_info = QVBoxLayout() + self.lv_info.setObjectName("lv_info") + self.lv_info_item = QVBoxLayout() + self.lv_info_item.setObjectName("lv_info_item") + self.horizontalLayout_2 = QHBoxLayout() + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.l_pm_cover = QLabel(self.w_central) + self.l_pm_cover.setObjectName("l_pm_cover") + sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + sizePolicy3.setHorizontalStretch(0) + sizePolicy3.setVerticalStretch(0) + sizePolicy3.setHeightForWidth(self.l_pm_cover.sizePolicy().hasHeightForWidth()) + self.l_pm_cover.setSizePolicy(sizePolicy3) + self.l_pm_cover.setMaximumSize(QSize(280, 280)) + self.l_pm_cover.setBaseSize(QSize(0, 0)) + self.l_pm_cover.setFrameShape(QFrame.NoFrame) + self.l_pm_cover.setPixmap(QPixmap("default_album_image.png")) + self.l_pm_cover.setScaledContents(True) + self.l_pm_cover.setAlignment(Qt.AlignHCenter | Qt.AlignTop) + + self.horizontalLayout_2.addWidget(self.l_pm_cover) + + self.lv_info_item.addLayout(self.horizontalLayout_2) + + self.lv_info.addLayout(self.lv_info_item) + + self.lv_queue_download = QVBoxLayout() + self.lv_queue_download.setObjectName("lv_queue_download") + self.l_h_queue_download = QLabel(self.w_central) + self.l_h_queue_download.setObjectName("l_h_queue_download") + font = QFont() + font.setBold(True) + font.setItalic(False) + font.setKerning(True) + self.l_h_queue_download.setFont(font) + + self.lv_queue_download.addWidget(self.l_h_queue_download) + + self.tr_queue_download = QTreeWidget(self.w_central) + __qtreewidgetitem4 = QTreeWidgetItem() + __qtreewidgetitem4.setText(0, "\ud83e\uddd1\u200d\ud83d\udcbb\ufe0f") + self.tr_queue_download.setHeaderItem(__qtreewidgetitem4) + self.tr_queue_download.setObjectName("tr_queue_download") + self.tr_queue_download.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.tr_queue_download.setTabKeyNavigation(False) + self.tr_queue_download.setProperty("showDropIndicator", False) + self.tr_queue_download.setDragDropOverwriteMode(False) + self.tr_queue_download.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.tr_queue_download.setSelectionBehavior(QAbstractItemView.SelectRows) + self.tr_queue_download.setRootIsDecorated(False) + self.tr_queue_download.setItemsExpandable(False) + self.tr_queue_download.setSortingEnabled(False) + self.tr_queue_download.setExpandsOnDoubleClick(False) + self.tr_queue_download.header().setProperty("showSortIndicator", False) + self.tr_queue_download.header().setStretchLastSection(False) + + self.lv_queue_download.addWidget(self.tr_queue_download) + + self.pb_queue_download_remove = QPushButton(self.w_central) + self.pb_queue_download_remove.setObjectName("pb_queue_download_remove") + self.pb_queue_download_remove.setEnabled(True) + + self.lv_queue_download.addWidget(self.pb_queue_download_remove) + + self.horizontalLayout_3 = QHBoxLayout() + self.horizontalLayout_3.setObjectName("horizontalLayout_3") + self.pb_queue_download_clear_finished = QPushButton(self.w_central) + self.pb_queue_download_clear_finished.setObjectName("pb_queue_download_clear_finished") + + self.horizontalLayout_3.addWidget(self.pb_queue_download_clear_finished) + + self.pb_queue_download_clear_all = QPushButton(self.w_central) + self.pb_queue_download_clear_all.setObjectName("pb_queue_download_clear_all") + + self.horizontalLayout_3.addWidget(self.pb_queue_download_clear_all) + + self.lv_queue_download.addLayout(self.horizontalLayout_3) + + self.lv_info.addLayout(self.lv_queue_download) + + self.horizontalLayout.addLayout(self.lv_info) + + self.horizontalLayout.setStretch(1, 50) + self.horizontalLayout.setStretch(2, 25) + MainWindow.setCentralWidget(self.w_central) + self.menubar = QMenuBar(MainWindow) + self.menubar.setObjectName("menubar") + self.menubar.setGeometry(QRect(0, 0, 1200, 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) + self.m_help = QMenu(self.menubar) + self.m_help.setObjectName("m_help") + 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.menubar.addAction(self.m_help.menuAction()) + self.m_file.addAction(self.a_preferences) + self.m_file.addAction(self.a_logout) + self.m_file.addAction(self.a_exit) + self.m_help.addAction(self.a_version) + self.m_help.addAction(self.a_updates_check) + + self.retranslateUi(MainWindow) + + QMetaObject.connectSlotsByName(MainWindow) + + # setupUi + + def retranslateUi(self, MainWindow): + MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", "MainWindow", None)) + self.a_version.setText(QCoreApplication.translate("MainWindow", "Version", None)) + self.a_exit.setText(QCoreApplication.translate("MainWindow", "Exit", None)) + self.a_logout.setText(QCoreApplication.translate("MainWindow", "Logout", None)) + self.a_updates_check.setText(QCoreApplication.translate("MainWindow", "Check for Updates", None)) + ___qtreewidgetitem = self.tr_lists_user.headerItem() + ___qtreewidgetitem.setText(1, 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) + + self.pb_reload_user_lists.setText(QCoreApplication.translate("MainWindow", "Reload", None)) + self.pb_download_list.setText(QCoreApplication.translate("MainWindow", "Download List", None)) + self.te_debug.setPlaceholderText(QCoreApplication.translate("MainWindow", "Logs...", None)) + self.l_pm_cover.setText("") + self.l_h_queue_download.setText(QCoreApplication.translate("MainWindow", "Download Queue", None)) + ___qtreewidgetitem4 = self.tr_queue_download.headerItem() + ___qtreewidgetitem4.setText(4, QCoreApplication.translate("MainWindow", "Quality", None)) + ___qtreewidgetitem4.setText(3, QCoreApplication.translate("MainWindow", "Type", None)) + ___qtreewidgetitem4.setText(2, QCoreApplication.translate("MainWindow", "Name", None)) + ___qtreewidgetitem4.setText(1, QCoreApplication.translate("MainWindow", "obj", None)) + self.pb_queue_download_remove.setText(QCoreApplication.translate("MainWindow", "Remove", None)) + self.pb_queue_download_clear_finished.setText(QCoreApplication.translate("MainWindow", "Clear Finished", None)) + self.pb_queue_download_clear_all.setText(QCoreApplication.translate("MainWindow", "Clear All", None)) + self.m_file.setTitle(QCoreApplication.translate("MainWindow", "File", None)) + self.m_help.setTitle(QCoreApplication.translate("MainWindow", "Help", None)) + + # retranslateUi diff --git a/tidal_dl_ng/ui/main.ui b/tidal_dl_ng/ui/main.ui new file mode 100644 index 0000000..0ad3693 --- /dev/null +++ b/tidal_dl_ng/ui/main.ui @@ -0,0 +1,801 @@ + + + MainWindow + + + + 0 + 0 + 1200 + 800 + + + + MainWindow + + + + true + + + + 100 + 100 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + QAbstractItemView::ExtendedSelection + + + 10 + + + true + + + true + + + true + + + true + + + true + + + + Name + + + + + + + + + + + + + + obj + + + + + Info + + + + + + + + + + + + + + Playlists + + + + + + + + + ItemIsEnabled + + + + + Mixes + + + + + + + + + ItemIsEnabled + + + + + Favorites + + + + + + + + + ItemIsEnabled + + + + + + + + + + + 0 + 0 + + + + Reload + + + + + + + + 0 + 0 + + + + Download List + + + + + + + + + + + -1 + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + Type and press ENTER to search... + + + true + + + + + + + + 100 + 0 + + + + + + + + + + + + + + + + + + + + + + QComboBox::AdjustToContentsOnFirstShow + + + + + + + + + + + + + + + + + + + + + + Search + + + + + + + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + false + + + false + + + QAbstractItemView::ExtendedSelection + + + 10 + + + true + + + + + + + + + + + + + + + + + + + + + + + + Audio + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 140 + 0 + + + + + + + + + + + + + + + + + + + + + + QComboBox::AdjustToContentsOnFirstShow + + + + + + true + + + + + + + + + + + + + + + + + + + + + + Video + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 100 + 0 + + + + + + + + + + + + + + + + + + + + + + QComboBox::AdjustToContentsOnFirstShow + + + + + + + + + + + + + + + + + + + + + + + + + Download + + + + + + + + + + + + true + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + false + + + + + + + + + + + + + + + + + + false + + + true + + + Logs... + + + + + + + + + + + + + + + + 0 + 0 + + + + + 280 + 280 + + + + + 0 + 0 + + + + QFrame::NoFrame + + + + + + default_album_image.png + + + true + + + Qt::AlignHCenter|Qt::AlignTop + + + + + + + + + + + + + + false + true + true + + + + Download Queue + + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + false + + + false + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + false + + + false + + + false + + + false + + + false + + + + 🧑‍💻️ + + + + + obj + + + + + Name + + + + + Type + + + + + Quality + + + + + + + + true + + + Remove + + + + + + + + + Clear Finished + + + + + + + Clear All + + + + + + + + + + + + + + + 0 + 0 + 1200 + 24 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + File + + + + + + + + Help + + + + + + + + + + + + + + + + + + + + + + + + + Qt::LeftToRight + + + + + true + + + Preferences... + + + Preferences... + + + Preferences... + + + + + + + + + + + Version + + + + + Exit + + + + + Logout + + + + + Check for Updates + + + + + + 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..a02b0f1 --- /dev/null +++ b/tidal_dl_ng/worker.py @@ -0,0 +1,34 @@ +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) + + def thread(self) -> QtCore.QThread: + return QtCore.QThread.currentThread()