This commit is contained in:
2025-12-02 14:07:35 +01:00
commit 9b84566eb4
62 changed files with 12861 additions and 0 deletions

145
tidal_dl_ng/__init__.py Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python
import importlib.metadata
from pathlib import Path
from urllib.parse import urlparse
import requests
import toml
from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC
from tidal_dl_ng.model.meta import ProjectInformation, ReleaseLatest
def metadata_project() -> ProjectInformation:
result: ProjectInformation
file_path: Path = Path(__file__)
tmp_result: dict = {}
paths: list[Path] = [
file_path.parent,
file_path.parent.parent,
file_path.parent.parent.parent,
]
for pyproject_toml_dir in paths:
pyproject_toml_file: Path = pyproject_toml_dir / "pyproject.toml"
if pyproject_toml_file.is_file():
tmp_result = toml.load(pyproject_toml_file)
break
if tmp_result:
result = ProjectInformation(
version=tmp_result["project"]["version"], repository_url=tmp_result["project"]["urls"]["repository"]
)
else:
try:
meta_info = importlib.metadata.metadata(name_package())
repo_url = meta_info["Home-page"]
if not repo_url:
urls = meta_info.get_all("Project-URL")
# attempt to parse, else use hardcoded fallback
repo_url = next(
(url.split(", ")[1] for url in urls if url.startswith("Repository")),
"https://github.com/exislow/tidal-dl-ng",
)
result = ProjectInformation(version=meta_info["Version"], repository_url=repo_url)
except Exception:
result = ProjectInformation(version="0.0.0", repository_url="https://anerroroccur.ed/sorry/for/that")
return result
def version_app() -> str:
metadata: ProjectInformation = metadata_project()
version: str = metadata.version
return version
def repository_url() -> str:
metadata: ProjectInformation = metadata_project()
url_repo: str = metadata.repository_url
return url_repo
def repository_path() -> str:
url_repo: str = repository_url()
url_path: str = urlparse(url_repo).path
return url_path
def latest_version_information() -> ReleaseLatest:
release_info: ReleaseLatest
repo_path: str = repository_path()
url: str = f"https://api.github.com/repos{repo_path}/releases/latest"
try:
response = requests.get(url, timeout=REQUESTS_TIMEOUT_SEC)
response.raise_for_status()
release_info_json: dict = response.json()
release_info = ReleaseLatest(
version=release_info_json["tag_name"],
url=release_info_json["html_url"],
release_info=release_info_json["body"],
)
except (requests.RequestException, KeyError, ValueError):
release_info = ReleaseLatest(
version="v0.0.0",
url=url,
release_info=f"Something went wrong calling {url}. Check your internet connection.",
)
return release_info
def name_package() -> str:
package_name: str = __package__ or __name__
return package_name
def is_dev_env() -> bool:
package_name: str = name_package()
result: bool = False
# Check if package is running from source code == dev mode
# If package is not running in Nuitka environment, try to import it from pip libraries.
# If this also fails, it is dev mode.
if "__compiled__" not in globals():
try:
importlib.metadata.version(package_name)
except Exception:
# If package is not installed
result = True
return result
def name_app() -> str:
app_name: str = name_package()
is_dev: bool = is_dev_env()
if is_dev:
app_name += "-dev"
return app_name
__name_display__ = name_app()
__version__ = version_app()
def update_available() -> tuple[bool, ReleaseLatest]:
latest_info: ReleaseLatest = latest_version_information()
version_current: str = f"v{__version__}"
result = version_current not in [latest_info.version, "v0.0.0"]
return result, latest_info

121
tidal_dl_ng/api.py Normal file
View File

@@ -0,0 +1,121 @@
import json
import requests
# See also
# https://github.com/yaronzz/Tidal-Media-Downloader/commit/1d5b8cd8f65fd1def45d6406778248249d6dfbdf
# https://github.com/yaronzz/Tidal-Media-Downloader/pull/840
# https://github.com/nathom/streamrip/tree/main/streamrip
# https://github.com/arnesongit/plugin.audio.tidal2/blob/e9429d601d0c303d775d05a19a66415b57479d87/resources/lib/tidal2/tidalapi/__init__.py#L86
# TODO: Implement this into `Download`: Session should randomize the usage.
__KEYS_JSON__ = """
{
"version": "1.0.1",
"keys": [
// Invalid
{
"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)"
},
// Only max MQA.
{
"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)"
},
// Invalid
{
"platform": "Android TV",
"formats": "Normal/High/HiFi(No Master)",
"clientId": "Pzd0ExNVHkyZLiYN",
"clientSecret": "W7X6UvBaho+XOi1MUeCX6ewv2zTdSOV3Y7qC3p3675I=",
"valid": "False",
"from": ""
},
// Invalid
{
"platform": "TV",
"formats": "Normal/High/HiFi/Master",
"clientId": "8SEZWa4J1NVC5U5Y",
"clientSecret": "owUYDkxddz+9FpvGX24DlxECNtFEMBxipU0lBfrbq60=",
"valid": "False",
"from": "morguldir (https://github.com/morguldir/python-tidal/commit/50f1afcd2079efb2b4cf694ef5a7d67fdf619d09)"
},
// Invalid
{
"platform": "Android Auto",
"formats": "Normal/High/HiFi/Master",
"clientId": "zU4XHVVkc2tDPo4t",
"clientSecret": "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4=",
"valid": "True",
"from": "1nikolas (https://github.com/yaronzz/Tidal-Media-Downloader/pull/840)"
}
]
}
"""
__API_KEYS__ = json.loads(__KEYS_JSON__)
__ERROR_KEY__ = (
{
"platform": "None",
"formats": "",
"clientId": "",
"clientSecret": "",
"valid": "False",
},
)
from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC
def getNum():
return len(__API_KEYS__["keys"])
def getItem(index: int):
if index < 0 or index >= len(__API_KEYS__["keys"]):
return __ERROR_KEY__
return __API_KEYS__["keys"][index]
def isItemValid(index: int):
item = getItem(index)
return item["valid"] == "True"
def getItems():
return __API_KEYS__["keys"]
def getLimitIndexs():
array = []
for i in range(len(__API_KEYS__["keys"])):
array.append(str(i))
return array
def getVersion():
return __API_KEYS__["version"]
# Load from gist
try:
respond = requests.get(
"https://api.github.com/gists/48d01f5a24b4b7b37f19443977c22cd6", timeout=REQUESTS_TIMEOUT_SEC
)
respond.raise_for_status()
if respond.status_code == 200:
content = respond.json()["files"]["tidal-api-key.json"]["content"]
__API_KEYS__ = json.loads(content)
except requests.RequestException as e:
# Failed to load API keys from gist, will use fallback keys
print(f"Failed to load API keys from gist: {e}")
pass

536
tidal_dl_ng/cli.py Normal file
View File

@@ -0,0 +1,536 @@
#!/usr/bin/env python
import signal
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Annotated
from urllib.parse import urlparse
import typer
from rich.console import Console, Group
from rich.live import Live
from rich.progress import (
BarColumn,
Progress,
SpinnerColumn,
TaskProgressColumn,
TextColumn,
)
from rich.table import Table
from tidal_dl_ng import __version__
from tidal_dl_ng.config import HandlingApp, Settings, Tidal
from tidal_dl_ng.constants import CTX_TIDAL, MediaType
from tidal_dl_ng.download import Download
from tidal_dl_ng.helper.path import get_format_template, path_file_settings
from tidal_dl_ng.helper.tidal import (
all_artist_album_ids,
get_tidal_media_id,
get_tidal_media_type,
instantiate_media,
url_ending_clean,
)
from tidal_dl_ng.helper.wrapper import LoggerWrapped
from tidal_dl_ng.model.cfg import HelpSettings
app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]}, add_completion=False)
app_dl_fav = typer.Typer(
context_settings={"help_option_names": ["-h", "--help"]},
add_completion=True,
help="Download from a favorites collection.",
)
app.add_typer(app_dl_fav, name="dl_fav")
def version_callback(value: bool):
"""Callback to print version and exit if version flag is set.
Args:
value (bool): If True, prints version and exits.
"""
if value:
print(f"{__version__}")
raise typer.Exit()
@app.callback()
def callback_app(
ctx: typer.Context,
version: Annotated[bool | None, typer.Option("--version", "-v", callback=version_callback, is_eager=True)] = None,
):
"""App callback to initialize context and handle version option.
Args:
ctx (typer.Context): Typer context object.
version (bool | None, optional): Version flag. Defaults to None.
"""
ctx.obj = {"tidal": None}
def _handle_track_or_video(
dl: Download, ctx: typer.Context, item: str, media: object, file_template: str, idx: int, urls_pos_last: int
) -> None:
"""Handle downloading a track or video item.
Args:
dl (Download): The Download instance.
ctx (typer.Context): Typer context object.
item (str): The URL or identifier of the item.
media: The media object to download.
file_template (str): The file template for saving the media.
idx (int): The index of the item in the list.
urls_pos_last (int): The last index in the URLs list.
"""
settings = ctx.obj[CTX_TIDAL].settings
download_delay: bool = bool(settings.data.download_delay and idx < urls_pos_last)
dl.item(
media=media,
file_template=file_template,
download_delay=download_delay,
quality_audio=settings.data.quality_audio,
quality_video=settings.data.quality_video,
)
def _handle_album_playlist_mix_artist(
ctx: typer.Context,
dl: Download,
handling_app: HandlingApp,
media_type: MediaType,
media: object,
item_id: str,
file_template: str,
) -> bool:
"""Handle downloading albums, playlists, mixes, or artist collections.
Args:
ctx (typer.Context): Typer context object.
dl (Download): The Download instance.
handling_app (HandlingApp): The HandlingApp instance.
media_type (MediaType): The type of media (album, playlist, mix, or artist).
media: The media object to download.
item_id (str): The ID of the media item.
file_template (str): The file template for saving the media.
Returns:
bool: False if aborted, True otherwise.
"""
item_ids: list[str] = []
settings = ctx.obj[CTX_TIDAL].settings
if media_type == MediaType.ARTIST:
media_type = MediaType.ALBUM
item_ids += all_artist_album_ids(media)
else:
item_ids.append(item_id)
for _item_id in item_ids:
if handling_app.event_abort.is_set():
return False
dl.items(
media_id=_item_id,
media_type=media_type,
file_template=file_template,
video_download=settings.data.video_download,
download_delay=settings.data.download_delay,
quality_audio=settings.data.quality_audio,
quality_video=settings.data.quality_video,
)
return True
def _process_url(
dl: Download,
ctx: typer.Context,
handling_app: HandlingApp,
url: str,
idx: int,
urls_pos_last: int,
) -> bool:
"""Process a single URL or ID for download.
Args:
dl (Download): The Download instance.
ctx (typer.Context): Typer context object.
handling_app (HandlingApp): The HandlingApp instance.
url (str): The URL or identifier to process.
idx (int): The index of the url in the list.
urls_pos_last (int): The last index in the URLs list.
Returns:
bool: False if aborted, True otherwise.
"""
settings = ctx.obj[CTX_TIDAL].settings
if handling_app.event_abort.is_set():
return False
if "http" not in url:
print(f"It seems like you have supplied an invalid URL: {url}")
return True
url_clean: str = url_ending_clean(url)
media_type = get_tidal_media_type(url_clean)
if not isinstance(media_type, MediaType):
print(f"Could not determine media type for: {url_clean}")
return True
url_clean_id = get_tidal_media_id(url_clean)
if not isinstance(url_clean_id, str):
print(f"Could not determine media id for: {url_clean}")
return True
file_template = get_format_template(media_type, settings)
if not isinstance(file_template, str):
print(f"Could not determine file template for: {url_clean}")
return True
try:
media = instantiate_media(ctx.obj[CTX_TIDAL].session, media_type, url_clean_id)
except Exception:
print(f"Media not found (ID: {url_clean_id}). Maybe it is not available anymore.")
return True
if media_type in [MediaType.TRACK, MediaType.VIDEO]:
_handle_track_or_video(dl, ctx, url_clean, media, file_template, idx, urls_pos_last)
elif media_type in [MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX, MediaType.ARTIST]:
return _handle_album_playlist_mix_artist(ctx, dl, handling_app, media_type, media, url_clean_id, file_template)
return True
def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bool:
"""Invokes download function and tracks progress.
Args:
ctx (typer.Context): The typer context object.
urls (list[str]): The list of URLs to download.
try_login (bool, optional): If true, attempts to login to TIDAL. Defaults to True.
Returns:
bool: True if ran successfully.
"""
if try_login:
ctx.invoke(login, ctx)
settings: Settings = ctx.obj[CTX_TIDAL].settings
handling_app: HandlingApp = HandlingApp()
progress: Progress = Progress(
TextColumn("[progress.description]{task.description}"),
SpinnerColumn(),
BarColumn(),
TaskProgressColumn(),
refresh_per_second=20,
auto_refresh=True,
expand=True,
transient=False,
)
progress_overall = Progress(
TextColumn("[progress.description]{task.description}"),
SpinnerColumn(),
BarColumn(),
TaskProgressColumn(),
refresh_per_second=20,
auto_refresh=True,
expand=True,
transient=False,
)
fn_logger = LoggerWrapped(progress.print)
dl = Download(
tidal_obj=ctx.obj[CTX_TIDAL],
skip_existing=settings.data.skip_existing,
path_base=settings.data.download_base_path,
fn_logger=fn_logger,
progress=progress,
progress_overall=progress_overall,
event_abort=handling_app.event_abort,
event_run=handling_app.event_run,
)
progress_table = Table.grid()
progress_table.add_row(progress)
progress_table.add_row(progress_overall)
progress_group = Group(progress_table)
urls_pos_last = len(urls) - 1
with Live(progress_group, refresh_per_second=20, vertical_overflow="visible"):
try:
for idx, item in enumerate(urls):
if _process_url(dl, ctx, handling_app, item, idx, urls_pos_last) is False:
return False
finally:
progress.refresh()
progress.stop()
return True
@app.command(name="cfg")
def settings_management(
names: Annotated[list[str] | None, typer.Argument()] = None,
editor: Annotated[
bool, typer.Option("--editor", "-e", help="Open the settings file in your default editor.")
] = False,
) -> None:
"""Print or set an option, or open the settings file in an editor.
Args:
names (list[str] | None, optional): None (list all options), one (list the value only for this option) or two arguments (set the value for the option). Defaults to None.
editor (bool, optional): If set, your default system editor will be opened. Defaults to False.
"""
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!')
elif 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")
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:
"""Login to TIDAL and update context object.
Args:
ctx (typer.Context): Typer context object.
Returns:
bool: True if login was successful, False otherwise.
"""
print("Let us check if you are already logged in... ", end="")
settings = Settings()
tidal = Tidal(settings)
result = tidal.login(fn_print=print)
ctx.obj[CTX_TIDAL] = tidal
return result
@app.command(name="logout")
def logout() -> bool:
"""Logout from TIDAL.
Returns:
bool: True if logout was successful, False otherwise.
"""
settings = Settings()
tidal = Tidal(settings)
result = tidal.logout()
if result:
print("You have been successfully logged out.")
return result
@app.command(name="dl")
def download(
ctx: typer.Context,
urls: Annotated[list[str] | None, typer.Argument()] = None,
file_urls: Annotated[
Path | None,
typer.Option(
"--list",
"-l",
exists=True,
file_okay=True,
dir_okay=False,
writable=False,
readable=True,
resolve_path=True,
help="File with URLs to download. One URL per line.",
),
] = None,
) -> bool:
"""Download media from provided URLs or a file containing URLs.
Args:
ctx (typer.Context): Typer context object.
urls (list[str] | None, optional): List of URLs to download. Defaults to None.
file_urls (Path | None, optional): Path to file containing URLs. Defaults to None.
Returns:
bool: True if download was successful, False otherwise.
"""
if not urls:
# Read the text file provided.
if file_urls:
text: str = file_urls.read_text()
urls = text.splitlines()
else:
print("Provide either URLs or a file containing URLs (one per line).")
raise typer.Abort()
return _download(ctx, urls)
@app_dl_fav.command(
name="tracks",
help="Download your favorite track collection.",
)
def download_fav_tracks(ctx: typer.Context) -> bool:
"""Download your favorite track collection.
Args:
ctx (typer.Context): Typer context object.
Returns:
bool: Download result.
"""
# Method name
func_name_favorites: str = "tracks"
return _download_fav_factory(ctx, func_name_favorites)
@app_dl_fav.command(
name="artists",
help="Download your favorite artist collection.",
)
def download_fav_artists(ctx: typer.Context) -> bool:
"""Download your favorite artist collection.
Args:
ctx (typer.Context): Typer context object.
Returns:
bool: Download result.
"""
# Method name
func_name_favorites: str = "artists"
return _download_fav_factory(ctx, func_name_favorites)
@app_dl_fav.command(
name="albums",
help="Download your favorite album collection.",
)
def download_fav_albums(ctx: typer.Context) -> bool:
"""Download your favorite album collection.
Args:
ctx (typer.Context): Typer context object.
Returns:
bool: Download result.
"""
# Method name
func_name_favorites: str = "albums"
return _download_fav_factory(ctx, func_name_favorites)
@app_dl_fav.command(
name="videos",
help="Download your favorite video collection.",
)
def download_fav_videos(ctx: typer.Context) -> bool:
"""Download your favorite video collection.
Args:
ctx (typer.Context): Typer context object.
Returns:
bool: Download result.
"""
# Method name
func_name_favorites: str = "videos"
return _download_fav_factory(ctx, func_name_favorites)
def _download_fav_factory(ctx: typer.Context, func_name_favorites: str) -> bool:
"""Factory which helps to download items from the favorites collections.
Args:
ctx (typer.Context): Typer context object.
func_name_favorites (str): Method name to call from `tidalapi` favorites object.
Returns:
bool: Download result.
"""
ctx.invoke(login, ctx)
func_favorites: Callable = getattr(ctx.obj[CTX_TIDAL].session.user.favorites, func_name_favorites)
media_urls: list[str] = [media.share_url for media in func_favorites()]
return _download(ctx, media_urls, try_login=False)
@app.command()
def gui(ctx: typer.Context):
"""Launch the GUI for the application.
Args:
ctx (typer.Context): Typer context object.
"""
from tidal_dl_ng.gui import gui_activate
ctx.invoke(login, ctx)
gui_activate(ctx.obj[CTX_TIDAL])
def handle_sigint_term(signum, frame):
"""Set app abort event, so threads can check it and shutdown.
Args:
signum: Signal number.
frame: Current stack frame.
"""
handling_app: HandlingApp = HandlingApp()
handling_app.event_abort.set()
if __name__ == "__main__":
# Catch CTRL+C
signal.signal(signal.SIGINT, handle_sigint_term)
signal.signal(signal.SIGTERM, handle_sigint_term)
# Check if the first argument is a URL. Hacky solution, since Typer does not support positional arguments without options / commands.
if len(sys.argv) > 1:
first_arg = sys.argv[1]
parsed_url = urlparse(first_arg)
if parsed_url.scheme in ["http", "https"] and parsed_url.netloc:
# Rewrite sys.argv to simulate `dl <URL>`
sys.argv.insert(1, "dl")
app()

293
tidal_dl_ng/config.py Normal file
View File

