This commit is contained in:
2024-12-27 22:00:28 +09:00
commit 2353324570
56 changed files with 8265 additions and 0 deletions

52
main.py Normal file
View File

@ -0,0 +1,52 @@
from tidal_dl_ng.config import Settings, Tidal
from tidal_dl_ng.constants import CTX_TIDAL, MediaType
from tidal_dl_ng.download import Download
from tidal_dl_ng.helper.path import get_format_template, path_file_settings
from tidal_dl_ng.helper.tidal import (
all_artist_album_ids,
get_tidal_media_id,
get_tidal_media_type,
instantiate_media,
)
from tidal_dl_ng.helper.wrapper import LoggerWrapped
from tidal_dl_ng.model.cfg import HelpSettings
from rich.live import Live
from rich.panel import Panel
from rich.progress import BarColumn, Console, Progress, SpinnerColumn, TextColumn
from rich.table import Table
from pathlib import Path
from typing import Annotated, Optional
from fastapi import FastAPI, HTTPException, Depends, Request
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import List, Optional
import uvicorn
# Initialize FastAPI app
app = FastAPI(
title="FastAPI Template", description="A simple FastAPI template with basic CRUD operations", version="1.0.0"
)
app.mount("/static", StaticFiles(directory="static"), name="static")
# Initialize Jinja2 templates
templates = Jinja2Templates(directory="templates")
# Main Landing Page
@app.get("/")
async def main(request: Request):
return templates.TemplateResponse("pages/index.html", {"request": request, "title": "Home Page"})
# Health check endpoint
@app.get("/health")
async def health_check():
return {"status": "healthy"}
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

55
requirements.txt Normal file
View File

@ -0,0 +1,55 @@
annotated-types==0.7.0
anyio==4.7.0
certifi==2024.12.14
charset-normalizer==3.4.1
click==8.1.8
dataclasses-json==0.6.7
dnspython==2.7.0
email_validator==2.2.0
fastapi==0.115.6
fastapi-cli==0.0.7
ffmpeg-python==0.2.0
future==1.0.0
h11==0.14.0
httpcore==1.0.7
httptools==0.6.4
httpx==0.28.1
idna==3.10
isodate==0.7.2
Jinja2==3.1.5
m3u8==6.0.0
markdown-it-py==3.0.0
MarkupSafe==3.0.2
marshmallow==3.23.2
mdurl==0.1.2
mpegdash==0.4.0
mutagen==1.47.0
mypy-extensions==1.0.0
packaging==24.2
pathvalidate==3.2.1
pycryptodome==3.21.0
pydantic==2.10.4
pydantic_core==2.27.2
Pygments==2.18.0
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
python-multipart==0.0.20
PyYAML==6.0.2
ratelimit==2.2.1
requests==2.32.3
rich==13.9.4
rich-toolkit==0.12.0
shellingham==1.5.4
six==1.17.0
sniffio==1.3.1
starlette==0.41.3
tidalapi==0.8.2
toml==0.10.2
typer==0.15.1
typing-inspect==0.9.0
typing_extensions==4.12.2
urllib3==2.3.0
uvicorn==0.34.0
uvloop==0.21.0
watchfiles==1.0.3
websockets==14.1

43
templates/base.html Normal file
View File

