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