@@ -0,0 +1,293 @@
import contextlib
import json
import os
import shutil
from collections.abc import Callable
from json import JSONDecodeError
from pathlib import Path
from threading import Event, Lock
from typing import Any
import tidalapi
from tidal_dl_ng.constants import (
ATMOS_CLIENT_ID,
ATMOS_CLIENT_SECRET,
ATMOS_REQUEST_QUALITY,
)
from tidal_dl_ng.helper.decorator import SingletonMeta
from tidal_dl_ng.helper.path import path_config_base, path_file_settings, path_file_token
from tidal_dl_ng.model.cfg import Settings as ModelSettings
from tidal_dl_ng.model.cfg import Token as ModelToken
class BaseConfig:
data: ModelSettings | ModelToken
file_path: str
cls_model: ModelSettings | ModelToken
path_base: str = path_config_base()
def save(self, config_to_compare: str = None) -> None:
data_json = self.data.to_json()
# If old and current config is equal, skip the write operation.
if config_to_compare == data_json:
return
# Try to create the base folder.
os.makedirs(self.path_base, exist_ok=True)
with open(self.file_path, encoding="utf-8", mode="w") as f:
# Save it in a pretty format
obj_json_config = json.loads(data_json)
json.dump(obj_json_config, f, indent=4)
def set_option(self, key: str, value: Any) -> None:
value_old: Any = getattr(self.data, key)
if type(value_old) == bool: # noqa: E721
value = True if value.lower() in ("true", "1", "yes", "y") else False # noqa: SIM210
elif type(value_old) == int and type(value) != int: # noqa: E721
value = int(value)
setattr(self.data, key, value)
def read(self, path: str) -> bool:
result: bool = False
settings_json: str = ""
try:
with open(path, encoding="utf-8") as f:
settings_json = f.read()
self.data = self.cls_model.from_json(settings_json)
result = True
except (JSONDecodeError, TypeError, FileNotFoundError, ValueError) as e:
if isinstance(e, ValueError):
path_bak = path + ".bak"
# First check if a backup file already exists. If yes, remove it.
if os.path.exists(path_bak):
os.remove(path_bak)
# Move the invalid config file to the backup location.
shutil.move(path, path_bak)
print(
"Something is wrong with your config. Maybe it is not compatible anymore due to a new app version."
f" You can find a backup of your old config here: '{path_bak}'. A new default config was created."
)
self.data = self.cls_model()
# Call save in case of we need to update the saved config, due to changes in code.
self.save(settings_json)
return result
class Settings(BaseConfig, metaclass=SingletonMeta):
def __init__(self):
self.cls_model = ModelSettings
self.file_path = path_file_settings()
self.read(self.file_path)
class Tidal(BaseConfig, metaclass=SingletonMeta):
session: tidalapi.Session
token_from_storage: bool = False
settings: Settings
is_pkce: bool
def __init__(self, settings: Settings = None):
self.cls_model = ModelToken
tidal_config: tidalapi.Config = tidalapi.Config(item_limit=10000)
self.session = tidalapi.Session(tidal_config)
self.original_client_id = self.session.config.client_id
self.original_client_secret = self.session.config.client_secret
# Lock to ensure session-switching is thread-safe.
# This lock protects against a race condition where one thread
# changes the session credentials while another is using them.
# It is intentionally held by Download._get_stream_info
# for the *entire* duration of the credential switch AND
# the get_stream() call.
self.stream_lock = Lock()
# State-tracking flag to prevent redundant, expensive
# session re-authentication when the session is already in the
# correct mode (Atmos or Normal).
self.is_atmos_session = False
# self.session.config.client_id = "km8T1xS355y7dd3H"
# self.session.config.client_secret = "vcmeGW1OuZ0fWYMCSZ6vNvSLJlT3XEpW0ambgYt5ZuI="
self.file_path = path_file_token()
self.token_from_storage = self.read(self.file_path)
if settings:
self.settings = settings
self.settings_apply()
def settings_apply(self, settings: Settings = None) -> bool:
if settings:
self.settings = settings
if not self.is_atmos_session:
self.session.audio_quality = tidalapi.Quality(self.settings.data.quality_audio)
self.session.video_quality = tidalapi.VideoQuality.high
return True
def login_token(self, do_pkce: bool = False) -> bool:
result = False
self.is_pkce = do_pkce
if self.token_from_storage:
try:
result = self.session.load_oauth_session(
self.data.token_type,
self.data.access_token,
self.data.refresh_token,
self.data.expiry_time,
is_pkce=do_pkce,
)
except Exception:
result = False
# Remove token file. Probably corrupt or invalid.
if os.path.exists(self.file_path):
os.remove(self.file_path)
print(
"Either there is something wrong with your credentials / account or some server problems on TIDALs "
"side. Anyway... Try to login again by re-starting this app."
)
return result
def login_finalize(self) -> bool:
result = self.session.check_login()
if result:
self.token_persist()
return result
def token_persist(self) -> None:
self.set_option("token_type", self.session.token_type)
self.set_option("access_token", self.session.access_token)
self.set_option("refresh_token", self.session.refresh_token)
self.set_option("expiry_time", self.session.expiry_time)
self.save()
# Set restrictive permissions on token file (Unix-based systems only)
with contextlib.suppress(OSError, NotImplementedError):
os.chmod(self.file_path, 0o600)
def switch_to_atmos_session(self) -> bool:
"""
Switches the shared session to Dolby Atmos credentials.
Only re-authenticates if not already in Atmos mode.
Returns:
bool: True if successful or already in Atmos mode, False otherwise.
"""
# If we are already in Atmos mode, do nothing.
if self.is_atmos_session:
return True
print("Switching session context to Dolby Atmos...")
self.session.config.client_id = ATMOS_CLIENT_ID
self.session.config.client_secret = ATMOS_CLIENT_SECRET
self.session.audio_quality = ATMOS_REQUEST_QUALITY
# Re-login with new credentials
if not self.login_token(do_pkce=self.is_pkce):
print("Warning: Atmos session authentication failed.")
# Try to switch back to normal to be safe
self.restore_normal_session(force=True)
return False
self.is_atmos_session = True # Set the flag
print("Session is now in Atmos mode.")
return True
def restore_normal_session(self, force: bool = False) -> bool:
"""
Restores the shared session to the original user credentials.
Only re-authenticates if not already in Normal mode.
Args:
force: If True, forces restoration even if already in Normal mode.
Returns:
bool: True if successful or already in Normal mode, False otherwise.
"""
# If we are already in Normal mode (and not forced), do nothing.
if not self.is_atmos_session and not force:
return True
print("Restoring session context to Normal...")
self.session.config.client_id = self.original_client_id
self.session.config.client_secret = self.original_client_secret
# Explicitly restore audio quality to user's configured setting
self.session.audio_quality = tidalapi.Quality(self.settings.data.quality_audio)
# Re-login with original credentials
if not self.login_token(do_pkce=self.is_pkce):
print("Warning: Restoring the original session context failed. Please restart the application.")
return False
self.is_atmos_session = False # Set the flag
print("Session is now in Normal mode.")
return True
def login(self, fn_print: Callable) -> bool:
is_token = self.login_token()
result = False
if is_token:
fn_print("Yep, looks good! You are logged in.")
result = True
elif not is_token:
fn_print("You either do not have a token or your token is invalid.")
fn_print("No worries, we will handle this...")
# Login method: Device linking
self.session.login_oauth_simple(fn_print)
# Login method: PKCE authorization (was necessary for HI_RES_LOSSLESS streaming earlier)
# self.session.login_pkce(fn_print)
is_login = self.login_finalize()
if is_login:
fn_print("The login was successful. I have stored your credentials (token).")
result = True
else:
fn_print("Something went wrong. Did you login using your browser correctly? May try again...")
return result
def logout(self):
Path(self.file_path).unlink(missing_ok=True)
self.token_from_storage = False
del self.session
return True
def is_authentication_error(self, error: Exception) -> bool:
"""Check if an error is related to authentication/OAuth issues.
Args:
error (Exception): The exception to check.
Returns:
bool: True if the error is authentication-related, False otherwise.
"""
error_msg = str(error)
return "401" in error_msg or "OAuth" in error_msg or "token" in error_msg.lower()
class HandlingApp(metaclass=SingletonMeta):
event_abort: Event = Event()
event_run: Event = Event()
def __init__(self):
self.event_run.set()

97
tidal_dl_ng/constants.py Normal file
View File

@@ -0,0 +1,97 @@
import base64
from enum import StrEnum
from tidalapi import Quality
CTX_TIDAL: str = "tidal"
REQUESTS_TIMEOUT_SEC: int = 45
EXTENSION_LYRICS: str = ".lrc"
UNIQUIFY_THRESHOLD: int = 99
FILENAME_SANITIZE_PLACEHOLDER: str = "_"
COVER_NAME: str = "cover.jpg"
BLOCK_SIZE: int = 4096
BLOCKS: int = 1024
CHUNK_SIZE: int = BLOCK_SIZE * BLOCKS
PLAYLIST_EXTENSION: str = ".m3u"
PLAYLIST_PREFIX: str = "_"
FILENAME_LENGTH_MAX: int = 255
FORMAT_TEMPLATE_EXPLICIT: str = " (Explicit)"
METADATA_EXPLICIT: str = " 🅴"
# Dolby Atmos API credentials (obfuscated)
ATMOS_ID_B64 = "N203QX" + "AwSkM5aj" + "FjT00zbg=="
ATMOS_SECRET_B64 = "dlJBZEEx" + "MDh0bHZrSnB" + "Uc0daUzhyR1" + "o3eFRsYkow" + "cWFaMks5c2F" + "FenNnWT0="
ATMOS_CLIENT_ID = base64.b64decode(ATMOS_ID_B64).decode("utf-8")
ATMOS_CLIENT_SECRET = base64.b64decode(ATMOS_SECRET_B64).decode("utf-8")
ATMOS_REQUEST_QUALITY = Quality.low_320k
class QualityVideo(StrEnum):
P360 = "360"
P480 = "480"
P720 = "720"
P1080 = "1080"
class MediaType(StrEnum):
TRACK = "track"
VIDEO = "video"
PLAYLIST = "playlist"
ALBUM = "album"
MIX = "mix"
ARTIST = "artist"
class CoverDimensions(StrEnum):
Px80 = "80"
Px160 = "160"
Px320 = "320"
Px640 = "640"
Px1280 = "1280"
PxORIGIN = "origin"
class TidalLists(StrEnum):
Playlists = "Playlists"
Favorites = "Favorites"
Mixes = "Mixes"
class QueueDownloadStatus(StrEnum):
Waiting = "⏳️"
Downloading = "▶️"
Finished = ""
Failed = ""
Skipped = "↪️"
FAVORITES: dict[str, dict[str, str]] = {
"fav_videos": {"name": "Videos", "function_name": "videos"},
"fav_tracks": {"name": "Tracks", "function_name": "tracks_paginated"},
"fav_mixes": {"name": "Mixes & Radio", "function_name": "mixes"},
"fav_artists": {"name": "Artists", "function_name": "artists_paginated"},
"fav_albums": {"name": "Albums", "function_name": "albums_paginated"},
}
class AudioExtensionsValid(StrEnum):
FLAC = ".flac"
M4A = ".m4a"
MP4 = ".mp4"
MP3 = ".mp3"
OGG = ".ogg"
ALAC = ".alac"
class MetadataTargetUPC(StrEnum):
UPC = "UPC"
BARCODE = "BARCODE"
EAN = "EAN"
METADATA_LOOKUP_UPC: dict[str, dict[str, str]] = {
"UPC": {"MP3": "UPC", "MP4": "UPC", "FLAC": "UPC"},
"BARCODE": {"MP3": "BARCODE", "MP4": "BARCODE", "FLAC": "BARCODE"},
"EAN": {"MP3": "EAN", "MP4": "EAN", "FLAC": "EAN"},
}

360
tidal_dl_ng/dialog.py Normal file
View File

@@ -0,0 +1,360 @@
import datetime
import os
import os.path
import shutil
import webbrowser
from enum import StrEnum
from pathlib import Path
from PySide6 import QtCore, QtGui, QtWidgets
from tidalapi import Quality as QualityAudio
from tidal_dl_ng import __version__
from tidal_dl_ng.config import Settings
from tidal_dl_ng.constants import CoverDimensions, QualityVideo
from tidal_dl_ng.model.cfg import HelpSettings
from tidal_dl_ng.model.cfg import Settings as ModelSettings
from tidal_dl_ng.model.meta import ReleaseLatest
from tidal_dl_ng.ui.dialog_login import Ui_DialogLogin
from tidal_dl_ng.ui.dialog_settings import Ui_DialogSettings
from tidal_dl_ng.ui.dialog_version import Ui_DialogVersion
class DialogVersion(QtWidgets.QDialog):
"""Version dialog."""
ui: Ui_DialogVersion
def __init__(
self, parent=None, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest = None
):
super().__init__(parent)
# Create an instance of the GUI
self.ui = Ui_DialogVersion()
# Run the .setupUi() method to show the GUI
self.ui.setupUi(self)
# Set the version.
self.ui.l_version.setText("v" + __version__)
if not update_check:
self.update_info_hide()
self.error_hide()
else:
self.update_info(update_available, update_info)
# Show
self.exec()
def update_info(self, update_available: bool, update_info: ReleaseLatest):
if not update_available and update_info.version == "v0.0.0":
self.update_info_hide()
self.ui.l_error_details.setText(
"Cannot retrieve update information. Maybe something is wrong with your internet connection."
)
else:
self.error_hide()
if not update_available:
self.ui.l_h_version_new.setText("Latest available version:")
self.changelog_hide()
else:
self.ui.l_changelog_details.setText(update_info.release_info)
self.ui.pb_download.clicked.connect(lambda: webbrowser.open(update_info.url))
self.ui.l_version_new.setText(update_info.version)
def error_hide(self):
self.ui.l_error.setHidden(True)
self.ui.l_error_details.setHidden(True)
def update_info_hide(self):
self.ui.l_h_version_new.setHidden(True)
self.ui.l_version_new.setHidden(True)
self.changelog_hide()
def changelog_hide(self):
self.ui.l_changelog.setHidden(True)
self.ui.l_changelog_details.setHidden(True)
self.ui.pb_download.setHidden(True)
class DialogLogin(QtWidgets.QDialog):
"""Version dialog."""
ui: Ui_DialogLogin
url_redirect: str
def __init__(self, url_login: str, hint: str, expires_in: int, parent=None):
super().__init__(parent)
datetime_current: datetime.datetime = datetime.datetime.now()
datetime_expires: datetime.datetime = datetime_current + datetime.timedelta(0, expires_in)
# Create an instance of the GUI
self.ui = Ui_DialogLogin()
# Run the .setupUi() method to show the GUI
self.ui.setupUi(self)
# Set data.
self.ui.tb_url_login.setText(f'<a href="https://{url_login}">https://{url_login}</a>')
self.ui.l_hint.setText(hint)
self.ui.l_expires_date_time.setText(datetime_expires.strftime("%Y-%m-%d %H:%M"))
# Show
self.return_code = self.exec()
class DialogPreferences(QtWidgets.QDialog):
"""Preferences dialog (non-blocking, deferred population).
The macOS Text Services Manager (TSM) can emit CFMessagePortSendRequest failures
when text widgets (e.g., QLineEdit) are populated before the window is fully
registered with the window server. To avoid this, heavy UI population is deferred
until after the dialog is shown via an overridden showEvent using a QTimer.
All expensive or input-related operations (setting text, pixmaps) are performed
only once after first show to prevent premature IME/TSM activation.
"""
ui: Ui_DialogSettings
settings: Settings
data: ModelSettings
s_settings_save: object # Accept any signal-like object (loosened for runtime SignalInstance)
help_settings: HelpSettings
parameters_checkboxes: list[str]
parameters_combo: list[tuple[str, StrEnum]]
parameters_line_edit: list[str]
parameters_spin_box: list[str]
prefix_checkbox: str = "cb_"
prefix_label: str = "l_"
prefix_icon: str = "icon_"
prefix_line_edit: str = "le_"
prefix_combo: str = "c_"
prefix_spin_box: str = "sb_"
_icon: QtGui.QIcon | None = None
_populated: bool = False
def __init__(self, settings: Settings, settings_save: object, parent=None):
super().__init__(parent)
self.settings = settings
self.data = settings.data
self.s_settings_save = settings_save
self.help_settings = HelpSettings()
self._init_checkboxes()
self._init_comboboxes()
self._init_line_edit()
self._init_spin_box()
# Create an instance of the GUI and perform lightweight UI setup only.
self.ui = Ui_DialogSettings()
self.ui.setupUi(self)
# Non-blocking pattern: caller will invoke .show() / .open(); we do NOT call exec().
# Heavy population deferred to showEvent.
# ------------------------------
# Internal helpers
# ------------------------------
def ensure_main_thread(self) -> None:
"""Ensure method runs on the main (GUI) thread."""
app = QtWidgets.QApplication.instance()
if app and QtCore.QThread.currentThread() is not app.thread():
raise RuntimeError
@property
def icon(self) -> QtGui.QIcon:
"""Lazy-create and cache the dialog icon."""
if self._icon is None:
pixmapi: QtWidgets.QStyle.StandardPixmap = QtWidgets.QStyle.StandardPixmap.SP_MessageBoxQuestion
self._icon = self.style().standardIcon(pixmapi)
return self._icon
def showEvent(self, event: QtGui.QShowEvent) -> None:
"""On first show, defer population to avoid macOS TSM early activation."""
super().showEvent(event)
if not self._populated:
self._populated = True
# Slight delay on macOS to ensure window server registration.
delay_ms = 50 if os.name == "posix" and shutil.which("uname") and os.uname().sysname == "Darwin" else 0
QtCore.QTimer.singleShot(delay_ms, self._deferred_populate)
def _deferred_populate(self) -> None:
"""Populate widgets after dialog is visible (safe for macOS TSM)."""
self.ensure_main_thread()
QtWidgets.QApplication.processEvents()
self.gui_populate()
def _init_line_edit(self):
self.parameters_line_edit = [
"download_base_path",
"format_album",
"format_playlist",
"format_mix",
"format_track",
"format_video",
"path_binary_ffmpeg",
]
def _init_spin_box(self):
self.parameters_spin_box = ["album_track_num_pad_min", "downloads_concurrent_max"]
def _init_comboboxes(self):
self.parameters_combo = [
("quality_audio", QualityAudio),
("quality_video", QualityVideo),
("metadata_cover_dimension", CoverDimensions),
]
def _init_checkboxes(self):
self.parameters_checkboxes = [
"lyrics_embed",
"lyrics_file",
"use_primary_album_artist",
"video_download",
"download_dolby_atmos",
"download_delay",
"video_convert_mp4",
"extract_flac",
"metadata_cover_embed",
"mark_explicit",
"cover_album_file",
"skip_existing",
"symlink_to_track",
"playlist_create",
]
def gui_populate(self):
self.populate_checkboxes()
self.populate_combo()
self.populate_line_edit()
self.populate_spin_box()
def dialog_chose_file(
self,
obj_line_edit: QtWidgets.QLineEdit,
file_mode: QtWidgets.QFileDialog.FileMode = QtWidgets.QFileDialog.FileMode.Directory,
path_default: str | None = None,
) -> None:
# If a path is set, use it otherwise the users home directory.
path_settings: str = os.path.expanduser(obj_line_edit.text()) if obj_line_edit.text() else ""
# Check if obj_line_edit is empty but path_default can be used instead
if not path_settings and path_default:
expanded_default = os.path.expanduser(path_default)
path_settings = expanded_default
dir_current: str = path_settings if path_settings and os.path.exists(path_settings) else str(Path.home())
dialog: QtWidgets.QFileDialog = QtWidgets.QFileDialog()
# Set to directory mode only but show files.
dialog.setFileMode(file_mode)
dialog.setViewMode(QtWidgets.QFileDialog.ViewMode.Detail)
dialog.setOption(QtWidgets.QFileDialog.Option.ShowDirsOnly, False)
dialog.setOption(QtWidgets.QFileDialog.Option.DontResolveSymlinks, True)
# There is a bug in the PyQt implementation, which hides files in Directory mode.
# Thus, we need to use the PyQt dialog instead of the native dialog.
if os.name == "nt" and file_mode == QtWidgets.QFileDialog.Directory:
dialog.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True)
dialog.setDirectory(dir_current)
# Execute dialog and set path if something is chosen.
if dialog.exec():
dir_name: str = dialog.selectedFiles()[0]
path: Path = Path(dir_name)
obj_line_edit.setText(str(path))
def populate_line_edit(self):
for pn in self.parameters_line_edit:
label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn)
label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn)
line_edit: QtWidgets.QLineEdit = getattr(self.ui, self.prefix_line_edit + pn)
label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16))))
label_icon.setToolTip(getattr(self.help_settings, pn))
label.setText(pn)
# Safe line edit updates (suppress signals/UI updates during bulk setText)
line_edit.blockSignals(True)
line_edit.setUpdatesEnabled(False)
line_edit.setText(str(getattr(self.data, pn)))
line_edit.setUpdatesEnabled(True)
line_edit.blockSignals(False)
# Base Path File Dialog
self.ui.pb_download_base_path.clicked.connect(lambda x: self.dialog_chose_file(self.ui.le_download_base_path))
# Defer shutil.which() call to prevent TSM errors during initialization
self.ui.pb_path_binary_ffmpeg.clicked.connect(
lambda x: self.dialog_chose_file(
self.ui.le_path_binary_ffmpeg,
file_mode=QtWidgets.QFileDialog.FileMode.ExistingFiles,
path_default=self._get_ffmpeg_path(),
)
)
def populate_combo(self):
for pn, values in self.parameters_combo:
label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn)
label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn)
combo: QtWidgets.QComboBox = getattr(self.ui, self.prefix_combo + pn)
setting_current = getattr(self.data, pn)
label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16))))
label_icon.setToolTip(getattr(self.help_settings, pn))
label.setText(pn)
for index, v in enumerate(list(values)):
combo.addItem(v.name, v)
if v == setting_current:
combo.setCurrentIndex(index)
def populate_checkboxes(self):
for pn in self.parameters_checkboxes:
checkbox: QtWidgets.QCheckBox = getattr(self.ui, self.prefix_checkbox + pn)
checkbox.setText(pn)
checkbox.setToolTip(getattr(self.help_settings, pn))
checkbox.setIcon(self.icon)
checkbox.setChecked(getattr(self.data, pn))
def populate_spin_box(self):
for pn in self.parameters_spin_box:
label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn)
label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn)
spin_box: QtWidgets.QSpinBox = getattr(self.ui, self.prefix_spin_box + pn)
label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16))))
label_icon.setToolTip(getattr(self.help_settings, pn))
label.setText(pn)
spin_box.setValue(getattr(self.data, pn))
def accept(self):
# Get settings.
self.to_settings()
self.done(1)
def _get_ffmpeg_path(self) -> str | None:
"""Get the ffmpeg binary path using shutil.which.
This method is called only when needed (when button is clicked),
not during initialization, to prevent TSM errors on macOS.
Returns:
str | None: Path to ffmpeg binary or None if not found.
"""
return shutil.which("ffmpeg")
def to_settings(self):
for item in self.parameters_checkboxes:
setattr(self.settings.data, item, getattr(self.ui, self.prefix_checkbox + item).isChecked())
for item in self.parameters_line_edit:
setattr(self.settings.data, item, getattr(self.ui, self.prefix_line_edit + item).text())
for item in self.parameters_combo:
setattr(self.settings.data, item[0], getattr(self.ui, self.prefix_combo + item[0]).currentData())
for item in self.parameters_spin_box:
setattr(self.settings.data, item, getattr(self.ui, self.prefix_spin_box + item).value())
self.s_settings_save.emit()

1787
tidal_dl_ng/download.py Normal file

File diff suppressed because it is too large Load Diff

2521
tidal_dl_ng/gui.py Normal file

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,22 @@
from typing import ClassVar
class SingletonMeta(type):
"""
The Singleton class can be implemented in different ways in Python. Some
possible methods include: base class, decorator, metaclass. We will use the
metaclass because it is best suited for this purpose.
"""
_instances: ClassVar[dict] = {}
def __call__(cls, *args, **kwargs):
"""
Possible changes to the value of the `__init__` argument do not affect
the returned instance.
"""
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]

View File