@ -0,0 +1,43 @@
{# templates/base.html #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ title }}{% endblock %} - My Site</title>
{# CSS Block #}
{% block css %}
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
{% endblock %}
</head>
<body>
{# Header Block #}
{% block header %}
<header>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</header>
{% endblock %}
{# Main Content Block #}
<main>
{% block content %}
{% endblock %}
</main>
{# Footer Block #}
{% block footer %}
<footer>
<p>&copy; 2024 My Site. All rights reserved.</p>
</footer>
{% endblock %}
{# JavaScript Block #}
{% block javascript %}
<script src="{{ url_for('static', path='/js/main.js') }}"></script>
{% endblock %}
</body>
</html>

View File

@ -0,0 +1,20 @@
{# templates/pages/home.html #}
{% extends "base.html" %}
{% block content %}
<div class="container">
<h1>Welcome to the Home Page</h1>
<p>This is the home page content.</p>
</div>
{% endblock %}
{% block css %}
{{ super() }}
<style>
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
</style>
{% endblock %}

134
tidal_dl_ng/__init__.py Normal file
View File

@ -0,0 +1,134 @@
#!/usr/bin/env python
import importlib.metadata
import sys
from pathlib import Path
from urllib.parse import urlparse
import requests
import toml
from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC
from tidal_dl_ng.model.meta import ProjectInformation, ReleaseLatest
def metadata_project() -> ProjectInformation:
result: ProjectInformation
file_path: Path = Path(__file__)
tmp_result: dict = {}
paths: [Path] = [
file_path.parent,
file_path.parent.parent,
file_path.parent.parent.parent,
]
for pyproject_toml_dir in paths:
pyproject_toml_file: Path = pyproject_toml_dir / "pyproject.toml"
if pyproject_toml_file.exists() and pyproject_toml_file.is_file():
tmp_result = toml.load(pyproject_toml_file)
break
if tmp_result:
result = ProjectInformation(
version=tmp_result["tool"]["poetry"]["version"], repository_url=tmp_result["tool"]["poetry"]["repository"]
)
else:
try:
meta_info = importlib.metadata.metadata(name_package())
result = ProjectInformation(version=meta_info["Version"], repository_url=meta_info["Home-page"])
except:
result = ProjectInformation(version="0.0.0", repository_url="https://anerroroccur.ed/sorry/for/that")
return result
def version_app() -> str:
metadata: ProjectInformation = metadata_project()
version: str = metadata.version
return version
def repository_url() -> str:
metadata: ProjectInformation = metadata_project()
url_repo: str = metadata.repository_url
return url_repo
def repository_path() -> str:
url_repo: str = repository_url()
url_path: str = urlparse(url_repo).path
return url_path
def latest_version_information() -> ReleaseLatest:
release_info: ReleaseLatest
repo_path: str = repository_path()
url: str = f"https://api.github.com/repos{repo_path}/releases/latest"
try:
response = requests.get(url, timeout=REQUESTS_TIMEOUT_SEC)
release_info: str = response.json()
release_info = ReleaseLatest(
version=release_info["tag_name"], url=release_info["html_url"], release_info=release_info["body"]
)
except:
release_info = ReleaseLatest(
version="v0.0.0",
url=url,
release_info=f"Something went wrong calling {url}. Check your internet connection.",
)
return release_info
def name_package() -> str:
package_name: str = __package__ or __name__
return package_name
def is_dev_env() -> bool:
package_name: str = name_package()
result: bool = False
# Check if package is running from source code == dev mode
# If package is not running in PyInstaller environment.
if not getattr(sys, "frozen", False) and not hasattr(sys, "_MEIPASS"):
try:
importlib.metadata.version(package_name)
except:
# If package is not installed
result = True
return result
def name_app() -> str:
app_name: str = name_package()
is_dev: bool = is_dev_env()
if is_dev:
app_name = app_name + "-dev"
return app_name
__name_display__ = name_app()
__version__ = version_app()
def update_available() -> (bool, ReleaseLatest):
latest_info: ReleaseLatest = latest_version_information()
result: bool = False
version_current: str = "v" + __version__
if version_current != latest_info.version and version_current != "v0.0.0":
result = True
return result, latest_info

114
tidal_dl_ng/api.py Normal file
View File

@ -0,0 +1,114 @@
import json
import requests
# See also
# https://github.com/yaronzz/Tidal-Media-Downloader/commit/1d5b8cd8f65fd1def45d6406778248249d6dfbdf
# https://github.com/yaronzz/Tidal-Media-Downloader/pull/840
# https://github.com/nathom/streamrip/tree/main/streamrip
# https://github.com/arnesongit/plugin.audio.tidal2/blob/e9429d601d0c303d775d05a19a66415b57479d87/resources/lib/tidal2/tidalapi/__init__.py#L86
# TODO: Implement this into `Download`: Session should randomize the usage.
__KEYS_JSON__ = """
{
"version": "1.0.1",
"keys": [
{
"platform": "Fire TV",
"formats": "Normal/High/HiFi(No Master)",
"clientId": "OmDtrzFgyVVL6uW56OnFA2COiabqm",
"clientSecret": "zxen1r3pO0hgtOC7j6twMo9UAqngGrmRiWpV7QC1zJ8=",
"valid": "False",
"from": "Fokka-Engineering (https://github.com/Fokka-Engineering/libopenTIDAL/blob/655528e26e4f3ee2c426c06ea5b8440cf27abc4a/README.md#example)"
},
{
"platform": "Fire TV",
"formats": "Master-Only(Else Error)",
"clientId": "7m7Ap0JC9j1cOM3n",
"clientSecret": "vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=",
"valid": "True",
"from": "Dniel97 (https://github.com/Dniel97/RedSea/blob/4ba02b88cee33aeb735725cb854be6c66ff372d4/config/settings.example.py#L68)"
},
{
"platform": "Android TV",
"formats": "Normal/High/HiFi(No Master)",
"clientId": "Pzd0ExNVHkyZLiYN",
"clientSecret": "W7X6UvBaho+XOi1MUeCX6ewv2zTdSOV3Y7qC3p3675I=",
"valid": "False",
"from": ""
},
{
"platform": "TV",
"formats": "Normal/High/HiFi/Master",
"clientId": "8SEZWa4J1NVC5U5Y",
"clientSecret": "owUYDkxddz+9FpvGX24DlxECNtFEMBxipU0lBfrbq60=",
"valid": "False",
"from": "morguldir (https://github.com/morguldir/python-tidal/commit/50f1afcd2079efb2b4cf694ef5a7d67fdf619d09)"
},
{
"platform": "Android Auto",
"formats": "Normal/High/HiFi/Master",
"clientId": "zU4XHVVkc2tDPo4t",
"clientSecret": "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4=",
"valid": "True",
"from": "1nikolas (https://github.com/yaronzz/Tidal-Media-Downloader/pull/840)"
}
]
}
"""
__API_KEYS__ = json.loads(__KEYS_JSON__)
__ERROR_KEY__ = (
{
"platform": "None",
"formats": "",
"clientId": "",
"clientSecret": "",
"valid": "False",
},
)
from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC
def getNum():
return len(__API_KEYS__["keys"])
def getItem(index: int):
if index < 0 or index >= len(__API_KEYS__["keys"]):
return __ERROR_KEY__
return __API_KEYS__["keys"][index]
def isItemValid(index: int):
item = getItem(index)
return item["valid"] == "True"
def getItems():
return __API_KEYS__["keys"]
def getLimitIndexs():
array = []
for i in range(len(__API_KEYS__["keys"])):
array.append(str(i))
return array
def getVersion():
return __API_KEYS__["version"]
# Load from gist
try:
respond = requests.get(
"https://api.github.com/gists/48d01f5a24b4b7b37f19443977c22cd6", timeout=REQUESTS_TIMEOUT_SEC
)
if respond.status_code == 200:
content = respond.json()["files"]["tidal-api-key.json"]["content"]
__API_KEYS__ = json.loads(content)
except Exception as e:
# TODO: Implement proper logging.
print(e)
pass

234
tidal_dl_ng/cli.py Normal file
View File

@ -0,0 +1,234 @@
#!/usr/bin/env python
from pathlib import Path
from typing import Annotated, Optional
import typer
from rich.live import Live
from rich.panel import Panel
from rich.progress import BarColumn, Console, Progress, SpinnerColumn, TextColumn
from rich.table import Table
from tidal_dl_ng import __version__
from tidal_dl_ng.config import Settings, Tidal
from tidal_dl_ng.constants import CTX_TIDAL, MediaType
from tidal_dl_ng.download import Download
from tidal_dl_ng.helper.path import get_format_template, path_file_settings
from tidal_dl_ng.helper.tidal import (
all_artist_album_ids,
get_tidal_media_id,
get_tidal_media_type,
instantiate_media,
)
from tidal_dl_ng.helper.wrapper import LoggerWrapped
from tidal_dl_ng.model.cfg import HelpSettings
app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]}, add_completion=False)
def version_callback(value: bool):
if value:
print(f"{__version__}")
raise typer.Exit()
@app.callback()
def callback_app(
ctx: typer.Context,
version: Annotated[
Optional[bool], typer.Option("--version", "-v", callback=version_callback, is_eager=True)
] = None,
):
ctx.obj = {"tidal": None}
@app.command(name="cfg")
def settings_management(
names: Annotated[Optional[list[str]], typer.Argument()] = None,
editor: Annotated[
bool, typer.Option("--editor", "-e", help="Open the settings file in your default editor.")
] = False,
):
"""
Print or set an option.
If no arguments are given, all options will be listed.
If only one argument is given, the value will be printed for this option.
To set a value for an option simply pass the value as the second argument
:param names: (Optional) None (list all options), one (list the value only for this option) or two arguments
(set the value for the option).
"""
if editor:
config_path: Path = Path(path_file_settings())
if not config_path.is_file():
config_path.write_text('{"version": "1.0.0"}')
config_file_str = str(config_path)
typer.launch(config_file_str)
else:
settings = Settings()
d_settings = settings.data.to_dict()
if names:
if names[0] not in d_settings:
print(f'Option "{names[0]}" is not valid!')
else:
if len(names) == 1:
print(f'{names[0]}: "{d_settings[names[0]]}"')
elif len(names) > 1:
settings.set_option(names[0], names[1])
settings.save()
else:
help_settings: dict = HelpSettings().to_dict()
table = Table(title=f"Config: {path_file_settings()}")
table.add_column("Key", style="cyan", no_wrap=True)
table.add_column("Value", style="magenta")
table.add_column("Description", style="green")
# Iterate over the attributes of the dataclass
for key, value in sorted(d_settings.items()):
table.add_row(key, str(value), help_settings[key])
console = Console()
console.print(table)
@app.command(name="login")
def login(ctx: typer.Context) -> bool:
print("Let us check, if you are already logged in... ", end="")
settings = Settings()
tidal = Tidal(settings)
result = tidal.login(fn_print=print)
ctx.obj[CTX_TIDAL] = tidal
return result
@app.command(name="logout")
def logout() -> bool:
settings = Settings()
tidal = Tidal(settings)
result = tidal.logout()
if result:
print("You have been successfully logged out.")
return result
@app.command(name="dl")
def download(
ctx: typer.Context,
urls: Annotated[Optional[list[str]], typer.Argument()] = None,
file_urls: Annotated[
Optional[Path],
typer.Option(
"--list",
"-l",
exists=True,
file_okay=True,
dir_okay=False,
writable=False,
readable=True,
resolve_path=True,
help="List with URLs to download. One per line",
),
] = None,
):
if not urls:
# Read the text file provided.
if file_urls:
text = file_urls.read_text()
urls = text.splitlines()
else:
print("Provide either URLs, IDs or a file containing URLs (one per line).")
raise typer.Abort()
# Call login method to validate the token.
ctx.invoke(login, ctx)
# Create initial objects.
settings: Settings = Settings()
progress: Progress = Progress(
"{task.description}",
SpinnerColumn(),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
)
fn_logger = LoggerWrapped(progress.print)
dl = Download(
session=ctx.obj[CTX_TIDAL].session,
skip_existing=ctx.obj[CTX_TIDAL].settings.data.skip_existing,
path_base=settings.data.download_base_path,
fn_logger=fn_logger,
progress=progress,
)
progress_table = Table.grid()
# Style Progress display.
progress_table.add_row(Panel.fit(progress, title="Download Progress", border_style="green", padding=(2, 2)))
urls_pos_last = len(urls) - 1
for item in urls:
media_type: MediaType | bool = False
# Extract media name and id from link.
if "http" in item:
media_type = get_tidal_media_type(item)
item_id = get_tidal_media_id(item)
file_template = get_format_template(media_type, settings)
# If url is invalid skip to next url in list.
if not media_type:
print(f"It seems like that you have supplied an invalid URL: {item}")
continue
# Create Live display for Progress.
with Live(progress_table, refresh_per_second=10):
# Download media.
if media_type in [MediaType.TRACK, MediaType.VIDEO]:
download_delay: bool = bool(settings.data.download_delay and urls.index(item) < urls_pos_last)
dl.item(
media_id=item_id, media_type=media_type, file_template=file_template, download_delay=download_delay
)
elif media_type in [MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX, MediaType.ARTIST]:
item_ids: [int] = []
if media_type == MediaType.ARTIST:
media = instantiate_media(ctx.obj[CTX_TIDAL].session, media_type, item_id)
media_type = MediaType.ALBUM
item_ids = item_ids + all_artist_album_ids(media)
else:
item_ids.append(item_id)
for item_id in item_ids:
dl.items(
media_id=item_id,
media_type=media_type,
file_template=file_template,
video_download=ctx.obj[CTX_TIDAL].settings.data.video_download,
download_delay=settings.data.download_delay,
)
# Stop Progress display.
progress.stop()
return True
@app.command()
def gui(ctx: typer.Context):
from tidal_dl_ng.gui import gui_activate
ctx.invoke(login, ctx)
gui_activate(ctx.obj[CTX_TIDAL])
if __name__ == "__main__":
app()

191
tidal_dl_ng/config.py Normal file
View File

@ -0,0 +1,191 @@
import json
import os
import shutil
from collections.abc import Callable
from json import JSONDecodeError
from pathlib import Path
from typing import Any
import tidalapi
from requests import HTTPError
from tidal_dl_ng.helper.decorator import SingletonMeta
from tidal_dl_ng.helper.path import path_config_base, path_file_settings, path_file_token
from tidal_dl_ng.model.cfg import Settings as ModelSettings
from tidal_dl_ng.model.cfg import Token as ModelToken
class BaseConfig:
data: ModelSettings | ModelToken
file_path: str
cls_model: ModelSettings | ModelToken
path_base: str = path_config_base()
def save(self, config_to_compare: str = None) -> None:
data_json = self.data.to_json()
# If old and current config is equal, skip the write operation.
if config_to_compare == data_json:
return
# Try to create the base folder.
os.makedirs(self.path_base, exist_ok=True)
with open(self.file_path, encoding="utf-8", mode="w") as f:
# Save it in a pretty format
obj_json_config = json.loads(data_json)
json.dump(obj_json_config, f, indent=4)
def set_option(self, key: str, value: Any) -> None:
value_old: Any = getattr(self.data, key)
if type(value_old) == bool: # noqa: E721
value = True if value.lower() in ("true", "1", "yes", "y") else False # noqa: SIM210
elif type(value_old) == int and type(value) != int: # noqa: E721
value = int(value)
setattr(self.data, key, value)
def read(self, path: str) -> bool:
result: bool = False
settings_json: str = ""
try:
with open(path, encoding="utf-8") as f:
settings_json = f.read()
self.data = self.cls_model.from_json(settings_json)
result = True
except (JSONDecodeError, TypeError, FileNotFoundError, ValueError) as e:
if isinstance(e, ValueError):
path_bak = path + ".bak"
# First check if a backup file already exists. If yes, remove it.
if os.path.exists(path_bak):
os.remove(path_bak)
# Move the invalid config file to the backup location.
shutil.move(path, path_bak)
# TODO: Implement better global logger.
print(
"Something is wrong with your config. Maybe it is not compatible anymore due to a new app version."
f" You can find a backup of your old config here: '{path_bak}'. A new default config was created."
)
self.data = self.cls_model()
# Call save in case of we need to update the saved config, due to changes in code.
self.save(settings_json)
return result
class Settings(BaseConfig, metaclass=SingletonMeta):
def __init__(self):
self.cls_model = ModelSettings
self.file_path = path_file_settings()
self.read(self.file_path)
class Tidal(BaseConfig, metaclass=SingletonMeta):
session: tidalapi.Session
token_from_storage: bool = False
settings: Settings
is_pkce: bool
def __init__(self, settings: Settings = None):
self.cls_model = ModelToken
tidal_config: tidalapi.Config = tidalapi.Config(item_limit=10000)
self.session = tidalapi.Session(tidal_config)
# self.session.config.client_id = "km8T1xS355y7dd3H"
# self.session.config.client_secret = "vcmeGW1OuZ0fWYMCSZ6vNvSLJlT3XEpW0ambgYt5ZuI="
self.file_path = path_file_token()
self.token_from_storage = self.read(self.file_path)
if settings:
self.settings = settings
self.settings_apply()
def settings_apply(self, settings: Settings = None) -> bool:
if settings:
self.settings = settings
self.session.audio_quality = self.settings.data.quality_audio
self.session.video_quality = tidalapi.VideoQuality.high
return True
def login_token(self, do_pkce: bool = False) -> bool:
result = False
self.is_pkce = do_pkce
if self.token_from_storage:
try:
result = self.session.load_oauth_session(
self.data.token_type,
self.data.access_token,
self.data.refresh_token,
self.data.expiry_time,
is_pkce=do_pkce,
)
except (HTTPError, JSONDecodeError):
result = False
# Remove token file. Probably corrupt or invalid.
if os.path.exists(self.file_path):
os.remove(self.file_path)
print(
"Either there is something wrong with your credentials / account or some server problems on TIDALs "
"side. Anyway... Try to login again by re-starting this app."
)
return result
def login_finalize(self) -> bool:
result = self.session.check_login()
if result:
self.token_persist()
return result
def token_persist(self) -> None:
self.set_option("token_type", self.session.token_type)
self.set_option("access_token", self.session.access_token)
self.set_option("refresh_token", self.session.refresh_token)
self.set_option("expiry_time", self.session.expiry_time)
self.save()
def login(self, fn_print: Callable) -> bool:
is_token = self.login_token()
result = False
if is_token:
fn_print("Yep, looks good! You are logged in.")
result = True
elif not is_token:
fn_print("You either do not have a token or your token is invalid.")
fn_print("No worries, we will handle this...")
# Login method: Device linking
self.session.login_oauth_simple(fn_print)
# Login method: PKCE authorization (was necessary for HI_RES_LOSSLESS streaming earlier)
# self.session.login_pkce(fn_print)
is_login = self.login_finalize()
if is_login:
fn_print("The login was successful. I have stored your credentials (token).")
result = True
else:
fn_print("Something went wrong. Did you login using your browser correctly? May try again...")
return result
def logout(self):
Path(self.file_path).unlink(missing_ok=True)
self.token_from_storage = False
del self.session
return True

60
tidal_dl_ng/constants.py Normal file
View File

@ -0,0 +1,60 @@
from enum import StrEnum
CTX_TIDAL: str = "tidal"
REQUESTS_TIMEOUT_SEC: int = 45
EXTENSION_LYRICS: str = ".lrc"
UNIQUIFY_THRESHOLD: int = 99
FILENAME_SANITIZE_PLACEHOLDER: str = "_"
COVER_NAME: str = "cover.jpg"
BLOCK_SIZE: int = 4096
BLOCKS: int = 1024
CHUNK_SIZE: int = BLOCK_SIZE * BLOCKS
PLAYLIST_EXTENSION: str = ".m3u"
PLAYLIST_PREFIX: str = "_"
class QualityVideo(StrEnum):
P360: str = "360"
P480: str = "480"
P720: str = "720"
P1080: str = "1080"
class MediaType(StrEnum):
TRACK: str = "track"
VIDEO: str = "video"
PLAYLIST: str = "playlist"
ALBUM: str = "album"
MIX: str = "mix"
ARTIST: str = "artist"
class CoverDimensions(StrEnum):
Px80: str = "80"
Px160: str = "160"
Px320: str = "320"
Px640: str = "640"
Px1280: str = "1280"
class TidalLists(StrEnum):
Playlists: str = "Playlists"
Favorites: str = "Favorites"
Mixes: str = "Mixes"
class QueueDownloadStatus(StrEnum):
Waiting: str = "⏳️"
Downloading: str = "▶️"
Finished: str = ""
Failed: str = ""
Skipped: str = "↪️"
FAVORITES: {} = {
"fav_videos": {"name": "Videos", "function_name": "videos"},
"fav_tracks": {"name": "Tracks", "function_name": "tracks"},
"fav_mixes": {"name": "Mixes & Radio", "function_name": "mixes"},
"fav_artists": {"name": "Artists", "function_name": "artists"},
"fav_albums": {"name": "Albums", "function_name": "albums"},
}

307
tidal_dl_ng/dialog.py Normal file
View File

@ -0,0 +1,307 @@
import datetime
import os.path
import shutil
import webbrowser
from enum import Enum, StrEnum
from pathlib import Path
from PySide6 import QtCore, QtGui, QtWidgets
from tidalapi import Quality as QualityAudio
from tidal_dl_ng import __version__
from tidal_dl_ng.config import Settings
from tidal_dl_ng.constants import CoverDimensions, QualityVideo
from tidal_dl_ng.model.cfg import HelpSettings
from tidal_dl_ng.model.cfg import Settings as ModelSettings
from tidal_dl_ng.model.meta import ReleaseLatest
from tidal_dl_ng.ui.dialog_login import Ui_DialogLogin
from tidal_dl_ng.ui.dialog_settings import Ui_DialogSettings
from tidal_dl_ng.ui.dialog_version import Ui_DialogVersion
class DialogVersion(QtWidgets.QDialog):
"""Version dialog."""
ui: Ui_DialogVersion
def __init__(
self, parent=None, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest = None
):
super().__init__(parent)
# Create an instance of the GUI
self.ui = Ui_DialogVersion()
# Run the .setupUi() method to show the GUI
self.ui.setupUi(self)
# Set the version.
self.ui.l_version.setText("v" + __version__)
if not update_check:
self.update_info_hide()
self.error_hide()
else:
self.update_info(update_available, update_info)
# Show
self.exec()
def update_info(self, update_available: bool, update_info: ReleaseLatest):
if not update_available and update_info.version == "v0.0.0":
self.update_info_hide()
self.ui.l_error_details.setText(
"Cannot retrieve update information. Maybe something is wrong with your internet connection."
)
else:
self.error_hide()
if not update_available:
self.ui.l_h_version_new.setText("Latest available version:")
self.changelog_hide()
else:
self.ui.l_changelog_details.setText(update_info.release_info)
self.ui.pb_download.clicked.connect(lambda: webbrowser.open(update_info.url))
self.ui.l_version_new.setText(update_info.version)
def error_hide(self):
self.ui.l_error.setHidden(True)
self.ui.l_error_details.setHidden(True)
def update_info_hide(self):
self.ui.l_h_version_new.setHidden(True)
self.ui.l_version_new.setHidden(True)
self.changelog_hide()
def changelog_hide(self):
self.ui.l_changelog.setHidden(True)
self.ui.l_changelog_details.setHidden(True)
self.ui.pb_download.setHidden(True)
class DialogLogin(QtWidgets.QDialog):
"""Version dialog."""
ui: Ui_DialogLogin
url_redirect: str
def __init__(self, url_login: str, hint: str, expires_in: int, parent=None):
super().__init__(parent)
datetime_current: datetime.datetime = datetime.datetime.now()
datetime_expires: datetime.datetime = datetime_current + datetime.timedelta(0, expires_in)
# Create an instance of the GUI
self.ui = Ui_DialogLogin()
# Run the .setupUi() method to show the GUI
self.ui.setupUi(self)
# Set data.
self.ui.tb_url_login.setText(f'<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."""
ui: Ui_DialogSettings
settings: Settings
data: ModelSettings
s_settings_save: QtCore.Signal
icon: QtGui.QIcon
help_settings: HelpSettings
parameters_checkboxes: [str]
parameters_combo: [(str, StrEnum)]
parameters_line_edit: [str]
parameters_spin_box: [str]
prefix_checkbox: str = "cb_"
prefix_label: str = "l_"
prefix_icon: str = "icon_"
prefix_line_edit: str = "le_"
prefix_combo: str = "c_"
prefix_spin_box: str = "sb_"
def __init__(self, settings: Settings, settings_save: QtCore.Signal, parent=None):
super().__init__(parent)
self.settings = settings
self.data = settings.data
self.s_settings_save = settings_save
self.help_settings = HelpSettings()
pixmapi: QtWidgets.QStyle.StandardPixmap = QtWidgets.QStyle.SP_MessageBoxQuestion
self.icon = self.style().standardIcon(pixmapi)
self._init_checkboxes()
self._init_comboboxes()
self._init_line_edit()
self._init_spin_box()
# Create an instance of the GUI
self.ui = Ui_DialogSettings()
# Run the .setupUi() method to show the GUI
self.ui.setupUi(self)
# Set data.
self.gui_populate()
# Post setup
self.exec()
def _init_line_edit(self):
self.parameters_line_edit = [
"download_base_path",
"format_album",
"format_playlist",
"format_mix",
"format_track",
"format_video",
"path_binary_ffmpeg",
]
def _init_spin_box(self):
self.parameters_spin_box = ["album_track_num_pad_min", "downloads_concurrent_max"]
def _init_comboboxes(self):
self.parameters_combo = [
("quality_audio", QualityAudio),
("quality_video", QualityVideo),
("metadata_cover_dimension", CoverDimensions),
]
def _init_checkboxes(self):
self.parameters_checkboxes = [
"lyrics_embed",
"lyrics_file",
"video_download",
"download_delay",
"video_convert_mp4",
"extract_flac",
"metadata_cover_embed",
"cover_album_file",
"skip_existing",
"symlink_to_track",
"playlist_create",
]
def gui_populate(self):
self.populate_checkboxes()
self.populate_combo()
self.populate_line_edit()
self.populate_spin_box()
def dialog_chose_file(
self,
obj_line_edit: QtWidgets.QLineEdit,
file_mode: QtWidgets.QFileDialog | QtWidgets.QFileDialog.FileMode = QtWidgets.QFileDialog.Directory,
path_default: str = None,
):
# If a path is set, use it otherwise the users home directory.
path_settings: str = os.path.expanduser(obj_line_edit.text()) if obj_line_edit.text() else ""
# Check if obj_line_edit is empty but path_default can be usd instead
path_settings = (
path_settings if path_settings else os.path.expanduser(path_default) if path_default else path_settings
)
dir_current: str = path_settings if path_settings and os.path.exists(path_settings) else str(Path.home())
dialog: QtWidgets.QFileDialog = QtWidgets.QFileDialog()
# Set to directory mode only but show files.
dialog.setFileMode(file_mode)
dialog.setViewMode(QtWidgets.QFileDialog.Detail)
dialog.setOption(QtWidgets.QFileDialog.ShowDirsOnly, False)
dialog.setOption(QtWidgets.QFileDialog.DontResolveSymlinks, True)
# There is a bug in the PyQt implementation, which hides files in Directory mode.
# Thus, we need to use the PyQt dialog instead of the native dialog.
if os.name == "nt" and file_mode == QtWidgets.QFileDialog.Directory:
dialog.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True)
dialog.setDirectory(dir_current)
# Execute dialog and set path is something is choosen.
if dialog.exec():
dir_name: str = dialog.selectedFiles()[0]
path: Path = Path(dir_name)
obj_line_edit.setText(str(path))
def populate_line_edit(self):
for pn in self.parameters_line_edit:
label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn)
label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn)
line_edit: QtWidgets.QLineEdit = getattr(self.ui, self.prefix_line_edit + pn)
label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16))))
label_icon.setToolTip(getattr(self.help_settings, pn))
label.setText(pn)
line_edit.setText(str(getattr(self.data, pn)))
# Base Path File Dialog
self.ui.pb_download_base_path.clicked.connect(lambda x: self.dialog_chose_file(self.ui.le_download_base_path))
self.ui.pb_path_binary_ffmpeg.clicked.connect(
lambda x: self.dialog_chose_file(
self.ui.le_path_binary_ffmpeg,
file_mode=QtWidgets.QFileDialog.FileMode.ExistingFiles,
path_default=shutil.which("ffmpeg"),
)
)
def populate_combo(self):
for p in self.parameters_combo:
pn: str = p[0]
values: Enum = p[1]
label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn)
label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn)
combo: QtWidgets.QComboBox = getattr(self.ui, self.prefix_combo + pn)
setting_current = getattr(self.data, pn)
label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16))))
label_icon.setToolTip(getattr(self.help_settings, pn))
label.setText(pn)
for index, v in enumerate(values):
combo.addItem(v.name, v)
if v == setting_current:
combo.setCurrentIndex(index)
def populate_checkboxes(self):
for pn in self.parameters_checkboxes:
checkbox: QtWidgets.QCheckBox = getattr(self.ui, self.prefix_checkbox + pn)
checkbox.setText(pn)
checkbox.setToolTip(getattr(self.help_settings, pn))
checkbox.setIcon(self.icon)
checkbox.setChecked(getattr(self.data, pn))
def populate_spin_box(self):
for pn in self.parameters_spin_box:
label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn)
label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn)
spin_box: QtWidgets.QSpinBox = getattr(self.ui, self.prefix_spin_box + pn)
label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16))))
label_icon.setToolTip(getattr(self.help_settings, pn))
label.setText(pn)
spin_box.setValue(getattr(self.data, pn))
def accept(self):
# Get settings.
self.to_settings()
self.done(1)
def to_settings(self):
for item in self.parameters_checkboxes:
setattr(self.settings.data, item, getattr(self.ui, self.prefix_checkbox + item).isChecked())
for item in self.parameters_line_edit:
setattr(self.settings.data, item, getattr(self.ui, self.prefix_line_edit + item).text())
for item in self.parameters_combo:
setattr(self.settings.data, item[0], getattr(self.ui, self.prefix_combo + item[0]).currentData())
for item in self.parameters_spin_box:
setattr(self.settings.data, item, getattr(self.ui, self.prefix_spin_box + item).value())
self.s_settings_save.emit()

