🌟 Re-add all project files.

This commit is contained in:
Robert Honz
2023-12-19 23:58:57 +01:00
parent 82a8fe96aa
commit 0d4a9d6b87
42 changed files with 6762 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python
import importlib.metadata
try:
# __package__ allows for the case where __name__ is "__main__"
__version__ = importlib.metadata.version(__package__ or __name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
+102
View File
@@ -0,0 +1,102 @@
import json
import requests
# TODO: Implement this into `Download`: Session should randomize the usage.
__KEYS_JSON__ = """
{
"version": "1.0.1",
"keys": [
{
"platform": "Fire TV",
"formats": "Normal/High/HiFi(No Master)",
"clientId": "OmDtrzFgyVVL6uW56OnFA2COiabqm",
"clientSecret": "zxen1r3pO0hgtOC7j6twMo9UAqngGrmRiWpV7QC1zJ8=",
"valid": "False",
"from": "Fokka-Engineering (https://github.com/Fokka-Engineering/libopenTIDAL/blob/655528e26e4f3ee2c426c06ea5b8440cf27abc4a/README.md#example)"
},
{
"platform": "Fire TV",
"formats": "Master-Only(Else Error)",
"clientId": "7m7Ap0JC9j1cOM3n",
"clientSecret": "vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=",
"valid": "True",
"from": "Dniel97 (https://github.com/Dniel97/RedSea/blob/4ba02b88cee33aeb735725cb854be6c66ff372d4/config/settings.example.py#L68)"
},
{
"platform": "Android TV",
"formats": "Normal/High/HiFi(No Master)",
"clientId": "Pzd0ExNVHkyZLiYN",
"clientSecret": "W7X6UvBaho+XOi1MUeCX6ewv2zTdSOV3Y7qC3p3675I=",
"valid": "False",
"from": ""
},
{
"platform": "TV",
"formats": "Normal/High/HiFi/Master",
"clientId": "8SEZWa4J1NVC5U5Y",
"clientSecret": "owUYDkxddz+9FpvGX24DlxECNtFEMBxipU0lBfrbq60=",
"valid": "False",
"from": "morguldir (https://github.com/morguldir/python-tidal/commit/50f1afcd2079efb2b4cf694ef5a7d67fdf619d09)"
},
{
"platform": "Android Auto",
"formats": "Normal/High/HiFi/Master",
"clientId": "zU4XHVVkc2tDPo4t",
"clientSecret": "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4=",
"valid": "True",
"from": "1nikolas (https://github.com/yaronzz/Tidal-Media-Downloader/pull/840)"
}
]
}
"""
__API_KEYS__ = json.loads(__KEYS_JSON__)
__ERROR_KEY__ = (
{
"platform": "None",
"formats": "",
"clientId": "",
"clientSecret": "",
"valid": "False",
},
)
def getNum():
return len(__API_KEYS__["keys"])
def getItem(index: int):
if index < 0 or index >= len(__API_KEYS__["keys"]):
return __ERROR_KEY__
return __API_KEYS__["keys"][index]
def isItemValid(index: int):
item = getItem(index)
return item["valid"] == "True"
def getItems():
return __API_KEYS__["keys"]
def getLimitIndexs():
array = []
for i in range(len(__API_KEYS__["keys"])):
array.append(str(i))
return array
def getVersion():
return __API_KEYS__["version"]
# Load from gist
try:
respond = requests.get("https://api.github.com/gists/48d01f5a24b4b7b37f19443977c22cd6")
if respond.status_code == 200:
content = respond.json()["files"]["tidal-api-key.json"]["content"]
__API_KEYS__ = json.loads(content)
except:
pass
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python
from typing import Annotated, Optional
from pathlib import Path
import typer
from tidal_dl_ng.config import Settings, Tidal
from tidal_dl_ng.constants import CTX_TIDAL, MediaType
from tidal_dl_ng.helper.path import path_file_settings
from tidal_dl_ng.helper.url import get_tidal_media_id, get_tidal_media_type
from tidal_dl_ng.helper.wrapper import WrapperLogger
from tidal_dl_ng.model.cfg import HelpSettings
from rich.live import Live
from rich.panel import Panel
from rich.progress import BarColumn, Console, Progress, SpinnerColumn, TextColumn
from rich.table import Table
from tidal_dl_ng.download import Download
from tidal_dl_ng import __version__
app = typer.Typer()
def version_callback(value: bool):
if value:
print(f"{__version__}")
raise typer.Exit()
@app.callback()
def callback_app(
ctx: typer.Context,
version: Annotated[Optional[bool], typer.Option("--version", "-v", callback=version_callback, is_eager=True)] = None,
):
ctx.obj = {"tidal": None}
@app.command(name="cfg")
def settings_management(
names: list[str] = None,
editor: Annotated[
bool, typer.Option("--editor", "-e", help="Open the settings file in your default editor.")
] = False,
):
"""
Print or set an option.
If no arguments are given, all options will be listed.
If only one argument is given, the value will be printed for this option.
To set a value for an option simply pass the value as the second argument
:param names: (Optional) None (list all options), one (list the value only for this option) or two arguments
(set the value for the option).
"""
if editor:
config_path: Path = Path(path_file_settings())
if not config_path.is_file():
config_path.write_text('{"version": "1.0.0"}')
config_file_str = str(config_path)
typer.launch(config_file_str)
else:
settings = Settings()
d_settings = settings.data.to_dict()
if names:
if names[0] not in d_settings:
print(f'Option "{names[0]}" is not valid!')
else:
if len(names) == 1:
print(f'{names[0]}: "{d_settings[names[0]]}"')
elif len(names) > 1:
settings.set_option(names[0], names[1])
settings.save()
else:
help_settings: dict = HelpSettings().to_dict()
table = Table(title=f"Config: {path_file_settings()}")
table.add_column("Key", style="cyan", no_wrap=True)
table.add_column("Value", style="magenta")
table.add_column("Description", style="green")
# Iterate over the attributes of the dataclass
for key, value in sorted(d_settings.items()):
table.add_row(key, str(value), help_settings[key])
console = Console()
console.print(table)
@app.command(name="login")
def login(ctx: typer.Context) -> bool:
print("Let us check, if you are already logged in... ", end="")
settings = Settings()
tidal = Tidal(settings)
result = tidal.login(print)
ctx.obj[CTX_TIDAL] = tidal
return result
@app.command(name="dl")
def download(
ctx: typer.Context,
urls_or_ids: Annotated[Optional[list[str]], typer.Argument()] = None,
list_urls: Annotated[
Optional[Path],
typer.Option(
"--list",
"-l",
exists=True,
file_okay=True,
dir_okay=False,
writable=False,
readable=True,
resolve_path=True,
help="List with URLs to download. One per line",
),
] = None,
):
if not urls_or_ids:
if list_urls:
text = list_urls.read_text()
urls_or_ids = text.splitlines()
else:
print("Provide either URLs, IDs or a file containing URLs (one per line).")
raise typer.Abort()
ctx.invoke(login, ctx)
dl = Download(ctx.obj[CTX_TIDAL].session, ctx.obj[CTX_TIDAL].settings.data.skip_existing)
settings: Settings = Settings()
media_type: MediaType = None
progress: Progress = Progress(
"{task.description}",
SpinnerColumn(),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
)
progress_table = Table.grid()
fn_logger = WrapperLogger(progress.print)
progress_table.add_row(Panel.fit(progress, title="Download Progress", border_style="green", padding=(2, 2)))
for item in urls_or_ids:
if "http" in item:
media_name = get_tidal_media_type(item)
id_item = get_tidal_media_id(item)
else:
media_name = False
id_item = item
if not media_name:
print(f"It seems like that you have supplied an invalid URL: {item}")
continue
with Live(progress_table, refresh_per_second=10):
# TODO: Fix media type
if media_name in ["track", "video"]:
if media_name == "track":
file_template = settings.data.format_track
media_type = MediaType.Track
elif media_name == "video":
file_template = settings.data.format_video
media_type = MediaType.Video
dl.item(
id=id_item,
media_type=media_type,
path_base=settings.data.download_base_path,
file_template=file_template,
progress=progress,
fn_logger=fn_logger
)
elif media_name in ["album", "playlist", "mix"]:
# TODO: Handle mixes.
file_template: str | bool = False
if media_name == "album":
file_template = settings.data.format_album
media_type = MediaType.Album
elif media_name == "playlist":
file_template = settings.data.format_playlist
media_type = MediaType.Playlist
elif media_name == "mix":
file_template = settings.data.format_mix
media_type = MediaType.Mix
dl.list(
id_media=id_item,
media_type=media_type,
path_base=settings.data.download_base_path,
file_template=file_template,
video_download=ctx.obj[CTX_TIDAL].settings.data.video_download,
progress=progress,
download_delay=settings.data.download_delay,
fn_logger=fn_logger,
)
progress.stop()
return True
@app.command()
def gui(ctx: typer.Context):
from tidal_dl_ng.gui import gui_activate
ctx.invoke(login, ctx)
gui_activate(ctx.obj[CTX_TIDAL])
if __name__ == "__main__":
app()
+132
View File
@@ -0,0 +1,132 @@
from typing import Any
from collections.abc import Callable
from json import JSONDecodeError
import tidalapi
from tidal_dl_ng.helper.decorator import SingletonMeta
from tidal_dl_ng.helper.path import path_file_settings, path_file_token
from tidal_dl_ng.model.cfg import Settings as ModelSettings
from tidal_dl_ng.model.cfg import Token as ModelToken
from requests import HTTPError
class BaseConfig:
data: ModelSettings | ModelToken = None
file_path: str = None
cls_model: object = None
def save(self) -> None:
data_json = self.data.to_json()
with open(self.file_path, encoding="utf-8", mode="w") as f:
f.write(data_json)
def set_option(self, key: str, value: Any) -> None:
setattr(self.data, key, value)
def read(self, path: str) -> bool:
result = False
try:
with open(path, encoding="utf-8") as f:
settings_json = f.read()
self.data = self.cls_model.from_json(settings_json)
result = True
except (JSONDecodeError, TypeError, FileNotFoundError):
self.data = self.cls_model()
self.save()
return result
class Settings(BaseConfig, metaclass=SingletonMeta):
cls_model = ModelSettings
data = None
def __init__(self):
self.file_path = path_file_settings()
self.read(self.file_path)
class Tidal(BaseConfig, metaclass=SingletonMeta):
cls_model = ModelToken
session: tidalapi.Session = None
data: ModelToken = None
token_from_storage: bool = False
settings: Settings = None
def __init__(self, settings: Settings = None):
self.session = tidalapi.Session()
self.session.video_quality = tidalapi.VideoQuality.high
self.file_path = path_file_token()
self.token_from_storage = self.read(self.file_path)
self.login_token()
if settings:
self.settings = settings
self.settings_apply()
def settings_apply(self, settings: Settings = None) -> bool:
if settings:
self.settings = settings
self.session.audio_quality = self.settings.data.quality_audio
return True
def login_token(self) -> bool:
result = False
if self.token_from_storage:
try:
result = self.session.load_oauth_session(
self.data.token_type, self.data.access_token, self.data.refresh_token, self.data.expiry_time
)
except HTTPError:
result = False
return result
def login_oauth_start(self, function=print) -> None:
self.session.login_oauth_simple(function)
def login_oauth_finish(self) -> bool:
result = self.session.check_login()
if result:
self.token_persist()
return result
def token_persist(self) -> None:
self.set_option("token_type", self.session.token_type)
self.set_option("access_token", self.session.access_token)
self.set_option("refresh_token", self.session.refresh_token)
self.set_option("expiry_time", self.session.expiry_time)
self.save()
def login(self, fn_print: Callable) -> bool:
is_token = self.login_token()
result = False
if is_token:
fn_print("Yep, looks good! You are logged in.")
result = True
elif not is_token:
fn_print("You either do not have a token or your token is invalid.")
fn_print("No worries, we will handle this...")
self.login_oauth_start(fn_print)
is_login = self.login_oauth_finish()
if is_login:
fn_print("The login was successful. I have stored your credentials (token).")
result = True
else:
fn_print("Something went wrong. Did you login using your browser correctly? May try again...")
return result
+18
View File
@@ -0,0 +1,18 @@
from enum import Enum
CTX_TIDAL: str = "tidal"
class QualityVideo(Enum):
P360 = 360
P480 = 480
P720 = 720
P1080 = 1080
class MediaType(Enum):
Track = "track"
Video = "video"
Playlist = "playlist"
Album = "album"
Mix = "mix"
+331
View File
@@ -0,0 +1,331 @@
import base64
import json
import os
import random
import shutil
import tempfile
import time
from logging import Logger
from requests.exceptions import HTTPError
from uuid import uuid4
import ffmpeg
import m3u8
import requests
from tidal_dl_ng.config import Settings
from tidal_dl_ng.constants import MediaType
from tidal_dl_ng.helper.decryption import decrypt_file, decrypt_security_token
from tidal_dl_ng.helper.exceptions import MediaUnknown
from tidal_dl_ng.helper.path import check_file_exists, format_path_media, path_validate
from tidal_dl_ng.helper.wrapper import WrapperLogger
from tidal_dl_ng.metadata import Metadata
from tidal_dl_ng.model.gui_data import ProgressBars
from pathvalidate import sanitize_filepath
from rich.progress import Progress
from tidalapi import Album, Playlist, Session, Track, UserPlaylist, Video, Mix
# TODO: Set appropriate client string and use it for video download.
# https://github.com/globocom/m3u8#using-different-http-clients
class RequestsClient:
def download(self, uri, timeout=None, headers={}, verify_ssl=True):
o = requests.get(uri, timeout=timeout, headers=headers)
return o.text, o.url
class Download:
# TODO: Implement download cover 1280.
session: Session = None
skip_existing: bool = False
def __init__(self, session: Session, skip_existing: bool = False):
self.session = session
self.skip_existing = skip_existing
def _video(self, video: Video, path_file: str) -> str | None:
result: str | None = None
m3u8_variant: m3u8.M3U8 = m3u8.load(video.get_url())
m3u8_playlist: m3u8.M3U8 | bool = False
settings: Settings = Settings()
resolution_best: int = 0
if m3u8_variant.is_variant:
for playlist in m3u8_variant.playlists:
if resolution_best < playlist.stream_info.resolution[1]:
resolution_best = playlist.stream_info.resolution[1]
m3u8_playlist = m3u8.load(playlist.uri)
if settings.data.quality_video.value == playlist.stream_info.resolution[1]:
break
if m3u8_playlist:
with open(path_file, "wb") as f:
for segment in m3u8_playlist.data["segments"]:
url = segment["uri"]
r = requests.get(url)
f.write(r.content)
result = path_file
return result
def item(
self,
path_base: str,
fn_logger: Logger | WrapperLogger,
id: str = None,
file_template: str = None,
media: Track | Video = None,
media_type: MediaType = None,
video_download: bool = True,
progress_gui: ProgressBars = None,
progress: Progress = None
) -> (bool, str):
if id:
if media_type == MediaType.Track:
media = Track(self.session, id)
elif media_type == MediaType.Video:
media = Video(self.session, id)
# If video download is not allowed
if not video_download:
return False, ""
else:
raise MediaUnknown
else:
media = media
if file_template:
file_name_relative = format_path_media(file_template, media)
path_file = os.path.abspath(os.path.normpath(os.path.join(path_base, file_name_relative)))
else:
path_file = os.path.abspath(os.path.normpath(format_path_media(path_base, media)))
if isinstance(media, Track):
stream = media.stream()
# TODO: Check for `manifest_mime_type'. It could be also xml.
stream_manifest = json.loads(base64.b64decode(stream.manifest).decode("utf-8"))
# TODO: Handle more than one dowload URL
stream_url = stream_manifest["urls"][0]
file_extension = self.get_file_extension(stream_url, stream_manifest["codecs"])
elif isinstance(media, Video):
file_extension = ".ts"
path_file = sanitize_filepath(path_file + file_extension)
# Check if path & filename longer than the OS allows. Shorten if necessary.
validation_result, path_file = path_validate(path_file, True)
download_skip = check_file_exists(path_file) if self.skip_existing else False
if not download_skip:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir:
tmp_path_file = os.path.join(tmp_path_dir, str(uuid4()) + file_extension)
if isinstance(media, Track):
# TODO: Refactor to separate method.
if progress_gui is None:
progress_stdout: bool = True
else:
progress_stdout: bool = False
progress_gui.item_name.emit(media.name)
try:
# Streaming, so we can iterate over the response.
r = requests.get(stream_url, stream=True)
r.raise_for_status()
total_size_in_bytes = int(r.headers.get("content-length", 0))
block_size = 4096
p_task = progress.add_task(
f"[blue]Item '{media.name[:30]}'",
total=total_size_in_bytes / block_size,
visible=progress_stdout,
)
while not progress.tasks[p_task].finished:
with open(tmp_path_file, "wb") as f:
for data in r.iter_content(chunk_size=block_size):
f.write(data)
progress.advance(p_task, advance=1)
if not progress_stdout:
progress_gui.item.emit(progress.tasks[p_task].percentage)
except HTTPError:
# TODO: Handle Exception...
pass
needs_decryption = self.is_encrypted(stream_manifest)
if needs_decryption:
key, nonce = decrypt_security_token(stream_manifest["encryptionKey"])
tmp_path_file_decrypted = tmp_path_file + "_decrypted"
decrypt_file(tmp_path_file, tmp_path_file_decrypted, key, nonce)
else:
tmp_path_file_decrypted = tmp_path_file
self.metadata_write(media, tmp_path_file_decrypted)
elif isinstance(media, Video):
tmp_path_file_decrypted = self._video(media, tmp_path_file)
# TODO: Check if is possible to write metadata to MPEG Transport Stream files.
# TODO: Make optional.
if True:
tmp_path_file_decrypted = self._video_convert(tmp_path_file_decrypted)
path_file = os.path.splitext(path_file)[0] + ".mp4"
os.makedirs(os.path.dirname(path_file), exist_ok=True)
shutil.move(tmp_path_file_decrypted, path_file)
else:
fn_logger.debug(f"Download skipped, since file exists: '{path_file}'")
return not download_skip, path_file
def cover_url(self, sid: str, width: int = 320, height: int = 320):
if sid is None:
return ""
return f"https://resources.tidal.com/images/{sid.replace('-', '/')}/{int(width)}x{int(height)}.jpg"
def cover_data(self, sid, width: int = 320, height: int = 320):
url = self.cover_url(sid, width, height)
try:
result: str = requests.get(url).content
except:
result: str = ""
return result
def metadata_write(self, track: Track, path_file: str):
result: bool = False
release_date: str = track.album.release_date.strftime("%Y-%m-%d") if track.album.release_date else ""
copy_right: str = track.copyright if track.copyright else ""
try:
lyrics: str = track.lyrics().text if hasattr(track, "lyrics") else ""
except HTTPError:
lyrics: str = ""
m: Metadata = Metadata(
path_file=path_file,
lyrics=lyrics,
copy_right=copy_right,
title=track.name,
artists=[artist.name for artist in track.artists],
album=track.album.name,
tracknumber=track.track_num,
date=release_date,
isrc=track.isrc,
albumartist=track.artist.name,
totaltrack=track.album.num_tracks if track.album.num_tracks else 1,
totaldisc=track.album.num_volumes if track.album.num_volumes else 1,
discnumber=track.volume_num,
url_cover=self.cover_url(track.album.cover),
)
m.save()
result = True
return result
def list(
self,
path_base: str,
fn_logger: Logger | WrapperLogger,
id_media: str = None,
media_type: MediaType = None,
file_template: str = None,
list_media: Album | Playlist | UserPlaylist | Mix = None,
video_download: bool = False,
progress_gui: ProgressBars = None,
progress: Progress = None,
download_delay: bool = True,
):
if not list_media:
if media_type == MediaType.Album:
list_media = Album(self.session, id_media)
elif media_type == MediaType.Playlist:
list_media = Playlist(self.session, id_media)
elif media_type == MediaType.Mix:
list_media = Mix(self.session, id_media)
else:
raise MediaUnknown
if file_template:
file_name_relative = format_path_media(file_template, list_media)
path_file = path_base
else:
file_name_relative = file_template
path_file = format_path_media(path_base, list_media)
# TODO: Extend with pagination support: Iterate through `items` and `tracks`until len(returned list) == 0
if isinstance(list_media, Mix):
items = list_media.items()
list_media_name = list_media.title[:30]
elif video_download:
items = list_media.items(limit=100)
list_media_name = list_media.name[:30]
else:
items = list_media.tracks(limit=999)
list_media_name = list_media.name[:30]
if progress_gui is None:
progress_stdout: bool = True
else:
progress_stdout: bool = False
p_task1 = progress.add_task(f"[green]List '{list_media_name}'", total=len(items), visible=progress_stdout)
while not progress.finished:
for media in items:
Progress()
# TODO: Handle return value of `track` method.
self.item(
path_base=path_file,
file_template=file_name_relative,
media=media,
progress_gui=progress_gui,
progress=progress,
fn_logger=fn_logger
)
progress.advance(p_task1)
if not progress_stdout:
progress_gui.list_item.emit(progress.tasks[p_task1].percentage)
if download_delay:
time_sleep: float = round(random.SystemRandom().uniform(2, 5), 1)
# TODO: Fix logging. Is not displayed in debug window.
fn_logger.debug(f"Next download will start in {time_sleep} seconds.")
time.sleep(time_sleep)
def is_encrypted(self, manifest: dict) -> bool:
result = manifest["encryptionType"] != "NONE"
return result
def get_file_extension(self, stream_url: str, stream_codec: str) -> str:
result = None
if ".flac" in stream_url:
result = ".flac"
elif ".mp4" in stream_url:
if "ac4" in stream_codec or "mha1" in stream_codec:
result = ".mp4"
elif "flac" in stream_codec:
result = ".flac"
else:
result = ".m4a"
else:
result = ".m4a"
return result
def _video_convert(self, path_file: str) -> str:
path_file_out = os.path.splitext(path_file)[0] + ".mp4"
result, _ = ffmpeg.input(path_file).output(path_file_out, map=0, c="copy").run()
return path_file_out
+481
View File
@@ -0,0 +1,481 @@
import math
import sys
from collections.abc import Callable
try:
import qdarktheme
from PySide6 import QtCore, QtGui, QtWidgets
except ImportError as e:
print(e)
print("Qt dependencies missing. Cannot start GUI. Please execute: 'pip install pyside6 pyqtdarktheme'")
sys.exit(1)
import coloredlogs.converter
from rich.progress import Progress
from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video
from tidalapi.session import SearchTypes
from tidal_dl_ng.config import Settings, Tidal
from tidal_dl_ng.constants import QualityVideo
from tidal_dl_ng.download import Download
from tidal_dl_ng.logger import XStream, logger_cli, logger_gui
from tidal_dl_ng.model.gui_data import ProgressBars, ResultSearch
from tidal_dl_ng.ui.main import Ui_MainWindow
from tidal_dl_ng.ui.spinner import QtWaitingSpinner
from tidal_dl_ng.worker import Worker
# TODO: Make more use of Exceptions
class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
settings: Settings = None
tidal: Tidal = None
download: Download = None
threadpool: QtCore.QThreadPool = None
spinner: QtWaitingSpinner = None
spinner_start: QtCore.Signal = QtCore.Signal(QtWidgets.QWidget)
spinner_stop: QtCore.Signal = QtCore.Signal()
pb_item: QtWidgets.QProgressBar = None
s_item_advance: QtCore.Signal = QtCore.Signal(float)
s_item_name: QtCore.Signal = QtCore.Signal(str)
pb_list: QtWidgets.QProgressBar = None
s_list_advance: QtCore.Signal = QtCore.Signal(float)
def __init__(self, tidal: Tidal | None = None):
super().__init__()
self.setupUi(self)
# self.setGeometry(50, 50, 500, 300)
self.setWindowTitle("TIDAL Downloader Next Gen!")
# TODO: Fix icons (make them visible).
my_pixmap = QtGui.QPixmap("tidal_dl_ng/ui/icon.png")
my_icon = QtGui.QIcon("tidal_dl_ng/ui/icon.png")
self.setWindowIcon(my_icon)
tray = QtWidgets.QSystemTrayIcon()
tray.setIcon(my_icon)
tray.setVisible(True)
# Logging redirect.
XStream.stdout().messageWritten.connect(self._log_output)
# XStream.stderr().messageWritten.connect(self._log_output)
self.settings = Settings()
self.threadpool = QtCore.QThreadPool()
# TODO: Show GUI, create a progress bar showing the TIDAL querying progress.
self._init_tree_results(self.tr_results)
self._init_tree_lists(self.tr_lists_user)
self._init_progressbar()
self._populate_quality(self.cb_quality_audio, Quality)
self._populate_quality(self.cb_quality_video, QualityVideo)
self._populate_search_types(self.cb_search_type, SearchTypes)
self.apply_settings(self.settings)
self._init_signals()
self.thread_it(self.init_tidal, tidal)
logger_gui.debug("Multithreading with maximum %d threads" % self.threadpool.maxThreadCount())
logger_gui.debug("All setup.")
def init_tidal(self, tidal: Tidal = None):
result: bool = False
if tidal:
self.tidal = tidal
result = True
else:
self.tidal = Tidal(self.settings)
while True:
result = self.tidal.login(logger_gui.info)
if result:
break
if result:
self.download = Download(self.tidal.session, self.tidal.settings.data.skip_existing)
self.thread_it(self.populate_tree_lists, self.tidal)
def _init_progressbar(self):
self.pb_list = QtWidgets.QProgressBar()
self.pb_item = QtWidgets.QProgressBar()
pbs = [self.pb_list, self.pb_item]
for pb in pbs:
pb.setRange(0, 100)
# self.pb_progress.setVisible()
self.statusbar.addPermanentWidget(pb)
def progress_reset(self):
self.pb_list.setValue(0)
self.pb_item.setValue(0)
def _log_output(self, text):
display_msg = coloredlogs.converter.convert(text)
cursor: QtGui.QTextCursor = self.te_debug.textCursor()
cursor.movePosition(QtGui.QTextCursor.End)
cursor.insertHtml(display_msg)
self.te_debug.setTextCursor(cursor)
self.te_debug.ensureCursorVisible()
def _populate_quality(self, ui_target: QtWidgets.QComboBox, options: type[Quality | QualityVideo]):
for item in options:
ui_target.addItem(item.name, item)
def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: SearchTypes):
for item in options:
if item and not item.__name__ == "Artist":
ui_target.addItem(item.__name__, item)
self.cb_search_type.setCurrentIndex(1)
def _init_tree_results(self, tree: QtWidgets.QTableWidget):
tree.setColumnHidden(5, True)
def populate_tree_lists(self, tidal: Tidal):
# Start loading spinner
self.spinner_start.emit(self.tr_lists_user)
self.tr_results.clear()
# TODO: Refactor list item names to constants.
twi_playlists: QtWidgets.QTableWidgetItem = self.tr_lists_user.findItems(
"Playlists", QtCore.Qt.MatchExactly, 0
)[0]
twi_mixes: QtWidgets.QTableWidgetItem = self.tr_lists_user.findItems("Favorites", QtCore.Qt.MatchExactly, 0)[0]
twi_favorites: QtWidgets.QTableWidgetItem = self.tr_lists_user.findItems("Mixes", QtCore.Qt.MatchExactly, 0)[0]
user_playlists: [Playlist | UserPlaylist] = tidal.session.user.playlist_and_favorite_playlists()
user_mixes: [Mix] = tidal.session.mixes().categories[0].items
user_all: [Playlist | UserPlaylist | Mix] = user_playlists + user_mixes
for item in user_all:
if isinstance(item, UserPlaylist):
twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
name: str = item.name
info: str = f"({item.num_tracks + item.num_videos} Tracks)"
elif isinstance(item, Playlist):
twi_child = QtWidgets.QTreeWidgetItem(twi_mixes)
name: str = item.name
info: str = f"({item.num_tracks + item.num_videos} Tracks) {item.description}"
elif isinstance(item, Mix):
twi_child = QtWidgets.QTreeWidgetItem(twi_favorites)
name: str = item.title
info: str = item.sub_title
twi_child.setText(0, name)
twi_child.setText(1, info)
twi_child.setData(3, QtCore.Qt.ItemDataRole.UserRole, item)
# Stop load spinner
self.spinner_stop.emit()
def _init_tree_lists(self, tree: QtWidgets.QTreeWidget):
tree.setColumnWidth(0, 200)
tree.setColumnWidth(1, 300)
tree.setColumnHidden(2, True)
tree.expandAll()
# Connect the contextmenu
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
tree.customContextMenuRequested.connect(self.menu_context_tree_lists)
def apply_settings(self, settings: Settings):
l_cb = [
{"element": self.cb_quality_audio, "setting": settings.data.quality_audio.name, "default_id": 1},
{"element": self.cb_quality_video, "setting": settings.data.quality_video.name, "default_id": 0},
]
for item in l_cb:
idx = item["element"].findText(item["setting"])
if idx > -1:
item["element"].setCurrentIndex(idx)
else:
item["element"].setCurrentIndex(item["default_id"])
@QtCore.Slot(QtWidgets.QWidget)
def on_spinner_start(self, parent: QtWidgets.QWidget):
self.spinner = QtWaitingSpinner(parent, True, True)
self.spinner.setColor(QtGui.QColor(255, 255, 255))
self.spinner.start()
@QtCore.Slot()
def on_spinner_stop(self):
self.spinner.stop()
self.spinner = None
def menu_context_tree_lists(self, point):
# Infos about the node selected.
index = self.tr_lists_user.indexAt(point)
# Do not open menu if something went wrong or a parent node is clicked.
if not index.isValid() or not index.parent().data():
return
# We build the menu.
menu = QtWidgets.QMenu()
action = menu.addAction("Dowloading Playlist", lambda: self.thread_download_list_media(point))
menu.exec(self.tr_lists_user.mapToGlobal(point))
def thread_download_list_media(self, point):
self.thread_it(self.on_download_list_media, point)
self.thread_it(self.list_items_show, point=point)
def on_download_list_media(self, point: QtCore.QPoint):
item = self.tr_lists_user.itemAt(point)
media = item.data(3, QtCore.Qt.ItemDataRole.UserRole)
# TODO: Implement disable download button etc.
self.download_item(media, self.download)
def search_populate_results(self, query: str, type_media: SearchTypes):
self.tr_results.clear()
results: [ResultSearch] = self.search(query, [type_media])
self.populate_tree_results(results)
def populate_tree_results(self, results: [ResultSearch]):
self.tr_results.clear()
for item in results:
# Format seconds to mm:ss.
m, s = divmod(item.duration_sec, 60)
duration: str = f"{m:02d}:{s:02d}"
child = QtWidgets.QTreeWidgetItem()
child.setText(0, str(item.position))
child.setText(1, item.artist)
child.setText(2, item.title)
child.setText(3, item.album)
child.setText(4, duration)
child.setData(5, QtCore.Qt.ItemDataRole.UserRole, item.obj)
self.tr_results.addTopLevelItem(child)
def search(self, query: str, types_media: SearchTypes) -> [ResultSearch]:
result_search: [dict[str, *SearchTypes]] = self.tidal.session.search(query, models=types_media, limit=999)
result: [ResultSearch] = []
for media_type, l_media in result_search.items():
if isinstance(l_media, list):
result = result + self.search_result_to_model(l_media)
return result
def search_result_to_model(self, items: [*SearchTypes]) -> [ResultSearch]:
result = []
for idx, item in enumerate(items):
if isinstance(item, Track):
result_item: ResultSearch = ResultSearch(
position=idx,
artist=item.artist.name,
title=item.name,
album=item.album.name,
duration_sec=item.duration,
obj=item,
)
result.append(result_item)
elif isinstance(item, Video):
result_item: ResultSearch = ResultSearch(
position=idx,
artist=item.artist.name,
title=item.name,
album=item.album.name if item.album else "",
duration_sec=item.duration,
obj=item,
)
result.append(result_item)
elif isinstance(item, Playlist):
result_item: ResultSearch = ResultSearch(
position=idx,
artist=f"{item.promoted_artists[0].name}, ..." if item.promoted_artists else "",
title=item.name,
album="",
duration_sec=item.duration,
obj=item,
)
result.append(result_item)
elif isinstance(item, Album):
result_item: ResultSearch = ResultSearch(
position=idx,
artist=item.artist.name,
title="",
album=item.name,
duration_sec=item.duration,
obj=item,
)
result.append(result_item)
return result
def _init_signals(self):
self.b_download.clicked.connect(lambda: self.thread_it(self.on_download_results))
self.l_search.returnPressed.connect(
lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData())
)
self.b_search.clicked.connect(
lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData())
)
self.cb_quality_audio.currentIndexChanged.connect(self.quality_set_audio)
self.cb_quality_video.currentIndexChanged.connect(self.quality_set_video)
self.tr_lists_user.itemClicked.connect(self.on_list_items_show)
self.spinner_start[QtWidgets.QWidget].connect(self.on_spinner_start)
self.spinner_stop.connect(self.on_spinner_stop)
self.s_item_advance.connect(self.progress_item)
self.s_item_name.connect(self.progress_item_name)
self.s_list_advance.connect(self.progress_list)
def progress_list(self, value: float):
self.pb_list.setValue(int(math.ceil(value)))
def progress_item(self, value: float):
self.pb_item.setValue(int(math.ceil(value)))
def progress_item_name(self, value: str):
self.pb_item.setFormat(f"%p% {value}")
def progress_list_name(self, value: str):
self.pb_list.setFormat(f"%p% {value}")
def quality_set_audio(self, index):
self.settings.data.quality_audio = Quality(self.cb_quality_audio.itemData(index).value)
self.settings.save()
if self.tidal:
self.tidal.settings_apply()
def quality_set_video(self, index):
self.settings.data.quality_video = QualityVideo(self.cb_quality_video.itemData(index).value)
self.settings.save()
if self.tidal:
self.tidal.settings_apply()
def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem):
media_list: Album | Playlist = item.data(3, QtCore.Qt.ItemDataRole.UserRole)
# Only if clicked item is not a top level item.
if media_list:
self.list_items_show(media_list)
def list_items_show(self, media_list: Album | Playlist | None = None, point: QtCore.QPoint | None = None):
if point:
item = self.tr_lists_user.itemAt(point)
media_list = item.data(3, QtCore.Qt.ItemDataRole.UserRole)
media_items = media_list.items()
result = self.search_result_to_model(media_items)
self.populate_tree_results(result)
def thread_it(self, fn: Callable, *args, **kwargs):
# Any other args, kwargs are passed to the run function
worker = Worker(fn, *args, **kwargs)
# Execute
self.threadpool.start(worker)
def on_download_results(self):
self.b_download.setEnabled(False)
self.b_download.setText(f"Downloading...")
items: [QtWidgets.QTreeWidgetItem] = self.tr_results.selectedItems()
if len(items) == 0:
logger_gui.error("Please select a row first.")
else:
for item in items:
media: Track | Album | Playlist | Video = item.data(5, QtCore.Qt.ItemDataRole.UserRole)
result_dl: bool = self.download_item(media, self.download)
self.b_download.setText(f"Download")
self.b_download.setEnabled(True)
def download_item(self, media: Track | Album | Playlist | Video | Mix, dl: Download) -> bool:
# TODO: Refactor this. Move this logic to `Download` class and provide a generic interface.
self.progress_reset()
self.statusbar.showMessage("Download started...")
data_pb: ProgressBars = ProgressBars(
item=self.s_item_advance, list_item=self.s_list_advance, item_name=self.s_item_name
)
progress: Progress = Progress()
if isinstance(media, Track) or isinstance(media, Video):
if isinstance(media, Track):
file_template: str = self.settings.data.format_track
elif isinstance(media, Video):
file_template: str = self.settings.data.format_video
result_download, download_path_file = dl.item(
media=media,
path_base=self.settings.data.download_base_path,
file_template=file_template,
progress_gui=data_pb,
progress=progress,
fn_logger=logger_gui
)
if result_download:
logger_gui.info(f"Download successful: {download_path_file}")
else:
logger_gui.info(f"Download skipped (file exists): {download_path_file}")
elif isinstance(media, Album) or isinstance(media, Playlist) or isinstance(media, Mix):
file_template: str | bool = False
if isinstance(media, Album):
file_template: str = self.settings.data.format_album
progress_name = media.name
elif isinstance(media, Playlist):
file_template: str = self.settings.data.format_playlist
progress_name = media.name
elif isinstance(media, Mix):
file_template: str = self.settings.data.format_mix
progress_name = media.title
else:
progress_name = "List N/A"
self.progress_list_name(progress_name)
dl.list(
path_base=self.settings.data.download_base_path,
file_template=file_template,
list_media=media,
video_download=self.settings.data.video_download,
progress_gui=data_pb,
progress=progress,
download_delay=self.settings.data.download_delay,
fn_logger=logger_gui,
)
self.statusbar.showMessage("Download finished.", 2000)
progress.stop()
# TODO: Refactor to useful return value.
return True
def progress_reset(self):
self.pb_list.setValue(0)
self.pb_item.setValue(0)
# TODO: Comment with Google Docstrings.
def gui_activate(tidal: Tidal | None = None):
qdarktheme.enable_hi_dpi()
app = QtWidgets.QApplication(sys.argv)
qdarktheme.setup_theme()
window = MainWindow(tidal)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
gui_activate()
View File
+19
View File
@@ -0,0 +1,19 @@
class SingletonMeta(type):
"""
The Singleton class can be implemented in different ways in Python. Some
possible methods include: base class, decorator, metaclass. We will use the
metaclass because it is best suited for this purpose.
"""
_instances = {}
def __call__(cls, *args, **kwargs):
"""
Possible changes to the value of the `__init__` argument do not affect
the returned instance.
"""
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
+53
View File
@@ -0,0 +1,53 @@
import base64
from Crypto.Cipher import AES
from Crypto.Util import Counter
def decrypt_security_token(security_token: str) -> (str, str):
"""
Decrypts security token into key and nonce pair
security_token should match the securityToken value from the web response
"""
# Do not change this
master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754="
# Decode the base64 strings to ascii strings
master_key = base64.b64decode(master_key)
security_token = base64.b64decode(security_token)
# Get the IV from the first 16 bytes of the securityToken
iv = security_token[:16]
encrypted_st = security_token[16:]
# Initialize decryptor
decryptor = AES.new(master_key, AES.MODE_CBC, iv)
# Decrypt the security token
decrypted_st = decryptor.decrypt(encrypted_st)
# Get the audio stream decryption key and nonce from the decrypted security token
key = decrypted_st[:16]
nonce = decrypted_st[16:24]
return key, nonce
def decrypt_file(path_file_encrypted: str, path_file_destination: str, key: str, nonce: str) -> None:
"""
Decrypts an encrypted MQA file given the file, key and nonce
"""
# Initialize counter and file decryptor
counter = Counter.new(64, prefix=nonce, initial_value=0)
decryptor = AES.new(key, AES.MODE_CTR, counter=counter)
# Open and decrypt
with open(path_file_encrypted, "rb") as f_src:
audio_decrypted = decryptor.decrypt(f_src.read())
# Replace with decrypted file
with open(path_file_destination, "wb") as f_dst:
f_dst.write(audio_decrypted)
+6
View File
@@ -0,0 +1,6 @@
class LoginError(Exception):
pass
class MediaUnknown(Exception):
pass
+177
View File
@@ -0,0 +1,177 @@
import os
import pathlib
import platform
import re
from collections.abc import Callable
from pathlib import Path
from pathvalidate import sanitize_filename
from tidalapi import Album, Playlist, Track, UserPlaylist, Video, Mix
def path_base():
if "XDG_CONFIG_HOME" in os.environ:
return os.environ["XDG_CONFIG_HOME"]
elif "HOME" in os.environ:
return os.environ["HOME"]
elif "HOMEDRIVE" in os.environ and "HOMEPATH" in os.environ:
return os.path.join(os.environ["HOMEDRIVE"], os.environ["HOMEPATH"])
else:
return os.path.abspath("./")
def path_file_log():
return os.path.join(path_base(), ".tidal-dl-ng.log")
def path_file_token():
return os.path.join(path_base(), ".tidal-dl-ng_token.json")
def path_file_settings():
return os.path.join(path_base(), ".tidal-dl-ng_settings.json")
def format_path_media(fmt_template: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix) -> str:
result = fmt_template
# Search track format template for placeholder.
regex = r"\{(.+?)\}"
matches = re.finditer(regex, fmt_template, re.MULTILINE)
fn_format = get_fn_format(media)
for _matchNum, match in enumerate(matches, start=1):
template_str = match.group()
result_fmt = fn_format(match.group(1), media)
if result_fmt:
value = sanitize_filename(result_fmt)
result = result.replace(template_str, value)
return result
def format_str_track(name: str, media: Track) -> str | bool:
result: str | bool = False
if name == "track_num":
result = str(media.track_num).rjust(2, "0")
elif name == "artist_name":
result = ", ".join(artist.name for artist in media.artists)
elif name == "track_title":
result = media.name
return result
def format_str_album(name: str, media: Album) -> str | bool:
result: str | bool = False
if name == "album_title":
result = media.name
elif name == "artist_name":
result = media.artist.name
return result
def format_str_playlist(name: str, media: Playlist) -> str | bool:
result: str | bool = False
if name == "playlist_name":
result = media.name
return result
def format_str_mix(name: str, media: Mix) -> str | bool:
result: str | bool = False
if name == "mix_name":
result = media.title
return result
def format_str_video(name: str, media: Video) -> str | bool:
result: str | bool = False
if name == "artist_name":
result = ", ".join(artist.name for artist in media.artists)
elif name == "track_title":
result = media.name
return result
def get_fn_format(media: Track | Album | Playlist | UserPlaylist | Video | Mix) -> Callable:
result = None
if isinstance(media, Track):
result = format_str_track
elif isinstance(media, Album):
result = format_str_album
elif isinstance(media, (Playlist, UserPlaylist)):
result = format_str_playlist
elif isinstance(media, Mix):
result = format_str_mix
elif isinstance(media, Video):
result = format_str_video
return result
def length_max_name_file() -> int:
system = platform.system()
result: int = 255
try:
if system in ['Darwin', 'Linux']:
result: int = os.pathconf('/', 'PC_NAME_MAX')
except Exception as e:
pass
return result
def length_max_name_path() -> int:
system = platform.system()
result: int = 255
try:
if system in ['Darwin', 'Linux']:
result: int = os.pathconf('/', 'PC_PATH_MAX')
except Exception as e:
pass
return result
def path_validate(path_file: str, adapt: bool = False) -> (bool, str):
result: bool = False
length_max_path: int = length_max_name_path()
length_max_file: int = length_max_name_file()
path, file = os.path.split(path_file)
filename, extension = os.path.splitext(file)
if len(path) >= length_max_path:
result = False
if adapt:
path = Path.home()
if len(file) >= length_max_file:
result = False
if adapt:
file = f'{filename[:length_max_file - len(extension + "_")]}_{extension}'
path_file = os.path.join(path, file)
return result, path_file
def check_file_exists(path_file: str):
result = pathlib.Path(path_file).is_file()
return result
+15
View File
@@ -0,0 +1,15 @@
def get_tidal_media_id(url_or_id_media: str) -> str:
id_dirty = url_or_id_media.rsplit("/", 1)[-1]
id = id_dirty.rsplit("?", 1)[0]
return id
def get_tidal_media_type(url_media: str) -> str | bool:
result = False
url_split = url_media.split("/")[-2]
if len(url_split) > 1:
result = url_media.split("/")[-2]
return result
+23
View File
@@ -0,0 +1,23 @@
from collections.abc import Callable
class WrapperLogger:
fn_print: Callable = None
def __init__(self, fn_print: Callable):
self.fn_print = fn_print
def debug(self, value):
self.fn_print(value)
def warning(self, value):
self.fn_print(value)
def info(self, value):
self.fn_print(value)
def error(self, value):
self.fn_print(value)
def critical(self, value):
self.fn_print(value)
+65
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)
+163
View File
@@ -0,0 +1,163 @@
import pathlib
import mutagen
import requests
from mutagen import flac, mp4
from mutagen.id3 import APIC, TALB, TCOM, TCOP, TDRC, TIT2, TOPE, TPE1, TRCK, TSRC, USLT
class Metadata:
path_file: str = None
type_audio: str = None
title: str = None
album: str = None
albumartist: str = None
artists: [str] = None
copy_right: str = None
tracknumber: int = None
discnumber: int = None
totaldisc: int = None
totaltrack: int = None
date: str = None
composer: [str] = None
isrc: str = None
lyrics: str = None
path_cover: str = None
url_cover: str = None
m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC = None
# TODO: What about videos?
def __init__(
self,
path_file: str,
type_audio: str = "",
album: str = "",
title: str = "",
artists: [str] = [""],
copy_right: str = "",
tracknumber: int = 0,
discnumber: int = 0,
totaltrack: int = 0,
totaldisc: int = 0,
composer: [str] = [""],
isrc: str = "",
albumartist: str = "",
date: str = "",
lyrics: str = "",
path_cover: str = "",
url_cover: str = "",
):
self.path_file = path_file
if type_audio:
self.type_audio = type_audio
else:
self.type_audio = pathlib.Path(self.path_file).suffix[1:]
self.title = title
self.album = album
self.albumartist = albumartist
self.artists = artists
self.copy_right = copy_right
self.tracknumber = tracknumber
self.discnumber = discnumber
self.totaldisc = totaldisc
self.totaltrack = totaltrack
self.date = date
self.composer = composer
self.isrc = isrc
self.lyrics = lyrics
self.path_cover = path_cover
self.url_cover = url_cover
self.m: mutagen.MP4.MP4 | mutagen.m4a.M4A | mutagen.flac.FLAC | mutagen.mp3.MP3 = mutagen.File(self.path_file)
def _cover(self) -> bool:
result = False
data_cover = None
if self.url_cover:
try:
data_cover = requests.get(self.url_cover).content
except:
data_cover = ""
elif self.path_cover:
with open(self.path_cover, "rb") as f:
data_cover = f.read()
if data_cover:
if self.type_audio == "flac":
flac_cover = flac.Picture()
flac_cover.data = data_cover
flac_cover.mime = "image/jpeg"
self.m.clear_pictures()
self.m.add_picture(flac_cover)
elif self.type_audio == "mp3":
self.m.tags.add(APIC(encoding=3, data=data_cover))
elif self.type_audio in ["mp4", "m4a"]:
cover_mp4 = mp4.MP4Cover(data_cover)
self.m.tags["covr"] = [cover_mp4]
result = True
return result
def save(self):
if self.type_audio == "flac":
self.set_flac()
elif self.type_audio in ["mp3", "ts"]:
self.set_mp3()
elif self.type_audio in ["mp4", "m4a"]:
self.set_mp4()
self._cover()
self.m.save()
return True
def set_flac(self):
if not self.m.tags:
self.m.add_tags()
self.m.tags["title"] = self.title
self.m.tags["album"] = self.album
self.m.tags["albumartist"] = self.albumartist
self.m.tags["artist"] = ", ".join(self.artists)
self.m.tags["copyright"] = self.copy_right
self.m.tags["tracknumber"] = str(self.tracknumber)
self.m.tags["tracktotal"] = str(self.totaltrack)
self.m.tags["discnumber"] = str(self.discnumber)
self.m.tags["disctotal"] = str(self.totaldisc)
self.m.tags["date"] = self.date
self.m.tags["composer"] = ", ".join(self.composer)
self.m.tags["isrc"] = self.isrc
self.m.tags["lyrics"] = self.lyrics
def set_mp3(self):
if not self.m.tags:
self.m.add_tags()
self.m.tags.add(TIT2(encoding=3, text=self.title))
self.m.tags.add(TALB(encoding=3, text=self.album))
self.m.tags.add(TOPE(encoding=3, text=self.albumartist))
self.m.tags.add(TPE1(encoding=3, text=", ".join(self.artists)))
self.m.tags.add(TCOP(encoding=3, text=self.copy_right))
self.m.tags.add(TRCK(encoding=3, text=str(self.tracknumber)))
self.m.tags.add(TRCK(encoding=3, text=self.discnumber))
self.m.tags.add(TDRC(encoding=3, text=self.date))
self.m.tags.add(TCOM(encoding=3, text=", ".join(self.composer)))
self.m.tags.add(TSRC(encoding=3, text=self.isrc))
self.m.tags.add(USLT(encoding=3, lang="eng", desc="desc", text=self.lyrics))
def set_mp4(self):
self.m.tags["\xa9nam"] = self.title
self.m.tags["\xa9alb"] = self.album
self.m.tags["aART"] = self.albumartist
self.m.tags["\xa9ART"] = ", ".join(self.artists)
self.m.tags["cprt"] = self.copy_right
self.m.tags["trkn"] = [[self.tracknumber, self.totaltrack]]
self.m.tags["disk"] = [[self.discnumber, self.totaldisc]]
# self.m.tags['\xa9gen'] = self.genre
self.m.tags["\xa9day"] = self.date
self.m.tags["\xa9wrt"] = ", ".join(self.composer)
self.m.tags["\xa9lyr"] = self.lyrics
View File
+71
View File
@@ -0,0 +1,71 @@
from dataclasses import dataclass
from tidal_dl_ng.constants import QualityVideo
from dataclasses_json import dataclass_json
from tidalapi import Quality
@dataclass_json
@dataclass
class Settings:
skip_existing: bool = False
album_cover_save: bool = True
lyrics_save: bool = False
# TODO: Implement API KEY selection.
api_key_index: bool = 0
album_info_save: bool = False
video_download: bool = True
multi_thread: bool = False
download_delay: bool = True
download_base_path: str = "./download/"
quality_audio: Quality = Quality.low_320k
quality_video: QualityVideo = QualityVideo.P480
use_playlist_folder: bool = True
format_album: str = "Albums/{artist_name} - {title}/{track_num}. {artist_name} - {track_title}"
format_playlist: str = "Playlists/{playlist_name}/{artist_name} - {track_title}"
format_mix: str = "Mix/{mix_name}/{artist_name} - {track_title}"
format_track: str = "Tracks/{artist_name} - {track_title}"
format_video: str = "Videos/{artist_name} - {track_title}"
video_convert_mp4: bool = True
@dataclass_json
@dataclass
class HelpSettings:
skip_existing: str = "Do not download, if file already exists."
album_cover_save: str = "Safe cover to album folder."
lyrics_save: str = "Safe lyrics to audio file."
api_key_index: str = "Set the device API KEY."
album_info_save: str = "Save album info to track?"
video_download: str = "Allow download of videos."
multi_thread: str = "Download several tracks in parallel."
download_delay: str = "Activate randomized download delay to mimic human behaviour."
download_base_path: str = "Where to store the downloaded media."
quality_audio: str = (
'Desired audio download quality: "LOW" (96kbps), "HIGH" (320kbps), '
'"LOSSLESS" (16 Bit, 44,1 kHz), '
'"HI_RES" (MQA 24 Bit, 96 kHz), "HI_RES_LOSSLESS" (up to 24 Bit, 192 kHz)'
)
quality_video: str = 'Desired video download quality: "360", "480", "720", "1080"'
use_playlist_folder: str = (
"Should items of playlist be downloaded to a separate playlist folder or to the tracks folder?"
)
# TODO: Describe possible variables.
format_album: str = "Where to download albums and how to name the items."
format_playlist: str = "Where to download playlists and how to name the items."
format_mix: str = "Where to download mixes and how to name the items."
format_track: str = "Where to download tracks and how to name the items."
format_video: str = "Where to download videos and how to name the items."
video_convert_mp4: str = (
"Videos are downloaded as MPEG Transport Stream (TS) files. With this option each video "
"will be converted to MP4. FFMPEG must be installed and added to your 'PATH' variable."
)
@dataclass_json
@dataclass
class Token:
token_type: str | None = None
access_token: str | None = None
refresh_token: str | None = None
expiry_time: float = 0.0
+32
View File
@@ -0,0 +1,32 @@
from dataclasses import dataclass
try:
from PySide6 import QtCore
@dataclass
class ProgressBars:
item: QtCore.Signal
item_name: QtCore.Signal
list_item: QtCore.Signal
except ModuleNotFoundError:
class ProgressBars:
pass
# TODO: Remove if not used?
@dataclass
class UserList:
name: str
count: str
obj: object
@dataclass
class ResultSearch:
position: int
artist: str
title: str
album: str
duration_sec: int
obj: object
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+456
View File
@@ -0,0 +1,456 @@
################################################################################
## Form generated from reading UI file 'main.ui'
##
## Created by: Qt User Interface Compiler version 6.6.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import QCoreApplication, QLocale, QMetaObject, QRect, QSize, Qt
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QHBoxLayout,
QLabel,
QLayout,
QLineEdit,
QMenu,
QMenuBar,
QPlainTextEdit,
QPushButton,
QSizePolicy,
QStatusBar,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
class Ui_MainWindow:
def setupUi(self, MainWindow):
if not MainWindow.objectName():
MainWindow.setObjectName("MainWindow")
MainWindow.resize(900, 700)
self.a_options = QAction(MainWindow)
self.a_options.setObjectName("a_options")
self.a_options.setEnabled(False)
self.a_options.setText("Options")
self.a_options.setIconText("Options")
# if QT_CONFIG(tooltip)
self.a_options.setToolTip("Options")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.a_options.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.a_options.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
self.w_central = QWidget(MainWindow)
self.w_central.setObjectName("w_central")
self.w_central.setEnabled(True)
sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
sizePolicy.setHorizontalStretch(100)
sizePolicy.setVerticalStretch(100)
sizePolicy.setHeightForWidth(self.w_central.sizePolicy().hasHeightForWidth())
self.w_central.setSizePolicy(sizePolicy)
# if QT_CONFIG(tooltip)
self.w_central.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.w_central.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.w_central.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.w_central.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.w_central.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.horizontalLayout = QHBoxLayout(self.w_central)
self.horizontalLayout.setObjectName("horizontalLayout")
self.lh_main = QHBoxLayout()
self.lh_main.setObjectName("lh_main")
self.lh_main.setSizeConstraint(QLayout.SetNoConstraint)
self.tr_lists_user = QTreeWidget(self.w_central)
__qtreewidgetitem = QTreeWidgetItem()
__qtreewidgetitem.setText(1, "Info")
__qtreewidgetitem.setText(0, "Playlist")
self.tr_lists_user.setHeaderItem(__qtreewidgetitem)
__qtreewidgetitem1 = QTreeWidgetItem(self.tr_lists_user)
__qtreewidgetitem1.setFlags(Qt.ItemIsEnabled)
__qtreewidgetitem2 = QTreeWidgetItem(self.tr_lists_user)
__qtreewidgetitem2.setFlags(Qt.ItemIsEnabled)
__qtreewidgetitem3 = QTreeWidgetItem(self.tr_lists_user)
__qtreewidgetitem3.setFlags(Qt.ItemIsEnabled)
self.tr_lists_user.setObjectName("tr_lists_user")
# if QT_CONFIG(tooltip)
self.tr_lists_user.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.tr_lists_user.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.tr_lists_user.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.tr_lists_user.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.tr_lists_user.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.tr_lists_user.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.tr_lists_user.setProperty("showDropIndicator", False)
self.tr_lists_user.setIndentation(10)
self.tr_lists_user.setUniformRowHeights(True)
self.tr_lists_user.setSortingEnabled(True)
self.tr_lists_user.header().setCascadingSectionResizes(True)
self.tr_lists_user.header().setHighlightSections(True)
self.tr_lists_user.header().setProperty("showSortIndicator", True)
self.lh_main.addWidget(self.tr_lists_user)
self.lv_search_result = QVBoxLayout()
# ifndef Q_OS_MAC
self.lv_search_result.setSpacing(-1)
# endif
self.lv_search_result.setObjectName("lv_search_result")
self.lh_search = QHBoxLayout()
self.lh_search.setObjectName("lh_search")
self.l_search = QLineEdit(self.w_central)
self.l_search.setObjectName("l_search")
self.l_search.setAcceptDrops(False)
# if QT_CONFIG(tooltip)
self.l_search.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.l_search.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.l_search.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.l_search.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.l_search.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.l_search.setLocale(QLocale(QLocale.English, QLocale.UnitedStates))
self.l_search.setText("")
self.l_search.setPlaceholderText("Type and press ENTER to search...")
self.l_search.setClearButtonEnabled(True)
self.lh_search.addWidget(self.l_search)
self.cb_search_type = QComboBox(self.w_central)
self.cb_search_type.setObjectName("cb_search_type")
# if QT_CONFIG(tooltip)
self.cb_search_type.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.cb_search_type.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.cb_search_type.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.cb_search_type.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.cb_search_type.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.cb_search_type.setCurrentText("")
self.cb_search_type.setPlaceholderText("")
self.lh_search.addWidget(self.cb_search_type)
self.b_search = QPushButton(self.w_central)
self.b_search.setObjectName("b_search")
# if QT_CONFIG(statustip)
self.b_search.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.b_search.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.b_search.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.b_search.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.b_search.setText("Search")
# if QT_CONFIG(shortcut)
self.b_search.setShortcut("")
# endif // QT_CONFIG(shortcut)
self.lh_search.addWidget(self.b_search)
self.lv_search_result.addLayout(self.lh_search)
self.tr_results = QTreeWidget(self.w_central)
self.tr_results.setObjectName("tr_results")
self.tr_results.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.tr_results.setProperty("showDropIndicator", False)
self.tr_results.setDragDropOverwriteMode(False)
self.tr_results.setAlternatingRowColors(False)
self.tr_results.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.tr_results.setIndentation(10)
self.tr_results.setSortingEnabled(True)
self.tr_results.header().setProperty("showSortIndicator", True)
self.tr_results.header().setStretchLastSection(False)
self.lv_search_result.addWidget(self.tr_results)
self.lh_download = QHBoxLayout()
self.lh_download.setObjectName("lh_download")
self.l_quality_audio = QLabel(self.w_central)
self.l_quality_audio.setObjectName("l_quality_audio")
# if QT_CONFIG(tooltip)
self.l_quality_audio.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.l_quality_audio.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.l_quality_audio.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.l_quality_audio.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.l_quality_audio.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.l_quality_audio.setText("Audio")
self.l_quality_audio.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter)
self.lh_download.addWidget(self.l_quality_audio)
self.cb_quality_audio = QComboBox(self.w_central)
self.cb_quality_audio.setObjectName("cb_quality_audio")
# if QT_CONFIG(tooltip)
self.cb_quality_audio.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.cb_quality_audio.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.cb_quality_audio.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.cb_quality_audio.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.cb_quality_audio.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.cb_quality_audio.setCurrentText("")
self.cb_quality_audio.setPlaceholderText("")
self.cb_quality_audio.setFrame(True)
self.lh_download.addWidget(self.cb_quality_audio)
self.l_quality_video = QLabel(self.w_central)
self.l_quality_video.setObjectName("l_quality_video")
# if QT_CONFIG(tooltip)
self.l_quality_video.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.l_quality_video.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.l_quality_video.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.l_quality_video.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.l_quality_video.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.l_quality_video.setText("Video")
self.l_quality_video.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter)
self.lh_download.addWidget(self.l_quality_video)
self.cb_quality_video = QComboBox(self.w_central)
self.cb_quality_video.setObjectName("cb_quality_video")
# if QT_CONFIG(tooltip)
self.cb_quality_video.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.cb_quality_video.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.cb_quality_video.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.cb_quality_video.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.cb_quality_video.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.cb_quality_video.setCurrentText("")
self.cb_quality_video.setPlaceholderText("")
self.lh_download.addWidget(self.cb_quality_video)
self.b_download = QPushButton(self.w_central)
self.b_download.setObjectName("b_download")
# if QT_CONFIG(tooltip)
self.b_download.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.b_download.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.b_download.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.b_download.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.b_download.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.b_download.setText("Download")
# if QT_CONFIG(shortcut)
self.b_download.setShortcut("")
# endif // QT_CONFIG(shortcut)
self.lh_download.addWidget(self.b_download)
self.lh_download.setStretch(0, 5)
self.lh_download.setStretch(2, 5)
self.lh_download.setStretch(4, 15)
self.lv_search_result.addLayout(self.lh_download)
self.te_debug = QPlainTextEdit(self.w_central)
self.te_debug.setObjectName("te_debug")
self.te_debug.setEnabled(True)
sizePolicy1 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(0)
sizePolicy1.setHeightForWidth(self.te_debug.sizePolicy().hasHeightForWidth())
self.te_debug.setSizePolicy(sizePolicy1)
self.te_debug.setMaximumSize(QSize(16777215, 16777215))
self.te_debug.setAcceptDrops(False)
# if QT_CONFIG(tooltip)
self.te_debug.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.te_debug.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.te_debug.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.te_debug.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.te_debug.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.te_debug.setUndoRedoEnabled(False)
self.te_debug.setReadOnly(True)
self.lv_search_result.addWidget(self.te_debug)
self.lh_main.addLayout(self.lv_search_result)
self.lh_main.setStretch(0, 40)
self.lh_main.setStretch(1, 60)
self.horizontalLayout.addLayout(self.lh_main)
MainWindow.setCentralWidget(self.w_central)
self.menubar = QMenuBar(MainWindow)
self.menubar.setObjectName("menubar")
self.menubar.setGeometry(QRect(0, 0, 900, 24))
# if QT_CONFIG(tooltip)
self.menubar.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.menubar.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.menubar.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.menubar.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.menubar.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.m_file = QMenu(self.menubar)
self.m_file.setObjectName("m_file")
# if QT_CONFIG(tooltip)
self.m_file.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.m_file.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.m_file.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.m_file.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.m_file.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
MainWindow.setMenuBar(self.menubar)
self.statusbar = QStatusBar(MainWindow)
self.statusbar.setObjectName("statusbar")
# if QT_CONFIG(tooltip)
self.statusbar.setToolTip("")
# endif // QT_CONFIG(tooltip)
# if QT_CONFIG(statustip)
self.statusbar.setStatusTip("")
# endif // QT_CONFIG(statustip)
# if QT_CONFIG(whatsthis)
self.statusbar.setWhatsThis("")
# endif // QT_CONFIG(whatsthis)
# if QT_CONFIG(accessibility)
self.statusbar.setAccessibleName("")
# endif // QT_CONFIG(accessibility)
# if QT_CONFIG(accessibility)
self.statusbar.setAccessibleDescription("")
# endif // QT_CONFIG(accessibility)
self.statusbar.setLayoutDirection(Qt.LeftToRight)
MainWindow.setStatusBar(self.statusbar)
self.menubar.addAction(self.m_file.menuAction())
self.m_file.addAction(self.a_options)
self.retranslateUi(MainWindow)
QMetaObject.connectSlotsByName(MainWindow)
# setupUi
def retranslateUi(self, MainWindow):
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", "MainWindow", None))
___qtreewidgetitem = self.tr_lists_user.headerItem()
___qtreewidgetitem.setText(2, QCoreApplication.translate("MainWindow", "obj", None))
__sortingEnabled = self.tr_lists_user.isSortingEnabled()
self.tr_lists_user.setSortingEnabled(False)
___qtreewidgetitem1 = self.tr_lists_user.topLevelItem(0)
___qtreewidgetitem1.setText(0, QCoreApplication.translate("MainWindow", "Playlists", None))
___qtreewidgetitem2 = self.tr_lists_user.topLevelItem(1)
___qtreewidgetitem2.setText(0, QCoreApplication.translate("MainWindow", "Mixes", None))
___qtreewidgetitem3 = self.tr_lists_user.topLevelItem(2)
___qtreewidgetitem3.setText(0, QCoreApplication.translate("MainWindow", "Favorites", None))
self.tr_lists_user.setSortingEnabled(__sortingEnabled)
___qtreewidgetitem4 = self.tr_results.headerItem()
___qtreewidgetitem4.setText(5, QCoreApplication.translate("MainWindow", "obj", None))
___qtreewidgetitem4.setText(4, QCoreApplication.translate("MainWindow", "Duration", None))
___qtreewidgetitem4.setText(3, QCoreApplication.translate("MainWindow", "Album", None))
___qtreewidgetitem4.setText(2, QCoreApplication.translate("MainWindow", "Title", None))
___qtreewidgetitem4.setText(1, QCoreApplication.translate("MainWindow", "Artist", None))
___qtreewidgetitem4.setText(0, QCoreApplication.translate("MainWindow", "#", None))
self.te_debug.setPlaceholderText(QCoreApplication.translate("MainWindow", "Logs...", None))
self.m_file.setTitle(QCoreApplication.translate("MainWindow", "File", None))
# retranslateUi
+590
View File
@@ -0,0 +1,590 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>900</width>
<height>700</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="w_central">
<property name="enabled">
<bool>true</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>100</horstretch>
<verstretch>100</verstretch>
</sizepolicy>
</property>
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QHBoxLayout" name="lh_main" stretch="40,60">
<property name="sizeConstraint">
<enum>QLayout::SetNoConstraint</enum>
</property>
<item>
<widget class="QTreeWidget" name="tr_lists_user">
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="indentation">
<number>10</number>
</property>
<property name="uniformRowHeights">
<bool>true</bool>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<attribute name="headerCascadingSectionResizes">
<bool>true</bool>
</attribute>
<attribute name="headerHighlightSections">
<bool>true</bool>
</attribute>
<attribute name="headerShowSortIndicator" stdset="0">
<bool>true</bool>
</attribute>
<column>
<property name="text">
<string notr="true">Playlist</string>
</property>
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
</column>
<column>
<property name="text">
<string notr="true">Info</string>
</property>
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
</column>
<column>
<property name="text">
<string>obj</string>
</property>
</column>
<item>
<property name="text">
<string>Playlists</string>
</property>
<property name="text">
<string/>
</property>
<property name="text">
<string/>
</property>
<property name="flags">
<set>ItemIsEnabled</set>
</property>
</item>
<item>
<property name="text">
<string>Mixes</string>
</property>
<property name="text">
<string/>
</property>
<property name="text">
<string/>
</property>
<property name="flags">
<set>ItemIsEnabled</set>
</property>
</item>
<item>
<property name="text">
<string>Favorites</string>
</property>
<property name="text">
<string/>
</property>
<property name="text">
<string/>
</property>
<property name="flags">
<set>ItemIsEnabled</set>
</property>
</item>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="lv_search_result" stretch="0,0,0,0">
<property name="spacing">
<number>-1</number>
</property>
<item>
<layout class="QHBoxLayout" name="lh_search">
<item>
<widget class="QLineEdit" name="l_search">
<property name="acceptDrops">
<bool>false</bool>
</property>
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="locale">
<locale language="English" country="UnitedStates"/>
</property>
<property name="text">
<string notr="true"/>
</property>
<property name="placeholderText">
<string notr="true">Type and press ENTER to search...</string>
</property>
<property name="clearButtonEnabled">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="cb_search_type">
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="currentText">
<string notr="true"/>
</property>
<property name="placeholderText">
<string notr="true"/>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="b_search">
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="text">
<string notr="true">Search</string>
</property>
<property name="shortcut">
<string notr="true"/>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTreeWidget" name="tr_results">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="alternatingRowColors">
<bool>false</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
<property name="indentation">
<number>10</number>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<attribute name="headerShowSortIndicator" stdset="0">
<bool>true</bool>
</attribute>
<attribute name="headerStretchLastSection">
<bool>false</bool>
</attribute>
<column>
<property name="text">
<string>#</string>
</property>
</column>
<column>
<property name="text">
<string>Artist</string>
</property>
</column>
<column>
<property name="text">
<string>Title</string>
</property>
</column>
<column>
<property name="text">
<string>Album</string>
</property>
</column>
<column>
<property name="text">
<string>Duration</string>
</property>
</column>
<column>
<property name="text">
<string>obj</string>
</property>
</column>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="lh_download" stretch="5,0,5,0,15">
<item>
<widget class="QLabel" name="l_quality_audio">
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="text">
<string notr="true">Audio</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="cb_quality_audio">
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="currentText">
<string notr="true"/>
</property>
<property name="placeholderText">
<string notr="true"/>
</property>
<property name="frame">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_quality_video">
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="text">
<string notr="true">Video</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="cb_quality_video">
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="currentText">
<string notr="true"/>
</property>
<property name="placeholderText">
<string notr="true"/>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="b_download">
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="text">
<string notr="true">Download</string>
</property>
<property name="shortcut">
<string notr="true"/>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QPlainTextEdit" name="te_debug">
<property name="enabled">
<bool>true</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Maximum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="acceptDrops">
<bool>false</bool>
</property>
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="undoRedoEnabled">
<bool>false</bool>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
<property name="placeholderText">
<string>Logs...</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>900</width>
<height>24</height>
</rect>
</property>
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<widget class="QMenu" name="m_file">
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="title">
<string>File</string>
</property>
<addaction name="a_options"/>
</widget>
<addaction name="m_file"/>
</widget>
<widget class="QStatusBar" name="statusbar">
<property name="toolTip">
<string notr="true"/>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
<property name="accessibleName">
<string notr="true"/>
</property>
<property name="accessibleDescription">
<string notr="true"/>
</property>
<property name="layoutDirection">
<enum>Qt::LeftToRight</enum>
</property>
</widget>
<action name="a_options">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string notr="true">Options</string>
</property>
<property name="iconText">
<string notr="true">Options</string>
</property>
<property name="toolTip">
<string notr="true">Options</string>
</property>
<property name="statusTip">
<string notr="true"/>
</property>
<property name="whatsThis">
<string notr="true"/>
</property>
</action>
</widget>
<resources/>
<connections/>
</ui>
+221
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
+31
View File
@@ -0,0 +1,31 @@
from PySide6 import QtCore
# Taken from https://www.pythonguis.com/tutorials/multithreading-pyside6-applications-qthreadpool/
class Worker(QtCore.QRunnable):
"""
Worker thread
Inherits from QRunnable to handler worker thread setup, signals and wrap-up.
:param callback: The function callback to run on this worker thread. Supplied args and
kwargs will be passed through to the runner.
:type callback: function
:param args: Arguments to pass to the callback function
:param kwargs: Keywords to pass to the callback function
"""
def __init__(self, fn, *args, **kwargs):
super().__init__()
# Store constructor arguments (re-used for processing)
self.fn = fn
self.args = args
self.kwargs = kwargs
@QtCore.Slot() # QtCore.Slot
def run(self):
"""
Initialise the runner function with passed args, kwargs.
"""
self.fn(*self.args, **self.kwargs)