@@ -0,0 +1,63 @@
import base64
import pathlib
from Crypto.Cipher import AES
from Crypto.Util import Counter
def decrypt_security_token(security_token: str) -> (str, str):
"""
The `decrypt_security_token` function decrypts a security token into a key and nonce pair using AES
encryption.
Args:
security_token (str): The `security_token` parameter in the `decrypt_security_token` function is a
string that represents an encrypted security token. This function decrypts the security token into a
key and nonce pair using AES encryption. security_token should match the securityToken value from the web response.
Returns:
The `decrypt_security_token` function returns a tuple containing the key and nonce extracted from
the decrypted security token.
"""
# Do not change this
master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754="
# Decode the base64 strings to ascii strings
master_key = base64.b64decode(master_key)
security_token = base64.b64decode(security_token)
# Get the IV from the first 16 bytes of the securityToken
iv = security_token[:16]
encrypted_st = security_token[16:]
# Initialize decryptor
decryptor = AES.new(master_key, AES.MODE_CBC, iv)
# Decrypt the security token
decrypted_st = decryptor.decrypt(encrypted_st)
# Get the audio stream decryption key and nonce from the decrypted security token
key = decrypted_st[:16]
nonce = decrypted_st[16:24]
return key, nonce
def decrypt_file(path_file_encrypted: pathlib.Path, path_file_destination: pathlib.Path, key: str, nonce: str) -> None:
"""
Decrypts an encrypted MQA file given the file, key and nonce.
TODO: Is it really only necessary for MQA of for all other formats, too?
"""
# Initialize counter and file decryptor
counter = Counter.new(64, prefix=nonce, initial_value=0)
decryptor = AES.new(key, AES.MODE_CTR, counter=counter)
# Open and decrypt
with path_file_encrypted.open("rb") as f_src:
audio_decrypted = decryptor.decrypt(f_src.read())
# Replace with decrypted file
with path_file_destination.open("wb") as f_dst:
f_dst.write(audio_decrypted)

View File

@@ -0,0 +1,14 @@
class LoginError(Exception):
pass
class MediaUnknown(Exception):
pass
class UnknownManifestFormat(Exception):
pass
class MediaMissing(Exception):
pass

225
tidal_dl_ng/helper/gui.py Normal file
View File