805
tidal_dl_ng/download.py Normal file
View File

@ -0,0 +1,805 @@
import os
import pathlib
import random
import shutil
import tempfile
import time
from collections.abc import Callable
from concurrent import futures
from uuid import uuid4
import ffmpeg
import m3u8
import requests
from requests.adapters import HTTPAdapter, Retry
from requests.exceptions import HTTPError
from rich.progress import Progress, TaskID
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
from tidalapi.exceptions import TooManyRequests
from tidalapi.media import AudioExtensions, Codec, Quality, Stream, StreamManifest, VideoExtensions
from tidal_dl_ng.config import Settings
from tidal_dl_ng.constants import (
CHUNK_SIZE,
COVER_NAME,
EXTENSION_LYRICS,
PLAYLIST_EXTENSION,
PLAYLIST_PREFIX,
REQUESTS_TIMEOUT_SEC,
MediaType,
QualityVideo,
)
from tidal_dl_ng.helper.decryption import decrypt_file, decrypt_security_token
from tidal_dl_ng.helper.exceptions import MediaMissing
from tidal_dl_ng.helper.path import check_file_exists, format_path_media, path_file_sanitize, url_to_filename
from tidal_dl_ng.helper.tidal import (
instantiate_media,
items_results_all,
name_builder_album_artist,
name_builder_artist,
name_builder_item,
name_builder_title,
)
from tidal_dl_ng.metadata import Metadata
from tidal_dl_ng.model.downloader import DownloadSegmentResult
from tidal_dl_ng.model.gui_data import ProgressBars
# TODO: Set appropriate client string and use it for video download.
# https://github.com/globocom/m3u8#using-different-http-clients
class RequestsClient:
def download(
self, uri: str, timeout: int = REQUESTS_TIMEOUT_SEC, headers: dict | None = None, verify_ssl: bool = True
):
if not headers:
headers = {}
o = requests.get(uri, timeout=timeout, headers=headers)
return o.text, o.url
# TODO: Use pathlib.Path everywhere
class Download:
settings: Settings
session: Session
skip_existing: bool = False
fn_logger: Callable
progress_gui: ProgressBars
progress: Progress
def __init__(
self,
session: Session,
path_base: str,
fn_logger: Callable,
skip_existing: bool = False,
progress_gui: ProgressBars = None,
progress: Progress = None,
):
self.settings = Settings()
self.session = session
self.skip_existing = skip_existing
self.fn_logger = fn_logger
self.progress_gui = progress_gui
self.progress = progress
self.path_base = path_base
if not self.settings.data.path_binary_ffmpeg and (
self.settings.data.video_convert_mp4 or self.settings.data.extract_flac
):
self.settings.data.video_convert_mp4 = False
self.settings.data.extract_flac = False
self.fn_logger.error(
"FFmpeg path is not set. Videos can be downloaded but will not be processed. FLAC cannot be "
"extracted from MP4 containers. Make sure FFmpeg is installed. The path to the FFmpeg binary must "
"be set in (`path_binary_ffmpeg`)."
)
def _download(
self,
media: Track | Video,
path_file: pathlib.Path,
stream_manifest: StreamManifest | None = None,
) -> (bool, pathlib.Path):
media_name: str = name_builder_item(media)
urls: [str]
path_base: pathlib.Path = path_file.parent
result_segments: bool = True
dl_segment_results: [DownloadSegmentResult] = []
result_merge: bool = False
# Get urls for media.
try:
if isinstance(media, Track):
urls = stream_manifest.get_urls()
elif isinstance(media, Video):
m3u8_variant: m3u8.M3U8 = m3u8.load(media.get_url())
# Find the desired video resolution or the next best one.
m3u8_playlist, codecs = self._extract_video_stream(m3u8_variant, int(self.settings.data.quality_video))
# Populate urls.
urls = m3u8_playlist.files
except Exception:
return False, path_file
# Set the correct progress output channel.
if self.progress_gui is None:
progress_to_stdout: bool = True
else:
progress_to_stdout: bool = False
# Send signal to GUI with media name
self.progress_gui.item_name.emit(media_name[:30])
# Compute total iterations for progress
urls_count: int = len(urls)
if urls_count > 1:
progress_total: int = urls_count
block_size: int | None = None
elif urls_count == 1:
# Get file size and compute progress steps
r = requests.head(urls[0], timeout=REQUESTS_TIMEOUT_SEC)
total_size_in_bytes: int = int(r.headers.get("content-length", 0))
block_size: int | None = 1048576
progress_total: float = total_size_in_bytes / block_size
else:
raise ValueError
# Create progress Task
p_task: TaskID = self.progress.add_task(
f"[blue]Item '{media_name[:30]}'",
total=progress_total,
visible=progress_to_stdout,
)
# Download segments until progress is finished.
# TODO: Compute download speed (https://github.com/Textualize/rich/blob/master/examples/downloader.py)
while not self.progress.tasks[p_task].finished:
with futures.ThreadPoolExecutor(
max_workers=self.settings.data.downloads_simultaneous_per_track_max
) as executor:
# Dispatch all download tasks to worker threads
l_futures: [any] = [
executor.submit(self._download_segment, url, path_base, block_size, p_task, progress_to_stdout)
for url in urls
]
# Report results as they become available
for future in futures.as_completed(l_futures):
# Retrieve result
result_dl_segment: DownloadSegmentResult = future.result()
dl_segment_results.append(result_dl_segment)
# check for a link that was skipped
if not result_dl_segment.result and (result_dl_segment.url is not urls[-1]):
# Sometimes it happens, if a track is very short (< 8 seconds or so), that the last URL in `urls` is
# invalid (HTTP Error 500) and not necessary. File won't be corrupt.
# If this is NOT the case, but any other URL has resulted in an error,
# mark the whole thing as corrupt.
result_segments = False
self.fn_logger.error(f"Something went wrong while downloading {media_name}. File is corrupt!")
tmp_path_file_decrypted: pathlib.Path = path_file
# Only if no error happened while downloading.
if result_segments:
# Bring list into right order, so segments can be easily merged.
dl_segment_results.sort(key=lambda x: x.id_segment)
result_merge: bool = self._segments_merge(path_file, dl_segment_results)
if not result_merge:
self.fn_logger.error(f"Something went wrong while writing to {media_name}. File is corrupt!")
elif result_merge and isinstance(media, Track) and stream_manifest.is_encrypted:
key, nonce = decrypt_security_token(stream_manifest.encryption_key)
tmp_path_file_decrypted = path_file.with_suffix(".decrypted")
decrypt_file(path_file, tmp_path_file_decrypted, key, nonce)
return result_merge, tmp_path_file_decrypted
def _segments_merge(self, path_file, dl_segment_results) -> bool:
result: bool
# Copy the content of all segments into one file.
try:
with path_file.open("wb") as f_target:
for dl_segment_result in dl_segment_results:
with dl_segment_result.path_segment.open("rb") as f_segment:
# Read and write junks, which gives better HDD write performance
while segment := f_segment.read(CHUNK_SIZE):
f_target.write(segment)
# Delete segment from HDD
dl_segment_result.path_segment.unlink()
result = True
except Exception:
result = False
return result
def _download_segment(
self, url: str, path_base: pathlib.Path, block_size: int | None, p_task: TaskID, progress_to_stdout: bool
) -> DownloadSegmentResult:
result: bool = False
path_segment: pathlib.Path = path_base / url_to_filename(url)
# Calculate the segment ID based on the file name within the URL.
filename_stem: str = str(path_segment.stem).split("_")[-1]
# CAUTION: This is a workaround, so BTS (LOW quality) track will work. They usually have only ONE link.
id_segment: int = int(filename_stem) if filename_stem.isdecimal() else 0
error: HTTPError | None = None
# Retry download on failed segments, with an exponential delay between retries
s = requests.Session()
retries = Retry(total=5, backoff_factor=1) # , status_forcelist=[ 502, 503, 504 ])
s.mount("https://", HTTPAdapter(max_retries=retries))
try:
# Create the request object with stream=True, so the content won't be loaded into memory at once.
r = s.get(url, stream=True, timeout=REQUESTS_TIMEOUT_SEC)
r.raise_for_status()
# Write the content to disk. If `chunk_size` is set to `None` the whole file will be written at once.
with path_segment.open("wb") as f:
for data in r.iter_content(chunk_size=block_size):
f.write(data)
# Advance progress bar.
self.progress.advance(p_task)
result = True
except Exception:
self.progress.advance(p_task)
# To send the progress to the GUI, we need to emit the percentage.
if not progress_to_stdout:
self.progress_gui.item.emit(self.progress.tasks[p_task].percentage)
return DownloadSegmentResult(
result=result, url=url, path_segment=path_segment, id_segment=id_segment, error=error
)
def extension_guess(self, quality_audio: Quality, is_video: bool) -> AudioExtensions | VideoExtensions:
result: AudioExtensions | VideoExtensions
if is_video:
result = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS
else:
result = (
AudioExtensions.FLAC
if self.settings.data.extract_flac and quality_audio in (Quality.hi_res_lossless, Quality.high_lossless)
else AudioExtensions.M4A
)
return result
def item(
self,
file_template: str,
media: Track | Video = None,
media_id: str = None,
media_type: MediaType = None,
video_download: bool = True,
download_delay: bool = False,
quality_audio: Quality | None = None,
quality_video: QualityVideo | None = None,
is_parent_album: bool = False,
) -> (bool, pathlib.Path):
try:
if media_id and media_type:
# If no media instance is provided, we need to create the media instance.
media = instantiate_media(self.session, media_type, media_id)
elif isinstance(media, Track): # Check if media is available not deactivated / removed from TIDAL.
if not media.available:
self.fn_logger.info(
f"This track is not available for listening anymore on TIDAL. Skipping: {name_builder_item(media)}"
)
return False, ""
else:
# Re-create media instance with full album information
media = self.session.track(media.id, with_album=True)
elif not media:
raise MediaMissing
except:
return False, ""
# If video download is not allowed end here
if not video_download and isinstance(media, Video):
self.fn_logger.info(
f"Video downloads are deactivated (see settings). Skipping video: {name_builder_item(media)}"
)
return False, ""
# Create file name and path
file_extension_dummy: str = self.extension_guess(quality_audio, isinstance(media, Video))
file_name_relative: str = format_path_media(file_template, media, self.settings.data.album_track_num_pad_min)
path_media_dst: pathlib.Path = (
pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension_dummy)
).absolute()
# Sanitize final path_file to fit into OS boundaries.
path_media_dst = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True))
# Compute if and how downloads need to be skipped.
skip_download: bool = False
if self.skip_existing:
skip_file: bool = check_file_exists(path_media_dst, extension_ignore=False)
if self.settings.data.symlink_to_track and not isinstance(media, Video):
# Compute symlink tracks path, sanitize and check if file exists
file_name_track_dir_relative: str = format_path_media(self.settings.data.format_track, media)
path_media_track_dir: pathlib.Path = (
pathlib.Path(self.path_base).expanduser() / (file_name_track_dir_relative + file_extension_dummy)
).absolute()
path_media_track_dir = pathlib.Path(path_file_sanitize(str(path_media_track_dir), adapt=True))
file_exists_track_dir: bool = check_file_exists(path_media_track_dir, extension_ignore=False)
file_exists_playlist_dir: bool = (
not file_exists_track_dir and skip_file and not path_media_dst.is_symlink()
)
skip_download = file_exists_playlist_dir or file_exists_track_dir
# If
if skip_file and file_exists_playlist_dir:
skip_file = False
else:
skip_file: bool = False
if not skip_file:
# If a quality is explicitly set, change it and remember the previously set quality.
quality_audio_old: Quality = self.adjust_quality_audio(quality_audio) if quality_audio else quality_audio
quality_video_old: QualityVideo = (
self.adjust_quality_video(quality_video) if quality_video else quality_video
)
do_flac_extract = False
# Get extension.
file_extension: str
stream_manifest: StreamManifest | None = None
if isinstance(media, Track):
try:
media_stream: Stream = media.get_stream()
stream_manifest = media_stream.get_stream_manifest()
except TooManyRequests:
self.fn_logger.exception(
f"Too many requests against TIDAL backend. Skipping '{name_builder_item(media)}'. "
f"Consider to activate delay between downloads."
)
return False, ""
except Exception:
self.fn_logger.exception(f"Something went wrong. Skipping '{name_builder_item(media)}'.")
return False, ""
file_extension = stream_manifest.file_extension
if self.settings.data.extract_flac and (
stream_manifest.codecs.upper() == Codec.FLAC and file_extension != AudioExtensions.FLAC
):
file_extension = AudioExtensions.FLAC
do_flac_extract = True
elif isinstance(media, Video):
file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS
# Compute file name, sanitize once again and create destination directory
path_media_dst = path_media_dst.with_suffix(file_extension)
path_media_dst = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True))
os.makedirs(path_media_dst.parent, exist_ok=True)
if not skip_download:
# Create a temp directory and file.
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir:
tmp_path_file: pathlib.Path = pathlib.Path(tmp_path_dir) / str(uuid4())
# Create empty file
tmp_path_file.touch()
# Download media.
result_download, tmp_path_file = self._download(
media=media, stream_manifest=stream_manifest, path_file=tmp_path_file
)
if result_download:
# Convert video from TS to MP4
if isinstance(media, Video) and self.settings.data.video_convert_mp4:
# Convert `*.ts` file to `*.mp4` using ffmpeg
tmp_path_file = self._video_convert(tmp_path_file)
# Extract FLAC from MP4 container using ffmpeg
if isinstance(media, Track) and self.settings.data.extract_flac and do_flac_extract:
tmp_path_file = self._extract_flac(tmp_path_file)
tmp_path_lyrics: pathlib.Path | None = None
tmp_path_cover: pathlib.Path | None = None
# Write metadata to file.
if not isinstance(media, Video):
result_metadata, tmp_path_lyrics, tmp_path_cover = self.metadata_write(
media, tmp_path_file, is_parent_album, media_stream
)
# Move lyrics file
if self.settings.data.lyrics_file and not isinstance(media, Video) and tmp_path_lyrics:
self._move_lyrics(tmp_path_lyrics, path_media_dst)
# Move cover file
# TODO: Cover is downloaded with every track of the album. Needs refactoring, so cover is only
# downloaded for an album once.
if self.settings.data.cover_album_file and tmp_path_cover:
self._move_cover(tmp_path_cover, path_media_dst)
self.fn_logger.info(f"Downloaded item '{name_builder_item(media)}'.")
# Move final file to the configured destination directory.
shutil.move(tmp_path_file, path_media_dst)
# If files needs to be symlinked, do postprocessing here.
if self.settings.data.symlink_to_track and not isinstance(media, Video):
path_media_track_dir: pathlib.Path = self.media_move_and_symlink(media, path_media_dst, file_extension)
if quality_audio:
# Set quality back to the global user value
self.adjust_quality_audio(quality_audio_old)
if quality_video:
# Set quality back to the global user value
self.adjust_quality_video(quality_video_old)
else:
self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'")
status_download: bool = not skip_file
# Whether a file was downloaded or skipped and the download delay is enabled, wait until the next download.
# Only use this, if you have a list of several Track items.
if download_delay and not skip_file:
time_sleep: float = round(
random.SystemRandom().uniform(
self.settings.data.download_delay_sec_min, self.settings.data.download_delay_sec_max
),
1,
)
self.fn_logger.debug(f"Next download will start in {time_sleep} seconds.")
time.sleep(time_sleep)
return status_download, path_media_dst
def media_move_and_symlink(
self, media: Track | Video, path_media_src: pathlib.Path, file_extension: str
) -> pathlib.Path:
# Compute tracks path, sanitize and ensure path exists
file_name_relative: str = format_path_media(self.settings.data.format_track, media)
path_media_dst: pathlib.Path = (
pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension)
).absolute()
path_media_dst = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True))
os.makedirs(path_media_dst.parent, exist_ok=True)
# Move item and symlink it
if path_media_dst != path_media_src:
if self.skip_existing:
skip_file: bool = check_file_exists(path_media_dst, extension_ignore=False)
skip_symlink: bool = path_media_src.is_symlink()
else:
skip_file: bool = False
skip_symlink: bool = False
if not skip_file:
self.fn_logger.debug(f"Move: {path_media_src} -> {path_media_dst}")
shutil.move(path_media_src, path_media_dst)
if not skip_symlink:
self.fn_logger.debug(f"Symlink: {path_media_src} -> {path_media_dst}")
path_media_src.unlink(missing_ok=True)
path_media_src.symlink_to(path_media_dst)
return path_media_dst
def adjust_quality_audio(self, quality) -> Quality:
# Save original quality settings
quality_old: Quality = self.session.audio_quality
self.session.audio_quality = quality
return quality_old
def adjust_quality_video(self, quality) -> QualityVideo:
quality_old: QualityVideo = self.settings.data.quality_video
self.settings.data.quality_video = quality
return quality_old
def _move_file(self, path_file_source: pathlib.Path, path_file_destination: str | pathlib.Path) -> bool:
result: bool
# Check if the file was downloaded
if path_file_source and path_file_source.is_file():
# Move it.
shutil.move(path_file_source, path_file_destination)
result = True
else:
result = False
return result
def _move_lyrics(self, path_lyrics: pathlib.Path, file_media_dst: pathlib.Path) -> bool:
# Build tmp lyrics filename
path_file_lyrics: pathlib.Path = file_media_dst.with_suffix(EXTENSION_LYRICS)
result: bool = self._move_file(path_lyrics, path_file_lyrics)
return result
def _move_cover(self, path_cover: pathlib.Path, file_media_dst: pathlib.Path) -> bool:
# Build tmp lyrics filename
path_file_cover: pathlib.Path = file_media_dst.parent / COVER_NAME
result: bool = self._move_file(path_cover, path_file_cover)
return result
def lyrics_to_file(self, dir_destination: pathlib.Path, lyrics: str) -> str:
return self.write_to_tmp_file(dir_destination, mode="x", content=lyrics)
def cover_to_file(self, dir_destination: pathlib.Path, image: bytes) -> str:
return self.write_to_tmp_file(dir_destination, mode="xb", content=image)
def write_to_tmp_file(self, dir_destination: pathlib.Path, mode: str, content: str | bytes) -> str:
result: str = dir_destination / str(uuid4())
encoding: str | None = "utf-8" if isinstance(content, str) else None
try:
with open(result, mode=mode, encoding=encoding) as f:
f.write(content)
except:
result = ""
return result
@staticmethod
def cover_data(url: str = None, path_file: str = None) -> str | bytes:
result: str | bytes = ""
if url:
try:
result = requests.get(url, timeout=REQUESTS_TIMEOUT_SEC).content
except Exception as e:
# TODO: Implement propper logging.
print(e)
elif path_file:
try:
with open(path_file, "rb") as f:
result = f.read()
except OSError as e:
# TODO: Implement propper logging.
print(e)
return result
def metadata_write(
self, track: Track, path_media: pathlib.Path, is_parent_album: bool, media_stream: Stream
) -> (bool, pathlib.Path | None, pathlib.Path | None):
result: bool = False
path_lyrics: pathlib.Path | None = None
path_cover: pathlib.Path | None = None
release_date: str = (
track.album.available_release_date.strftime("%Y-%m-%d")
if track.album.available_release_date
else track.album.release_date.strftime("%Y-%m-%d") if track.album.release_date else ""
)
copy_right: str = track.copyright if hasattr(track, "copyright") and track.copyright else ""
isrc: str = track.isrc if hasattr(track, "isrc") and track.isrc else ""
lyrics: str = ""
cover_data: bytes = None
if self.settings.data.lyrics_embed or self.settings.data.lyrics_file:
# Try to retrieve lyrics.
try:
lyrics_obj = track.lyrics()
if lyrics_obj.subtitles:
lyrics = lyrics_obj.subtitles
elif lyrics_obj.text:
lyrics = lyrics_obj.text
except:
lyrics = ""
# TODO: Implement proper logging.
print(f"Could not retrieve lyrics for `{name_builder_item(track)}`.")
if lyrics and self.settings.data.lyrics_file:
path_lyrics = self.lyrics_to_file(path_media.parent, lyrics)
if self.settings.data.metadata_cover_embed or (self.settings.data.cover_album_file and is_parent_album):
url_cover = track.album.image(int(self.settings.data.metadata_cover_dimension))
cover_data = self.cover_data(url=url_cover)
if cover_data and self.settings.data.cover_album_file and is_parent_album:
path_cover = self.cover_to_file(path_media.parent, cover_data)
# `None` values are not allowed.
m: Metadata = Metadata(
path_file=path_media,
lyrics=lyrics,
copy_right=copy_right,
title=name_builder_title(track),
artists=name_builder_artist(track),
album=track.album.name if track.album else "",
tracknumber=track.track_num,
date=release_date,
isrc=isrc,
albumartist=name_builder_album_artist(track),
totaltrack=track.album.num_tracks if track.album and track.album.num_tracks else 1,
totaldisc=track.album.num_volumes if track.album and track.album.num_volumes else 1,
discnumber=track.volume_num if track.volume_num else 1,
cover_data=cover_data if self.settings.data.metadata_cover_embed else None,
album_replay_gain=media_stream.album_replay_gain,
album_peak_amplitude=media_stream.album_peak_amplitude,
track_replay_gain=media_stream.track_replay_gain,
track_peak_amplitude=media_stream.track_peak_amplitude,
)
m.save()
result = True
return result, path_lyrics, path_cover
def items(
self,
file_template: str,
media: Album | Playlist | UserPlaylist | Mix = None,
media_id: str = None,
media_type: MediaType = None,
video_download: bool = False,
download_delay: bool = True,
quality_audio: Quality | None = None,
quality_video: QualityVideo | None = None,
):
# If no media instance is provided, we need to create the media instance.
if media_id and media_type:
media = instantiate_media(self.session, media_type, media_id)
elif not media:
raise MediaMissing
# Create file name and path
file_name_relative: str = format_path_media(file_template, media, self.settings.data.album_track_num_pad_min)
# Get the name of the list and check, if videos should be included.
list_media_name: str = name_builder_title(media)
list_media_name_short: str = list_media_name[:30]
# Get all items of the list.
items = items_results_all(media, videos_include=video_download)
# Determine where to redirect the progress information.
if self.progress_gui is None:
progress_stdout: bool = True
else:
progress_stdout: bool = False
self.progress_gui.list_name.emit(list_media_name_short[:30])
# Create the list progress task.
p_task1: TaskID = self.progress.add_task(
f"[green]List '{list_media_name_short}'", total=len(items), visible=progress_stdout
)
is_album: bool = isinstance(media, Album)
result_dirs: [pathlib.Path] = []
# Iterate through list items
while not self.progress.finished:
with futures.ThreadPoolExecutor(max_workers=self.settings.data.downloads_concurrent_max) as executor:
# Dispatch all download tasks to worker threads
l_futures: [any] = [
executor.submit(
self.item,
media=item_media,
file_template=file_name_relative,
quality_audio=quality_audio,
quality_video=quality_video,
download_delay=download_delay,
is_parent_album=is_album,
)
for item_media in items
]
# Report results as they become available
for future in futures.as_completed(l_futures):
# Retrieve result
status, result_path_file = future.result()
if status:
result_dirs.append(result_path_file.parent)
# Advance progress bar.
self.progress.advance(p_task1)
if not progress_stdout:
self.progress_gui.list_item.emit(self.progress.tasks[p_task1].percentage)
# Create playlist file
if self.settings.data.playlist_create:
self.playlist_populate(set(result_dirs), list_media_name, is_album)
self.fn_logger.info(f"Finished list '{list_media_name}'.")
def playlist_populate(self, dirs_scoped: [pathlib.Path], name_list: str, is_album: bool) -> [pathlib.Path]:
result: [pathlib.Path] = []
# For each dir, which contains tracks
for dir_scoped in dirs_scoped:
# Sanitize final playlist name to fit into OS boundaries.
path_playlist = dir_scoped / (PLAYLIST_PREFIX + name_list + PLAYLIST_EXTENSION)
path_playlist = pathlib.Path(path_file_sanitize(path_playlist, adapt=True))
self.fn_logger.debug(f"Playlist: Creating {path_playlist}")
# Get all tracks in the directory
path_tracks: [pathlib.Path] = []
for extension_audio in AudioExtensions:
path_tracks = path_tracks + list(dir_scoped.glob(f"*{extension_audio!s}"))
# If it is not an album sort by modification time
if not is_album:
path_tracks.sort(key=lambda x: os.path.getmtime(x))
# Write data to m3u file
with path_playlist.open(mode="w", encoding="utf-8") as f:
for path_track in path_tracks:
# If it's a symlink write the relative file path to the actual track into the playlist file
if path_track.is_symlink():
media_file_target = path_track.resolve().relative_to(path_track.parent, walk_up=True)
f.write(str(media_file_target) + os.linesep)
result.append(path_playlist)
return result
def _video_convert(self, path_file: pathlib.Path) -> pathlib.Path:
path_file_out: pathlib.Path = path_file.with_suffix(AudioExtensions.MP4)
result, _ = (
ffmpeg.input(path_file)
.output(str(path_file_out), map=0, c="copy", loglevel="quiet")
.run(cmd=self.settings.data.path_binary_ffmpeg)
)
return path_file_out
def _extract_flac(self, path_media_src: pathlib.Path) -> pathlib.Path:
path_media_out = path_media_src.with_suffix(AudioExtensions.FLAC)
result, _ = (
ffmpeg.input(path_media_src)
.output(
str(path_media_out),
map=0,
movflags="use_metadata_tags",
acodec="copy",
map_metadata="0:g",
loglevel="quiet",
)
.run(cmd=self.settings.data.path_binary_ffmpeg)
)
return path_media_out
def _extract_video_stream(self, m3u8_variant: m3u8.M3U8, quality: int) -> (m3u8.M3U8 | bool, str):
m3u8_playlist: m3u8.M3U8 | bool = False
resolution_best: int = 0
mime_type: str = ""
if m3u8_variant.is_variant:
for playlist in m3u8_variant.playlists:
if resolution_best < playlist.stream_info.resolution[1]:
resolution_best = playlist.stream_info.resolution[1]
m3u8_playlist = m3u8.load(playlist.uri)
mime_type = playlist.stream_info.codecs
if quality == playlist.stream_info.resolution[1]:
break
return m3u8_playlist, mime_type