@@ -0,0 +1,225 @@
import re
from typing import cast
from PySide6 import QtCore, QtGui, QtWidgets
from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video
from tidalapi.artist import Artist
from tidalapi.media import Quality
from tidalapi.playlist import Folder
from tidal_dl_ng.constants import QualityVideo
def get_table_data(
item: QtWidgets.QTreeWidgetItem, column: int
) -> Track | Video | Album | Artist | Mix | Playlist | UserPlaylist:
result: Track | Video | Album | Artist = item.data(column, QtCore.Qt.ItemDataRole.UserRole)
return result
def get_table_text(item: QtWidgets.QTreeWidgetItem, column: int) -> str:
result: str = item.text(column)
return result
def get_results_media_item(
index: QtCore.QModelIndex, proxy: QtCore.QSortFilterProxyModel, model: QtGui.QStandardItemModel
) -> Track | Video | Album | Artist | Playlist | Mix:
# Switch column to "obj" column and map proxy data to our model.
item: QtGui.QStandardItem = model.itemFromIndex(proxy.mapToSource(index.siblingAtColumn(1)))
result: Track | Video | Album | Artist = item.data(QtCore.Qt.ItemDataRole.UserRole)
return result
def get_user_list_media_item(item: QtWidgets.QTreeWidgetItem) -> Mix | Playlist | UserPlaylist | Folder | str:
result: Mix | Playlist | UserPlaylist | Folder | str = get_table_data(item, 1)
return result
def get_queue_download_media(
item: QtWidgets.QTreeWidgetItem,
) -> Mix | Playlist | UserPlaylist | Track | Video | Album | Artist:
result: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist = get_table_data(item, 1)
return result
def get_queue_download_quality(
item: QtWidgets.QTreeWidgetItem,
column: int,
) -> str:
result: str = get_table_text(item, column)
return result
def get_queue_download_quality_audio(
item: QtWidgets.QTreeWidgetItem,
) -> Quality:
result: Quality = cast(Quality, get_queue_download_quality(item, 4))
return result
def get_queue_download_quality_video(
item: QtWidgets.QTreeWidgetItem,
) -> QualityVideo:
result: QualityVideo = cast(QualityVideo, get_queue_download_quality(item, 5))
return result
def set_table_data(
item: QtWidgets.QTreeWidgetItem,
data: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist | Folder | str,
column: int,
):
item.setData(column, QtCore.Qt.ItemDataRole.UserRole, data)
def set_results_media(item: QtWidgets.QTreeWidgetItem, media: Track | Video | Album | Artist):
set_table_data(item, media, 1)
def set_user_list_media(
item: QtWidgets.QTreeWidgetItem,
media: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist | Folder | str,
):
set_table_data(item, media, 1)
def set_queue_download_media(
item: QtWidgets.QTreeWidgetItem, media: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist
):
set_table_data(item, media, 1)
class FilterHeader(QtWidgets.QHeaderView):
filter_activated = QtCore.Signal()
def __init__(self, parent):
super().__init__(QtCore.Qt.Horizontal, parent)
self._editors = []
self._padding = 4
self.setCascadingSectionResizes(True)
self.setSectionResizeMode(QtWidgets.QHeaderView.Interactive)
self.setStretchLastSection(True)
self.setDefaultAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
self.setSortIndicatorShown(False)
self.setSectionsMovable(True)
self.sectionResized.connect(self.adjust_positions)
parent.horizontalScrollBar().valueChanged.connect(self.adjust_positions)
def set_filter_boxes(self, count):
while self._editors:
editor = self._editors.pop()
editor.deleteLater()
for _ in range(count):
editor = QtWidgets.QLineEdit(self.parent())
editor.setPlaceholderText("Filter")
editor.setClearButtonEnabled(True)
editor.returnPressed.connect(self.filter_activated.emit)
self._editors.append(editor)
self.adjust_positions()
def sizeHint(self):
size = super().sizeHint()
if self._editors:
height = self._editors[0].sizeHint().height()
size.setHeight(size.height() + height + self._padding)
return size
def updateGeometries(self):
if self._editors:
height = self._editors[0].sizeHint().height()
self.setViewportMargins(0, 0, 0, height + self._padding)
else:
self.setViewportMargins(0, 0, 0, 0)
super().updateGeometries()
self.adjust_positions()
def adjust_positions(self):
for index, editor in enumerate(self._editors):
height = editor.sizeHint().height()
editor.move(self.sectionPosition(index) - self.offset() + 2, height + (self._padding // 2))
editor.resize(self.sectionSize(index), height)
def filter_text(self, index) -> str:
if 0 <= index < len(self._editors):
return self._editors[index].text()
return ""
def set_filter_text(self, index, text):
if 0 <= index < len(self._editors):
self._editors[index].setText(text)
def clear_filters(self):
for editor in self._editors:
editor.clear()
class HumanProxyModel(QtCore.QSortFilterProxyModel):
def _human_key(self, key):
parts = re.split(r"(\d*\.\d+|\d+)", key)
return tuple((e.swapcase() if i % 2 == 0 else float(e)) for i, e in enumerate(parts))
def lessThan(self, source_left, source_right):
data_left = source_left.data()
data_right = source_right.data()
if isinstance(data_left, str) and isinstance(data_right, str):
return self._human_key(data_left) < self._human_key(data_right)
return super().lessThan(source_left, source_right)
@property
def filters(self):
if not hasattr(self, "_filters"):
self._filters = []
return self._filters
@filters.setter
def filters(self, filters):
self._filters = filters
self.invalidateFilter()
def filterAcceptsRow(self, source_row: int, source_parent: QtCore.QModelIndex) -> bool:
model = self.sourceModel()
source_index = model.index(source_row, 0, source_parent)
result: [bool] = []
# Show top level children
for child_row in range(model.rowCount(source_index)):
if self.filterAcceptsRow(child_row, source_index):
return True
# Filter for actual needle
for i, text in self.filters:
if 0 <= i < self.columnCount():
ix = self.sourceModel().index(source_row, i, source_parent)
data = ix.data()
# Append results to list to enable an AND operator for filtering.
result.append(bool(re.search(rf"{text}", data, re.MULTILINE | re.IGNORECASE)) if data else False)
# If no filter set, just set the result to True.
if not result:
result.append(True)
return all(result)

691
tidal_dl_ng/helper/path.py Normal file
View File

@@ -0,0 +1,691 @@
import math
import os
import pathlib
import posixpath
import re
import sys
from copy import deepcopy
from urllib.parse import unquote, urlsplit
from pathvalidate import sanitize_filename, sanitize_filepath
from pathvalidate.error import ValidationError
from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video
from tidalapi.media import AudioExtensions
from tidal_dl_ng import __name_display__
from tidal_dl_ng.constants import (
FILENAME_LENGTH_MAX,
FILENAME_SANITIZE_PLACEHOLDER,
FORMAT_TEMPLATE_EXPLICIT,
UNIQUIFY_THRESHOLD,
MediaType,
)
from tidal_dl_ng.helper.tidal import name_builder_album_artist, name_builder_artist, name_builder_title
def path_home() -> str:
"""Get the home directory path.
Returns:
str: The home directory path.
"""
if "XDG_CONFIG_HOME" in os.environ:
return os.environ["XDG_CONFIG_HOME"]
elif "HOME" in os.environ:
return os.environ["HOME"]
elif "HOMEDRIVE" in os.environ and "HOMEPATH" in os.environ:
return os.path.join(os.environ["HOMEDRIVE"], os.environ["HOMEPATH"])
else:
return os.path.abspath("./")
def path_config_base() -> str:
"""Get the base configuration path.
Returns:
str: The base configuration path.
"""
# https://wiki.archlinux.org/title/XDG_Base_Directory
# X11 workaround: If user specified config path is set, do not point to "~/.config"
path_user_custom: str = os.environ.get("XDG_CONFIG_HOME", "")
path_config: str = ".config" if not path_user_custom else ""
path_base: str = os.path.join(path_home(), path_config, __name_display__)
return path_base
def path_file_log() -> str:
"""Get the path to the log file.
Returns:
str: The log file path.
"""
return os.path.join(path_config_base(), "app.log")
def path_file_token() -> str:
"""Get the path to the token file.
Returns:
str: The token file path.
"""
return os.path.join(path_config_base(), "token.json")
def path_file_settings() -> str:
"""Get the path to the settings file.
Returns:
str: The settings file path.
"""
return os.path.join(path_config_base(), "settings.json")
def format_path_media(
fmt_template: str,
media: Track | Album | Playlist | UserPlaylist | Video | Mix,
album_track_num_pad_min: int = 0,
list_pos: int = 0,
list_total: int = 0,
delimiter_artist: str = ", ",
delimiter_album_artist: str = ", ",
use_primary_album_artist: bool = False,
) -> str:
"""Formats a media path string using a template and media attributes.
Replaces placeholders in the format template with sanitized media attribute values to generate a valid file path.
Args:
fmt_template (str): The format template string containing placeholders.
media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract values from.
album_track_num_pad_min (int, optional): Minimum padding for track numbers. Defaults to 0.
list_pos (int, optional): Position in a list. Defaults to 0.
list_total (int, optional): Total items in a list. Defaults to 0.
delimiter_artist (str, optional): Delimiter for artist names. Defaults to ", ".
delimiter_album_artist (str, optional): Delimiter for album artist names. Defaults to ", ".
use_primary_album_artist (bool, optional): If True, uses first album artist for folder paths. Defaults to False.
Returns:
str: The formatted and sanitized media path string.
"""
result = fmt_template
# Search track format template for placeholder.
regex = r"\{(.+?)\}"
matches = re.finditer(regex, fmt_template, re.MULTILINE)
for _matchNum, match in enumerate(matches, start=1):
template_str = match.group()
result_fmt = format_str_media(
match.group(1),
media,
album_track_num_pad_min,
list_pos,
list_total,
delimiter_artist=delimiter_artist,
delimiter_album_artist=delimiter_album_artist,
use_primary_album_artist=use_primary_album_artist,
)
if result_fmt != match.group(1):
# Sanitize here, in case of the filename has slashes or something, which will be recognized later as a directory separator.
# Do not sanitize if value is the FORMAT_TEMPLATE_EXPLICIT placeholder, since it has a leading whitespace which otherwise gets removed.
value = (
sanitize_filename(result_fmt) if result_fmt != FORMAT_TEMPLATE_EXPLICIT else FORMAT_TEMPLATE_EXPLICIT
)
result = result.replace(template_str, value)
return result
def format_str_media(
name: str,
media: Track | Album | Playlist | UserPlaylist | Video | Mix,
album_track_num_pad_min: int = 0,
list_pos: int = 0,
list_total: int = 0,
delimiter_artist: str = ", ",
delimiter_album_artist: str = ", ",
use_primary_album_artist: bool = False,
) -> str:
"""Formats a string for media attributes based on the provided name.
Attempts to format the given name using a sequence of formatter functions, returning the first successful result.
Args:
name (str): The format string name to process.
media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract values from.
album_track_num_pad_min (int, optional): Minimum padding for track numbers. Defaults to 0.
list_pos (int, optional): Position in a list. Defaults to 0.
list_total (int, optional): Total items in a list. Defaults to 0.
delimiter_artist (str, optional): Delimiter for artist names. Defaults to ", ".
delimiter_album_artist (str, optional): Delimiter for album artist names. Defaults to ", ".
use_primary_album_artist (bool, optional): If True, uses first album artist for folder paths. Defaults to False.
Returns:
str: The formatted string for the media attribute, or the original name if no formatter matches.
"""
try:
# Try each formatter function in sequence
for formatter in (
_format_names,
_format_numbers,
_format_ids,
_format_durations,
_format_dates,
_format_metadata,
_format_volumes,
):
result = formatter(
name,
media,
album_track_num_pad_min,
list_pos,
list_total,
delimiter_artist=delimiter_artist,
delimiter_album_artist=delimiter_album_artist,
use_primary_album_artist=use_primary_album_artist,
)
if result is not None:
return result
except (AttributeError, KeyError, TypeError, ValueError) as e:
print(f"Error formatting path for media attribute '{name}': {e}")
return name
def _format_artist_names(
name: str,
media: Track | Album | Playlist | UserPlaylist | Video | Mix,
delimiter_artist: str = ", ",
delimiter_album_artist: str = ", ",
*_args,
use_primary_album_artist: bool = False,
**kwargs,
) -> str | None:
"""Handle artist name-related format strings.
Args:
name (str): The format string name to check.
media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract artist information from.
delimiter_artist (str, optional): Delimiter for artist names. Defaults to ", ".
delimiter_album_artist (str, optional): Delimiter for album artist names. Defaults to ", ".
use_primary_album_artist (bool, optional): If True, uses first album artist for folder paths. Defaults to False.
*_args (Any): Additional arguments (not used).
Returns:
str | None: The formatted artist name or None if the format string is not artist-related.
"""
if name == "artist_name" and isinstance(media, Track | Video):
# For folder paths, use album artist if setting is enabled
if use_primary_album_artist and hasattr(media, "album") and media.album and media.album.artists:
return media.album.artists[0].name
# Otherwise use track artists as before
if hasattr(media, "artists"):
return name_builder_artist(media, delimiter=delimiter_artist)
elif hasattr(media, "artist"):
return media.artist.name
elif name == "album_artist":
return name_builder_album_artist(media, first_only=True)
elif name == "album_artists":
return name_builder_album_artist(media, delimiter=delimiter_album_artist)
return None
def _format_titles(
name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs
) -> str | None:
"""Handle title-related format strings.
Args:
name (str): The format string name to check.
media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract title information from.
*_args (Any): Additional arguments (not used).
Returns:
str | None: The formatted title or None if the format string is not title-related.
"""
if name == "track_title" and isinstance(media, Track | Video):
return name_builder_title(media)
elif name == "mix_name" and isinstance(media, Mix):
return media.title
elif name == "playlist_name" and isinstance(media, Playlist | UserPlaylist):
return media.name
elif name == "album_title":
if isinstance(media, Album):
return media.name
elif isinstance(media, Track):
return media.album.name
return None
def _format_names(
name: str,
media: Track | Album | Playlist | UserPlaylist | Video | Mix,
*args,
delimiter_artist: str = ", ",
delimiter_album_artist: str = ", ",
use_primary_album_artist: bool = False,
**kwargs,
) -> str | None:
"""Handles name-related format strings for media.
Tries to format the provided name as an artist or title, returning the first matching result.
Args:
name (str): The format string name to check.
media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract name information from.
*args: Additional arguments (not used).
delimiter_artist (str, optional): Delimiter for artist names. Defaults to ", ".
delimiter_album_artist (str, optional): Delimiter for album artist names. Defaults to ", ".
use_primary_album_artist (bool, optional): If True, uses first album artist for folder paths. Defaults to False.
Returns:
str | None: The formatted name or None if the format string is not name-related.
"""
# First try artist name formats
result = _format_artist_names(
name,
media,
delimiter_artist=delimiter_artist,
delimiter_album_artist=delimiter_album_artist,
use_primary_album_artist=use_primary_album_artist,
)
if result is not None:
return result
# Then try title formats
return _format_titles(name, media)
def _format_numbers(
name: str,
media: Track | Album | Playlist | UserPlaylist | Video | Mix,
album_track_num_pad_min: int,
list_pos: int,
list_total: int,
*_args,
**kwargs,
) -> str | None:
"""Handle number-related format strings.
Args:
name (str): The format string name to check.
media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract number information from.
album_track_num_pad_min (int): Minimum padding for track numbers.
list_pos (int): Position in a list.
list_total (int): Total items in a list.
*_args (Any): Additional arguments (not used).
Returns:
str | None: The formatted number or None if the format string is not number-related.
"""
if name == "album_track_num" and isinstance(media, Track | Video):
return calculate_number_padding(
album_track_num_pad_min,
media.track_num,
media.album.num_tracks if hasattr(media, "album") else 1,
)
elif name == "album_num_tracks" and isinstance(media, Track | Video):
return str(media.album.num_tracks if hasattr(media, "album") else 1)
elif name == "list_pos" and isinstance(media, Track | Video):
# TODO: Rename `album_track_num_pad_min` globally.
return calculate_number_padding(album_track_num_pad_min, list_pos, list_total)
return None
def _format_ids(
name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs
) -> str | None:
"""Handle ID-related format strings.
Args:
name (str): The format string name to check.
media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract ID information from.
*_args (Any): Additional arguments (not used).
Returns:
str | None: The formatted ID or None if the format string is not ID-related.
"""
# Handle track and playlist IDs
if (
(name == "track_id" and isinstance(media, Track))
or (name == "playlist_id" and isinstance(media, Playlist))
or (name == "video_id" and isinstance(media, Video))
):
return str(media.id)
# Handle album IDs
elif name == "album_id":
if isinstance(media, Album):
return str(media.id)
elif isinstance(media, Track):
return str(media.album.id)
# Handle ISRC
elif name == "isrc" and isinstance(media, Track):
return media.isrc
elif name == "album_artist_id" and isinstance(media, Album):
return str(media.artist.id)
elif name == "track_artist_id" and isinstance(media, Track):
return str(media.album.artist.id)
return None
def _format_durations(
name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs
) -> str | None:
"""Handle duration-related format strings.
Args:
name (str): The format string name to check.
media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract duration information from.
*_args (Any): Additional arguments (not used).
Returns:
str | None: The formatted duration or None if the format string is not duration-related.
"""
# Format track durations
if name == "track_duration_seconds" and isinstance(media, Track | Video):
return str(media.duration)
elif name == "track_duration_minutes" and isinstance(media, Track | Video):
m, s = divmod(media.duration, 60)
return f"{m:01d}:{s:02d}"
# Format album durations
elif name == "album_duration_seconds" and isinstance(media, Album):
return str(media.duration)
elif name == "album_duration_minutes" and isinstance(media, Album):
m, s = divmod(media.duration, 60)
return f"{m:01d}:{s:02d}"
# Format playlist durations
elif name == "playlist_duration_seconds" and isinstance(media, Album):
return str(media.duration)
elif name == "playlist_duration_minutes" and isinstance(media, Album):
m, s = divmod(media.duration, 60)
return f"{m:01d}:{s:02d}"
return None
def _format_dates(
name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs
) -> str | None:
"""Handle date-related format strings.
Args:
name (str): The format string name to check.
media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract date information from.
*_args (Any): Additional arguments (not used).
Returns:
str | None: The formatted date or None if the format string is not date-related.
"""
if name == "album_year":
if isinstance(media, Album):
return str(media.year)
elif isinstance(media, Track):
return str(media.album.year)
elif name == "album_date":
if isinstance(media, Album):
return media.release_date.strftime("%Y-%m-%d") if media.release_date else None
elif isinstance(media, Track):
return media.album.release_date.strftime("%Y-%m-%d") if media.album.release_date else None
return None
def _format_metadata(
name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs
) -> str | None:
"""Handle metadata-related format strings.
Args:
name (str): The format string name to check.
media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract metadata information from.
*_args (Any): Additional arguments (not used).
Returns:
str | None: The formatted metadata or None if the format string is not metadata-related.
"""
if name == "video_quality" and isinstance(media, Video):
return media.video_quality
elif name == "track_quality" and isinstance(media, Track):
return ", ".join(tag for tag in media.media_metadata_tags if tag is not None)
elif (name == "track_explicit" and isinstance(media, Track | Video)) or (
name == "album_explicit" and isinstance(media, Album)
):
return FORMAT_TEMPLATE_EXPLICIT if media.explicit else ""
elif name == "media_type":
if isinstance(media, Album):
return media.type
elif isinstance(media, Track):
return media.album.type
return None
def _format_volumes(
name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs
) -> str | None:
"""Handle volume-related format strings.
Args:
name (str): The format string name to check.
media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract volume information from.
*_args (Any): Additional arguments (not used).
Returns:
str | None: The formatted volume information or None if the format string is not volume-related.
"""
if name == "album_num_volumes" and isinstance(media, Album):
return str(media.num_volumes)
elif name == "track_volume_num" and isinstance(media, Track | Video):
return str(media.volume_num)
elif name == "track_volume_num_optional" and isinstance(media, Track | Video):
num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1
return "" if num_volumes == 1 else str(media.volume_num)
elif name == "track_volume_num_optional_CD" and isinstance(media, Track | Video):
num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1
return "" if num_volumes == 1 else f"CD{media.volume_num!s}"
return None
def calculate_number_padding(padding_minimum: int, item_position: int, items_max: int) -> str:
"""Calculate the padded number string for an item.
Args:
padding_minimum (int): Minimum number of digits for padding.
item_position (int): The position of the item.
items_max (int): The maximum number of items.
Returns:
str: The padded number string.
"""
result: str
if items_max > 0:
count_digits = max(int(math.log10(items_max)) + 1, padding_minimum)
result = str(item_position).zfill(count_digits)
else:
result = str(item_position)
return result
def get_format_template(
media: Track | Album | Playlist | UserPlaylist | Video | Mix | MediaType, settings
) -> str | bool:
"""Get the format template for a given media type.
Args:
media (Track | Album | Playlist | UserPlaylist | Video | Mix | MediaType): The media object or type.
settings: The settings object containing format templates.
Returns:
str | bool: The format template string or False if not found.
"""
result = False
if isinstance(media, Track) or media == MediaType.TRACK:
result = settings.data.format_track
elif isinstance(media, Album) or media == MediaType.ALBUM or media == MediaType.ARTIST:
result = settings.data.format_album
elif isinstance(media, Playlist | UserPlaylist) or media == MediaType.PLAYLIST:
result = settings.data.format_playlist
elif isinstance(media, Mix) or media == MediaType.MIX:
result = settings.data.format_mix
elif isinstance(media, Video) or media == MediaType.VIDEO:
result = settings.data.format_video
return result
def path_file_sanitize(path_file: pathlib.Path, adapt: bool = False, uniquify: bool = False) -> pathlib.Path:
"""Sanitize a file path to ensure it is valid and optionally make it unique.
Args:
path_file (pathlib.Path): The file path to sanitize.
adapt (bool, optional): Whether to adapt the path in case of errors. Defaults to False.
uniquify (bool, optional): Whether to make the file name unique. Defaults to False.
Returns:
pathlib.Path: The sanitized file path.
"""
sanitized_filename = sanitize_filename(
path_file.name, replacement_text="_", validate_after_sanitize=True, platform="auto"
)
if not sanitized_filename.endswith(path_file.suffix):
sanitized_filename = (
sanitized_filename[: -len(path_file.suffix) - len(FILENAME_SANITIZE_PLACEHOLDER)]
+ FILENAME_SANITIZE_PLACEHOLDER
+ path_file.suffix
)
sanitized_path = pathlib.Path(
*[
(
sanitize_filename(part, replacement_text="_", validate_after_sanitize=True, platform="auto")
if part not in path_file.anchor
else part
)
for part in path_file.parent.parts
]
)
try:
sanitized_path = sanitize_filepath(
sanitized_path, replacement_text="_", validate_after_sanitize=True, platform="auto"
)
except ValidationError as e:
if adapt and str(e).startswith("[PV1101]"):
sanitized_path = pathlib.Path.home()
else:
raise
result = sanitized_path / sanitized_filename
return path_file_uniquify(result) if uniquify else result
def path_file_uniquify(path_file: pathlib.Path) -> pathlib.Path:
"""Ensure a file path is unique by appending a suffix if necessary.
Args:
path_file (pathlib.Path): The file path to uniquify.
Returns:
pathlib.Path: The unique file path.
"""
unique_suffix: str = file_unique_suffix(path_file)
if unique_suffix:
file_suffix = unique_suffix + path_file.suffix
# For most OS filename has a character limit of 255.
path_file = (
path_file.parent / (str(path_file.stem)[: -len(file_suffix)] + file_suffix)
if len(str(path_file.parent / (path_file.stem + unique_suffix))) > FILENAME_LENGTH_MAX
else path_file.parent / (path_file.stem + unique_suffix)
)
return path_file
def file_unique_suffix(path_file: pathlib.Path, separator: str = "_") -> str:
"""Generate a unique suffix for a file path.
Args:
path_file (pathlib.Path): The file path to check for uniqueness.
separator (str, optional): The separator to use for the suffix. Defaults to "_".
Returns:
str: The unique suffix, or an empty string if not needed.
"""
threshold_zfill: int = len(str(UNIQUIFY_THRESHOLD))
count: int = 0
path_file_tmp: pathlib.Path = deepcopy(path_file)
unique_suffix: str = ""
while check_file_exists(path_file_tmp) and count < UNIQUIFY_THRESHOLD:
count += 1
unique_suffix = separator + str(count).zfill(threshold_zfill)
path_file_tmp = path_file.parent / (path_file.stem + unique_suffix + path_file.suffix)
return unique_suffix
def check_file_exists(path_file: pathlib.Path, extension_ignore: bool = False) -> bool:
"""Check if a file exists.
Args:
path_file (pathlib.Path): The file path to check.
extension_ignore (bool, optional): Whether to ignore the file extension. Defaults to False.
Returns:
bool: True if the file exists, False otherwise.
"""
if extension_ignore:
path_file_stem: str = pathlib.Path(path_file).stem
path_parent: pathlib.Path = pathlib.Path(path_file).parent
path_files: list[str] = []
path_files.extend(str(path_parent.joinpath(path_file_stem + extension)) for extension in AudioExtensions)
else:
path_files: list[str] = [str(path_file)]
return any(os.path.isfile(_file) for _file in path_files)
def resource_path(relative_path: str) -> str:
"""Get the absolute path to a resource.
Args:
relative_path: The relative path to the resource.
Returns:
str: The absolute path to the resource.
"""
# PyInstaller creates a temp folder and stores path in _MEIPASS
base_path = getattr(sys, "_MEIPASS", os.path.abspath("."))
return os.path.join(base_path, relative_path)
def url_to_filename(url: str) -> str:
"""Convert a URL to a valid filename.
Args:
url (str): The URL to convert.
Returns:
str: The corresponding filename.
Raises:
ValueError: If the URL contains invalid characters for a filename.
"""
urlpath: str = urlsplit(url).path
basename: str = posixpath.basename(unquote(urlpath))
if os.path.basename(basename) != basename or unquote(posixpath.basename(urlpath)) != basename:
raise ValueError # reject '%2f' or 'dir%5Cbasename.ext' on Windows
return basename

274
tidal_dl_ng/helper/tidal.py Normal file
View File

@@ -0,0 +1,274 @@
from collections.abc import Callable
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
from tidalapi.artist import Artist, Role
from tidalapi.media import MediaMetadataTags, Quality
from tidalapi.session import SearchTypes
from tidalapi.user import LoggedInUser
from tidal_dl_ng.constants import FAVORITES, MediaType
from tidal_dl_ng.helper.exceptions import MediaUnknown
def name_builder_artist(media: Track | Video | Album, delimiter: str = ", ") -> str:
"""Builds a string of artist names for a track, video, or album.
Returns a delimited string of all artist names associated with the given media.
Args:
media (Track | Video | Album): The media object to extract artist names from.
delimiter (str, optional): The delimiter to use between artist names. Defaults to ", ".
Returns:
str: A delimited string of artist names.
"""
return delimiter.join(artist.name for artist in media.artists)
def name_builder_album_artist(media: Track | Album, first_only: bool = False, delimiter: str = ", ") -> str:
"""Builds a string of main album artist names for a track or album.
Returns a delimited string of main artist names from the album, optionally including only the first main artist.
Args:
media (Track | Album): The media object to extract artist names from.
first_only (bool, optional): If True, only the first main artist is included. Defaults to False.
delimiter (str, optional): The delimiter to use between artist names. Defaults to ", ".
Returns:
str: A delimited string of main album artist names.
"""
artists_tmp: [str] = []
artists: [Artist] = media.album.artists if isinstance(media, Track) else media.artists
for artist in artists:
if Role.main in artist.roles:
artists_tmp.append(artist.name)
if first_only:
break
return delimiter.join(artists_tmp)
def name_builder_title(media: Track | Video | Mix | Playlist | Album | Video) -> str:
result: str = (
media.title if isinstance(media, Mix) else media.full_name if hasattr(media, "full_name") else media.name
)
return result
def name_builder_item(media: Track | Video) -> str:
return f"{name_builder_artist(media)} - {name_builder_title(media)}"
def get_tidal_media_id(url_or_id_media: str) -> str:
id_dirty = url_or_id_media.rsplit("/", 1)[-1]
id_media = id_dirty.rsplit("?", 1)[0]
return id_media
def get_tidal_media_type(url_media: str) -> MediaType | bool:
result: MediaType | bool = False
url_split = url_media.split("/")[-2]
if len(url_split) > 1:
media_name = url_media.split("/")[-2]
if media_name == "track":
result = MediaType.TRACK
elif media_name == "video":
result = MediaType.VIDEO
elif media_name == "album":
result = MediaType.ALBUM
elif media_name == "playlist":
result = MediaType.PLAYLIST
elif media_name == "mix":
result = MediaType.MIX
elif media_name == "artist":
result = MediaType.ARTIST
return result
def url_ending_clean(url: str) -> str:
"""Checks if a link ends with "/u" or "?u" and removes that part.
Args:
url (str): The URL to clean.
Returns:
str: The cleaned URL.
"""
return url[:-2] if url.endswith("/u") or url.endswith("?u") else url
def search_results_all(session: Session, needle: str, types_media: SearchTypes = None) -> dict[str, [SearchTypes]]:
limit: int = 300
offset: int = 0
done: bool = False
result: dict[str, [SearchTypes]] = {}
while not done:
tmp_result: dict[str, [SearchTypes]] = session.search(
query=needle, models=types_media, limit=limit, offset=offset
)
tmp_done: bool = True
for key, value in tmp_result.items():
# Append pagination results, if there are any
if offset == 0:
result = tmp_result
tmp_done = False
elif bool(value):
result[key] += value
tmp_done = False
# Next page
offset += limit
done = tmp_done
return result
def items_results_all(
media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True
) -> [Track | Video | Album]:
result: [Track | Video | Album] = []
if isinstance(media_list, Mix):
result = media_list.items()
else:
func_get_items_media: [Callable] = []
if isinstance(media_list, Playlist | Album):
if videos_include:
func_get_items_media.append(media_list.items)
else:
func_get_items_media.append(media_list.tracks)
else:
func_get_items_media.append(media_list.get_albums)
func_get_items_media.append(media_list.get_ep_singles)
result = paginate_results(func_get_items_media)
return result
def all_artist_album_ids(media_artist: Artist) -> [int | None]:
result: [int] = []
func_get_items_media: [Callable] = [media_artist.get_albums, media_artist.get_ep_singles]
albums: [Album] = paginate_results(func_get_items_media)
for album in albums:
result.append(album.id)
return result
def paginate_results(func_get_items_media: [Callable]) -> [Track | Video | Album | Playlist | UserPlaylist]:
result: [Track | Video | Album] = []
for func_media in func_get_items_media:
limit: int = 100
offset: int = 0
done: bool = False
if func_media.__func__ == LoggedInUser.playlist_and_favorite_playlists:
limit: int = 50
while not done:
tmp_result: [Track | Video | Album | Playlist | UserPlaylist] = func_media(limit=limit, offset=offset)
if bool(tmp_result):
result += tmp_result
# Get the next page in the next iteration.
offset += limit
else:
done = True
return result
def user_media_lists(session: Session) -> dict[str, list]:
"""Fetch user media lists using tidalapi's built-in pagination where available.
Returns a dictionary with 'playlists' and 'mixes' keys containing lists of media items.
For playlists, includes both Folder and Playlist objects at the root level.
Args:
session (Session): TIDAL session object.
Returns:
dict[str, list]: Dictionary with 'playlists' (includes Folder and Playlist) and 'mixes' lists.
"""
# Use built-in pagination for playlists (root level only)
playlists = session.user.favorites.playlists_paginated()
# Fetch root-level folders manually (no paginated version available)
folders = []
offset = 0
limit = 50
while True:
batch = session.user.favorites.playlist_folders(limit=limit, offset=offset, parent_folder_id="root")
if not batch:
break
folders.extend(batch)
if len(batch) < limit:
break
offset += limit
# Combine folders and playlists
all_playlists = folders + playlists
# Get mixes
user_mixes = session.mixes().categories[0].items
return {"playlists": all_playlists, "mixes": user_mixes}
def instantiate_media(
session: Session,
media_type: type[MediaType.TRACK, MediaType.VIDEO, MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX],
id_media: str,
) -> Track | Video | Album | Playlist | Mix | Artist:
if media_type == MediaType.TRACK:
media = session.track(id_media, with_album=True)
elif media_type == MediaType.VIDEO:
media = session.video(id_media)
elif media_type == MediaType.ALBUM:
media = session.album(id_media)
elif media_type == MediaType.PLAYLIST:
media = session.playlist(id_media)
elif media_type == MediaType.MIX:
media = session.mix(id_media)
elif media_type == MediaType.ARTIST:
media = session.artist(id_media)
else:
raise MediaUnknown
return media
def quality_audio_highest(media: Track | Album) -> Quality:
quality: Quality
if MediaMetadataTags.hi_res_lossless in media.media_metadata_tags:
quality = Quality.hi_res_lossless
elif MediaMetadataTags.lossless in media.media_metadata_tags:
quality = Quality.high_lossless
else:
quality = media.audio_quality
return quality
def favorite_function_factory(tidal, favorite_item: str):
function_name: str = FAVORITES[favorite_item]["function_name"]
function_list: Callable = getattr(tidal.session.user.favorites, function_name)
return function_list

View File

@@ -0,0 +1,26 @@
from collections.abc import Callable
class LoggerWrapped:
fn_print: Callable = None
def __init__(self, fn_print: Callable):
self.fn_print = fn_print
def debug(self, value):
self.fn_print(value)
def warning(self, value):
self.fn_print(value)
def info(self, value):
self.fn_print(value)
def error(self, value):
self.fn_print(value)
def critical(self, value):
self.fn_print(value)
def exception(self, value):
self.fn_print(value)

65
tidal_dl_ng/logger.py Normal file
View File

@@ -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)

206
tidal_dl_ng/metadata.py Normal file
View File

@@ -0,0 +1,206 @@
import pathlib
import mutagen
from mutagen import flac, id3, mp4
from mutagen.id3 import APIC, SYLT, TALB, TCOM, TCOP, TDRC, TIT2, TOPE, TPE1, TRCK, TSRC, TXXX, USLT, WOAS
class Metadata:
path_file: str | pathlib.Path
title: str
album: str
albumartist: str
artists: str
copy_right: str
tracknumber: int
discnumber: int
totaldisc: int
totaltrack: int
date: str
composer: str
isrc: str
lyrics: str
lyrics_unsynced: str
path_cover: str
cover_data: bytes
album_replay_gain: float
album_peak_amplitude: float
track_replay_gain: float
track_peak_amplitude: float
url_share: str
replay_gain_write: bool
upc: str
target_upc: dict[str, str]
explicit: bool
m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC
def __init__(
self,
path_file: str | pathlib.Path,
target_upc: dict[str, 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 = "",
lyrics_unsynced: str = "",
cover_data: bytes = None,
album_replay_gain: float = 1.0,
album_peak_amplitude: float = 1.0,
track_replay_gain: float = 1.0,
track_peak_amplitude: float = 1.0,
url_share: str = "",
replay_gain_write: bool = True,
upc: str = "",
explicit: bool = False,
):
self.path_file = path_file
self.title = title
self.album = album
self.albumartist = albumartist
self.artists = artists
self.copy_right = copy_right
self.tracknumber = tracknumber
self.discnumber = discnumber
self.totaldisc = totaldisc
self.totaltrack = totaltrack
self.date = date
self.composer = composer
self.isrc = isrc
self.lyrics = lyrics
self.lyrics_unsynced = lyrics_unsynced
self.cover_data = cover_data
self.album_replay_gain = album_replay_gain
self.album_peak_amplitude = album_peak_amplitude
self.track_replay_gain = track_replay_gain
self.track_peak_amplitude = track_peak_amplitude
self.url_share = url_share
self.replay_gain_write = replay_gain_write
self.upc = upc
self.target_upc = target_upc
self.explicit = explicit
self.m: mutagen.FileType = mutagen.File(self.path_file)
def _cover(self) -> bool:
result: bool = False
if self.cover_data:
if isinstance(self.m, mutagen.flac.FLAC):
flac_cover = flac.Picture()
flac_cover.type = id3.PictureType.COVER_FRONT
flac_cover.data = self.cover_data
flac_cover.mime = "image/jpeg"
self.m.clear_pictures()
self.m.add_picture(flac_cover)
elif isinstance(self.m, mutagen.mp3.MP3):
self.m.tags.add(APIC(encoding=3, data=self.cover_data))
elif isinstance(self.m, mutagen.mp4.MP4):
cover_mp4 = mp4.MP4Cover(self.cover_data)
self.m.tags["covr"] = [cover_mp4]
result = True
return result
def save(self):
if not self.m.tags:
self.m.add_tags()
if isinstance(self.m, mutagen.flac.FLAC):
self.set_flac()
elif isinstance(self.m, mutagen.mp3.MP3):
self.set_mp3()
elif isinstance(self.m, mutagen.mp4.MP4):
self.set_mp4()
self._cover()
self.cleanup_tags()
self.m.save()
return True
def set_flac(self):
self.m.tags["TITLE"] = self.title
self.m.tags["ALBUM"] = self.album
self.m.tags["ALBUMARTIST"] = self.albumartist
self.m.tags["ARTIST"] = self.artists
self.m.tags["COPYRIGHT"] = self.copy_right
self.m.tags["TRACKNUMBER"] = str(self.tracknumber)
self.m.tags["TRACKTOTAL"] = str(self.totaltrack)
self.m.tags["DISCNUMBER"] = str(self.discnumber)
self.m.tags["DISCTOTAL"] = str(self.totaldisc)
self.m.tags["DATE"] = self.date
self.m.tags["COMPOSER"] = self.composer
self.m.tags["ISRC"] = self.isrc
self.m.tags["LYRICS"] = self.lyrics
self.m.tags["UNSYNCEDLYRICS"] = self.lyrics_unsynced
self.m.tags["URL"] = self.url_share
self.m.tags[self.target_upc["FLAC"]] = self.upc
if self.replay_gain_write:
self.m.tags["REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain)
self.m.tags["REPLAYGAIN_ALBUM_PEAK"] = str(self.album_peak_amplitude)
self.m.tags["REPLAYGAIN_TRACK_GAIN"] = str(self.track_replay_gain)
self.m.tags["REPLAYGAIN_TRACK_PEAK"] = str(self.track_peak_amplitude)
def set_mp3(self):
# ID3 Frame (tags) overview: https://exiftool.org/TagNames/ID3.html / https://id3.org/id3v2.3.0
# Mapping overview: https://docs.mp3tag.de/mapping/
self.m.tags.add(TIT2(encoding=3, text=self.title))
self.m.tags.add(TALB(encoding=3, text=self.album))
self.m.tags.add(TOPE(encoding=3, text=self.albumartist))
self.m.tags.add(TPE1(encoding=3, text=self.artists))
self.m.tags.add(TCOP(encoding=3, text=self.copy_right))
self.m.tags.add(TRCK(encoding=3, text=str(self.tracknumber)))
self.m.tags.add(TDRC(encoding=3, text=self.date))
self.m.tags.add(TCOM(encoding=3, text=self.composer))
self.m.tags.add(TSRC(encoding=3, text=self.isrc))
self.m.tags.add(SYLT(encoding=3, desc="text", text=self.lyrics))
self.m.tags.add(USLT(encoding=3, desc="text", text=self.lyrics_unsynced))
self.m.tags.add(WOAS(encoding=3, text=self.isrc))
self.m.tags.add(TXXX(encoding=3, desc=self.target_upc["MP3"], text=self.upc))
if self.replay_gain_write:
self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_ALBUM_GAIN", text=str(self.album_replay_gain)))
self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_ALBUM_PEAK", text=str(self.album_peak_amplitude)))
self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_TRACK_GAIN", text=str(self.track_replay_gain)))
self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_TRACK_PEAK", text=str(self.track_peak_amplitude)))
def set_mp4(self):
self.m.tags["\xa9nam"] = self.title
self.m.tags["\xa9alb"] = self.album
self.m.tags["aART"] = self.albumartist
self.m.tags["\xa9ART"] = self.artists
self.m.tags["cprt"] = self.copy_right
self.m.tags["trkn"] = [[self.tracknumber, self.totaltrack]]
self.m.tags["disk"] = [[self.discnumber, self.totaldisc]]
# self.m.tags['\xa9gen'] = self.genre
self.m.tags["\xa9day"] = self.date
self.m.tags["\xa9wrt"] = self.composer
self.m.tags["\xa9lyr"] = self.lyrics
self.m.tags["----:com.apple.iTunes:UNSYNCEDLYRICS"] = self.lyrics_unsynced.encode("utf-8")
self.m.tags["isrc"] = self.isrc
self.m.tags["\xa9url"] = self.url_share
self.m.tags[f"----:com.apple.iTunes:{self.target_upc['MP4']}"] = self.upc.encode("utf-8")
self.m.tags["rtng"] = [1 if self.explicit else 0]
if self.replay_gain_write:
self.m.tags["----:com.apple.iTunes:REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain).encode("utf-8")
self.m.tags["----:com.apple.iTunes:REPLAYGAIN_ALBUM_PEAK"] = str(self.album_peak_amplitude).encode("utf-8")
self.m.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN"] = str(self.track_replay_gain).encode("utf-8")
self.m.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK"] = str(self.track_peak_amplitude).encode("utf-8")
def cleanup_tags(self):
# Collect keys to delete first to avoid RuntimeError during iteration
keys_to_delete = [key for key, value in self.m.tags.items() if value == "" or value == [""]]
for key in keys_to_delete:
del self.m.tags[key]

View File

145
tidal_dl_ng/model/cfg.py Normal file
View File

@@ -0,0 +1,145 @@
from dataclasses import dataclass
from dataclasses_json import dataclass_json
from tidalapi import Quality
from tidal_dl_ng.constants import CoverDimensions, MetadataTargetUPC, QualityVideo
@dataclass_json
@dataclass
class Settings:
skip_existing: bool = True
lyrics_embed: bool = False
lyrics_file: bool = False
use_primary_album_artist: bool = (
False # When True, uses first album artist instead of track artists for folder paths
)
# TODO: Implement API KEY selection.
# api_key_index: bool = 0
# TODO: Implement album info download to separate file.
# album_info_save: bool = False
video_download: bool = True
# TODO: Implement multi threading for downloads.
# multi_thread: bool = False
download_delay: bool = True
download_base_path: str = "~/download"
quality_audio: Quality = Quality.low_320k
quality_video: QualityVideo = QualityVideo.P480
download_dolby_atmos: bool = False
format_album: str = (
"Albums/{album_artist} - {album_title}{album_explicit}/{track_volume_num_optional}"
"{album_track_num}. {artist_name} - {track_title}{album_explicit}"
)
format_playlist: str = "Playlists/{playlist_name}/{list_pos}. {artist_name} - {track_title}"
format_mix: str = "Mix/{mix_name}/{artist_name} - {track_title}"
format_track: str = "Tracks/{artist_name} - {track_title}{track_explicit}"
format_video: str = "Videos/{artist_name} - {track_title}{track_explicit}"
video_convert_mp4: bool = True
path_binary_ffmpeg: str = ""
metadata_cover_dimension: CoverDimensions = CoverDimensions.Px320
metadata_cover_embed: bool = True
mark_explicit: bool = False
cover_album_file: bool = True
extract_flac: bool = True
downloads_simultaneous_per_track_max: int = 20
download_delay_sec_min: float = 3.0
download_delay_sec_max: float = 5.0
album_track_num_pad_min: int = 1
downloads_concurrent_max: int = 3
symlink_to_track: bool = False
playlist_create: bool = False
metadata_replay_gain: bool = False
metadata_write_url: bool = True
window_x: int = 50
window_y: int = 50
window_w: int = 1200
window_h: int = 800
metadata_delimiter_artist: str = ", "
metadata_delimiter_album_artist: str = ", "
filename_delimiter_artist: str = ", "
filename_delimiter_album_artist: str = ", "
metadata_target_upc: MetadataTargetUPC = MetadataTargetUPC.UPC
# Rate limiting for API calls (tweaking variables)
api_rate_limit_batch_size: int = 20 # Number of albums to process before applying rate limit delay
api_rate_limit_delay_sec: float = 3.0 # Delay in seconds between batches to avoid rate limiting
@dataclass_json
@dataclass
class HelpSettings:
skip_existing: str = "Skip download if file already exists."
album_cover_save: str = "Save cover to album folder."
lyrics_embed: str = "Embed lyrics in audio file, if lyrics are available."
use_primary_album_artist: str = "Use only the primary album artist for folder paths instead of track artists."
lyrics_file: str = "Save lyrics to separate *.lrc file, if lyrics are available."
api_key_index: str = "Set the device API KEY."
album_info_save: str = "Save album info to track?"
video_download: str = "Allow download of videos."
multi_thread: str = "Download several tracks in parallel."
download_delay: str = "Activate randomized download delay to mimic human behaviour."
download_base_path: str = "Where to store the downloaded media."
quality_audio: str = (
'Desired audio download quality: "LOW" (96kbps), "HIGH" (320kbps), '
'"LOSSLESS" (16 Bit, 44,1 kHz), '
'"HI_RES_LOSSLESS" (up to 24 Bit, 192 kHz)'
)
quality_video: str = 'Desired video download quality: "360", "480", "720", "1080"'
download_dolby_atmos: str = "Download Dolby Atmos audio streams if available."
# TODO: Describe possible variables.
format_album: str = "Where to download albums and how to name the items."
format_playlist: str = "Where to download playlists and how to name the items."
format_mix: str = "Where to download mixes and how to name the items."
format_track: str = "Where to download tracks and how to name the items."
format_video: str = "Where to download videos and how to name the items."
video_convert_mp4: str = (
"Videos are downloaded as MPEG Transport Stream (TS) files. With this option each video "
"will be converted to MP4. FFmpeg must be installed."
)
path_binary_ffmpeg: str = (
"Path to FFmpeg binary file (executable). Only necessary if FFmpeg is not set in $PATH. Mandatory for Windows: "
"The directory of `ffmpeg.exe` must be set in %PATH%."
)
metadata_cover_dimension: str = (
"The dimensions of the cover image embedded into the track. Possible values: 320x320, 640x640, 1280x1280."
)
metadata_cover_embed: str = "Embed album cover into file."
mark_explicit: str = "Mark explicit tracks with '🅴' in track title (only applies to metadata)."
cover_album_file: str = "Save cover to 'cover.jpg', if an album is downloaded."
extract_flac: str = "Extract FLAC audio tracks from MP4 containers and save them as `*.flac` (uses FFmpeg)."
downloads_simultaneous_per_track_max: str = "Maximum number of simultaneous chunk downloads per track."
download_delay_sec_min: str = "Lower boundary for the calculation of the download delay in seconds."
download_delay_sec_max: str = "Upper boundary for the calculation of the download delay in seconds."
album_track_num_pad_min: str = (
"Minimum length of the album track count, will be padded with zeroes (0). To disable padding set this to 1."
)
downloads_concurrent_max: str = "Maximum concurrent number of downloads (threads)."
symlink_to_track: str = (
"If enabled the tracks of albums, playlists and mixes will be downloaded to the track directory but symlinked "
"accordingly."
)
playlist_create: str = "Creates a '_playlist.m3u8' file for downloaded albums, playlists and mixes."
metadata_replay_gain: str = "Replay gain information will be written to metadata."
metadata_write_url: str = "URL of the media file will be written to metadata."
window_x: str = "X-Coordinate of saved window location."
window_y: str = "Y-Coordinate of saved window location."
window_w: str = "Width of saved window size."
window_h: str = "Height of saved window size."
metadata_delimiter_artist: str = "Metadata tag delimiter for multiple artists. Default: ', '"
metadata_delimiter_album_artist: str = "Metadata tag delimiter for multiple album artists. Default: ', '"
filename_delimiter_artist: str = "Filename delimiter for multiple artists. Default: ', '"
filename_delimiter_album_artist: str = "Filename delimiter for multiple album artists. Default: ', '"
metadata_target_upc: str = (
"Select the target metadata tag ('UPC', 'BARCODE', 'EAN') where to write the UPC information to. Default: 'UPC'."
)
api_rate_limit_batch_size: str = "Number of albums to process before applying rate limit delay (tweaking variable)."
api_rate_limit_delay_sec: str = "Delay in seconds between batches to avoid API rate limiting (tweaking 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

View File

@@ -0,0 +1,24 @@
import pathlib
from dataclasses import dataclass
from requests import HTTPError
from tidalapi.media import Stream, StreamManifest
@dataclass
class DownloadSegmentResult:
result: bool
url: str
path_segment: pathlib.Path
id_segment: int
error: HTTPError | None = None
@dataclass
class TrackStreamInfo:
"""Container for track stream information."""
stream_manifest: StreamManifest | None
file_extension: str
requires_flac_extraction: bool
media_stream: Stream | None

View File

@@ -0,0 +1,50 @@
from dataclasses import dataclass
from tidalapi.media import Quality
from tidal_dl_ng.constants import QualityVideo
try:
from PySide6 import QtCore
@dataclass
class ProgressBars:
item: QtCore.Signal
item_name: QtCore.Signal
list_item: QtCore.Signal
list_name: QtCore.Signal
except ModuleNotFoundError:
class ProgressBars:
pass
@dataclass
class ResultItem:
position: int
artist: str
title: str
album: str
duration_sec: int
obj: object
quality: str
explicit: bool
date_user_added: str
date_release: str
@dataclass
class StatusbarMessage:
message: str
timeout: int = 0
@dataclass
class QueueDownloadItem:
status: str
name: str
type_media: str
quality_audio: Quality
quality_video: QualityVideo
obj: object

14
tidal_dl_ng/model/meta.py Normal file
View File

@@ -0,0 +1,14 @@
from dataclasses import dataclass
@dataclass
class ReleaseLatest:
version: str
url: str
release_info: str
@dataclass
class ProjectInformation:
version: str
repository_url: str

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,119 @@
################################################################################
## Form generated from reading UI file 'dialog_login.ui'
##
## Created by: Qt User Interface Compiler version 6.8.0
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import QCoreApplication, QMetaObject, QRect, Qt
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QDialogButtonBox, QHBoxLayout, QLabel, QSizePolicy, QTextBrowser, QVBoxLayout, QWidget
class Ui_DialogLogin:
def setupUi(self, DialogLogin):
if not DialogLogin.objectName():
DialogLogin.setObjectName("DialogLogin")
DialogLogin.resize(451, 400)
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(DialogLogin.sizePolicy().hasHeightForWidth())
DialogLogin.setSizePolicy(sizePolicy)
self.bb_dialog = QDialogButtonBox(DialogLogin)
self.bb_dialog.setObjectName("bb_dialog")
self.bb_dialog.setGeometry(QRect(20, 350, 411, 32))
sizePolicy.setHeightForWidth(self.bb_dialog.sizePolicy().hasHeightForWidth())
self.bb_dialog.setSizePolicy(sizePolicy)
self.bb_dialog.setLayoutDirection(Qt.LayoutDirection.LeftToRight)
self.bb_dialog.setStyleSheet("")
self.bb_dialog.setOrientation(Qt.Orientation.Horizontal)
self.bb_dialog.setStandardButtons(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok)
self.verticalLayoutWidget = QWidget(DialogLogin)
self.verticalLayoutWidget.setObjectName("verticalLayoutWidget")
self.verticalLayoutWidget.setGeometry(QRect(20, 20, 411, 325))
self.lv_main = QVBoxLayout(self.verticalLayoutWidget)
self.lv_main.setObjectName("lv_main")
self.lv_main.setContentsMargins(0, 0, 0, 0)
self.l_header = QLabel(self.verticalLayoutWidget)
self.l_header.setObjectName("l_header")
sizePolicy.setHeightForWidth(self.l_header.sizePolicy().hasHeightForWidth())
self.l_header.setSizePolicy(sizePolicy)
font = QFont()
font.setPointSize(23)
font.setBold(True)
self.l_header.setFont(font)
self.lv_main.addWidget(self.l_header)
self.l_description = QLabel(self.verticalLayoutWidget)
self.l_description.setObjectName("l_description")
sizePolicy.setHeightForWidth(self.l_description.sizePolicy().hasHeightForWidth())
self.l_description.setSizePolicy(sizePolicy)
font1 = QFont()
font1.setItalic(True)
self.l_description.setFont(font1)
self.l_description.setWordWrap(True)
self.lv_main.addWidget(self.l_description)
self.tb_url_login = QTextBrowser(self.verticalLayoutWidget)
self.tb_url_login.setObjectName("tb_url_login")
self.tb_url_login.setOpenExternalLinks(True)
self.lv_main.addWidget(self.tb_url_login)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.l_expires_description = QLabel(self.verticalLayoutWidget)
self.l_expires_description.setObjectName("l_expires_description")
self.horizontalLayout.addWidget(self.l_expires_description)
self.l_expires_date_time = QLabel(self.verticalLayoutWidget)
self.l_expires_date_time.setObjectName("l_expires_date_time")
font2 = QFont()
font2.setBold(True)
self.l_expires_date_time.setFont(font2)
self.l_expires_date_time.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTrailing | Qt.AlignmentFlag.AlignVCenter
)
self.horizontalLayout.addWidget(self.l_expires_date_time)
self.lv_main.addLayout(self.horizontalLayout)
self.l_hint = QLabel(self.verticalLayoutWidget)
self.l_hint.setObjectName("l_hint")
sizePolicy.setHeightForWidth(self.l_hint.sizePolicy().hasHeightForWidth())
self.l_hint.setSizePolicy(sizePolicy)
self.l_hint.setFont(font1)
self.l_hint.setWordWrap(True)
self.lv_main.addWidget(self.l_hint)
self.retranslateUi(DialogLogin)
self.bb_dialog.accepted.connect(DialogLogin.accept)
self.bb_dialog.rejected.connect(DialogLogin.reject)
QMetaObject.connectSlotsByName(DialogLogin)
# setupUi
def retranslateUi(self, DialogLogin):
DialogLogin.setWindowTitle(QCoreApplication.translate("DialogLogin", "Dialog", None))
self.l_header.setText(QCoreApplication.translate("DialogLogin", "TIDAL Login (as Device)", None))
self.l_description.setText(
QCoreApplication.translate(
"DialogLogin",
"Click the link below and login with your TIDAL credentials. TIDAL will ask you, if you like to add this app as a new device. You need to confirm this.",
None,
)
)
self.tb_url_login.setPlaceholderText(QCoreApplication.translate("DialogLogin", "Copy this login URL...", None))
self.l_expires_description.setText(QCoreApplication.translate("DialogLogin", "This link expires at:", None))
self.l_expires_date_time.setText(QCoreApplication.translate("DialogLogin", "COMPUTING", None))
self.l_hint.setText(QCoreApplication.translate("DialogLogin", "Waiting...", None))
# retranslateUi

View File

@@ -0,0 +1,195 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>DialogLogin</class>
<widget class="QDialog" name="DialogLogin">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>451</width>
<height>400</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<widget class="QDialogButtonBox" name="bb_dialog">
<property name="geometry">
<rect>
<x>20</x>
<y>350</y>
<width>411</width>
<height>32</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="layoutDirection">
<enum>Qt::LayoutDirection::LeftToRight</enum>
</property>
<property name="styleSheet">
<string notr="true"/>
</property>
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
</property>
</widget>
<widget class="QWidget" name="verticalLayoutWidget">
<property name="geometry">
<rect>
<x>20</x>
<y>20</y>
<width>411</width>
<height>325</height>
</rect>
</property>
<layout class="QVBoxLayout" name="lv_main">
<item>
<widget class="QLabel" name="l_header">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<pointsize>23</pointsize>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>TIDAL Login (as Device)</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_description">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<italic>true</italic>
</font>
</property>
<property name="text">
<string>Click the link below and login with your TIDAL credentials. TIDAL will ask you, if you like to add this app as a new device. You need to confirm this.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QTextBrowser" name="tb_url_login">
<property name="placeholderText">
<string>Copy this login URL...</string>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="l_expires_description">
<property name="text">
<string>This link expires at:</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_expires_date_time">
<property name="font">
<font>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>COMPUTING</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="l_hint">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<italic>true</italic>
</font>
</property>
<property name="text">
<string>Waiting...</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
<resources/>
<connections>
<connection>
<sender>bb_dialog</sender>
<signal>accepted()</signal>
<receiver>DialogLogin</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>bb_dialog</sender>
<signal>rejected()</signal>
<receiver>DialogLogin</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,660 @@
################################################################################
## Form generated from reading UI file 'dialog_settings.ui'
##
## Created by: Qt User Interface Compiler version 6.10.0
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import QCoreApplication, QMetaObject, Qt
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialogButtonBox,
QGroupBox,
QHBoxLayout,
QLabel,
QLayout,
QLineEdit,
QPushButton,
QSizePolicy,
QSpinBox,
QVBoxLayout,
)
class Ui_DialogSettings:
def setupUi(self, DialogSettings):
if not DialogSettings.objectName():
DialogSettings.setObjectName("DialogSettings")
DialogSettings.resize(640, 832)
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(100)
sizePolicy.setVerticalStretch(100)
sizePolicy.setHeightForWidth(DialogSettings.sizePolicy().hasHeightForWidth())
DialogSettings.setSizePolicy(sizePolicy)
DialogSettings.setSizeGripEnabled(True)
self.lv_dialog_settings = QVBoxLayout(DialogSettings)
self.lv_dialog_settings.setObjectName("lv_dialog_settings")
self.lv_dialog_settings.setContentsMargins(0, 0, 0, 0)
self.lv_main = QVBoxLayout()
self.lv_main.setObjectName("lv_main")
self.lv_main.setContentsMargins(12, 12, 12, 12)
self.gb_flags = QGroupBox(DialogSettings)
self.gb_flags.setObjectName("gb_flags")
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
sizePolicy1.setHorizontalStretch(100)
sizePolicy1.setVerticalStretch(100)
sizePolicy1.setHeightForWidth(self.gb_flags.sizePolicy().hasHeightForWidth())
self.gb_flags.setSizePolicy(sizePolicy1)
self.gb_flags.setFlat(False)
self.gb_flags.setCheckable(False)
self.lv_flags = QVBoxLayout(self.gb_flags)
self.lv_flags.setObjectName("lv_flags")
self.lh_flags_1 = QHBoxLayout()
self.lh_flags_1.setObjectName("lh_flags_1")
self.lv_flag_video_download = QVBoxLayout()
self.lv_flag_video_download.setObjectName("lv_flag_video_download")
self.cb_video_download = QCheckBox(self.gb_flags)
self.cb_video_download.setObjectName("cb_video_download")
sizePolicy1.setHeightForWidth(self.cb_video_download.sizePolicy().hasHeightForWidth())
self.cb_video_download.setSizePolicy(sizePolicy1)
self.lv_flag_video_download.addWidget(self.cb_video_download)
self.lh_flags_1.addLayout(self.lv_flag_video_download)
self.lv_flag_video_convert = QVBoxLayout()
self.lv_flag_video_convert.setObjectName("lv_flag_video_convert")
self.cb_video_convert_mp4 = QCheckBox(self.gb_flags)
self.cb_video_convert_mp4.setObjectName("cb_video_convert_mp4")
sizePolicy1.setHeightForWidth(self.cb_video_convert_mp4.sizePolicy().hasHeightForWidth())
self.cb_video_convert_mp4.setSizePolicy(sizePolicy1)
self.lv_flag_video_convert.addWidget(self.cb_video_convert_mp4)
self.lh_flags_1.addLayout(self.lv_flag_video_convert)
self.lv_flags.addLayout(self.lh_flags_1)
self.lh_flags_2 = QHBoxLayout()
self.lh_flags_2.setObjectName("lh_flags_2")
self.lv_flag_lyrics_embed = QVBoxLayout()
self.lv_flag_lyrics_embed.setObjectName("lv_flag_lyrics_embed")
self.cb_lyrics_embed = QCheckBox(self.gb_flags)
self.cb_lyrics_embed.setObjectName("cb_lyrics_embed")
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
sizePolicy2.setHorizontalStretch(0)
sizePolicy2.setVerticalStretch(0)
sizePolicy2.setHeightForWidth(self.cb_lyrics_embed.sizePolicy().hasHeightForWidth())
self.cb_lyrics_embed.setSizePolicy(sizePolicy2)
self.lv_flag_lyrics_embed.addWidget(self.cb_lyrics_embed)
self.lh_flags_2.addLayout(self.lv_flag_lyrics_embed)
self.lv_flag_lyrics_file = QVBoxLayout()
self.lv_flag_lyrics_file.setObjectName("lv_flag_lyrics_file")
self.cb_lyrics_file = QCheckBox(self.gb_flags)
self.cb_lyrics_file.setObjectName("cb_lyrics_file")
sizePolicy1.setHeightForWidth(self.cb_lyrics_file.sizePolicy().hasHeightForWidth())
self.cb_lyrics_file.setSizePolicy(sizePolicy1)
self.lv_flag_lyrics_file.addWidget(self.cb_lyrics_file)
self.lh_flags_2.addLayout(self.lv_flag_lyrics_file)
self.lv_flags.addLayout(self.lh_flags_2)
self.lh_flag_3 = QHBoxLayout()
self.lh_flag_3.setObjectName("lh_flag_3")
self.lv_flag_download_delay = QVBoxLayout()
self.lv_flag_download_delay.setObjectName("lv_flag_download_delay")
self.cb_download_delay = QCheckBox(self.gb_flags)
self.cb_download_delay.setObjectName("cb_download_delay")
sizePolicy1.setHeightForWidth(self.cb_download_delay.sizePolicy().hasHeightForWidth())
self.cb_download_delay.setSizePolicy(sizePolicy1)
self.lv_flag_download_delay.addWidget(self.cb_download_delay)
self.lh_flag_3.addLayout(self.lv_flag_download_delay)
self.lv_flag_extract_flac = QVBoxLayout()
self.lv_flag_extract_flac.setObjectName("lv_flag_extract_flac")
self.cb_extract_flac = QCheckBox(self.gb_flags)
self.cb_extract_flac.setObjectName("cb_extract_flac")
sizePolicy2.setHeightForWidth(self.cb_extract_flac.sizePolicy().hasHeightForWidth())
self.cb_extract_flac.setSizePolicy(sizePolicy2)
self.lv_flag_extract_flac.addWidget(self.cb_extract_flac)
self.lh_flag_3.addLayout(self.lv_flag_extract_flac)
self.lv_flags.addLayout(self.lh_flag_3)
self.lh_flags_4 = QHBoxLayout()
self.lh_flags_4.setObjectName("lh_flags_4")
self.lv_flag_metadata_cover_embed = QVBoxLayout()
self.lv_flag_metadata_cover_embed.setObjectName("lv_flag_metadata_cover_embed")
self.cb_metadata_cover_embed = QCheckBox(self.gb_flags)
self.cb_metadata_cover_embed.setObjectName("cb_metadata_cover_embed")
self.lv_flag_metadata_cover_embed.addWidget(self.cb_metadata_cover_embed)
self.lh_flags_4.addLayout(self.lv_flag_metadata_cover_embed)
self.lv_flag_cover_album_file = QVBoxLayout()
self.lv_flag_cover_album_file.setObjectName("lv_flag_cover_album_file")
self.cb_cover_album_file = QCheckBox(self.gb_flags)
self.cb_cover_album_file.setObjectName("cb_cover_album_file")
self.lv_flag_cover_album_file.addWidget(self.cb_cover_album_file)
self.lh_flags_4.addLayout(self.lv_flag_cover_album_file)
self.lv_flags.addLayout(self.lh_flags_4)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.lv_flag_skip_existing = QVBoxLayout()
self.lv_flag_skip_existing.setObjectName("lv_flag_skip_existing")
self.cb_skip_existing = QCheckBox(self.gb_flags)
self.cb_skip_existing.setObjectName("cb_skip_existing")
self.lv_flag_skip_existing.addWidget(self.cb_skip_existing)
self.horizontalLayout.addLayout(self.lv_flag_skip_existing)
self.lv_symlink_to_track = QVBoxLayout()
self.lv_symlink_to_track.setObjectName("lv_symlink_to_track")
self.cb_symlink_to_track = QCheckBox(self.gb_flags)
self.cb_symlink_to_track.setObjectName("cb_symlink_to_track")
self.lv_symlink_to_track.addWidget(self.cb_symlink_to_track)
self.horizontalLayout.addLayout(self.lv_symlink_to_track)
self.lv_flags.addLayout(self.horizontalLayout)
self.horizontalLayout_12 = QHBoxLayout()
self.horizontalLayout_12.setObjectName("horizontalLayout_12")
self.lv_playlist_create = QVBoxLayout()
self.lv_playlist_create.setObjectName("lv_playlist_create")
self.cb_playlist_create = QCheckBox(self.gb_flags)
self.cb_playlist_create.setObjectName("cb_playlist_create")
self.lv_playlist_create.addWidget(self.cb_playlist_create)
self.horizontalLayout_12.addLayout(self.lv_playlist_create)
self.lv_mark_explicit = QVBoxLayout()
self.lv_mark_explicit.setObjectName("lv_mark_explicit")
self.cb_mark_explicit = QCheckBox(self.gb_flags)
self.cb_mark_explicit.setObjectName("cb_mark_explicit")
self.lv_mark_explicit.addWidget(self.cb_mark_explicit)
self.horizontalLayout_12.addLayout(self.lv_mark_explicit)
self.lv_flags.addLayout(self.horizontalLayout_12)
self.horizontalLayout_13 = QHBoxLayout()
self.horizontalLayout_13.setObjectName("horizontalLayout_13")
self.lv_use_primary_album_artist = QVBoxLayout()
self.lv_use_primary_album_artist.setObjectName("lv_use_primary_album_artist")
self.cb_use_primary_album_artist = QCheckBox(self.gb_flags)
self.cb_use_primary_album_artist.setObjectName("cb_use_primary_album_artist")
self.lv_use_primary_album_artist.addWidget(self.cb_use_primary_album_artist)
self.horizontalLayout_13.addLayout(self.lv_use_primary_album_artist)
self.lv_download_dolby_atmos = QVBoxLayout()
self.lv_download_dolby_atmos.setObjectName("lv_download_dolby_atmos")
self.cb_download_dolby_atmos = QCheckBox(self.gb_flags)
self.cb_download_dolby_atmos.setObjectName("cb_download_dolby_atmos")
self.lv_download_dolby_atmos.addWidget(self.cb_download_dolby_atmos)
self.horizontalLayout_13.addLayout(self.lv_download_dolby_atmos)
self.lv_flags.addLayout(self.horizontalLayout_13)
self.lv_main.addWidget(self.gb_flags)
self.gb_choices = QGroupBox(DialogSettings)
self.gb_choices.setObjectName("gb_choices")
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
sizePolicy3.setHorizontalStretch(0)
sizePolicy3.setVerticalStretch(0)
sizePolicy3.setHeightForWidth(self.gb_choices.sizePolicy().hasHeightForWidth())
self.gb_choices.setSizePolicy(sizePolicy3)
self.lv_choices = QVBoxLayout(self.gb_choices)
self.lv_choices.setObjectName("lv_choices")
self.lh_choices_quality_audio = QHBoxLayout()
self.lh_choices_quality_audio.setObjectName("lh_choices_quality_audio")
self.l_icon_quality_audio = QLabel(self.gb_choices)
self.l_icon_quality_audio.setObjectName("l_icon_quality_audio")
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
sizePolicy4.setHorizontalStretch(0)
sizePolicy4.setVerticalStretch(0)
sizePolicy4.setHeightForWidth(self.l_icon_quality_audio.sizePolicy().hasHeightForWidth())
self.l_icon_quality_audio.setSizePolicy(sizePolicy4)
self.lh_choices_quality_audio.addWidget(self.l_icon_quality_audio)
self.l_quality_audio = QLabel(self.gb_choices)
self.l_quality_audio.setObjectName("l_quality_audio")
sizePolicy4.setHeightForWidth(self.l_quality_audio.sizePolicy().hasHeightForWidth())
self.l_quality_audio.setSizePolicy(sizePolicy4)
self.lh_choices_quality_audio.addWidget(self.l_quality_audio)
self.c_quality_audio = QComboBox(self.gb_choices)
self.c_quality_audio.setObjectName("c_quality_audio")
self.lh_choices_quality_audio.addWidget(self.c_quality_audio)
self.lh_choices_quality_audio.setStretch(2, 50)
self.lv_choices.addLayout(self.lh_choices_quality_audio)
self.lh_choices_quality_video = QHBoxLayout()
self.lh_choices_quality_video.setObjectName("lh_choices_quality_video")
self.l_icon_quality_video = QLabel(self.gb_choices)
self.l_icon_quality_video.setObjectName("l_icon_quality_video")
sizePolicy4.setHeightForWidth(self.l_icon_quality_video.sizePolicy().hasHeightForWidth())
self.l_icon_quality_video.setSizePolicy(sizePolicy4)
self.lh_choices_quality_video.addWidget(self.l_icon_quality_video)
self.l_quality_video = QLabel(self.gb_choices)
self.l_quality_video.setObjectName("l_quality_video")
sizePolicy4.setHeightForWidth(self.l_quality_video.sizePolicy().hasHeightForWidth())
self.l_quality_video.setSizePolicy(sizePolicy4)
self.lh_choices_quality_video.addWidget(self.l_quality_video)
self.c_quality_video = QComboBox(self.gb_choices)
self.c_quality_video.setObjectName("c_quality_video")
self.lh_choices_quality_video.addWidget(self.c_quality_video)
self.lh_choices_quality_video.setStretch(2, 50)
self.lv_choices.addLayout(self.lh_choices_quality_video)
self.lh_choices_cover_dimension = QHBoxLayout()
self.lh_choices_cover_dimension.setObjectName("lh_choices_cover_dimension")
self.lh_choices_cover_dimension.setSizeConstraint(QLayout.SizeConstraint.SetDefaultConstraint)
self.l_icon_metadata_cover_dimension = QLabel(self.gb_choices)
self.l_icon_metadata_cover_dimension.setObjectName("l_icon_metadata_cover_dimension")
sizePolicy4.setHeightForWidth(self.l_icon_metadata_cover_dimension.sizePolicy().hasHeightForWidth())
self.l_icon_metadata_cover_dimension.setSizePolicy(sizePolicy4)
self.lh_choices_cover_dimension.addWidget(self.l_icon_metadata_cover_dimension)
self.l_metadata_cover_dimension = QLabel(self.gb_choices)
self.l_metadata_cover_dimension.setObjectName("l_metadata_cover_dimension")
sizePolicy4.setHeightForWidth(self.l_metadata_cover_dimension.sizePolicy().hasHeightForWidth())
self.l_metadata_cover_dimension.setSizePolicy(sizePolicy4)
self.lh_choices_cover_dimension.addWidget(self.l_metadata_cover_dimension)
self.c_metadata_cover_dimension = QComboBox(self.gb_choices)
self.c_metadata_cover_dimension.setObjectName("c_metadata_cover_dimension")
sizePolicy5 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
sizePolicy5.setHorizontalStretch(10)
sizePolicy5.setVerticalStretch(0)
sizePolicy5.setHeightForWidth(self.c_metadata_cover_dimension.sizePolicy().hasHeightForWidth())
self.c_metadata_cover_dimension.setSizePolicy(sizePolicy5)
self.lh_choices_cover_dimension.addWidget(self.c_metadata_cover_dimension)
self.lh_choices_cover_dimension.setStretch(2, 50)
self.lv_choices.addLayout(self.lh_choices_cover_dimension)
self.lv_main.addWidget(self.gb_choices)
self.gb_numbers = QGroupBox(DialogSettings)
self.gb_numbers.setObjectName("gb_numbers")
self.verticalLayout_8 = QVBoxLayout(self.gb_numbers)
self.verticalLayout_8.setObjectName("verticalLayout_8")
self.horizontalLayout_9 = QHBoxLayout()
self.horizontalLayout_9.setObjectName("horizontalLayout_9")
self.l_album_track_num_pad_min = QLabel(self.gb_numbers)
self.l_album_track_num_pad_min.setObjectName("l_album_track_num_pad_min")
self.horizontalLayout_9.addWidget(self.l_album_track_num_pad_min)
self.l_icon_album_track_num_pad_min = QLabel(self.gb_numbers)
self.l_icon_album_track_num_pad_min.setObjectName("l_icon_album_track_num_pad_min")
self.horizontalLayout_9.addWidget(self.l_icon_album_track_num_pad_min)
self.sb_album_track_num_pad_min = QSpinBox(self.gb_numbers)
self.sb_album_track_num_pad_min.setObjectName("sb_album_track_num_pad_min")
self.sb_album_track_num_pad_min.setMaximum(4)
self.horizontalLayout_9.addWidget(self.sb_album_track_num_pad_min)
self.verticalLayout_8.addLayout(self.horizontalLayout_9)
self.horizontalLayout_11 = QHBoxLayout()
self.horizontalLayout_11.setObjectName("horizontalLayout_11")
self.l_downloads_concurrent_max = QLabel(self.gb_numbers)
self.l_downloads_concurrent_max.setObjectName("l_downloads_concurrent_max")
self.horizontalLayout_11.addWidget(self.l_downloads_concurrent_max)
self.l_icon_downloads_concurrent_max = QLabel(self.gb_numbers)
self.l_icon_downloads_concurrent_max.setObjectName("l_icon_downloads_concurrent_max")
self.horizontalLayout_11.addWidget(self.l_icon_downloads_concurrent_max)
self.sb_downloads_concurrent_max = QSpinBox(self.gb_numbers)
self.sb_downloads_concurrent_max.setObjectName("sb_downloads_concurrent_max")
self.sb_downloads_concurrent_max.setMinimum(1)
self.sb_downloads_concurrent_max.setMaximum(5)
self.horizontalLayout_11.addWidget(self.sb_downloads_concurrent_max)
self.verticalLayout_8.addLayout(self.horizontalLayout_11)
self.lv_main.addWidget(self.gb_numbers)
self.gb_path = QGroupBox(DialogSettings)
self.gb_path.setObjectName("gb_path")
self.horizontalLayout_2 = QHBoxLayout(self.gb_path)
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.verticalLayout_2 = QVBoxLayout()
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.lh_path_base = QHBoxLayout()
self.lh_path_base.setObjectName("lh_path_base")
self.l_icon_download_base_path = QLabel(self.gb_path)
self.l_icon_download_base_path.setObjectName("l_icon_download_base_path")
sizePolicy4.setHeightForWidth(self.l_icon_download_base_path.sizePolicy().hasHeightForWidth())
self.l_icon_download_base_path.setSizePolicy(sizePolicy4)
self.lh_path_base.addWidget(self.l_icon_download_base_path)
self.l_download_base_path = QLabel(self.gb_path)
self.l_download_base_path.setObjectName("l_download_base_path")
sizePolicy3.setHeightForWidth(self.l_download_base_path.sizePolicy().hasHeightForWidth())
self.l_download_base_path.setSizePolicy(sizePolicy3)
self.lh_path_base.addWidget(self.l_download_base_path)
self.verticalLayout_2.addLayout(self.lh_path_base)
self.lh_path_fmt_track = QHBoxLayout()
self.lh_path_fmt_track.setObjectName("lh_path_fmt_track")
self.l_icon_format_track = QLabel(self.gb_path)
self.l_icon_format_track.setObjectName("l_icon_format_track")
sizePolicy4.setHeightForWidth(self.l_icon_format_track.sizePolicy().hasHeightForWidth())
self.l_icon_format_track.setSizePolicy(sizePolicy4)
self.lh_path_fmt_track.addWidget(self.l_icon_format_track)
self.l_format_track = QLabel(self.gb_path)
self.l_format_track.setObjectName("l_format_track")
sizePolicy3.setHeightForWidth(self.l_format_track.sizePolicy().hasHeightForWidth())
self.l_format_track.setSizePolicy(sizePolicy3)
self.lh_path_fmt_track.addWidget(self.l_format_track)
self.verticalLayout_2.addLayout(self.lh_path_fmt_track)
self.lh_path_fmt_video = QHBoxLayout()
self.lh_path_fmt_video.setObjectName("lh_path_fmt_video")
self.l_icon_format_video = QLabel(self.gb_path)
self.l_icon_format_video.setObjectName("l_icon_format_video")
sizePolicy4.setHeightForWidth(self.l_icon_format_video.sizePolicy().hasHeightForWidth())
self.l_icon_format_video.setSizePolicy(sizePolicy4)
self.lh_path_fmt_video.addWidget(self.l_icon_format_video)
self.l_format_video = QLabel(self.gb_path)
self.l_format_video.setObjectName("l_format_video")
sizePolicy3.setHeightForWidth(self.l_format_video.sizePolicy().hasHeightForWidth())
self.l_format_video.setSizePolicy(sizePolicy3)
self.lh_path_fmt_video.addWidget(self.l_format_video)
self.verticalLayout_2.addLayout(self.lh_path_fmt_video)
self.lh_path_fmt_album = QHBoxLayout()
self.lh_path_fmt_album.setObjectName("lh_path_fmt_album")
self.l_icon_format_album = QLabel(self.gb_path)
self.l_icon_format_album.setObjectName("l_icon_format_album")
sizePolicy6 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
sizePolicy6.setHorizontalStretch(0)
sizePolicy6.setVerticalStretch(0)
sizePolicy6.setHeightForWidth(self.l_icon_format_album.sizePolicy().hasHeightForWidth())
self.l_icon_format_album.setSizePolicy(sizePolicy6)
self.lh_path_fmt_album.addWidget(self.l_icon_format_album)
self.l_format_album = QLabel(self.gb_path)
self.l_format_album.setObjectName("l_format_album")
sizePolicy3.setHeightForWidth(self.l_format_album.sizePolicy().hasHeightForWidth())
self.l_format_album.setSizePolicy(sizePolicy3)
self.lh_path_fmt_album.addWidget(self.l_format_album)
self.verticalLayout_2.addLayout(self.lh_path_fmt_album)
self.lh_fpath_mt_playlist = QHBoxLayout()
self.lh_fpath_mt_playlist.setObjectName("lh_fpath_mt_playlist")
self.l_icon_format_playlist = QLabel(self.gb_path)
self.l_icon_format_playlist.setObjectName("l_icon_format_playlist")
sizePolicy4.setHeightForWidth(self.l_icon_format_playlist.sizePolicy().hasHeightForWidth())
self.l_icon_format_playlist.setSizePolicy(sizePolicy4)
self.lh_fpath_mt_playlist.addWidget(self.l_icon_format_playlist)
self.l_format_playlist = QLabel(self.gb_path)
self.l_format_playlist.setObjectName("l_format_playlist")
sizePolicy3.setHeightForWidth(self.l_format_playlist.sizePolicy().hasHeightForWidth())
self.l_format_playlist.setSizePolicy(sizePolicy3)
self.lh_fpath_mt_playlist.addWidget(self.l_format_playlist)
self.verticalLayout_2.addLayout(self.lh_fpath_mt_playlist)
self.lh_path_fmt_mix = QHBoxLayout()
self.lh_path_fmt_mix.setObjectName("lh_path_fmt_mix")
self.l_icon_format_mix = QLabel(self.gb_path)
self.l_icon_format_mix.setObjectName("l_icon_format_mix")
sizePolicy6.setHeightForWidth(self.l_icon_format_mix.sizePolicy().hasHeightForWidth())
self.l_icon_format_mix.setSizePolicy(sizePolicy6)
self.lh_path_fmt_mix.addWidget(self.l_icon_format_mix)
self.l_format_mix = QLabel(self.gb_path)
self.l_format_mix.setObjectName("l_format_mix")
sizePolicy3.setHeightForWidth(self.l_format_mix.sizePolicy().hasHeightForWidth())
self.l_format_mix.setSizePolicy(sizePolicy3)
self.lh_path_fmt_mix.addWidget(self.l_format_mix)
self.verticalLayout_2.addLayout(self.lh_path_fmt_mix)
self.lh_path_binary_ffmpeg = QHBoxLayout()
self.lh_path_binary_ffmpeg.setObjectName("lh_path_binary_ffmpeg")
self.l_icon_path_binary_ffmpeg = QLabel(self.gb_path)
self.l_icon_path_binary_ffmpeg.setObjectName("l_icon_path_binary_ffmpeg")
sizePolicy4.setHeightForWidth(self.l_icon_path_binary_ffmpeg.sizePolicy().hasHeightForWidth())
self.l_icon_path_binary_ffmpeg.setSizePolicy(sizePolicy4)
self.lh_path_binary_ffmpeg.addWidget(self.l_icon_path_binary_ffmpeg)
self.l_path_binary_ffmpeg = QLabel(self.gb_path)
self.l_path_binary_ffmpeg.setObjectName("l_path_binary_ffmpeg")
sizePolicy3.setHeightForWidth(self.l_path_binary_ffmpeg.sizePolicy().hasHeightForWidth())
self.l_path_binary_ffmpeg.setSizePolicy(sizePolicy3)
self.lh_path_binary_ffmpeg.addWidget(self.l_path_binary_ffmpeg)
self.verticalLayout_2.addLayout(self.lh_path_binary_ffmpeg)
self.horizontalLayout_2.addLayout(self.verticalLayout_2)
self.verticalLayout = QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout")
self.horizontalLayout_10 = QHBoxLayout()
self.horizontalLayout_10.setObjectName("horizontalLayout_10")
self.le_download_base_path = QLineEdit(self.gb_path)
self.le_download_base_path.setObjectName("le_download_base_path")
sizePolicy2.setHeightForWidth(self.le_download_base_path.sizePolicy().hasHeightForWidth())
self.le_download_base_path.setSizePolicy(sizePolicy2)
self.le_download_base_path.setDragEnabled(True)
self.horizontalLayout_10.addWidget(self.le_download_base_path)
self.pb_download_base_path = QPushButton(self.gb_path)
self.pb_download_base_path.setObjectName("pb_download_base_path")
self.horizontalLayout_10.addWidget(self.pb_download_base_path)
self.verticalLayout.addLayout(self.horizontalLayout_10)
self.horizontalLayout_7 = QHBoxLayout()
self.horizontalLayout_7.setObjectName("horizontalLayout_7")
self.le_format_track = QLineEdit(self.gb_path)
self.le_format_track.setObjectName("le_format_track")
self.horizontalLayout_7.addWidget(self.le_format_track)
self.verticalLayout.addLayout(self.horizontalLayout_7)
self.horizontalLayout_5 = QHBoxLayout()
self.horizontalLayout_5.setObjectName("horizontalLayout_5")
self.le_format_video = QLineEdit(self.gb_path)
self.le_format_video.setObjectName("le_format_video")
self.horizontalLayout_5.addWidget(self.le_format_video)
self.verticalLayout.addLayout(self.horizontalLayout_5)
self.horizontalLayout_6 = QHBoxLayout()
self.horizontalLayout_6.setObjectName("horizontalLayout_6")
self.le_format_album = QLineEdit(self.gb_path)
self.le_format_album.setObjectName("le_format_album")
self.horizontalLayout_6.addWidget(self.le_format_album)
self.verticalLayout.addLayout(self.horizontalLayout_6)
self.horizontalLayout_4 = QHBoxLayout()
self.horizontalLayout_4.setObjectName("horizontalLayout_4")
self.le_format_playlist = QLineEdit(self.gb_path)
self.le_format_playlist.setObjectName("le_format_playlist")
sizePolicy2.setHeightForWidth(self.le_format_playlist.sizePolicy().hasHeightForWidth())
self.le_format_playlist.setSizePolicy(sizePolicy2)
self.horizontalLayout_4.addWidget(self.le_format_playlist)
self.verticalLayout.addLayout(self.horizontalLayout_4)
self.horizontalLayout_8 = QHBoxLayout()
self.horizontalLayout_8.setObjectName("horizontalLayout_8")
self.le_format_mix = QLineEdit(self.gb_path)
self.le_format_mix.setObjectName("le_format_mix")
self.horizontalLayout_8.addWidget(self.le_format_mix)
self.verticalLayout.addLayout(self.horizontalLayout_8)
self.horizontalLayout_3 = QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.le_path_binary_ffmpeg = QLineEdit(self.gb_path)
self.le_path_binary_ffmpeg.setObjectName("le_path_binary_ffmpeg")
sizePolicy2.setHeightForWidth(self.le_path_binary_ffmpeg.sizePolicy().hasHeightForWidth())
self.le_path_binary_ffmpeg.setSizePolicy(sizePolicy2)
self.le_path_binary_ffmpeg.setDragEnabled(True)
self.horizontalLayout_3.addWidget(self.le_path_binary_ffmpeg)
self.pb_path_binary_ffmpeg = QPushButton(self.gb_path)
self.pb_path_binary_ffmpeg.setObjectName("pb_path_binary_ffmpeg")
self.horizontalLayout_3.addWidget(self.pb_path_binary_ffmpeg)
self.verticalLayout.addLayout(self.horizontalLayout_3)
self.horizontalLayout_2.addLayout(self.verticalLayout)
self.horizontalLayout_2.setStretch(1, 50)
self.lv_main.addWidget(self.gb_path)
self.bb_dialog = QDialogButtonBox(DialogSettings)
self.bb_dialog.setObjectName("bb_dialog")
self.bb_dialog.setOrientation(Qt.Orientation.Horizontal)
self.bb_dialog.setStandardButtons(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok)
self.lv_main.addWidget(self.bb_dialog)
self.lv_dialog_settings.addLayout(self.lv_main)
self.retranslateUi(DialogSettings)
self.bb_dialog.accepted.connect(DialogSettings.accept)
self.bb_dialog.rejected.connect(DialogSettings.reject)
QMetaObject.connectSlotsByName(DialogSettings)
# setupUi
def retranslateUi(self, DialogSettings):
DialogSettings.setWindowTitle(QCoreApplication.translate("DialogSettings", "Preferences", None))
self.gb_flags.setTitle(QCoreApplication.translate("DialogSettings", "Flags", None))
self.cb_video_download.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_video_convert_mp4.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
# if QT_CONFIG(whatsthis)
self.cb_lyrics_embed.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
self.cb_lyrics_embed.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_lyrics_file.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_download_delay.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_extract_flac.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_metadata_cover_embed.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_cover_album_file.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_skip_existing.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_symlink_to_track.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_playlist_create.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_mark_explicit.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_use_primary_album_artist.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_download_dolby_atmos.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.gb_choices.setTitle(QCoreApplication.translate("DialogSettings", "Choices", None))
self.l_icon_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_icon_quality_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_quality_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_icon_metadata_cover_dimension.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_metadata_cover_dimension.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.gb_numbers.setTitle(QCoreApplication.translate("DialogSettings", "Numbers", None))
self.l_album_track_num_pad_min.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_icon_album_track_num_pad_min.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_downloads_concurrent_max.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_icon_downloads_concurrent_max.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.gb_path.setTitle(QCoreApplication.translate("DialogSettings", "Path", None))
self.l_icon_download_base_path.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_download_base_path.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_icon_format_track.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_format_track.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_icon_format_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_format_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_icon_format_album.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_format_album.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_icon_format_playlist.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_format_playlist.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_icon_format_mix.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_format_mix.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_icon_path_binary_ffmpeg.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_path_binary_ffmpeg.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.pb_download_base_path.setText(QCoreApplication.translate("DialogSettings", "...", None))
self.pb_path_binary_ffmpeg.setText(QCoreApplication.translate("DialogSettings", "...", None))
# retranslateUi

View File

@@ -0,0 +1,846 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>DialogSettings</class>
<widget class="QDialog" name="DialogSettings">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>640</width>
<height>832</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>100</horstretch>
<verstretch>100</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Preferences</string>
</property>
<property name="sizeGripEnabled">
<bool>true</bool>
</property>
<layout class="QVBoxLayout" name="lv_dialog_settings">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QVBoxLayout" name="lv_main">
<property name="leftMargin">
<number>12</number>
</property>
<property name="topMargin">
<number>12</number>
</property>
<property name="rightMargin">
<number>12</number>
</property>
<property name="bottomMargin">
<number>12</number>
</property>
<item>
<widget class="QGroupBox" name="gb_flags">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>100</horstretch>
<verstretch>100</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Flags</string>
</property>
<property name="flat">
<bool>false</bool>
</property>
<property name="checkable">
<bool>false</bool>
</property>
<layout class="QVBoxLayout" name="lv_flags">
<item>
<layout class="QHBoxLayout" name="lh_flags_1">
<item>
<layout class="QVBoxLayout" name="lv_flag_video_download">
<item>
<widget class="QCheckBox" name="cb_video_download">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>100</horstretch>
<verstretch>100</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="lv_flag_video_convert">
<item>
<widget class="QCheckBox" name="cb_video_convert_mp4">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>100</horstretch>
<verstretch>100</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="lh_flags_2">
<item>
<layout class="QVBoxLayout" name="lv_flag_lyrics_embed">
<item>
<widget class="QCheckBox" name="cb_lyrics_embed">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="whatsThis">
<string/>
</property>
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="lv_flag_lyrics_file">
<item>
<widget class="QCheckBox" name="cb_lyrics_file">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>100</horstretch>
<verstretch>100</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="lh_flag_3">
<item>
<layout class="QVBoxLayout" name="lv_flag_download_delay">
<item>
<widget class="QCheckBox" name="cb_download_delay">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>100</horstretch>
<verstretch>100</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="lv_flag_extract_flac">
<item>
<widget class="QCheckBox" name="cb_extract_flac">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="lh_flags_4">
<item>
<layout class="QVBoxLayout" name="lv_flag_metadata_cover_embed">
<item>
<widget class="QCheckBox" name="cb_metadata_cover_embed">
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="lv_flag_cover_album_file">
<item>
<widget class="QCheckBox" name="cb_cover_album_file">
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="lv_flag_skip_existing">
<item>
<widget class="QCheckBox" name="cb_skip_existing">
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="lv_symlink_to_track">
<item>
<widget class="QCheckBox" name="cb_symlink_to_track">
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_12">
<item>
<layout class="QVBoxLayout" name="lv_playlist_create">
<item>
<widget class="QCheckBox" name="cb_playlist_create">
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="lv_mark_explicit">
<item>
<widget class="QCheckBox" name="cb_mark_explicit">
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_13">
<item>
<layout class="QVBoxLayout" name="lv_use_primary_album_artist">
<item>
<widget class="QCheckBox" name="cb_use_primary_album_artist">
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="lv_download_dolby_atmos">
<item>
<widget class="QCheckBox" name="cb_download_dolby_atmos">
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="gb_choices">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Choices</string>
</property>
<layout class="QVBoxLayout" name="lv_choices">
<item>
<layout class="QHBoxLayout" name="lh_choices_quality_audio" stretch="0,0,50">
<item>
<widget class="QLabel" name="l_icon_quality_audio">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_quality_audio">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="c_quality_audio"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="lh_choices_quality_video" stretch="0,0,50">
<item>
<widget class="QLabel" name="l_icon_quality_video">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_quality_video">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="c_quality_video"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="lh_choices_cover_dimension" stretch="0,0,50">
<property name="sizeConstraint">
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
</property>
<item>
<widget class="QLabel" name="l_icon_metadata_cover_dimension">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_metadata_cover_dimension">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="c_metadata_cover_dimension">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>10</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="gb_numbers">
<property name="title">
<string>Numbers</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_8">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_9">
<item>
<widget class="QLabel" name="l_album_track_num_pad_min">
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_icon_album_track_num_pad_min">
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="sb_album_track_num_pad_min">
<property name="maximum">
<number>4</number>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_11">
<item>
<widget class="QLabel" name="l_downloads_concurrent_max">
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_icon_downloads_concurrent_max">
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="sb_downloads_concurrent_max">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>5</number>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="gb_path">
<property name="title">
<string>Path</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2" stretch="0,50">
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="lh_path_base">
<item>
<widget class="QLabel" name="l_icon_download_base_path">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_download_base_path">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="lh_path_fmt_track">
<item>
<widget class="QLabel" name="l_icon_format_track">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_format_track">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="lh_path_fmt_video">
<item>
<widget class="QLabel" name="l_icon_format_video">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_format_video">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="lh_path_fmt_album">
<item>
<widget class="QLabel" name="l_icon_format_album">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_format_album">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="lh_fpath_mt_playlist">
<item>
<widget class="QLabel" name="l_icon_format_playlist">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_format_playlist">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="lh_path_fmt_mix">
<item>
<widget class="QLabel" name="l_icon_format_mix">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_format_mix">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="lh_path_binary_ffmpeg">
<item>
<widget class="QLabel" name="l_icon_path_binary_ffmpeg">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_path_binary_ffmpeg">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_10">
<item>
<widget class="QLineEdit" name="le_download_base_path">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_download_base_path">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_7">
<item>
<widget class="QLineEdit" name="le_format_track"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_5">
<item>
<widget class="QLineEdit" name="le_format_video"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="QLineEdit" name="le_format_album"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<widget class="QLineEdit" name="le_format_playlist">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_8">
<item>
<widget class="QLineEdit" name="le_format_mix"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QLineEdit" name="le_path_binary_ffmpeg">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_path_binary_ffmpeg">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="bb_dialog">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>bb_dialog</sender>
<signal>accepted()</signal>
<receiver>DialogSettings</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>319</x>
<y>661</y>
</hint>
<hint type="destinationlabel">
<x>319</x>
<y>340</y>
</hint>
</hints>
</connection>
<connection>
<sender>bb_dialog</sender>
<signal>rejected()</signal>
<receiver>DialogSettings</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>319</x>
<y>661</y>
</hint>
<hint type="destinationlabel">
<x>319</x>
<y>340</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,161 @@
################################################################################
## Form generated from reading UI file 'dialog_version.ui'
##
## Created by: Qt User Interface Compiler version 6.6.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import QCoreApplication, QMetaObject, QSize, Qt
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSizePolicy, QSpacerItem, QVBoxLayout
class Ui_DialogVersion:
def setupUi(self, DialogVersion):
if not DialogVersion.objectName():
DialogVersion.setObjectName("DialogVersion")
DialogVersion.resize(436, 235)
sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(DialogVersion.sizePolicy().hasHeightForWidth())
DialogVersion.setSizePolicy(sizePolicy)
DialogVersion.setMaximumSize(QSize(436, 235))
self.verticalLayout = QVBoxLayout(DialogVersion)
self.verticalLayout.setObjectName("verticalLayout")
self.l_name_app = QLabel(DialogVersion)
self.l_name_app.setObjectName("l_name_app")
sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(0)
sizePolicy1.setHeightForWidth(self.l_name_app.sizePolicy().hasHeightForWidth())
self.l_name_app.setSizePolicy(sizePolicy1)
font = QFont()
font.setBold(True)
self.l_name_app.setFont(font)
self.l_name_app.setAlignment(Qt.AlignCenter)
self.l_name_app.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse)
self.verticalLayout.addWidget(self.l_name_app)
self.lv_version = QVBoxLayout()
self.lv_version.setObjectName("lv_version")
self.lh_version = QHBoxLayout()
self.lh_version.setObjectName("lh_version")
self.l_h_version = QLabel(DialogVersion)
self.l_h_version.setObjectName("l_h_version")
self.l_h_version.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse)
self.lh_version.addWidget(self.l_h_version)
self.l_version = QLabel(DialogVersion)
self.l_version.setObjectName("l_version")
self.l_version.setFont(font)
self.l_version.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse)
self.lh_version.addWidget(self.l_version)
self.lv_version.addLayout(self.lh_version)
self.verticalLayout.addLayout(self.lv_version)
self.lv_update = QVBoxLayout()
self.lv_update.setObjectName("lv_update")
self.l_error = QLabel(DialogVersion)
self.l_error.setObjectName("l_error")
self.l_error.setFont(font)
self.l_error.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse)
self.lv_update.addWidget(self.l_error)
self.l_error_details = QLabel(DialogVersion)
self.l_error_details.setObjectName("l_error_details")
self.l_error_details.setFont(font)
self.l_error_details.setAlignment(Qt.AlignCenter)
self.l_error_details.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse)
self.lv_update.addWidget(self.l_error_details)
self.lh_update_version = QHBoxLayout()
self.lh_update_version.setObjectName("lh_update_version")
self.l_h_version_new = QLabel(DialogVersion)
self.l_h_version_new.setObjectName("l_h_version_new")
self.l_h_version_new.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse)
self.lh_update_version.addWidget(self.l_h_version_new)
self.l_version_new = QLabel(DialogVersion)
self.l_version_new.setObjectName("l_version_new")
self.l_version_new.setFont(font)
self.l_version_new.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse)
self.lh_update_version.addWidget(self.l_version_new)
self.lv_update.addLayout(self.lh_update_version)
self.l_changelog = QLabel(DialogVersion)
self.l_changelog.setObjectName("l_changelog")
self.l_changelog.setFont(font)
self.l_changelog.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse)
self.lv_update.addWidget(self.l_changelog)
self.l_changelog_details = QLabel(DialogVersion)
self.l_changelog_details.setObjectName("l_changelog_details")
self.l_changelog_details.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse)
self.lv_update.addWidget(self.l_changelog_details)
self.lv_download = QHBoxLayout()
self.lv_download.setObjectName("lv_download")
self.lv_download.setContentsMargins(-1, 20, -1, -1)
self.pb_download = QPushButton(DialogVersion)
self.pb_download.setObjectName("pb_download")
self.pb_download.setFlat(False)
self.lv_download.addWidget(self.pb_download)
self.sh_download = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
self.lv_download.addItem(self.sh_download)
self.lv_update.addLayout(self.lv_download)
self.verticalLayout.addLayout(self.lv_update)
self.l_url_github = QLabel(DialogVersion)
self.l_url_github.setObjectName("l_url_github")
self.l_url_github.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter)
self.l_url_github.setOpenExternalLinks(True)
self.l_url_github.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse)
self.verticalLayout.addWidget(self.l_url_github)
self.retranslateUi(DialogVersion)
QMetaObject.connectSlotsByName(DialogVersion)
# setupUi
def retranslateUi(self, DialogVersion):
DialogVersion.setWindowTitle(QCoreApplication.translate("DialogVersion", "Version", None))
self.l_name_app.setText(QCoreApplication.translate("DialogVersion", "TIDAL Downloader Next Generation!", None))
self.l_h_version.setText(QCoreApplication.translate("DialogVersion", "Installed Version:", None))
self.l_version.setText(QCoreApplication.translate("DialogVersion", "v1.2.3", None))
self.l_error.setText(QCoreApplication.translate("DialogVersion", "ERROR", None))
self.l_error_details.setText(QCoreApplication.translate("DialogVersion", "<ERROR>", None))
self.l_h_version_new.setText(QCoreApplication.translate("DialogVersion", "New Version Available:", None))
self.l_version_new.setText(QCoreApplication.translate("DialogVersion", "v0.0.0", None))
self.l_changelog.setText(QCoreApplication.translate("DialogVersion", "Changelog", None))
self.l_changelog_details.setText(QCoreApplication.translate("DialogVersion", "<CHANGELOG>", None))
self.pb_download.setText(QCoreApplication.translate("DialogVersion", "Download", None))
self.l_url_github.setText(
QCoreApplication.translate(
"DialogVersion",
'<a href="https://github.com/exislow/tidal-dl-ng/">https://github.com/exislow/tidal-dl-ng/</a>',
None,
)
)
# retranslateUi