1103
tidal_dl_ng/gui.py Normal file

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

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,55 @@
import base64
import pathlib
from Crypto.Cipher import AES
from Crypto.Util import Counter
def decrypt_security_token(security_token: str) -> (str, str):
"""
Decrypts security token into key and nonce pair
security_token should match the securityToken value from the web response
"""
# Do not change this
master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754="
# Decode the base64 strings to ascii strings
master_key = base64.b64decode(master_key)
security_token = base64.b64decode(security_token)
# Get the IV from the first 16 bytes of the securityToken
iv = security_token[:16]
encrypted_st = security_token[16:]
# Initialize decryptor
decryptor = AES.new(master_key, AES.MODE_CBC, iv)
# Decrypt the security token
decrypted_st = decryptor.decrypt(encrypted_st)
# Get the audio stream decryption key and nonce from the decrypted security token
key = decrypted_st[:16]
nonce = decrypted_st[16:24]
return key, nonce
def decrypt_file(path_file_encrypted: pathlib.Path, path_file_destination: pathlib.Path, key: str, nonce: str) -> None:
"""
Decrypts an encrypted MQA file given the file, key and nonce.
TODO: Is it really only necessary for MQA of for all other formats, too?
"""
# Initialize counter and file decryptor
counter = Counter.new(64, prefix=nonce, initial_value=0)
decryptor = AES.new(key, AES.MODE_CTR, counter=counter)
# Open and decrypt
with path_file_encrypted.open("rb") as f_src:
audio_decrypted = decryptor.decrypt(f_src.read())
# Replace with decrypted file
with path_file_destination.open("wb") as f_dst:
f_dst.write(audio_decrypted)