View File

@@ -0,0 +1,227 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>DialogVersion</class>
<widget class="QDialog" name="DialogVersion">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>436</width>
<height>235</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>436</width>
<height>235</height>
</size>
</property>
<property name="windowTitle">
<string>Version</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="l_name_app">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>TIDAL Downloader Next Generation!</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="lv_version">
<item>
<layout class="QHBoxLayout" name="lh_version">
<item>
<widget class="QLabel" name="l_h_version">
<property name="text">
<string>Installed Version:</string>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_version">
<property name="font">
<font>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>v1.2.3</string>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="lv_update">
<item>
<widget class="QLabel" name="l_error">
<property name="font">
<font>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>ERROR</string>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_error_details">
<property name="font">
<font>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>&lt;ERROR&gt;</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="lh_update_version">
<item>
<widget class="QLabel" name="l_h_version_new">
<property name="text">
<string>New Version Available:</string>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_version_new">
<property name="font">
<font>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>v0.0.0</string>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="l_changelog">
<property name="font">
<font>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Changelog</string>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_changelog_details">
<property name="text">
<string>&lt;CHANGELOG&gt;</string>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="lv_download">
<property name="topMargin">
<number>20</number>
</property>
<item>
<widget class="QPushButton" name="pb_download">
<property name="text">
<string>Download</string>
</property>
<property name="flat">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="sh_download">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="l_url_github">
<property name="text">
<string>&lt;a href=&quot;https://github.com/exislow/tidal-dl-ng/&quot;&gt;https://github.com/exislow/tidal-dl-ng/&lt;/a&gt;</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,33 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from .dummy_wiggly import WigglyWidget
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin
TOOLTIP = "A cool wiggly widget (Python)"
DOM_XML = """
<ui language='c++'>
<widget class='WigglyWidget' name='wigglyWidget'>
<property name='geometry'>
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>200</height>
</rect>
</property>
<property name='text'>
<string>Hello, world</string>
</property>
</widget>
</ui>
"""
if __name__ == "__main__":
QPyDesignerCustomWidgetCollection.registerCustomWidget(
WigglyWidget, module="wigglywidget", tool_tip=TOOLTIP, xml=DOM_XML
)

View File

@@ -0,0 +1,68 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from PySide6.QtCore import Property, QBasicTimer
from PySide6.QtGui import QColor, QFontMetrics, QPainter, QPalette
from PySide6.QtWidgets import QWidget
class WigglyWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._step = 0
self._text = ""
self.setBackgroundRole(QPalette.Midlight)
self.setAutoFillBackground(True)
new_font = self.font()
new_font.setPointSize(new_font.pointSize() + 20)
self.setFont(new_font)
self._timer = QBasicTimer()
def isRunning(self):
return self._timer.isActive()
def setRunning(self, r):
if r == self.isRunning():
return
if r:
self._timer.start(60, self)
else:
self._timer.stop()
def paintEvent(self, event):
if not self._text:
return
sineTable = [0, 38, 71, 92, 100, 92, 71, 38, 0, -38, -71, -92, -100, -92, -71, -38]
metrics = QFontMetrics(self.font())
x = (self.width() - metrics.horizontalAdvance(self.text)) / 2
y = (self.height() + metrics.ascent() - metrics.descent()) / 2
color = QColor()
with QPainter(self) as painter:
for i in range(len(self.text)):
index = (self._step + i) % 16
color.setHsv((15 - index) * 16, 255, 191)
painter.setPen(color)
dy = (sineTable[index] * metrics.height()) / 400
c = self._text[i]
painter.drawText(x, y - dy, str(c))
x += metrics.horizontalAdvance(c)
def timerEvent(self, event):
if event.timerId() == self._timer.timerId():
self._step += 1
self.update()
else:
QWidget.timerEvent(event)
def text(self):
return self._text
def setText(self, text):
self._text = text
running = Property(bool, isRunning, setRunning)
text = Property(str, text, setText)