View File

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

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

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

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

@ -0,0 +1,324 @@
import math
import os
import pathlib
import posixpath
import re
import sys
from urllib.parse import unquote, urlsplit
from pathvalidate import sanitize_filename, sanitize_filepath
from pathvalidate.error import ValidationError
from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video
from tidalapi.media import AudioExtensions
from tidal_dl_ng import __name_display__
from tidal_dl_ng.constants import FILENAME_SANITIZE_PLACEHOLDER, UNIQUIFY_THRESHOLD, MediaType
from tidal_dl_ng.helper.tidal import name_builder_album_artist, name_builder_artist, name_builder_title
def path_home() -> str:
if "XDG_CONFIG_HOME" in os.environ:
return os.environ["XDG_CONFIG_HOME"]
elif "HOME" in os.environ:
return os.environ["HOME"]
elif "HOMEDRIVE" in os.environ and "HOMEPATH" in os.environ:
return os.path.join(os.environ["HOMEDRIVE"], os.environ["HOMEPATH"])
else:
return os.path.abspath("./")
def path_config_base() -> str:
# https://wiki.archlinux.org/title/XDG_Base_Directory
# X11 workaround: If user specified config path is set, do not point to "~/.config"
path_user_custom: str = os.environ.get("XDG_CONFIG_HOME", "")
path_config: str = ".config" if not path_user_custom else ""
path_base: str = os.path.join(path_home(), path_config, __name_display__)
return path_base
def path_file_log() -> str:
return os.path.join(path_config_base(), "app.log")
def path_file_token() -> str:
return os.path.join(path_config_base(), "token.json")
def path_file_settings() -> str:
return os.path.join(path_config_base(), "settings.json")
def format_path_media(
fmt_template: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, album_track_num_pad_min: int = 0
) -> str:
result = fmt_template
# Search track format template for placeholder.
regex = r"\{(.+?)\}"
matches = re.finditer(regex, fmt_template, re.MULTILINE)
for _matchNum, match in enumerate(matches, start=1):
template_str = match.group()
result_fmt = format_str_media(match.group(1), media, album_track_num_pad_min)
if result_fmt != match.group(1):
value = sanitize_filename(result_fmt)
result = result.replace(template_str, value)
return result
def format_str_media(
name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, album_track_num_pad_min: int = 0
) -> str:
result: str = name
try:
match name:
case "artist_name":
if isinstance(media, Track | Video):
if hasattr(media, "artists"):
result = name_builder_artist(media)
elif hasattr(media, "artist"):
result = media.artist.name
case "album_artist":
result = name_builder_album_artist(media)
case "track_title":
if isinstance(media, Track | Video):
result = name_builder_title(media)
case "mix_name":
if isinstance(media, Mix):
result = media.title
case "playlist_name":
if isinstance(media, Playlist | UserPlaylist):
result = media.name
case "album_title":
if isinstance(media, Album):
result = media.name
elif isinstance(media, Track):
result = media.album.name
case "album_track_num":
if isinstance(media, Track | Video):
num_tracks: int = media.album.num_tracks if hasattr(media, "album") else 1
count_digits: int = int(math.log10(num_tracks)) + 1
count_digits_computed: int = (
count_digits if count_digits > album_track_num_pad_min else album_track_num_pad_min
)
result = str(media.track_num).zfill(count_digits_computed)
case "album_num_tracks":
if isinstance(media, Track | Video):
result = str(media.album.num_tracks if hasattr(media, "album") else 1)
case "track_id":
if isinstance(media, Track | Video):
result = str(media.id)
case "playlist_id":
if isinstance(media, Playlist):
result = str(media.id)
case "album_id":
if isinstance(media, Album):
result = str(media.id)
elif isinstance(media, Track):
result = str(media.album.id)
case "track_duration_seconds":
if isinstance(media, Track | Video):
result = str(media.duration)
case "track_duration_minutes":
if isinstance(media, Track | Video):
m, s = divmod(media.duration, 60)
result = f"{m:01d}:{s:02d}"
case "album_duration_seconds":
if isinstance(media, Album):
result = str(media.duration)
case "album_duration_minutes":
if isinstance(media, Album):
m, s = divmod(media.duration, 60)
result = f"{m:01d}:{s:02d}"
case "playlist_duration_seconds":
if isinstance(media, Album):
result = str(media.duration)
case "playlist_duration_minutes":
if isinstance(media, Album):
m, s = divmod(media.duration, 60)
result = f"{m:01d}:{s:02d}"
case "album_year":
if isinstance(media, Album):
result = str(media.year)
elif isinstance(media, Track):
result = str(media.album.year)
case "video_quality":
if isinstance(media, Video):
result = media.video_quality
case "track_quality":
if isinstance(media, Track):
result = ", ".join(tag for tag in media.media_metadata_tags)
case "track_explicit":
if isinstance(media, Track | Video):
result = " (Explicit)" if media.explicit else ""
case "album_explicit":
if isinstance(media, Album):
result = " (Explicit)" if media.explicit else ""
case "album_num_volumes":
if isinstance(media, Album):
result = str(media.num_volumes)
case "track_volume_num":
if isinstance(media, Track | Video):
result = str(media.volume_num)
case "track_volume_num_optional":
if isinstance(media, Track | Video):
num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1
result = "" if num_volumes == 1 else str(media.volume_num)
case "track_volume_num_optional_CD":
if isinstance(media, Track | Video):
num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1
result = "" if num_volumes == 1 else f"CD{media.volume_num!s}"
except Exception as e:
# TODO: Implement better exception logging.
print(e)
pass
return result
def get_format_template(
media: Track | Album | Playlist | UserPlaylist | Video | Mix | MediaType, settings
) -> str | bool:
result = False
if isinstance(media, Track) or media == MediaType.TRACK:
result = settings.data.format_track
elif isinstance(media, Album) or media == MediaType.ALBUM or media == MediaType.ARTIST:
result = settings.data.format_album
elif isinstance(media, Playlist | UserPlaylist) or media == MediaType.PLAYLIST:
result = settings.data.format_playlist
elif isinstance(media, Mix) or media == MediaType.MIX:
result = settings.data.format_mix
elif isinstance(media, Video) or media == MediaType.VIDEO:
result = settings.data.format_video
return result
def path_file_sanitize(path_file: str, adapt: bool = False, uniquify: bool = False) -> (bool, str):
# Split into path and filename
pathname, filename = os.path.split(path_file)
file_extension: str = pathlib.Path(path_file).suffix
# Sanitize path
try:
pathname_sanitized: str = sanitize_filepath(
pathname, replacement_text=" ", validate_after_sanitize=True, platform="auto"
)
except ValidationError:
# If adaption of path is allowed in case of an error set path to HOME.
if adapt:
pathname_sanitized: str = str(pathlib.Path.home())
else:
raise
# Sanitize filename
try:
filename_sanitized: str = sanitize_filename(
filename, replacement_text=" ", validate_after_sanitize=True, platform="auto"
)
# Check if the file extension was removed by shortening the filename length
if not filename_sanitized.endswith(file_extension):
# Add the original file extension
file_suffix: str = FILENAME_SANITIZE_PLACEHOLDER + file_extension
filename_sanitized = filename_sanitized[: -len(file_suffix)] + file_suffix
except ValidationError as e:
# TODO: Implement proper exception handling and logging.
# Hacky stuff, since the sanitizing function does not shorten the filename somehow (bug?)
# TODO: Remove after pathvalidate update.
# If filename too long
if e.description.startswith("[PV1101]"):
byte_ct: int = len(filename.encode("utf-8")) - 255
filename_sanitized = (
filename[: -byte_ct - len(FILENAME_SANITIZE_PLACEHOLDER) - len(file_extension)]
+ FILENAME_SANITIZE_PLACEHOLDER
+ file_extension
)
else:
print(e)
# Join path and filename
result: str = os.path.join(pathname_sanitized, filename_sanitized)
# Uniquify
if uniquify:
unique_suffix: str = file_unique_suffix(result)
if unique_suffix:
file_suffix = unique_suffix + file_extension
# For most OS filename has a character limit of 255.
filename_sanitized = (
filename_sanitized[: -len(file_suffix)] + file_suffix
if len(filename_sanitized + unique_suffix) > 255
else filename_sanitized[: -len(file_extension)] + file_suffix
)
# Join path and filename
result = os.path.join(pathname_sanitized, filename_sanitized)
return result
def file_unique_suffix(path_file: str, seperator: str = "_") -> str:
threshold_zfill: int = len(str(UNIQUIFY_THRESHOLD))
count: int = 0
path_file_tmp: str = path_file
unique_suffix: str = ""
while check_file_exists(path_file_tmp) and count < UNIQUIFY_THRESHOLD:
count += 1
unique_suffix = seperator + str(count).zfill(threshold_zfill)
filename, file_extension = os.path.splitext(path_file_tmp)
path_file_tmp = filename + unique_suffix + file_extension
return unique_suffix
def check_file_exists(path_file: pathlib.Path, extension_ignore: bool = False) -> bool:
if extension_ignore:
path_file_stem: str = pathlib.Path(path_file).stem
path_parent: pathlib.Path = pathlib.Path(path_file).parent
path_files: [str] = []
for extension in AudioExtensions:
path_files.append(str(path_parent.joinpath(path_file_stem + extension)))
else:
path_files: [str] = [path_file]
result = bool(sum([[True] if os.path.isfile(_file) else [] for _file in path_files], []))
return result
def resource_path(relative_path):
try:
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
def url_to_filename(url: str) -> str:
"""Return basename corresponding to url.
>>> print(url_to_filename('http://example.com/path/to/file%C3%80?opt=1'))
fileÀ
>>> print(url_to_filename('http://example.com/slash%2fname')) # '/' in name
Taken from https://gist.github.com/zed/c2168b9c52b032b5fb7d
Traceback (most recent call last):
...
ValueError
"""
urlpath: str = urlsplit(url).path
basename: str = posixpath.basename(unquote(urlpath))
if os.path.basename(basename) != basename or unquote(posixpath.basename(urlpath)) != basename:
raise ValueError # reject '%2f' or 'dir%5Cbasename.ext' on Windows
return basename

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