BIN
tidal_dl_ng/ui/icon.icns Normal file

Binary file not shown.

BIN
tidal_dl_ng/ui/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

BIN
tidal_dl_ng/ui/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 B

BIN
tidal_dl_ng/ui/icon256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
tidal_dl_ng/ui/icon32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

BIN
tidal_dl_ng/ui/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
tidal_dl_ng/ui/icon512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
tidal_dl_ng/ui/icon64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

607
tidal_dl_ng/ui/main.py Normal file
View File

@@ -0,0 +1,607 @@
################################################################################
## Form generated from reading UI file 'main.ui'
##
## Created by: Qt User Interface Compiler version 6.9.0
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import QCoreApplication, QLocale, QMetaObject, QRect, QSize, Qt
from PySide6.QtGui import QAction, QFont, QIcon, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QMenu,
QMenuBar,
QPlainTextEdit,
QPushButton,
QSizePolicy,
QStatusBar,
QTreeView,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
class Ui_MainWindow:
def setupUi(self, MainWindow):
if not MainWindow.objectName():
MainWindow.setObjectName("MainWindow")
MainWindow.resize(1200, 800)
self.a_preferences = QAction(MainWindow)
self.a_preferences.setObjectName("a_preferences")
self.a_preferences.setEnabled(True)
self.a_preferences.setText("Preferences...")
self.a_preferences.setIconText("Preferences...")
# if QT_CONFIG(tooltip)
self.a_preferences.setToolTip("Preferences...")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.a_preferences.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.a_preferences.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
self.a_version = QAction(MainWindow)
self.a_version.setObjectName("a_version")
self.a_exit = QAction(MainWindow)
self.a_exit.setObjectName("a_exit")
self.a_logout = QAction(MainWindow)
self.a_logout.setObjectName("a_logout")
self.a_updates_check = QAction(MainWindow)
self.a_updates_check.setObjectName("a_updates_check")
self.w_central = QWidget(MainWindow)
self.w_central.setObjectName("w_central")
self.w_central.setEnabled(True)
sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(100)
sizePolicy.setVerticalStretch(100)
sizePolicy.setHeightForWidth(self.w_central.sizePolicy().hasHeightForWidth())
self.w_central.setSizePolicy(sizePolicy)
# if QT_CONFIG(tooltip)
self.w_central.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.w_central.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.w_central.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.w_central.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.w_central.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.horizontalLayout = QHBoxLayout(self.w_central)
self.horizontalLayout.setObjectName("horizontalLayout")
self.lv_list_user = QVBoxLayout()
self.lv_list_user.setObjectName("lv_list_user")
self.tr_lists_user = QTreeWidget(self.w_central)
__qtreewidgetitem = QTreeWidgetItem()
__qtreewidgetitem.setText(2, "Info")
__qtreewidgetitem.setText(0, "Name")
self.tr_lists_user.setHeaderItem(__qtreewidgetitem)
__qtreewidgetitem1 = QTreeWidgetItem(self.tr_lists_user)
__qtreewidgetitem1.setFlags(Qt.ItemIsEnabled)
__qtreewidgetitem2 = QTreeWidgetItem(self.tr_lists_user)
__qtreewidgetitem2.setFlags(Qt.ItemIsEnabled)
__qtreewidgetitem3 = QTreeWidgetItem(self.tr_lists_user)
__qtreewidgetitem3.setFlags(Qt.ItemIsEnabled)
self.tr_lists_user.setObjectName("tr_lists_user")
# if QT_CONFIG(tooltip)
self.tr_lists_user.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.tr_lists_user.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.tr_lists_user.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.tr_lists_user.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.tr_lists_user.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.tr_lists_user.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.tr_lists_user.setProperty("showDropIndicator", False)
self.tr_lists_user.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.tr_lists_user.setIndentation(10)
self.tr_lists_user.setUniformRowHeights(True)
self.tr_lists_user.setSortingEnabled(True)
self.tr_lists_user.header().setCascadingSectionResizes(True)
self.tr_lists_user.header().setHighlightSections(True)
self.tr_lists_user.header().setProperty("showSortIndicator", True)
self.lv_list_user.addWidget(self.tr_lists_user)
self.lv_list_control = QHBoxLayout()
self.lv_list_control.setObjectName("lv_list_control")
self.pb_reload_user_lists = QPushButton(self.w_central)
self.pb_reload_user_lists.setObjectName("pb_reload_user_lists")
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(0)
sizePolicy1.setHeightForWidth(self.pb_reload_user_lists.sizePolicy().hasHeightForWidth())
self.pb_reload_user_lists.setSizePolicy(sizePolicy1)
self.lv_list_control.addWidget(self.pb_reload_user_lists)
self.pb_download_list = QPushButton(self.w_central)
self.pb_download_list.setObjectName("pb_download_list")
sizePolicy1.setHeightForWidth(self.pb_download_list.sizePolicy().hasHeightForWidth())
self.pb_download_list.setSizePolicy(sizePolicy1)
self.lv_list_control.addWidget(self.pb_download_list)
self.lv_list_user.addLayout(self.lv_list_control)
self.horizontalLayout.addLayout(self.lv_list_user)
self.lv_search_result = QVBoxLayout()
# ifndef Q_OS_MAC
self.lv_search_result.setSpacing(-1)
# endif
self.lv_search_result.setObjectName("lv_search_result")
self.lh_search = QHBoxLayout()
self.lh_search.setObjectName("lh_search")
self.l_search = QLineEdit(self.w_central)
self.l_search.setObjectName("l_search")
self.l_search.setAcceptDrops(False)
# if QT_CONFIG(tooltip)
self.l_search.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.l_search.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.l_search.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.l_search.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.l_search.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.l_search.setLocale(QLocale(QLocale.English, QLocale.UnitedStates))
self.l_search.setText("")
self.l_search.setPlaceholderText("Type and press ENTER to search...")
self.l_search.setClearButtonEnabled(True)
self.lh_search.addWidget(self.l_search)
self.cb_search_type = QComboBox(self.w_central)
self.cb_search_type.setObjectName("cb_search_type")
self.cb_search_type.setMinimumSize(QSize(100, 0))
# if QT_CONFIG(tooltip)
self.cb_search_type.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.cb_search_type.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.cb_search_type.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.cb_search_type.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.cb_search_type.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.cb_search_type.setCurrentText("")
self.cb_search_type.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContentsOnFirstShow)
self.cb_search_type.setPlaceholderText("")
self.lh_search.addWidget(self.cb_search_type)
self.pb_search = QPushButton(self.w_central)
self.pb_search.setObjectName("pb_search")
# if QT_CONFIG(statustip)
self.pb_search.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.pb_search.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.pb_search.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.pb_search.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.pb_search.setText("Search")
# if QT_CONFIG(shortcut)
self.pb_search.setShortcut("")
# endif // QT_CONFIG(shortcut)
self.lh_search.addWidget(self.pb_search)
self.lv_search_result.addLayout(self.lh_search)
self.tr_results = QTreeView(self.w_central)
self.tr_results.setObjectName("tr_results")
self.tr_results.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.tr_results.setProperty("showDropIndicator", False)
self.tr_results.setDragDropOverwriteMode(False)
self.tr_results.setAlternatingRowColors(False)
self.tr_results.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.tr_results.setIndentation(10)
self.tr_results.setSortingEnabled(True)
self.lv_search_result.addWidget(self.tr_results)
self.lh_download = QHBoxLayout()
self.lh_download.setObjectName("lh_download")
self.l_quality_audio = QLabel(self.w_central)
self.l_quality_audio.setObjectName("l_quality_audio")
# if QT_CONFIG(tooltip)
self.l_quality_audio.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.l_quality_audio.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.l_quality_audio.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.l_quality_audio.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.l_quality_audio.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.l_quality_audio.setText("Audio")
self.l_quality_audio.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTrailing | Qt.AlignmentFlag.AlignVCenter
)
self.lh_download.addWidget(self.l_quality_audio)
self.cb_quality_audio = QComboBox(self.w_central)
self.cb_quality_audio.setObjectName("cb_quality_audio")
self.cb_quality_audio.setMinimumSize(QSize(140, 0))
# if QT_CONFIG(tooltip)
self.cb_quality_audio.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.cb_quality_audio.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.cb_quality_audio.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.cb_quality_audio.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.cb_quality_audio.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.cb_quality_audio.setCurrentText("")
self.cb_quality_audio.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContentsOnFirstShow)
self.cb_quality_audio.setPlaceholderText("")
self.cb_quality_audio.setFrame(True)
self.lh_download.addWidget(self.cb_quality_audio)
self.l_quality_video = QLabel(self.w_central)
self.l_quality_video.setObjectName("l_quality_video")
# if QT_CONFIG(tooltip)
self.l_quality_video.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.l_quality_video.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.l_quality_video.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.l_quality_video.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.l_quality_video.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.l_quality_video.setText("Video")
self.l_quality_video.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTrailing | Qt.AlignmentFlag.AlignVCenter
)
self.lh_download.addWidget(self.l_quality_video)
self.cb_quality_video = QComboBox(self.w_central)
self.cb_quality_video.setObjectName("cb_quality_video")
self.cb_quality_video.setMinimumSize(QSize(100, 0))
# if QT_CONFIG(tooltip)
self.cb_quality_video.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.cb_quality_video.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.cb_quality_video.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.cb_quality_video.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.cb_quality_video.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.cb_quality_video.setCurrentText("")
self.cb_quality_video.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContentsOnFirstShow)
self.cb_quality_video.setPlaceholderText("")
self.lh_download.addWidget(self.cb_quality_video)
self.pb_download = QPushButton(self.w_central)
self.pb_download.setObjectName("pb_download")
# if QT_CONFIG(tooltip)
self.pb_download.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.pb_download.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.pb_download.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.pb_download.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.pb_download.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.pb_download.setText("Download")
# if QT_CONFIG(shortcut)
self.pb_download.setShortcut("")
# endif // QT_CONFIG(shortcut)
self.lh_download.addWidget(self.pb_download)
self.lh_download.setStretch(0, 5)
self.lh_download.setStretch(2, 5)
self.lh_download.setStretch(4, 15)
self.lv_search_result.addLayout(self.lh_download)
self.te_debug = QPlainTextEdit(self.w_central)
self.te_debug.setObjectName("te_debug")
self.te_debug.setEnabled(True)
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
sizePolicy2.setHorizontalStretch(0)
sizePolicy2.setVerticalStretch(0)
sizePolicy2.setHeightForWidth(self.te_debug.sizePolicy().hasHeightForWidth())
self.te_debug.setSizePolicy(sizePolicy2)
self.te_debug.setMaximumSize(QSize(16777215, 16777215))
self.te_debug.setAcceptDrops(False)
# if QT_CONFIG(tooltip)
self.te_debug.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.te_debug.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.te_debug.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.te_debug.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.te_debug.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.te_debug.setUndoRedoEnabled(False)
self.te_debug.setReadOnly(True)
self.lv_search_result.addWidget(self.te_debug)
self.horizontalLayout.addLayout(self.lv_search_result)
self.lv_info = QVBoxLayout()
self.lv_info.setObjectName("lv_info")
self.lv_info_item = QVBoxLayout()
self.lv_info_item.setObjectName("lv_info_item")
self.horizontalLayout_2 = QHBoxLayout()
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.l_pm_cover = QLabel(self.w_central)
self.l_pm_cover.setObjectName("l_pm_cover")
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
sizePolicy3.setHorizontalStretch(0)
sizePolicy3.setVerticalStretch(0)
sizePolicy3.setHeightForWidth(self.l_pm_cover.sizePolicy().hasHeightForWidth())
self.l_pm_cover.setSizePolicy(sizePolicy3)
self.l_pm_cover.setMaximumSize(QSize(280, 280))
self.l_pm_cover.setBaseSize(QSize(0, 0))
self.l_pm_cover.setFrameShape(QFrame.Shape.NoFrame)
self.l_pm_cover.setPixmap(QPixmap("default_album_image.png"))
self.l_pm_cover.setScaledContents(True)
self.l_pm_cover.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop)
self.horizontalLayout_2.addWidget(self.l_pm_cover)
self.lv_info_item.addLayout(self.horizontalLayout_2)
self.lv_info.addLayout(self.lv_info_item)
self.lv_queue_download = QVBoxLayout()
self.lv_queue_download.setObjectName("lv_queue_download")
self.l_h_queue_download = QLabel(self.w_central)
self.l_h_queue_download.setObjectName("l_h_queue_download")
font = QFont()
font.setBold(True)
font.setItalic(False)
font.setKerning(True)
self.l_h_queue_download.setFont(font)
self.lv_queue_download.addWidget(self.l_h_queue_download)
self.tr_queue_download = QTreeWidget(self.w_central)
__qtreewidgetitem4 = QTreeWidgetItem()
__qtreewidgetitem4.setText(0, "\ud83e\uddd1\u200d\ud83d\udcbb\ufe0f")
self.tr_queue_download.setHeaderItem(__qtreewidgetitem4)
self.tr_queue_download.setObjectName("tr_queue_download")
self.tr_queue_download.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.tr_queue_download.setTabKeyNavigation(False)
self.tr_queue_download.setProperty("showDropIndicator", False)
self.tr_queue_download.setDragDropOverwriteMode(False)
self.tr_queue_download.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.tr_queue_download.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.tr_queue_download.setRootIsDecorated(False)
self.tr_queue_download.setItemsExpandable(False)
self.tr_queue_download.setSortingEnabled(False)
self.tr_queue_download.setExpandsOnDoubleClick(False)
self.tr_queue_download.header().setProperty("showSortIndicator", False)
self.tr_queue_download.header().setStretchLastSection(False)
self.lv_queue_download.addWidget(self.tr_queue_download)
self.horizontalLayout_4 = QHBoxLayout()
self.horizontalLayout_4.setObjectName("horizontalLayout_4")
self.pb_queue_download_remove = QPushButton(self.w_central)
self.pb_queue_download_remove.setObjectName("pb_queue_download_remove")
self.pb_queue_download_remove.setEnabled(True)
icon = QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditClear))
self.pb_queue_download_remove.setIcon(icon)
self.horizontalLayout_4.addWidget(self.pb_queue_download_remove)
self.pb_queue_download_toggle = QPushButton(self.w_central)
self.pb_queue_download_toggle.setObjectName("pb_queue_download_toggle")
icon1 = QIcon(QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackPause))
self.pb_queue_download_toggle.setIcon(icon1)
self.horizontalLayout_4.addWidget(self.pb_queue_download_toggle)
self.lv_queue_download.addLayout(self.horizontalLayout_4)
self.horizontalLayout_3 = QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.pb_queue_download_clear_finished = QPushButton(self.w_central)
self.pb_queue_download_clear_finished.setObjectName("pb_queue_download_clear_finished")
self.horizontalLayout_3.addWidget(self.pb_queue_download_clear_finished)
self.pb_queue_download_clear_all = QPushButton(self.w_central)
self.pb_queue_download_clear_all.setObjectName("pb_queue_download_clear_all")
self.horizontalLayout_3.addWidget(self.pb_queue_download_clear_all)
self.lv_queue_download.addLayout(self.horizontalLayout_3)
self.lv_info.addLayout(self.lv_queue_download)
self.horizontalLayout.addLayout(self.lv_info)
self.horizontalLayout.setStretch(1, 50)
self.horizontalLayout.setStretch(2, 25)
MainWindow.setCentralWidget(self.w_central)
self.menubar = QMenuBar(MainWindow)
self.menubar.setObjectName("menubar")
self.menubar.setGeometry(QRect(0, 0, 1200, 24))
# if QT_CONFIG(tooltip)
self.menubar.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.menubar.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.menubar.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.menubar.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.menubar.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.m_file = QMenu(self.menubar)
self.m_file.setObjectName("m_file")
# if QT_CONFIG(tooltip)
self.m_file.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.m_file.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.m_file.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.m_file.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.m_file.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.m_help = QMenu(self.menubar)
self.m_help.setObjectName("m_help")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QStatusBar(MainWindow)
self.statusbar.setObjectName("statusbar")
# if QT_CONFIG(tooltip)
self.statusbar.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.statusbar.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.statusbar.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.statusbar.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.statusbar.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.statusbar.setLayoutDirection(Qt.LayoutDirection.LeftToRight)
MainWindow.setStatusBar(self.statusbar)
self.menubar.addAction(self.m_file.menuAction())
self.menubar.addAction(self.m_help.menuAction())
self.m_file.addAction(self.a_preferences)
self.m_file.addAction(self.a_logout)
self.m_file.addAction(self.a_exit)
self.m_help.addAction(self.a_version)
self.m_help.addAction(self.a_updates_check)
self.retranslateUi(MainWindow)
QMetaObject.connectSlotsByName(MainWindow)
# setupUi
def retranslateUi(self, MainWindow):
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", "MainWindow", None))
self.a_version.setText(QCoreApplication.translate("MainWindow", "Version", None))
self.a_exit.setText(QCoreApplication.translate("MainWindow", "Quit TIDAL-Downloader-NG", None))
self.a_logout.setText(QCoreApplication.translate("MainWindow", "Logout", None))
self.a_updates_check.setText(QCoreApplication.translate("MainWindow", "Check for Updates", None))
___qtreewidgetitem = self.tr_lists_user.headerItem()
___qtreewidgetitem.setText(1, QCoreApplication.translate("MainWindow", "obj", None))
__sortingEnabled = self.tr_lists_user.isSortingEnabled()
self.tr_lists_user.setSortingEnabled(False)
___qtreewidgetitem1 = self.tr_lists_user.topLevelItem(0)
___qtreewidgetitem1.setText(0, QCoreApplication.translate("MainWindow", "Playlists", None))
___qtreewidgetitem2 = self.tr_lists_user.topLevelItem(1)
___qtreewidgetitem2.setText(0, QCoreApplication.translate("MainWindow", "Mixes", None))
___qtreewidgetitem3 = self.tr_lists_user.topLevelItem(2)
___qtreewidgetitem3.setText(0, QCoreApplication.translate("MainWindow", "Favorites", None))
self.tr_lists_user.setSortingEnabled(__sortingEnabled)
self.pb_reload_user_lists.setText(QCoreApplication.translate("MainWindow", "Reload", None))
self.pb_download_list.setText(QCoreApplication.translate("MainWindow", "Download List", None))
self.te_debug.setPlaceholderText(QCoreApplication.translate("MainWindow", "Logs...", None))
self.l_pm_cover.setText("")
self.l_h_queue_download.setText(QCoreApplication.translate("MainWindow", "Download Queue", None))
___qtreewidgetitem4 = self.tr_queue_download.headerItem()
___qtreewidgetitem4.setText(5, QCoreApplication.translate("MainWindow", "Quality Video", None))
___qtreewidgetitem4.setText(4, QCoreApplication.translate("MainWindow", "Quality Audio", None))
___qtreewidgetitem4.setText(3, QCoreApplication.translate("MainWindow", "Type", None))
___qtreewidgetitem4.setText(2, QCoreApplication.translate("MainWindow", "Name", None))
___qtreewidgetitem4.setText(1, QCoreApplication.translate("MainWindow", "obj", None))
self.pb_queue_download_remove.setText(QCoreApplication.translate("MainWindow", "Remove", None))
self.pb_queue_download_toggle.setText(QCoreApplication.translate("MainWindow", "Queue", None))
self.pb_queue_download_clear_finished.setText(QCoreApplication.translate("MainWindow", "Clear Finished", None))
self.pb_queue_download_clear_all.setText(QCoreApplication.translate("MainWindow", "Clear All", None))
self.m_file.setTitle(QCoreApplication.translate("MainWindow", "File", None))
self.m_help.setTitle(QCoreApplication.translate("MainWindow", "Help", None))
# retranslateUi