@ -0,0 +1,205 @@
from collections.abc import Callable
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
from tidalapi.artist import Artist, Role
from tidalapi.media import MediaMetadataTags, Quality
from tidalapi.session import SearchTypes
from tidalapi.user import LoggedInUser
from tidal_dl_ng.constants import FAVORITES, MediaType
from tidal_dl_ng.helper.exceptions import MediaUnknown
def name_builder_artist(media: Track | Video | Album) -> str:
return ", ".join(artist.name for artist in media.artists)
def name_builder_album_artist(media: Track | Album) -> str:
artists_tmp: [str] = []
artists: [Artist] = media.album.artists if isinstance(media, Track) else media.artists
for artist in artists:
if Role.main in artist.roles:
artists_tmp.append(artist.name)
return ", ".join(artists_tmp)
def name_builder_title(media: Track | Video | Mix | Playlist | Album | Video) -> str:
result: str = (
media.title if isinstance(media, Mix) else media.full_name if hasattr(media, "full_name") else media.name
)
return result
def name_builder_item(media: Track) -> str:
return f"{name_builder_artist(media)} - {name_builder_title(media)}"
def get_tidal_media_id(url_or_id_media: str) -> str:
id_dirty = url_or_id_media.rsplit("/", 1)[-1]
id_media = id_dirty.rsplit("?", 1)[0]
return id_media
def get_tidal_media_type(url_media: str) -> MediaType | bool:
result: MediaType | bool = False
url_split = url_media.split("/")[-2]
if len(url_split) > 1:
media_name = url_media.split("/")[-2]
if media_name == "track":
result = MediaType.TRACK
elif media_name == "video":
result = MediaType.VIDEO
elif media_name == "album":
result = MediaType.ALBUM
elif media_name == "playlist":
result = MediaType.PLAYLIST
elif media_name == "mix":
result = MediaType.MIX
elif media_name == "artist":
result = MediaType.ARTIST
return result
def search_results_all(session: Session, needle: str, types_media: SearchTypes = None) -> dict[str, [SearchTypes]]:
limit: int = 300
offset: int = 0
done: bool = False
result: dict[str, [SearchTypes]] = {}
while not done:
tmp_result: dict[str, [SearchTypes]] = session.search(
query=needle, models=types_media, limit=limit, offset=offset
)
tmp_done: bool = True
for key, value in tmp_result.items():
# Append pagination results, if there are any
if offset == 0:
result = tmp_result
tmp_done = False
elif bool(value):
result[key] += value
tmp_done = False
# Next page
offset += limit
done = tmp_done
return result
def items_results_all(
media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True
) -> [Track | Video | Album]:
result: [Track | Video | Album] = []
if isinstance(media_list, Mix):
result = media_list.items()
else:
func_get_items_media: [Callable] = []
if isinstance(media_list, Playlist | Album):
if videos_include:
func_get_items_media.append(media_list.items)
else:
func_get_items_media.append(media_list.tracks)
else:
func_get_items_media.append(media_list.get_albums)
func_get_items_media.append(media_list.get_ep_singles)
result = paginate_results(func_get_items_media)
return result
def all_artist_album_ids(media_artist: Artist) -> [int | None]:
result: [int] = []
func_get_items_media: [Callable] = [media_artist.get_albums, media_artist.get_ep_singles]
albums: [Album] = paginate_results(func_get_items_media)
for album in albums:
result.append(album.id)
return result
def paginate_results(func_get_items_media: [Callable]) -> [Track | Video | Album | Playlist | UserPlaylist]:
result: [Track | Video | Album] = []
for func_media in func_get_items_media:
limit: int = 100
offset: int = 0
done: bool = False
if func_media.__func__ == LoggedInUser.playlist_and_favorite_playlists:
limit: int = 50
while not done:
tmp_result: [Track | Video | Album | Playlist | UserPlaylist] = func_media(limit=limit, offset=offset)
if bool(tmp_result):
result += tmp_result
# Get the next page in the next iteration.
offset += limit
else:
done = True
return result
def user_media_lists(session: Session) -> [Playlist | UserPlaylist | Mix]:
user_playlists: [Playlist | UserPlaylist] = paginate_results([session.user.playlist_and_favorite_playlists])
user_mixes: [Mix] = session.mixes().categories[0].items
result: [Playlist | UserPlaylist | Mix] = user_playlists + user_mixes
return result
def instantiate_media(
session: Session,
media_type: type[MediaType.TRACK, MediaType.VIDEO, MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX],
id_media: str,
) -> Track | Video | Album | Playlist | Mix | Artist:
if media_type == MediaType.TRACK:
media = session.track(id_media, with_album=True)
elif media_type == MediaType.VIDEO:
media = session.video(id_media)
elif media_type == MediaType.ALBUM:
media = session.album(id_media)
elif media_type == MediaType.PLAYLIST:
media = session.playlist(id_media)
elif media_type == MediaType.MIX:
media = session.mix(id_media)
elif media_type == MediaType.ARTIST:
media = session.artist(id_media)
else:
raise MediaUnknown
return media
def quality_audio_highest(media: Track | Album) -> Quality:
quality: Quality
if MediaMetadataTags.hi_res_lossless in media.media_metadata_tags:
quality = Quality.hi_res_lossless
elif MediaMetadataTags.lossless in media.media_metadata_tags:
quality = Quality.high_lossless
else:
quality = media.audio_quality
return quality
def favorite_function_factory(tidal, favorite_item: str):
function_name: str = FAVORITES[favorite_item]["function_name"]
function_list: Callable = getattr(tidal.session.user.favorites, function_name)
return function_list

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)

164
tidal_dl_ng/metadata.py Normal file
View File

@ -0,0 +1,164 @@
import mutagen
from mutagen import flac, id3, mp4
from mutagen.id3 import APIC, TALB, TCOM, TCOP, TDRC, TIT2, TOPE, TPE1, TRCK, TSRC, TXXX, USLT
class Metadata:
path_file: str
title: str
album: str
albumartist: str
artists: [str]
copy_right: str
tracknumber: int
discnumber: int
totaldisc: int
totaltrack: int
date: str
composer: [str]
isrc: str
lyrics: str
path_cover: str
cover_data: bytes
album_replay_gain: float
album_peak_amplitude: float
track_replay_gain: float
track_peak_amplitude: float
m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC
def __init__(
self,
path_file: str,
album: str = "",
title: str = "",
artists: str = "",
copy_right: str = "",
tracknumber: int = 0,
discnumber: int = 0,
totaltrack: int = 0,
totaldisc: int = 0,
composer: list[str] | None = None,
isrc: str = "",
albumartist: str = "",
date: str = "",
lyrics: str = "",
cover_data: bytes = None,
album_replay_gain: float = 1.0,
album_peak_amplitude: float = 1.0,
track_replay_gain: float = 1.0,
track_peak_amplitude: float = 1.0,
):
self.path_file = path_file
self.title = title
self.album = album
self.albumartist = albumartist
self.artists = artists
self.copy_right = copy_right
self.tracknumber = tracknumber
self.discnumber = discnumber
self.totaldisc = totaldisc
self.totaltrack = totaltrack
self.date = date
self.composer = composer
self.isrc = isrc
self.lyrics = lyrics
self.cover_data = cover_data
self.album_replay_gain = album_replay_gain
self.album_peak_amplitude = album_peak_amplitude
self.track_replay_gain = track_replay_gain
self.track_peak_amplitude = track_peak_amplitude
self.m: mutagen.mp4.MP4 | mutagen.flac.FLAC | mutagen.mp3.MP3 = mutagen.File(self.path_file)
def _cover(self) -> bool:
result: bool = False
if self.cover_data:
if isinstance(self.m, mutagen.flac.FLAC):
flac_cover = flac.Picture()
flac_cover.type = id3.PictureType.COVER_FRONT
flac_cover.data = self.cover_data
flac_cover.mime = "image/jpeg"
self.m.clear_pictures()
self.m.add_picture(flac_cover)
elif isinstance(self.m, mutagen.mp3.MP3):
self.m.tags.add(APIC(encoding=3, data=self.cover_data))
elif isinstance(self.m, mutagen.mp4.MP4):
cover_mp4 = mp4.MP4Cover(self.cover_data)
self.m.tags["covr"] = [cover_mp4]
result = True
return result
def save(self):
if not self.m.tags:
self.m.add_tags()
if isinstance(self.m, mutagen.flac.FLAC):
self.set_flac()
elif isinstance(self.m, mutagen.mp3.MP3):
self.set_mp3()
elif isinstance(self.m, mutagen.mp4.MP4):
self.set_mp4()
self._cover()
self.m.save()
return True
def set_flac(self):
self.m.tags["TITLE"] = self.title
self.m.tags["ALBUM"] = self.album
self.m.tags["ALBUMARTIST"] = self.albumartist
self.m.tags["ARTIST"] = self.artists
self.m.tags["COPYRIGHT"] = self.copy_right
self.m.tags["TRACKNUMBER"] = str(self.tracknumber)
self.m.tags["TRACKTOTAL"] = str(self.totaltrack)
self.m.tags["DISCNUMBER"] = str(self.discnumber)
self.m.tags["DISCTOTAL"] = str(self.totaldisc)
self.m.tags["DATE"] = self.date
self.m.tags["COMPOSER"] = ", ".join(self.composer) if self.composer else ""
self.m.tags["ISRC"] = self.isrc
self.m.tags["LYRICS"] = self.lyrics
self.m.tags["REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain)
self.m.tags["REPLAYGAIN_ALBUM_PEAK"] = str(self.album_peak_amplitude)
self.m.tags["REPLAYGAIN_TRACK_GAIN"] = str(self.track_replay_gain)
self.m.tags["REPLAYGAIN_TRACK_PEAK"] = str(self.track_peak_amplitude)
def set_mp3(self):
# ID3 Frame (tags) overview: https://exiftool.org/TagNames/ID3.html / https://id3.org/id3v2.3.0
# Mapping overview: https://docs.mp3tag.de/mapping/
self.m.tags.add(TIT2(encoding=3, text=self.title))
self.m.tags.add(TALB(encoding=3, text=self.album))
self.m.tags.add(TOPE(encoding=3, text=self.albumartist))
self.m.tags.add(TPE1(encoding=3, text=self.artists))
self.m.tags.add(TCOP(encoding=3, text=self.copy_right))
self.m.tags.add(TRCK(encoding=3, text=str(self.tracknumber)))
self.m.tags.add(TRCK(encoding=3, text=self.discnumber))
self.m.tags.add(TDRC(encoding=3, text=self.date))
self.m.tags.add(TCOM(encoding=3, text=", ".join(self.composer) if self.composer else ""))
self.m.tags.add(TSRC(encoding=3, text=self.isrc))
self.m.tags.add(USLT(encoding=3, lang="eng", desc="desc", text=self.lyrics))
self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_ALBUM_GAIN", text=str(self.album_replay_gain)))
self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_ALBUM_PEAK", text=str(self.album_peak_amplitude)))
self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_TRACK_GAIN", text=str(self.track_replay_gain)))
self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_TRACK_PEAK", text=str(self.track_peak_amplitude)))
def set_mp4(self):
self.m.tags["\xa9nam"] = self.title
self.m.tags["\xa9alb"] = self.album
self.m.tags["aART"] = self.albumartist
self.m.tags["\xa9ART"] = self.artists
self.m.tags["cprt"] = self.copy_right
self.m.tags["trkn"] = [[self.tracknumber, self.totaltrack]]
self.m.tags["disk"] = [[self.discnumber, self.totaldisc]]
# self.m.tags['\xa9gen'] = self.genre
self.m.tags["\xa9day"] = self.date
self.m.tags["\xa9wrt"] = ", ".join(self.composer) if self.composer else ""
self.m.tags["\xa9lyr"] = self.lyrics
self.m.tags["isrc"] = self.isrc
self.m.tags["----:com.apple.iTunes:REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain).encode("utf-8")
self.m.tags["----:com.apple.iTunes:REPLAYGAIN_ALBUM_PEAK"] = str(self.album_peak_amplitude).encode("utf-8")
self.m.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN"] = str(self.track_replay_gain).encode("utf-8")
self.m.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK"] = str(self.track_peak_amplitude).encode("utf-8")

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@ -0,0 +1,108 @@
from dataclasses import dataclass
from dataclasses_json import dataclass_json
from tidalapi import Quality
from tidal_dl_ng.constants import CoverDimensions, QualityVideo
@dataclass_json
@dataclass
class Settings:
skip_existing: bool = True
lyrics_embed: bool = False
lyrics_file: bool = False
# TODO: Implement API KEY selection.
# api_key_index: bool = 0
# TODO: Implement album info download to separate file.
# album_info_save: bool = False
video_download: bool = True
# TODO: Implement multi threading for downloads.
# multi_thread: bool = False
download_delay: bool = True
download_base_path: str = "~/download"
quality_audio: Quality = Quality.low_320k
quality_video: QualityVideo = QualityVideo.P480
format_album: str = (
"Albums/{album_artist} - {album_title}{album_explicit}/{track_volume_num_optional}"
"{album_track_num}. {artist_name} - {track_title}{album_explicit}"
)
format_playlist: str = "Playlists/{playlist_name}/{artist_name} - {track_title}"
format_mix: str = "Mix/{mix_name}/{artist_name} - {track_title}"
format_track: str = "Tracks/{artist_name} - {track_title}{track_explicit}"
format_video: str = "Videos/{artist_name} - {track_title}{track_explicit}"
video_convert_mp4: bool = True
path_binary_ffmpeg: str = ""
metadata_cover_dimension: CoverDimensions = CoverDimensions.Px320
metadata_cover_embed: bool = True
cover_album_file: bool = True
extract_flac: bool = True
downloads_simultaneous_per_track_max: int = 20
download_delay_sec_min: float = 3.0
download_delay_sec_max: float = 5.0
album_track_num_pad_min: int = 1
downloads_concurrent_max: int = 3
symlink_to_track: bool = False
playlist_create: bool = False
@dataclass_json
@dataclass
class HelpSettings:
skip_existing: str = "Skip download if file already exists."
album_cover_save: str = "Safe cover to album folder."
lyrics_embed: str = "Embed lyrics in audio file, if lyrics are available."
lyrics_file: str = "Save lyrics to separate *.lrc file, if lyrics are available."
api_key_index: str = "Set the device API KEY."
album_info_save: str = "Save album info to track?"
video_download: str = "Allow download of videos."
multi_thread: str = "Download several tracks in parallel."
download_delay: str = "Activate randomized download delay to mimic human behaviour."
download_base_path: str = "Where to store the downloaded media."
quality_audio: str = (
'Desired audio download quality: "LOW" (96kbps), "HIGH" (320kbps), '
'"LOSSLESS" (16 Bit, 44,1 kHz), '
'"HI_RES_LOSSLESS" (up to 24 Bit, 192 kHz)'
)
quality_video: str = 'Desired video download quality: "360", "480", "720", "1080"'
# TODO: Describe possible variables.
format_album: str = "Where to download albums and how to name the items."
format_playlist: str = "Where to download playlists and how to name the items."
format_mix: str = "Where to download mixes and how to name the items."
format_track: str = "Where to download tracks and how to name the items."
format_video: str = "Where to download videos and how to name the items."
video_convert_mp4: str = (
"Videos are downloaded as MPEG Transport Stream (TS) files. With this option each video "
"will be converted to MP4. FFmpeg must be installed."
)
path_binary_ffmpeg: str = (
"Path to FFmpeg binary file (executable). Only necessary if FFmpeg not set in $PATH. Mandatory for Windows: "
"The directory of `ffmpeg.exe`must be set in %PATH%."
)
metadata_cover_dimension: str = (
"The dimensions of the cover image embedded into the track. Possible values: 320x320, 640x640x 1280x1280."
)
metadata_cover_embed: str = "Embed album cover into file."
cover_album_file: str = "Save cover to 'cover.jpg', if an album is downloaded."
extract_flac: str = "Extract FLAC audio tracks from MP4 containers and save them as `*.flac` (uses FFmpeg)."
downloads_simultaneous_per_track_max: str = "Maximum number of simultaneous chunk downloads per track."
download_delay_sec_min: str = "Lower boundary for the calculation of the download delay in seconds."
download_delay_sec_max: str = "Upper boundary for the calculation of the download delay in seconds."
album_track_num_pad_min: str = (
"Minimum length of the album track count, will be padded with zeroes (0). To disable padding set this to 1."
)
downloads_concurrent_max: str = "Maximum concurrent number of downloads (threads)."
symlink_to_track: str = (
"If enabled the tracks of albums, playlists and mixes will be downloaded to the track directory but symlinked "
"accordingly."
)
playlist_create: str = "Creates a '_playlist.m3u8' file for downloaded albums, playlists and mixes."
@dataclass_json
@dataclass
class Token:
token_type: str | None = None
access_token: str | None = None
refresh_token: str | None = None
expiry_time: float = 0.0

View File

@ -0,0 +1,13 @@
import pathlib
from dataclasses import dataclass
from requests import HTTPError
@dataclass
class DownloadSegmentResult:
result: bool
url: str
path_segment: pathlib.Path
id_segment: int
error: HTTPError | None = None

View File

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

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

View File

@ -0,0 +1,812 @@
<?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>800</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="verticalLayout_4"/>
</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: 215 KiB

BIN
tidal_dl_ng/ui/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

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

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

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

@ -0,0 +1,801 @@
<?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::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::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::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::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="alternatingRowColors">
<bool>false</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
<property name="indentation">
<number>10</number>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="lh_download" stretch="5,0,5,0,15">
<item>
<widget class="QLabel" name="l_quality_audio">
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="text">
<string notr="true">Audio</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="cb_quality_audio">
<property name="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::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::AlignRight|Qt::AlignTrailing|Qt::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::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::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::AlignHCenter|Qt::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::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::ExtendedSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::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</string>
</property>
</column>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_queue_download_remove">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>Remove</string>
</property>
</widget>
</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::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>Exit</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()