823
tidal_dl_ng/ui/main.ui Normal file
View File

@@ -0,0 +1,823 @@
<?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>1200</width>
<height>800</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" stretch="0,50,25">
<item>
<layout class="QVBoxLayout" name="lv_list_user">
<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::EditTrigger::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SelectionMode::ExtendedSelection</enum>
</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">Name</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>
<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>
<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="QHBoxLayout" name="lv_list_control">
<item>
<widget class="QPushButton" name="pb_reload_user_lists">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Reload</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_download_list">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Download List</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</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="minimumSize">
<size>
<width>100</width>
<height>0</height>
</size>
</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="currentText">
<string notr="true"/>
</property>
<property name="sizeAdjustPolicy">
<enum>QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow</enum>
</property>
<property name="placeholderText">
<string notr="true"/>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_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="QTreeView" name="tr_results">
<property name="editTriggers">
<set>QAbstractItemView::EditTrigger::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::SelectionMode::ExtendedSelection</enum>
</property>
<property name="indentation">
<number>10</number>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
</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::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="cb_quality_audio">
<property name="minimumSize">
<size>
<width>140</width>
<height>0</height>
</size>
</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="currentText">
<string notr="true"/>
</property>
<property name="sizeAdjustPolicy">
<enum>QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow</enum>
</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::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="cb_quality_video">
<property name="minimumSize">
<size>
<width>100</width>
<height>0</height>
</size>
</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="currentText">
<string notr="true"/>
</property>
<property name="sizeAdjustPolicy">
<enum>QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow</enum>
</property>
<property name="placeholderText">
<string notr="true"/>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_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>
<item>
<layout class="QVBoxLayout" name="lv_info">
<item>
<layout class="QVBoxLayout" name="lv_info_item">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QLabel" name="l_pm_cover">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>280</width>
<height>280</height>
</size>
</property>
<property name="baseSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap>default_album_image.png</pixmap>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="lv_queue_download">
<item>
<widget class="QLabel" name="l_h_queue_download">
<property name="font">
<font>
<italic>false</italic>
<bold>true</bold>
<kerning>true</kerning>
</font>
</property>
<property name="text">
<string>Download Queue</string>
</property>
</widget>
</item>
<item>
<widget class="QTreeWidget" name="tr_queue_download">
<property name="editTriggers">
<set>QAbstractItemView::EditTrigger::NoEditTriggers</set>
</property>
<property name="tabKeyNavigation">
<bool>false</bool>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SelectionMode::ExtendedSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectionBehavior::SelectRows</enum>
</property>
<property name="rootIsDecorated">
<bool>false</bool>
</property>
<property name="itemsExpandable">
<bool>false</bool>
</property>
<property name="sortingEnabled">
<bool>false</bool>
</property>
<property name="expandsOnDoubleClick">
<bool>false</bool>
</property>
<attribute name="headerShowSortIndicator" stdset="0">
<bool>false</bool>
</attribute>
<attribute name="headerStretchLastSection">
<bool>false</bool>
</attribute>
<column>
<property name="text">
<string notr="true">🧑‍💻️</string>
</property>
</column>
<column>
<property name="text">
<string>obj</string>
</property>
</column>
<column>
<property name="text">
<string>Name</string>
</property>
</column>
<column>
<property name="text">
<string>Type</string>
</property>
</column>
<column>
<property name="text">
<string>Quality Audio</string>
</property>
</column>
<column>
<property name="text">
<string>Quality Video</string>
</property>
</column>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<widget class="QPushButton" name="pb_queue_download_remove">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>Remove</string>
</property>
<property name="icon">
<iconset theme="QIcon::ThemeIcon::EditClear"/>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_queue_download_toggle">
<property name="text">
<string>Queue</string>
</property>
<property name="icon">
<iconset theme="QIcon::ThemeIcon::MediaPlaybackPause"/>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QPushButton" name="pb_queue_download_clear_finished">
<property name="text">
<string>Clear Finished</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_queue_download_clear_all">
<property name="text">
<string>Clear All</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1200</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_preferences"/>
<addaction name="a_logout"/>
<addaction name="a_exit"/>
</widget>
<widget class="QMenu" name="m_help">
<property name="title">
<string>Help</string>
</property>
<addaction name="a_version"/>
<addaction name="a_updates_check"/>
</widget>
<addaction name="m_file"/>
<addaction name="m_help"/>
</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::LayoutDirection::LeftToRight</enum>
</property>
</widget>
<action name="a_preferences">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string notr="true">Preferences...</string>
</property>
<property name="iconText">
<string notr="true">Preferences...</string>
</property>
<property name="toolTip">
<string notr="true">Preferences...</string>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
</action>
<action name="a_version">
<property name="text">
<string>Version</string>
</property>
</action>
<action name="a_exit">
<property name="text">
<string>Quit TIDAL-Downloader-NG</string>
</property>
</action>
<action name="a_logout">
<property name="text">
<string>Logout</string>
</property>
</action>
<action name="a_updates_check">
<property name="text">
<string>Check for Updates</string>
</property>
</action>
</widget>
<resources/>
<connections/>
</ui>

221
tidal_dl_ng/ui/spinner.py Normal file
View File

@@ -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

34
tidal_dl_ng/worker.py Normal file
View File

@@ -0,0 +1,34 @@
from PySide6 import QtCore
# Taken from https://www.pythonguis.com/tutorials/multithreading-pyside6-applications-qthreadpool/
class Worker(QtCore.QRunnable):
"""
Worker thread
Inherits from QRunnable to handler worker thread setup, signals and wrap-up.
:param callback: The function callback to run on this worker thread. Supplied args and
kwargs will be passed through to the runner.
:type callback: function
:param args: Arguments to pass to the callback function
:param kwargs: Keywords to pass to the callback function
"""
def __init__(self, fn, *args, **kwargs):
super().__init__()
# Store constructor arguments (re-used for processing)
self.fn = fn
self.args = args
self.kwargs = kwargs
@QtCore.Slot() # QtCore.Slot
def run(self):
"""
Initialise the runner function with passed args, kwargs.
"""
self.fn(*self.args, **self.kwargs)
def thread(self) -> QtCore.QThread:
return QtCore.QThread.currentThread()