From 566d91db88d015dc66593fdf53418b90d876075d Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Tue, 24 Dec 2024 14:11:41 +0100 Subject: [PATCH 01/83] =?UTF-8?q?=F0=9F=9A=80=20add=20new=20cli?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.py | 2 +- tiddl/cli/__init__.py | 23 +++++++++++++ tiddl/cli/auth.py | 59 ++++++++++++++++++++++++++++++++++ tiddl/cli/ctx.py | 33 +++++++++++++++++++ tiddl/cli/download/__init__.py | 44 +++++++++++++++++++++++++ tiddl/cli/download/fav.py | 18 +++++++++++ tiddl/cli/download/file.py | 18 +++++++++++ tiddl/cli/download/search.py | 17 ++++++++++ tiddl/cli/download/url.py | 36 +++++++++++++++++++++ 9 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 tiddl/cli/__init__.py create mode 100644 tiddl/cli/auth.py create mode 100644 tiddl/cli/ctx.py create mode 100644 tiddl/cli/download/__init__.py create mode 100644 tiddl/cli/download/fav.py create mode 100644 tiddl/cli/download/file.py create mode 100644 tiddl/cli/download/search.py create mode 100644 tiddl/cli/download/url.py diff --git a/setup.py b/setup.py index 7d43502..1a57f0b 100644 --- a/setup.py +++ b/setup.py @@ -10,6 +10,6 @@ setup( author="oskvr37", packages=find_packages(), entry_points={ - "console_scripts": ["tiddl=tiddl:main"], + "console_scripts": ["tiddl=tiddl:main", "tiddl2=tiddl.cli:cli"], }, ) diff --git a/tiddl/cli/__init__.py b/tiddl/cli/__init__.py new file mode 100644 index 0000000..a57a3ee --- /dev/null +++ b/tiddl/cli/__init__.py @@ -0,0 +1,23 @@ +import click + +from .ctx import ContextObj, passContext, Context +from .auth import AuthGroup +from .download import UrlGroup, FavGroup, SearchGroup, FileGroup + + +@click.group() +@passContext +@click.option("--verbose", is_flag=True, help="Show debug logs") +def cli(ctx: Context, verbose: bool): + """TIDDL - Download Tidal tracks ✨""" + ctx.obj = ContextObj(verbose) + + +cli.add_command(AuthGroup) +cli.add_command(UrlGroup) +cli.add_command(FavGroup) +cli.add_command(SearchGroup) +cli.add_command(FileGroup) + +if __name__ == "__main__": + cli() diff --git a/tiddl/cli/auth.py b/tiddl/cli/auth.py new file mode 100644 index 0000000..6e5c4aa --- /dev/null +++ b/tiddl/cli/auth.py @@ -0,0 +1,59 @@ +import click + +from click import style +from time import sleep + +from tiddl.auth import getDeviceAuth, getToken, refreshToken +from .ctx import passContext, Context + + +@click.group("auth") +def AuthGroup(): + """Manage Tidal token""" + + +@AuthGroup.command("login") +@passContext +def login(ctx: Context): + """Add token to the config""" + + if ctx.obj.config._config.get("token"): + click.echo(style("Already logged in", fg="green")) + return + + auth = getDeviceAuth() + + uri = f'https://{auth["verificationUriComplete"]}' + click.launch(uri) + click.echo(f"Go to {style(uri, fg='cyan')} and complete authentication!") + + # TODO: show time left for auth with `expiresIn` + + while True: + sleep(auth["interval"]) + + token = getToken(auth["deviceCode"]) + error: str | None = token.get("error") + + if error == "authorization_pending": + continue + + if error == "expired_token": + click.echo(f"Time for authentication {style('has expired', fg='red')}.") + break + + assert error == None, token + + # TODO: save token to the config + click.echo(token) + + click.echo(style("Authenticated!", fg="green")) + + break + + +@AuthGroup.command("logout") +@passContext +def logout(ctx: Context): + """* Not implemented *""" + # https://github.com/Fokka-Engineering/TIDAL/wiki/log-out diff --git a/tiddl/cli/ctx.py b/tiddl/cli/ctx.py new file mode 100644 index 0000000..fbd3bfb --- /dev/null +++ b/tiddl/cli/ctx.py @@ -0,0 +1,33 @@ +import functools +import click + +from typing import Callable, TypeVar, cast +from tiddl import Config +from tiddl.types import Track + + +class ContextObj: + def __init__(self, verbose: bool) -> None: + self.config = Config() + self.tracks: list[Track] = [] + + self.verbose = verbose + + +class Context(click.Context): + obj: ContextObj + + +F = TypeVar("F", bound=Callable[..., None]) + + +def passContext(func: F) -> F: + """Wrapper for @click.pass_context to use custom Context""" + + @click.pass_context + @functools.wraps(func) + def wrapper(ctx: click.Context, *args, **kwargs): + custom_ctx = cast(Context, ctx) + return func(custom_ctx, *args, **kwargs) + + return cast(F, wrapper) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py new file mode 100644 index 0000000..be6b145 --- /dev/null +++ b/tiddl/cli/download/__init__.py @@ -0,0 +1,44 @@ +import click + +from .fav import FavGroup +from .file import FileGroup +from .search import SearchGroup +from .url import UrlGroup + +from ..ctx import Context, passContext + +from tiddl.types import TrackArg, Track + +DEFAULT_QUALITY: TrackArg = "normal" + + +def downloadTrack(track: Track, quality: TrackArg): + # TODO: create download function + # it should download track to user specified directory with specified filename + # then add the track id to the database with file path and quality + pass + + +@click.command("download") +@click.option( + "--quality", default=DEFAULT_QUALITY, type=click.Choice(TrackArg.__args__) +) +@passContext +def DownloadCommand(ctx: Context, quality: TrackArg): + """Download the tracks""" + + tracks = ctx.obj.tracks + + if not tracks: + click.echo("No tracks found.") + return + + for track in tracks: + click.echo(f"Downloading {track['title']}") + downloadTrack(track, quality) + + +UrlGroup.add_command(DownloadCommand) +SearchGroup.add_command(DownloadCommand) +FavGroup.add_command(DownloadCommand) +FileGroup.add_command(DownloadCommand) diff --git a/tiddl/cli/download/fav.py b/tiddl/cli/download/fav.py new file mode 100644 index 0000000..ede83e2 --- /dev/null +++ b/tiddl/cli/download/fav.py @@ -0,0 +1,18 @@ +import click + +from ..ctx import Context, passContext + +from tiddl.types import Track + + +@click.group("fav") +@click.argument("type") +@passContext +def FavGroup(ctx: Context, type: str): + """Get your Tidal favorites""" + + tracks: list[Track] = [] + + # TODO: fetch user favorites + + ctx.obj.tracks.extend(tracks) diff --git a/tiddl/cli/download/file.py b/tiddl/cli/download/file.py new file mode 100644 index 0000000..76945e2 --- /dev/null +++ b/tiddl/cli/download/file.py @@ -0,0 +1,18 @@ +import click + +from ..ctx import Context, passContext +from io import TextIOWrapper +from tiddl.types import Track + + +@click.group("file") +@click.argument("filename", type=click.File(mode="r")) +@passContext +def FileGroup(ctx: Context, filename: TextIOWrapper): + """Parse text or JSON file with urls""" + + tracks: list[Track] = [] + + # TODO: parse the file + + ctx.obj.tracks.extend(tracks) diff --git a/tiddl/cli/download/search.py b/tiddl/cli/download/search.py new file mode 100644 index 0000000..049224e --- /dev/null +++ b/tiddl/cli/download/search.py @@ -0,0 +1,17 @@ +import click + +from ..ctx import Context, passContext +from tiddl.types import Track + + +@click.group("search") +@click.argument("query") +@passContext +def SearchGroup(ctx: Context, query: str): + """Search on Tidal""" + + tracks: list[Track] = [] + + # TODO: search on Tidal + + ctx.obj.tracks.extend(tracks) diff --git a/tiddl/cli/download/url.py b/tiddl/cli/download/url.py new file mode 100644 index 0000000..d89c4f8 --- /dev/null +++ b/tiddl/cli/download/url.py @@ -0,0 +1,36 @@ +import click + +from ..ctx import Context, passContext +from tiddl.types import Track +from urllib import parse as urlparse + + +class URL(click.ParamType): + # TODO: create correct Tidal URL parsing, maybe with regex + name = "url" + + def convert(self, value, param, ctx): + if not isinstance(value, tuple): + value = urlparse.urlparse(value) + if value.scheme not in ("http", "https"): + self.fail( + f"invalid URL scheme ({value.scheme}). Only HTTP URLs are allowed", + param, + ctx, + ) + return value + + +@click.group("url") +@click.argument("url", type=URL()) +@passContext +def UrlGroup(ctx: Context, url: URL, filename): + """Get Tidal URLs""" + + print(url, filename) + + tracks: list[Track] = [] + + # TODO: parse the URL list + + ctx.obj.tracks.extend(tracks) From 215df9bb587cb02f1931d86820c4623214453cab Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Tue, 24 Dec 2024 14:37:37 +0100 Subject: [PATCH 02/83] =?UTF-8?q?=F0=9F=93=9D=20cleanup=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 39 +++------------------------------------ 1 file changed, 3 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 3e3948e..48afc24 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # Tidal Downloader -TIDDL is the Python CLI application that allows downloading Tidal tracks. -Fully typed, only 2 requirements. +TIDDL is Python CLI application that allows downloading Tidal tracks. ![GitHub top language](https://img.shields.io/github/languages/top/oskvr37/tiddl?style=for-the-badge) ![PyPI - Version](https://img.shields.io/pypi/v/tiddl?style=for-the-badge) @@ -19,41 +18,9 @@ Install package using `pip` pip install tiddl ``` -After installation you can use `tiddl` to set up auth token +# Usage -```bash -$ tiddl -> go to https://link.tidal.com/xxxxx and add device! -authenticated! -token expires in 7 days -``` - -Use `tiddl -h` to show help message - -# CLI - -After authentication - when your token is ready - you can start downloading! - -You can download `tracks` `albums` `playlists` `artists albums` - -- `tiddl -s -q high` sets high quality as default quality -- `tiddl ` downloads with high quality -- `tiddl -q master` downloads with best possible quality -- `tiddl 284165609 -p my_folder -o "{artist} - {title}"` downloads track to `my_folder/{artist} - {title}.flac` -- `tiddl track/284165609 -p my_folder -o "{artist} - {title}" -s` same as above, but saves `my_folder` as default download path and `{artist} - {title}` as default file format - -### Valid input - -- 284165609 (will treat this as track id) -- https://tidal.com/browse/track/284165609 -- track/284165609 -- https://listen.tidal.com/album/284165608/track/284165609 -- https://listen.tidal.com/album/284165608 -- album/284165608 -- https://listen.tidal.com/artist/7695548 -- artist/7695548 -- https://listen.tidal.com/playlist/803be625-97e4-4cbb-88dd-43f0b1c61ed7 -- playlist/803be625-97e4-4cbb-88dd-43f0b1c61ed7 +** In progress ** ### File formatting From 2c5d3f28474f703451ff8d66e420285f7bd1c539 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Tue, 24 Dec 2024 14:38:53 +0100 Subject: [PATCH 03/83] =?UTF-8?q?=F0=9F=9A=80=20bump=20to=20`2.0.0`=20and?= =?UTF-8?q?=20update=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 1a57f0b..ac71a2d 100644 --- a/setup.py +++ b/setup.py @@ -2,14 +2,14 @@ from setuptools import setup, find_packages setup( name="tiddl", - version="1.8.3", - description="TIDDL (Tidal Downloader) is a Python CLI application that allows downloading Tidal tracks.", + version="2.0.0", + description="TIDDL (Tidal Downloader) is CLI application that allows downloading Tidal tracks.", long_description=open("README.md", encoding="utf-8").read(), long_description_content_type="text/markdown", readme="README.md", author="oskvr37", packages=find_packages(), entry_points={ - "console_scripts": ["tiddl=tiddl:main", "tiddl2=tiddl.cli:cli"], + "console_scripts": ["tiddl=tiddl.cli:cli"], }, ) From c3ca17bd832385772eb2f8e9d414146558014e9a Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Thu, 26 Dec 2024 21:03:32 +0100 Subject: [PATCH 04/83] =?UTF-8?q?=F0=9F=92=A1=20`downloadTrack`=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index be6b145..f95cce8 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -14,8 +14,15 @@ DEFAULT_QUALITY: TrackArg = "normal" def downloadTrack(track: Track, quality: TrackArg): # TODO: create download function + # it should download track to user specified directory with specified filename # then add the track id to the database with file path and quality + + # we can cache api responses to avoid requesting the same track multiple times + # then we can use the cached data to download the track + + # we should be able to download multiple tracks at once + pass From 8dab87ebe022339add44567fe2164a25fa8937e4 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sat, 28 Dec 2024 13:31:23 +0100 Subject: [PATCH 05/83] =?UTF-8?q?=E2=9E=95add=20`click`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a9a4c67..ce2e86d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ requests>=2.20.0 mutagen>=1.47.0 -ffmpeg-python>=0.2.0 \ No newline at end of file +ffmpeg-python>=0.2.0 +click>=8.1.7 \ No newline at end of file From ff35de843bbf074f7221d158ff78ae0f047b732a Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 29 Dec 2024 12:38:21 +0100 Subject: [PATCH 06/83] =?UTF-8?q?=E2=9C=A8=20add=20`Config`=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/config.py | 87 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tiddl/cli/config.py diff --git a/tiddl/cli/config.py b/tiddl/cli/config.py new file mode 100644 index 0000000..7be7e8a --- /dev/null +++ b/tiddl/cli/config.py @@ -0,0 +1,87 @@ +import json + +from dataclasses import dataclass, field +from typing import TypedDict +from pathlib import Path + +from tiddl.types import TrackArg + + +CONFIG_PATH = Path.home() / "tiddl.json" +DOWNLOAD_PATH = Path.home() / "Music" / "Tiddl" +DEFAULT_QUALITY: TrackArg = "high" + + +class DownloadConfig(TypedDict, total=False): + quality: TrackArg + path: str + + +class AuthConfig(TypedDict, total=False): + token: str + refresh_token: str + expires: int + + +class ConfigFile(TypedDict, total=False): + download: DownloadConfig + auth: AuthConfig + + +DEFAULT_CONFIG: ConfigFile = { + "download": {"quality": DEFAULT_QUALITY, "path": str(DOWNLOAD_PATH)}, + "auth": {"token": "", "refresh_token": "", "expires": 0}, +} + + +@dataclass +class Config: + """Configuration class for loading and updating CLI configuration file.""" + + config: ConfigFile = field(default_factory=lambda: DEFAULT_CONFIG) + + def __post_init__(self): + """Merge loaded configuration with defaults after initialization.""" + + try: + with open(CONFIG_PATH, "r") as f: + loaded_config: ConfigFile = json.load(f) + + self.config = merge(loaded_config, self.config) + + except (FileNotFoundError, json.JSONDecodeError): + pass + + def update(self, new_config: ConfigFile): + """Update the configuration with the new values and save it to the file.""" + + self.config = merge(new_config, self.config) + + with open(CONFIG_PATH, "w") as f: + json.dump(self.config, f, indent=2) + + +def merge(source, destination): + """ + Recursively merge two dictionaries. + https://stackoverflow.com/a/20666342 + """ + + for key, value in source.items(): + if isinstance(value, dict): + node = destination.setdefault(key, {}) + merge(value, node) + else: + destination[key] = value + + return destination + + +# TODO: implement config commands + +# open the config file with the default editor +# click.launch(str(CONFIG_PATH)) + +# this will be useful to easily edit the config file with +# vim/nano/code $(tiddl config) +# click.echo(str(CONFIG_PATH)) From 063cf845414678199cfcd6f74c90cd1a2e8a42f8 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 29 Dec 2024 12:51:21 +0100 Subject: [PATCH 07/83] =?UTF-8?q?=E2=9C=A8=20add=20Config=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/__init__.py | 4 ++-- tiddl/cli/config/__init__.py | 19 +++++++++++++++++++ tiddl/cli/{ => config}/config.py | 10 ---------- 3 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 tiddl/cli/config/__init__.py rename tiddl/cli/{ => config}/config.py (88%) diff --git a/tiddl/cli/__init__.py b/tiddl/cli/__init__.py index a57a3ee..d1848d0 100644 --- a/tiddl/cli/__init__.py +++ b/tiddl/cli/__init__.py @@ -3,7 +3,7 @@ import click from .ctx import ContextObj, passContext, Context from .auth import AuthGroup from .download import UrlGroup, FavGroup, SearchGroup, FileGroup - +from .config import ConfigCommand @click.group() @passContext @@ -12,7 +12,7 @@ def cli(ctx: Context, verbose: bool): """TIDDL - Download Tidal tracks ✨""" ctx.obj = ContextObj(verbose) - +cli.add_command(ConfigCommand) cli.add_command(AuthGroup) cli.add_command(UrlGroup) cli.add_command(FavGroup) diff --git a/tiddl/cli/config/__init__.py b/tiddl/cli/config/__init__.py new file mode 100644 index 0000000..92a34fa --- /dev/null +++ b/tiddl/cli/config/__init__.py @@ -0,0 +1,19 @@ +import click + +from .config import CONFIG_PATH + + +@click.command("config") +@click.option( + "--open", + "-o", + is_flag=True, + help="Open the configuration file with the default editor", +) +def ConfigCommand(open: bool): + """Print path to the configuration file""" + + click.echo(str(CONFIG_PATH)) + + if open: + click.launch(str(CONFIG_PATH)) diff --git a/tiddl/cli/config.py b/tiddl/cli/config/config.py similarity index 88% rename from tiddl/cli/config.py rename to tiddl/cli/config/config.py index 7be7e8a..6b52cb3 100644 --- a/tiddl/cli/config.py +++ b/tiddl/cli/config/config.py @@ -75,13 +75,3 @@ def merge(source, destination): destination[key] = value return destination - - -# TODO: implement config commands - -# open the config file with the default editor -# click.launch(str(CONFIG_PATH)) - -# this will be useful to easily edit the config file with -# vim/nano/code $(tiddl config) -# click.echo(str(CONFIG_PATH)) From 9f3941cb7ebcedf8dd27f3470691b93657e2caa2 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 29 Dec 2024 14:37:41 +0100 Subject: [PATCH 08/83] =?UTF-8?q?=E2=9C=A8=20use=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/auth.py | 15 +++++++++++---- tiddl/cli/config/__init__.py | 2 +- tiddl/cli/config/config.py | 9 +++++++-- tiddl/cli/ctx.py | 2 +- tiddl/cli/download/__init__.py | 8 +++----- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/tiddl/cli/auth.py b/tiddl/cli/auth.py index 6e5c4aa..d81ec0c 100644 --- a/tiddl/cli/auth.py +++ b/tiddl/cli/auth.py @@ -1,7 +1,7 @@ import click from click import style -from time import sleep +from time import sleep, time from tiddl.auth import getDeviceAuth, getToken, refreshToken from .ctx import passContext, Context @@ -17,7 +17,7 @@ def AuthGroup(): def login(ctx: Context): """Add token to the config""" - if ctx.obj.config._config.get("token"): + if ctx.obj.config.config["auth"].get("token"): click.echo(style("Already logged in", fg="green")) return @@ -44,8 +44,15 @@ def login(ctx: Context): assert error == None, token - # TODO: save token to the config - click.echo(token) + ctx.obj.config.update( + { + "auth": { + "token": token["access_token"], + "refresh_token": token["refresh_token"], + "expires": token["expires_in"] + int(time()), + } + } + ) click.echo(style("Authenticated!", fg="green")) diff --git a/tiddl/cli/config/__init__.py b/tiddl/cli/config/__init__.py index 92a34fa..17d3c77 100644 --- a/tiddl/cli/config/__init__.py +++ b/tiddl/cli/config/__init__.py @@ -1,6 +1,6 @@ import click -from .config import CONFIG_PATH +from .config import CONFIG_PATH, Config @click.command("config") diff --git a/tiddl/cli/config/config.py b/tiddl/cli/config/config.py index 6b52cb3..c5e51b8 100644 --- a/tiddl/cli/config/config.py +++ b/tiddl/cli/config/config.py @@ -23,7 +23,12 @@ class AuthConfig(TypedDict, total=False): expires: int -class ConfigFile(TypedDict, total=False): +class ConfigFile(TypedDict): + download: DownloadConfig + auth: AuthConfig + + +class ConfigUpdate(TypedDict, total=False): download: DownloadConfig auth: AuthConfig @@ -52,7 +57,7 @@ class Config: except (FileNotFoundError, json.JSONDecodeError): pass - def update(self, new_config: ConfigFile): + def update(self, new_config: ConfigUpdate): """Update the configuration with the new values and save it to the file.""" self.config = merge(new_config, self.config) diff --git a/tiddl/cli/ctx.py b/tiddl/cli/ctx.py index fbd3bfb..6b53580 100644 --- a/tiddl/cli/ctx.py +++ b/tiddl/cli/ctx.py @@ -2,7 +2,7 @@ import functools import click from typing import Callable, TypeVar, cast -from tiddl import Config +from .config import Config from tiddl.types import Track diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index f95cce8..709a1a1 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -9,8 +9,6 @@ from ..ctx import Context, passContext from tiddl.types import TrackArg, Track -DEFAULT_QUALITY: TrackArg = "normal" - def downloadTrack(track: Track, quality: TrackArg): # TODO: create download function @@ -27,13 +25,13 @@ def downloadTrack(track: Track, quality: TrackArg): @click.command("download") -@click.option( - "--quality", default=DEFAULT_QUALITY, type=click.Choice(TrackArg.__args__) -) +@click.option("--quality", "-q", type=click.Choice(TrackArg.__args__)) @passContext def DownloadCommand(ctx: Context, quality: TrackArg): """Download the tracks""" + quality = quality or ctx.obj.config.config["download"]["quality"] + tracks = ctx.obj.tracks if not tracks: From d4193bc4b6eb6cef6ba27a1c9be9bd321d85bdf1 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 29 Dec 2024 14:57:03 +0100 Subject: [PATCH 09/83] =?UTF-8?q?=F0=9F=94=A5=20remove=20old=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/__init__.py | 347 ---------------------------------------------- tiddl/config.py | 105 -------------- tiddl/parser.py | 112 --------------- tiddl/tests.py | 200 -------------------------- tiddl/utils.py | 128 ----------------- 5 files changed, 892 deletions(-) delete mode 100644 tiddl/config.py delete mode 100644 tiddl/parser.py delete mode 100644 tiddl/tests.py diff --git a/tiddl/__init__.py b/tiddl/__init__.py index ae70c28..e69de29 100644 --- a/tiddl/__init__.py +++ b/tiddl/__init__.py @@ -1,347 +0,0 @@ -import os -import time -import logging -from random import randint - -from .api import TidalApi, ApiError -from .auth import getDeviceAuth, getToken, refreshToken -from .config import Config -from .download import downloadTrackStream, Cover -from .parser import QUALITY_ARGS, parser -from .types import TRACK_QUALITY, TrackQuality, Track -from .types.api import _PlaylistItem -from .utils import ( - RESOURCE, - parseURL, - formatFilename, - loadingSymbol, - setMetadata, - convertFileExtension, - initLogging, - parseFileInput, -) - -SAVE_COVER = True - - -def main(): - args = parser.parse_args() - initLogging( - silent=args.silent, verbose=args.verbose, colored_logging=not args.no_color - ) - - logger = logging.getLogger("TIDDL") - logger.debug(args) - - config = Config() - - include_singles = args.include_singles - download_path = args.download_path or config["settings"]["download_path"] - track_template = args.file_template or config["settings"]["track_template"] - track_quality = ( - QUALITY_ARGS[args.quality] - if args.quality - else config["settings"]["track_quality"] - ) - file_extension = args.file_extension or config["settings"]["file_extension"] - - if args.save_options: - logger.info("saving new settings...") - settings = config.update( - { - "settings": { - "download_path": download_path, - "track_quality": track_quality, - "track_template": track_template, - "file_extension": file_extension, - } - } - ).get("settings") - - if settings: - print("Current Settings:") - for k, v in settings.items(): - print(f'> {k.upper()} "{v}"') - - logger.info(f"saved settings to {config.config_path}") - - if not config["token"]: - auth = getDeviceAuth() - text = f"> go to https://{auth['verificationUriComplete']} and add device!" - expires_at = time.time() + auth["expiresIn"] - i = 0 - while time.time() < expires_at: - for _ in range(50): - loadingSymbol(i, text) - i += 1 - time.sleep(0.1) - token = getToken(auth["deviceCode"]) - - if token.get("error"): - continue - - print() - - config.update( - { - "token": token["access_token"], - "refresh_token": token["refresh_token"], - "token_expires_at": int(time.time()) + token["expires_in"], - "user": { - "user_id": str(token["user"]["userId"]), - "country_code": token["user"]["countryCode"], - }, - } - ) - logger.info(f"authenticated!") - break - else: - logger.info("time for authentication has expired") - return - - t_now = int(time.time()) - token_expired = t_now > config["token_expires_at"] - - if token_expired: - token = refreshToken(config["refresh_token"]) - config.update( - { - "token": token["access_token"], - "token_expires_at": int(time.time()) + token["expires_in"], - } - ) - logger.info(f"refreshed token!") - - time_to_expire = config["token_expires_at"] - t_now - days, hours = time_to_expire // (24 * 3600), time_to_expire % (24 * 3600) // 3600 - days_text = f" {days} {'day' if days == 1 else 'days'}" if days else "" - hours_text = f" {hours} {'hour' if hours == 1 else 'hours'}" if hours else "" - logger.debug(f"token expires in{days_text}{hours_text}") - - user_inputs: list[str] = args.input - - if args.input_file: - file_inputs = parseFileInput(args.input_file) - user_inputs.extend(file_inputs) - - if len(user_inputs) == 0: - logger.warning("no ID nor URL provided") - return - - api = TidalApi( - config["token"], config["user"]["user_id"], config["user"]["country_code"] - ) - - def downloadTrack( - track: Track, - file_template: str, - skip_existing=True, - sleep=False, - playlist="", - cover_data=b"", - ) -> tuple[str, str]: - if track.get("status") == 404: - raise ValueError(track) - - file_dir, file_name = formatFilename(file_template, track, playlist) - - file_path = f"{download_path}/{file_dir}/{file_name}" - if skip_existing and ( - os.path.isfile(file_path + ".m4a") or os.path.isfile(file_path + ".flac") - ): - logger.info(f"already exists: {file_path}") - return file_dir, file_name - - if sleep: - sleep_time = randint(5, 15) / 10 + 1 - logger.info(f"sleeping for {sleep_time}s") - try: - time.sleep(sleep_time) - except KeyboardInterrupt: - logger.info("stopping...") - exit() - - stream = api.getTrackStream(track["id"], track_quality) - logger.debug({"stream": stream}) - - quality = TRACK_QUALITY[stream["audioQuality"]] - - MASTER_QUALITIES: list[TrackQuality] = ["HI_RES_LOSSLESS", "LOSSLESS"] - if stream["audioQuality"] in MASTER_QUALITIES: - bit_depth, sample_rate = stream.get("bitDepth"), stream.get("sampleRate") - if bit_depth is None or sample_rate is None: - raise ValueError( - "bitDepth and sampleRate must be provided for master qualities" - ) - details = f"{bit_depth} bit {sample_rate/1000:.1f} kHz" - else: - details = quality["details"] - - logger.info(f"{file_name} :: {quality['name']} Quality - {details}") - - track_data, extension = downloadTrackStream( - stream["manifest"], - stream["manifestMimeType"], - ) - - os.makedirs(file_dir, exist_ok=True) - - file_path = f"{download_path}/{file_dir}/{file_name}.{extension}" - - with open(file_path, "wb+") as f: - f.write(track_data) - - if not cover_data: - cover = Cover(track["album"]["cover"]) - cover_data = cover.content - - setMetadata(file_path, extension, track, cover_data) - - if file_extension: - file_path = convertFileExtension( - source_path=file_path, file_extension=file_extension - ) - - logger.info(f"track saved as {file_path}") - - return file_dir, file_name - - def downloadAlbum(album_id: str | int, skip_existing: bool): - album = api.getAlbum(album_id) - logger.info(f"album: {album['title']}") - - # i dont know if limit 100 is suspicious - # but i will leave it here - album_items = api.getAlbumItems(album_id, limit=100) - album_cover = Cover(album["cover"]) - - for item in album_items["items"]: - track = item["item"] - try: - file_dir, file_name = downloadTrack( - track, - file_template=args.file_template - or config["settings"]["album_template"], - skip_existing=skip_existing, - sleep=True, - cover_data=album_cover.content, - ) - - if SAVE_COVER: - album_cover.save(f"{download_path}/{file_dir}") - - except ValueError: - logger.warning(f"track unavailable") - - skip_existing = not args.no_skip - failed_input = [] - - for user_input in user_inputs: - input_type: RESOURCE - input_id: str - - if user_input.isdigit(): - input_type = "track" - input_id = user_input - else: - try: - input_type, input_id = parseURL(user_input) - except ValueError as e: - logger.error(e) - failed_input.append(user_input) - continue - - match input_type: - case "track": - try: - track = api.getTrack(input_id) - except ApiError as e: - logger.warning(f"{e.error['userMessage']} ({e.error['status']})") - continue - - downloadTrack( - track, - file_template=track_template, - skip_existing=skip_existing, - ) - - continue - - case "album": - downloadAlbum(input_id, skip_existing) - continue - - case "artist": - all_albums = [] - artist_albums = api.getArtistAlbums(input_id) - all_albums.extend(artist_albums["items"]) - - if include_singles: - artist_singles = api.getArtistAlbums(input_id, onlyNonAlbum=True) - all_albums.extend(artist_singles["items"]) - - for album in all_albums: - downloadAlbum(album["id"], skip_existing) - - continue - - case "playlist": - # TODO: add option to limit and set offset of playlist ✨ - # or just make a feature in GUI that lets user choose - # which tracks from playlist to download - - playlist = api.getPlaylist(input_id) - logger.info(f"playlist: {playlist['title']} ({playlist['url']})") - - playlist_cover = Cover( - playlist["squareImage"], 1080 - ) # playlists have 1080x1080 size - - items: list[_PlaylistItem] = [] - offset = 0 - - while True: - playlist_items = api.getPlaylistItems(input_id, offset=offset) - items.extend(playlist_items["items"]) - - if ( - playlist_items["limit"] + playlist_items["offset"] - > playlist_items["totalNumberOfItems"] - ): - break - - offset += playlist_items["limit"] - - for index, item in enumerate(items, 1): - track = item["item"] - - track["playlistNumber"] = index - try: - file_dir, file_name = downloadTrack( - track, - file_template=args.file_template - or config["settings"]["playlist_template"], - skip_existing=skip_existing, - sleep=True, - playlist=playlist["title"], - ) - - if SAVE_COVER: - playlist_cover.save(f"{download_path}/{file_dir}") - - except ValueError as e: - logger.warning(f"track unavailable") - - continue - - case _: - logger.warning(f"invalid input: `{input_type}`") - - failed_input.append(input_id) - - if len(failed_input) > 0: - logger.info(f"failed: {failed_input}") - - -if __name__ == "__main__": - main() diff --git a/tiddl/config.py b/tiddl/config.py deleted file mode 100644 index 60c8a6d..0000000 --- a/tiddl/config.py +++ /dev/null @@ -1,105 +0,0 @@ -import json -import logging - -from pathlib import Path -from typing import TypedDict, Any -from .types import TrackQuality - - -class Settings(TypedDict, total=False): - download_path: str - track_quality: TrackQuality - track_template: str - album_template: str - playlist_template: str - file_extension: str - - -class User(TypedDict, total=False): - user_id: str - country_code: str - - -class ConfigData(TypedDict, total=False): - token: str - refresh_token: str - token_expires_at: int - settings: Settings - user: User - - -HOME_DIRECTORY = str(Path.home()) -CONFIG_FILENAME = ".tiddl_config.json" -DEFAULT_CONFIG: ConfigData = { - "token": "", - "refresh_token": "", - "token_expires_at": 0, - "settings": { - "download_path": f"{HOME_DIRECTORY}/tidal_download", - "track_quality": "HIGH", - "track_template": "{artist}/{title}", - "album_template": "{artist}/{album}/{title}", - "playlist_template": "{playlist}/{title}", - "file_extension": "" - }, - "user": {"user_id": "", "country_code": ""}, -} - - -class Config: - def __init__(self, config_path="") -> None: - if config_path == "": - self.config_directory = HOME_DIRECTORY - else: - self.config_directory = config_path - - self.config_path = f"{self.config_directory}/{CONFIG_FILENAME}" - self._config: ConfigData = DEFAULT_CONFIG - self._logger = logging.getLogger("Config") - - try: - with open(self.config_path, "r") as f: - loaded_config: ConfigData = json.load(f) - loaded_settings = loaded_config.get("settings") - self._logger.debug(f"loaded {loaded_settings}") - self.update(loaded_config) - - except FileNotFoundError: - self._logger.debug("creating new file") - self._save() # save default config if file does not exist - self._logger.debug("created new file") - - def _save(self) -> None: - with open(self.config_path, "w") as f: - self._logger.debug(self._config.get("settings")) - json.dump(self._config, f, indent=2) - - def __getitem__(self, key: str) -> Any: - return self._config[key] - - def __iter__(self): - return iter(self._config) - - def __str__(self) -> str: - return json.dumps(self._config, indent=2) - - def update(self, data: ConfigData) -> ConfigData: - self._logger.debug("updating") - merged_config: ConfigData = merge(data, self._config) - self._config.update(merged_config) - self._save() - self._logger.debug("updated") - return self._config.copy() - - -def merge(source, destination): - # https://stackoverflow.com/a/20666342 - for key, value in source.items(): - if isinstance(value, dict): - # get node or create one - node = destination.setdefault(key, {}) - merge(value, node) - else: - destination[key] = value - - return destination diff --git a/tiddl/parser.py b/tiddl/parser.py deleted file mode 100644 index b5b48c9..0000000 --- a/tiddl/parser.py +++ /dev/null @@ -1,112 +0,0 @@ -import os -import argparse - -from .types import TRACK_QUALITY -from .types.track import TrackQuality - - -def shouldNotColor() -> bool: - # TODO: add more checks ✨ - checks = ["NO_COLOR" in os.environ] - return any(checks) - - -parser = argparse.ArgumentParser( - description="\033[4mTIDDL\033[0m - Tidal Downloader", - epilog="options defaults will be fetched from your config file.", -) - -parser.add_argument( - "input", - type=str, - nargs="*", - help="track, album, playlist or artist - must be url, single id will be treated as track", -) - -parser.add_argument( - "-o", - type=str, - nargs="?", - const=True, - help="output file template, more info https://github.com/oskvr37/tiddl?tab=readme-ov-file#file-formatting", - dest="file_template", -) - -parser.add_argument( - "-p", - type=str, - nargs="?", - const=True, - help="download destination path", - dest="download_path", -) - -parser.add_argument( - "-e", - type=str, - nargs="?", - const=True, - help="choose file extension", - dest="file_extension", -) - -QUALITY_ARGS: dict[str, TrackQuality] = { - details["arg"]: quality for quality, details in TRACK_QUALITY.items() -} - -parser.add_argument( - "-q", - nargs="?", - help="track quality", - dest="quality", - choices=QUALITY_ARGS.keys(), -) - -parser.add_argument( - "-is", - help="include artist EPs and singles when downloading artist", - dest="include_singles", - action="store_true", -) - -parser.add_argument( - "-s", - help="save options to config // show config file", - dest="save_options", - action="store_true", -) - -parser.add_argument( - "-i", - type=str, - nargs="?", - const=True, - help="choose a file with urls (.txt file separated with newlines or .json list)", - dest="input_file", - default="", -) - -parser.add_argument( - "--no-skip", - help="dont skip already downloaded tracks", - action="store_true", -) - -parser.add_argument( - "--silent", - help="silent mode", - action="store_true", -) - -parser.add_argument( - "--verbose", - help="show debug logs", - action="store_true", -) - -parser.add_argument( - "--no-color", - help="suppress output colors", - action="store_true", - default=shouldNotColor(), -) diff --git a/tiddl/tests.py b/tiddl/tests.py deleted file mode 100644 index e67ab97..0000000 --- a/tiddl/tests.py +++ /dev/null @@ -1,200 +0,0 @@ -import unittest -import subprocess -import shutil - -from .utils import parseURL, formatFilename -from .types.track import Track - - -class TestUtils(unittest.TestCase): - - def test_parseURL(self): - self.assertEqual( - parseURL("https://tidal.com/browse/track/284165609"), ("track", "284165609") - ) - self.assertEqual( - parseURL("https://tidal.com/browse/track/284165609/"), - ("track", "284165609"), - ) - self.assertEqual( - parseURL("https://tidal.com/browse/track/284165609?u"), - ("track", "284165609"), - ) - self.assertEqual( - parseURL( - "https://listen.tidal.com/album/284165608/track/284165609", - ), - ("track", "284165609"), - ) - - self.assertEqual( - parseURL("https://listen.tidal.com/album/284165608"), ("album", "284165608") - ) - self.assertEqual( - parseURL("https://tidal.com/browse/album/284165608"), ("album", "284165608") - ) - self.assertEqual( - parseURL("https://tidal.com/browse/album/284165608?u"), - ("album", "284165608"), - ) - - self.assertEqual( - parseURL("https://listen.tidal.com/artist/7695548"), ("artist", "7695548") - ) - self.assertEqual( - parseURL("https://tidal.com/browse/artist/7695548"), ("artist", "7695548") - ) - - self.assertEqual( - parseURL( - "https://tidal.com/browse/playlist/803be625-97e4-4cbb-88dd-43f0b1c61ed7" - ), - ("playlist", "803be625-97e4-4cbb-88dd-43f0b1c61ed7"), - ) - self.assertEqual( - parseURL( - "https://listen.tidal.com/playlist/803be625-97e4-4cbb-88dd-43f0b1c61ed7" - ), - ("playlist", "803be625-97e4-4cbb-88dd-43f0b1c61ed7"), - ) - - self.assertEqual( - parseURL( - "https://listen.tidal.com/playlist/803be625-97e4-4cbb-88dd-43f0b1c61ed7" - ), - ("playlist", "803be625-97e4-4cbb-88dd-43f0b1c61ed7"), - ) - - self.assertEqual(parseURL("track/284165609"), ("track", "284165609")) - self.assertEqual( - parseURL("playlist/803be625-97e4-4cbb-88dd-43f0b1c61ed7"), - ("playlist", "803be625-97e4-4cbb-88dd-43f0b1c61ed7"), - ) - - # we can also omit domain - self.assertEqual( - parseURL("playlist/803be625-97e4-4cbb-88dd-43f0b1c61ed7"), - ("playlist", "803be625-97e4-4cbb-88dd-43f0b1c61ed7"), - ) - - self.assertRaises(ValueError, parseURL, "") - - def test_formatFilename(self): - track: Track = { - "id": 133017101, - "title": "HAUTE COUTURE", - "duration": 243, - "replayGain": -7.7, - "peak": 0.944031, - "allowStreaming": True, - "streamReady": True, - "adSupportedStreamReady": True, - "djReady": True, - "stemReady": False, - "streamStartDate": "2020-03-05T00:00:00.000+0000", - "premiumStreamingOnly": False, - "trackNumber": 1, - "volumeNumber": 1, - "version": None, - "popularity": 29, - "copyright": "2020 TUZZA Globale", - "bpm": None, - "url": "http://www.tidal.com/track/133017101", - "isrc": "PL70D1900060", - "editable": False, - "explicit": False, - "audioQuality": "LOSSLESS", - "audioModes": ["STEREO"], - "mediaMetadata": {"tags": ["LOSSLESS"]}, - "artist": { - "id": 9550100, - "name": "Tuzza Globale", - "type": "MAIN", - "picture": "125c9343-3257-407a-8285-5e9f1d283a2e", - }, - "artists": [ - { - "id": 9550100, - "name": "Tuzza Globale", - "type": "MAIN", - "picture": "125c9343-3257-407a-8285-5e9f1d283a2e", - }, - { - "id": 6847736, - "name": "Taco Hemingway", - "type": "FEATURED", - "picture": "7a1f5193-5d96-452c-b8dd-5ff0f81d5335", - }, - ], - "album": { - "id": 133017100, - "title": "HAUTE COUTURE", - "cover": "efd381c2-a982-4d09-bb15-da872006cadf", - "vibrantColor": "#f6a285", - "videoCover": None, - }, - "mixes": {"TRACK_MIX": "001ec78dae0d4a470999adefffd570"}, - "playlistNumber": None - } - - self.assertEqual(formatFilename("{title}", track), ("", "HAUTE COUTURE")) - self.assertEqual( - formatFilename("{artist} - {title}", track), - ("", "Tuzza Globale - HAUTE COUTURE"), - ) - self.assertEqual( - formatFilename("{album} - {title}", track), - ("", "HAUTE COUTURE - HAUTE COUTURE"), - ) - self.assertEqual( - formatFilename("{number}. {title}", track), ("", "1. HAUTE COUTURE") - ) - self.assertEqual( - formatFilename("{artists} - {title}", track), - ("", "Tuzza Globale, Taco Hemingway - HAUTE COUTURE"), - ) - self.assertEqual( - formatFilename("{id}", track), - ("", "133017101"), - ) - self.assertEqual( - formatFilename("{album}/{title}", track), - ("HAUTE COUTURE", "HAUTE COUTURE"), - ) - - -TRACK_ID = "284165609" -DOWNLOAD_DIR = "download_test" - - -class TestTiddl(unittest.TestCase): - - @classmethod - def setUpClass(cls): - try: - shutil.rmtree(DOWNLOAD_DIR) - except FileNotFoundError: - pass - - @classmethod - def tearDownClass(cls): - try: - shutil.rmtree(DOWNLOAD_DIR) - except FileNotFoundError: - pass - - def test_noInput(self): - result = subprocess.run(["tiddl"]) - self.assertEqual(result.returncode, 0) - - def test_downloadTrack(self): - result = subprocess.run(["tiddl", TRACK_ID, "-p", DOWNLOAD_DIR]) - self.assertEqual(result.returncode, 0) - - def test_downloadTrackExists(self): - result = subprocess.run(["tiddl", TRACK_ID, "-p", DOWNLOAD_DIR]) - self.assertEqual(result.returncode, 0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tiddl/utils.py b/tiddl/utils.py index 25b6554..30b7cb1 100644 --- a/tiddl/utils.py +++ b/tiddl/utils.py @@ -11,7 +11,6 @@ from mutagen.easymp4 import EasyMP4 as MutagenEasyMP4 from mutagen.mp4 import MP4Cover, MP4 as MutagenMP4 from .types.track import Track -from .config import HOME_DIRECTORY RESOURCE = Literal["track", "album", "artist", "playlist"] RESOURCE_LIST: List[RESOURCE] = list(get_args(RESOURCE)) @@ -122,12 +121,6 @@ def sanitizeFileName(file_name: str): return sanitized -def loadingSymbol(i: int, text: str): - symbols = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - symbol = symbols[i % len(symbols)] - print(f"\r{text} {symbol}", end="\r") - - def setMetadata(file: str, extension: str, track: Track, cover_data=b""): if extension == "flac": metadata = MutagenFLAC(file) @@ -187,124 +180,3 @@ def convertFileExtension(source_path: str, file_extension: str, remove_source=Tr os.remove(source_path) return dest_path - - -class Colors: - def __init__(self, colored=True) -> None: - if colored: - self.BLACK = "\033[0;30m" - self.GRAY = "\033[1;30m" - - self.RED = "\033[0;31m" - self.LIGHT_RED = "\033[1;31m" - - self.GREEN = "\033[0;32m" - self.LIGHT_GREEN = "\033[1;32m" - - self.YELLOW = "\033[0;33m" - self.LIGHT_YELLOW = "\033[1;33m" - - self.BLUE = "\033[0;34m" - self.LIGHT_BLUE = "\033[1;34m" - - self.PURPLE = "\033[0;35m" - self.LIGHT_PURPLE = "\033[1;35m" - - self.CYAN = "\033[0;36m" - self.LIGHT_CYAN = "\033[1;36m" - - self.LIGHT_GRAY = "\033[0;37m" - self.LIGHT_WHITE = "\033[1;37m" - - self.RESET = "\033[0m" - self.BOLD = "\033[1m" - self.FAINT = "\033[2m" - self.ITALIC = "\033[3m" - self.UNDERLINE = "\033[4m" - self.BLINK = "\033[5m" - self.NEGATIVE = "\033[7m" - self.CROSSED = "\033[9m" - else: - self.BLACK = "" - self.GRAY = "" - - self.RED = "" - self.LIGHT_RED = "" - - self.GREEN = "" - self.LIGHT_GREEN = "" - - self.YELLOW = "" - self.LIGHT_YELLOW = "" - - self.BLUE = "" - self.LIGHT_BLUE = "" - - self.PURPLE = "" - self.LIGHT_PURPLE = "" - - self.CYAN = "" - self.LIGHT_CYAN = "" - - self.LIGHT_GRAY = "" - self.LIGHT_WHITE = "" - - self.RESET = "" - self.BOLD = "" - self.FAINT = "" - self.ITALIC = "" - self.UNDERLINE = "" - self.BLINK = "" - self.NEGATIVE = "" - self.CROSSED = "" - - -def initLogging( - silent: bool, verbose: bool, directory=HOME_DIRECTORY, colored_logging=True -): - c = Colors(colored_logging) - - class StreamFormatter(logging.Formatter): - FORMATS = { - logging.DEBUG: f"{c.BLUE}[ %(name)s ] {c.CYAN}%(funcName)s {c.RESET}%(message)s", - logging.INFO: f"{c.GREEN}[ %(name)s ] {c.RESET}%(message)s", - logging.WARNING: f"{c.YELLOW}[ %(name)s ] {c.RESET}%(message)s", - logging.ERROR: f"{c.RED}[ %(name)s ] %(message)s", - logging.CRITICAL: f"{c.RED}[ %(name)s ] %(message)s", - } - - def format(self, record): - log_fmt = self.FORMATS.get(record.levelno) - formatter = logging.Formatter(log_fmt) - return formatter.format(record) + c.RESET - - stream_handler = logging.StreamHandler() - - file_handler = logging.FileHandler(f"{directory}/tiddl.log", "a", "utf-8") - - if silent: - log_level = logging.WARNING - elif verbose: - log_level = logging.DEBUG - else: - log_level = logging.INFO - - stream_handler.setLevel(log_level) - stream_handler.setFormatter(StreamFormatter()) - - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter( - logging.Formatter( - "%(asctime)s %(levelname)s\t%(name)s.%(funcName)s %(message)s", - datefmt="%x %X", - ) - ) - - # suppress logs from third-party libraries - logging.getLogger("requests").setLevel(logging.WARNING) - logging.getLogger("urllib3").setLevel(logging.WARNING) - - logging.basicConfig( - level=logging.DEBUG, - handlers=[file_handler, stream_handler], - ) From 2d69e2488b14806c1d91dfe48f192c3431421840 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 29 Dec 2024 15:03:18 +0100 Subject: [PATCH 10/83] =?UTF-8?q?=E2=9E=95=20add=20`pydantic`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ce2e86d..513fe5b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ requests>=2.20.0 mutagen>=1.47.0 ffmpeg-python>=0.2.0 -click>=8.1.7 \ No newline at end of file +click>=8.1.7 +pydantic>=2.9.2 \ No newline at end of file From 898bce36d6428f499e5abe4228dd654bc6a26a3d Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 29 Dec 2024 15:22:54 +0100 Subject: [PATCH 11/83] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20move=20`config.py`?= =?UTF-8?q?=20to=20main=20dir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/{config/__init__.py => config.py} | 2 +- tiddl/cli/ctx.py | 3 ++- tiddl/{cli/config => }/config.py | 0 3 files changed, 3 insertions(+), 2 deletions(-) rename tiddl/cli/{config/__init__.py => config.py} (89%) rename tiddl/{cli/config => }/config.py (100%) diff --git a/tiddl/cli/config/__init__.py b/tiddl/cli/config.py similarity index 89% rename from tiddl/cli/config/__init__.py rename to tiddl/cli/config.py index 17d3c77..c0587d0 100644 --- a/tiddl/cli/config/__init__.py +++ b/tiddl/cli/config.py @@ -1,6 +1,6 @@ import click -from .config import CONFIG_PATH, Config +from tiddl.config import CONFIG_PATH @click.command("config") diff --git a/tiddl/cli/ctx.py b/tiddl/cli/ctx.py index 6b53580..eae9508 100644 --- a/tiddl/cli/ctx.py +++ b/tiddl/cli/ctx.py @@ -2,7 +2,8 @@ import functools import click from typing import Callable, TypeVar, cast -from .config import Config + +from tiddl.config import Config from tiddl.types import Track diff --git a/tiddl/cli/config/config.py b/tiddl/config.py similarity index 100% rename from tiddl/cli/config/config.py rename to tiddl/config.py From 28af0ef8ee9e3477ce6508a1b9fe201ac27b7862 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 29 Dec 2024 16:33:55 +0100 Subject: [PATCH 12/83] =?UTF-8?q?=E2=9C=A8=20add=20user=20auth=20data=20to?= =?UTF-8?q?=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/auth.py | 2 ++ tiddl/config.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tiddl/cli/auth.py b/tiddl/cli/auth.py index d81ec0c..e20c72c 100644 --- a/tiddl/cli/auth.py +++ b/tiddl/cli/auth.py @@ -50,6 +50,8 @@ def login(ctx: Context): "token": token["access_token"], "refresh_token": token["refresh_token"], "expires": token["expires_in"] + int(time()), + "user_id": str(token["user"]["userId"]), + "country_code": token["user"]["countryCode"], } } ) diff --git a/tiddl/config.py b/tiddl/config.py index c5e51b8..2f2d130 100644 --- a/tiddl/config.py +++ b/tiddl/config.py @@ -21,6 +21,8 @@ class AuthConfig(TypedDict, total=False): token: str refresh_token: str expires: int + user_id: str + country_code: str class ConfigFile(TypedDict): @@ -35,7 +37,7 @@ class ConfigUpdate(TypedDict, total=False): DEFAULT_CONFIG: ConfigFile = { "download": {"quality": DEFAULT_QUALITY, "path": str(DOWNLOAD_PATH)}, - "auth": {"token": "", "refresh_token": "", "expires": 0}, + "auth": {"token": "", "refresh_token": "", "expires": 0, "country_code": "", "user_id": ""}, } From a5187836b1c43a6697ada5d09d4910680a3cf23e Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 29 Dec 2024 22:28:04 +0100 Subject: [PATCH 13/83] =?UTF-8?q?=E2=9C=A8=20add=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/__init__.py | 0 tests/test_api.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_api.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..6595bed --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,34 @@ +import unittest + +from tiddl.config import Config +from tiddl.api import TidalApi + + +class TestApi(unittest.TestCase): + api: TidalApi + + def setUp(self): + config = Config() + auth = config.config["auth"] + + token, user_id, country_code = ( + auth.get("token"), + auth.get("user_id"), + auth.get("country_code"), + ) + + assert token, "No token found in config file" + assert user_id, "No user_id found in config file" + assert country_code, "No country_code found in config file" + + self.api = TidalApi(token, user_id, country_code) + + def test_ready(self): + session = self.api.getSession() + + self.assertEqual(session["userId"], int(self.api.user_id)) + self.assertEqual(session["countryCode"], self.api.country_code) + + +if __name__ == "__main__": + unittest.main() From 552b859520dcd8bab6c05d3fdb78ee9b7a1e0d57 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Wed, 1 Jan 2025 15:38:18 +0100 Subject: [PATCH 14/83] =?UTF-8?q?=E2=9C=A8=20add=20`ApiError`=20exception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/api.py | 11 ++--------- tiddl/auth.py | 43 +++++++++++++++++++++++++++++++++---------- tiddl/exceptions.py | 11 +++++++++++ tiddl/types/api.py | 6 ------ 4 files changed, 46 insertions(+), 25 deletions(-) create mode 100644 tiddl/exceptions.py diff --git a/tiddl/api.py b/tiddl/api.py index b0324b7..0ffc729 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -1,9 +1,8 @@ import logging from requests import Session -from typing import TypedDict +from .exceptions import ApiError from .types import ( - ErrorResponse, SessionResponse, TrackQuality, Track, @@ -24,12 +23,6 @@ ALBUM_ITEMS_LIMIT = 10 PLAYLIST_LIMIT = 50 -class ApiError(Exception): - def __init__(self, message: str, error: ErrorResponse): - super().__init__(message) - self.error = error - - class TidalApi: def __init__(self, token: str, user_id: str, country_code: str) -> None: self.token = token @@ -49,7 +42,7 @@ class TidalApi: data = req.json() if req.status_code != 200: - raise ApiError(req.text, data) + raise ApiError(**data) return data diff --git a/tiddl/auth.py b/tiddl/auth.py index 0de887f..bcbadeb 100644 --- a/tiddl/auth.py +++ b/tiddl/auth.py @@ -1,21 +1,30 @@ from requests import request -from .types.auth import AuthDeviceResponse, AuthResponse, AuthResponseWithRefresh + +from .exceptions import ApiError +from .types import auth AUTH_URL = "https://auth.tidal.com/v1/oauth2" CLIENT_ID = "zU4XHVVkc2tDPo4t" CLIENT_SECRET = "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4=" -def getDeviceAuth() -> AuthDeviceResponse: - return request( +def getDeviceAuth(): + req = request( "POST", f"{AUTH_URL}/device_authorization", data={"client_id": CLIENT_ID, "scope": "r_usr+w_usr+w_sub"}, - ).json() + ) + + data = req.json() + + if req.status_code == 200: + return auth.AuthDeviceResponse(**data) + + raise ApiError(**data) -def getToken(device_code: str) -> AuthResponseWithRefresh: - return request( +def getToken(device_code: str): + req = request( "POST", f"{AUTH_URL}/token", data={ @@ -25,11 +34,18 @@ def getToken(device_code: str) -> AuthResponseWithRefresh: "scope": "r_usr+w_usr+w_sub", }, auth=(CLIENT_ID, CLIENT_SECRET), - ).json() + ) + + data = req.json() + + if req.status_code == 200: + return auth.AuthResponseWithRefresh(**data) + + raise ApiError(**data) -def refreshToken(refresh_token: str) -> AuthResponse: - return request( +def refreshToken(refresh_token: str): + req = request( "POST", f"{AUTH_URL}/token", data={ @@ -39,4 +55,11 @@ def refreshToken(refresh_token: str) -> AuthResponse: "scope": "r_usr+w_usr+w_sub", }, auth=(CLIENT_ID, CLIENT_SECRET), - ).json() + ) + + data = req.json() + + if req.status_code == 200: + return auth.AuthResponse(**data) + + raise ApiError(**data) diff --git a/tiddl/exceptions.py b/tiddl/exceptions.py new file mode 100644 index 0000000..fb4ea27 --- /dev/null +++ b/tiddl/exceptions.py @@ -0,0 +1,11 @@ +class ApiError(Exception): + def __init__( + self, status: int, error: str, sub_status: str, error_description: str + ): + self.status = status + self.error = error + self.sub_status = sub_status + self.error_description = error_description + + def __str__(self): + return f"{self.status}: {self.error} - {self.error_description}" diff --git a/tiddl/types/api.py b/tiddl/types/api.py index d676dee..3cdf9d7 100644 --- a/tiddl/types/api.py +++ b/tiddl/types/api.py @@ -3,12 +3,6 @@ from typing import TypedDict, Optional, List, Literal from .track import Track -class ErrorResponse(TypedDict): - status: int - subStatus: int - userMessage: str - - class Client(TypedDict): id: int name: str From 00dcac4b89843c0504474652f8aa4f28486b6607 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Wed, 1 Jan 2025 15:39:19 +0100 Subject: [PATCH 15/83] =?UTF-8?q?=E2=9C=A8=20convert=20auth=20api=20to=20p?= =?UTF-8?q?ydantic=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/auth.py | 34 ++++++++++++++++------------------ tiddl/types/auth.py | 9 +++++---- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/tiddl/cli/auth.py b/tiddl/cli/auth.py index e20c72c..0dfcc00 100644 --- a/tiddl/cli/auth.py +++ b/tiddl/cli/auth.py @@ -3,7 +3,7 @@ import click from click import style from time import sleep, time -from tiddl.auth import getDeviceAuth, getToken, refreshToken +from tiddl.auth import getDeviceAuth, getToken, refreshToken, ApiError from .ctx import passContext, Context @@ -23,35 +23,33 @@ def login(ctx: Context): auth = getDeviceAuth() - uri = f'https://{auth["verificationUriComplete"]}' + uri = f"https://{auth.verificationUriComplete}" click.launch(uri) click.echo(f"Go to {style(uri, fg='cyan')} and complete authentication!") # TODO: show time left for auth with `expiresIn` while True: - sleep(auth["interval"]) + sleep(auth.interval) - token = getToken(auth["deviceCode"]) - error: str | None = token.get("error") + try: + token = getToken(auth.deviceCode) + except ApiError as e: + if e.error == "authorization_pending": + continue - if error == "authorization_pending": - continue - - if error == "expired_token": - click.echo(f"Time for authentication {style('has expired', fg='red')}.") - break - - assert error == None, token + if e.error == "expired_token": + click.echo(f"Time for authentication {style('has expired', fg='red')}.") + break ctx.obj.config.update( { "auth": { - "token": token["access_token"], - "refresh_token": token["refresh_token"], - "expires": token["expires_in"] + int(time()), - "user_id": str(token["user"]["userId"]), - "country_code": token["user"]["countryCode"], + "token": token.access_token, + "refresh_token": token.refresh_token, + "expires": token.expires_in + int(time()), + "user_id": str(token.user.userId), + "country_code": token.user.countryCode, } } ) diff --git a/tiddl/types/auth.py b/tiddl/types/auth.py index 11b22a5..3b6bc76 100644 --- a/tiddl/types/auth.py +++ b/tiddl/types/auth.py @@ -1,7 +1,8 @@ -from typing import TypedDict, Optional +from typing import Optional +from pydantic import BaseModel -class _User(TypedDict): +class _User(BaseModel): userId: int email: str countryCode: str @@ -29,7 +30,7 @@ class _User(TypedDict): newUser: bool -class AuthResponse(TypedDict): +class AuthResponse(BaseModel): user: _User scope: str clientName: str @@ -43,7 +44,7 @@ class AuthResponseWithRefresh(AuthResponse): refresh_token: str -class AuthDeviceResponse(TypedDict): +class AuthDeviceResponse(BaseModel): deviceCode: str userCode: str verificationUri: str From 1bad1cea3ecc53026240e72ee55e106b369854c0 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Wed, 1 Jan 2025 18:06:27 +0100 Subject: [PATCH 16/83] =?UTF-8?q?=F0=9F=94=A5=20remove=20old=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/download.py | 301 ---------------------------------------------- tiddl/utils.py | 182 ---------------------------- 2 files changed, 483 deletions(-) delete mode 100644 tiddl/download.py delete mode 100644 tiddl/utils.py diff --git a/tiddl/download.py b/tiddl/download.py deleted file mode 100644 index 2a01bf5..0000000 --- a/tiddl/download.py +++ /dev/null @@ -1,301 +0,0 @@ -import logging -import requests -import json -import os -import ffmpeg - -from queue import Queue -from threading import Thread -from time import time -from xml.etree.ElementTree import fromstring -from base64 import b64decode -from typing import TypedDict, List - -from .types.track import ManifestMimeType - -THREADS_COUNT = 4 - -logger = logging.getLogger("download") - - -class Worker(Thread): - def __init__(self, queue: Queue, function): - Thread.__init__(self) - self.queue = queue - self.function = function - self.daemon = True - self.start() - - def run(self): - while True: - arg = self.queue.get() - self.function(arg) - self.queue.task_done() - - -class Threader: - def __init__(self, workers_num: int, target, args: list) -> None: - self.queue = Queue() - - for arg in args: - self.queue.put(arg) - - self.workers: list[Worker] = [ - Worker(self.queue, target) for _ in range(workers_num) - ] - - def run(self): - ts = time() - self.queue.join() - return round(time() - ts, 2) - - -class Downloader: - def __init__(self) -> None: - self.indexed_content: list[tuple[int, bytes]] = [] - self.session = requests.Session() - self.total = 0 - - def download(self, urls: list[str]) -> bytes: - self.total = len(urls) - indexed_urls = [(i, url) for (i, url) in enumerate(urls)] - threader = Threader(THREADS_COUNT, self._downloadFragment, indexed_urls) - threader.run() - sorted_content = sorted(self.indexed_content, key=lambda x: x[0]) - data = b"".join(content for _, content in sorted_content) - return data - - def _downloadFragment(self, arg: tuple[int, str]): - index, url = arg - req = self.session.get(url) - self.indexed_content.append((index, req.content)) - showProgressBar( - len(self.indexed_content), self.total, "threaded download", show_size=False - ) - - -def decodeManifest(manifest: str): - return b64decode(manifest).decode() - - -def parseManifest(manifest: str): - class AudioFileInfo(TypedDict): - mimeType: str - codecs: str - encryptionType: str - urls: List[str] - - data: AudioFileInfo = json.loads(manifest) - return data - - -def parseManifestXML(xml_content: str): - """ - Parses XML manifest file of the track. - """ - - NS = "{urn:mpeg:dash:schema:mpd:2011}" - - tree = fromstring(xml_content) - - representationElement = tree.find( - f"{NS}Period/{NS}AdaptationSet/{NS}Representation" - ) - if representationElement is None: - raise ValueError("Representation element not found") - - codecs = representationElement.get("codecs") - - segmentElement = representationElement.find(f"{NS}SegmentTemplate") - if segmentElement is None: - raise ValueError("SegmentTemplate element not found") - - url_template = segmentElement.get("media") - if url_template is None: - raise ValueError("No `media` attribute in SegmentTemplate") - - timelineElements = segmentElement.findall(f"{NS}SegmentTimeline/{NS}S") - if not timelineElements: - raise ValueError("SegmentTimeline elements not found") - - total = 0 - for element in timelineElements: - total += 1 - count = element.get("r") - if count is not None: - total += int(count) - - urls = [url_template.replace("$Number$", str(i)) for i in range(0, total + 1)] - - return urls, codecs - - -def showProgressBar(iteration: int, total: int, text: str, length=30, show_size=True): - SQUARE, SQUARE_FILL = "□", "■" - iteration_mb = iteration / 1024 / 1024 - total_mb = total / 1024 / 1024 - percent = 100 * (iteration / total) - progress = int(length * iteration // total) - bar = f"{SQUARE_FILL * progress}{SQUARE * (length - progress)}" - size = f" {iteration_mb:.2f} / {total_mb:.2f} MB" if show_size else "" - print( - f"\r{text} {bar} {percent:.0f}%{size}", - end="\r", - ) - if iteration >= total: - print() - - -def download(url: str) -> bytes: - logger.debug(url) - # use session for performance - session = requests.Session() - req = session.get(url, stream=True) - total_size = int(req.headers.get("content-length", 0)) - block_size = 1024 * 1024 - data = b"" - - for block in req.iter_content(block_size): - data += block - showProgressBar(len(data), total_size, "Single URL") - - return data - - -def threadDownload(urls: list[str]) -> bytes: - dl = Downloader() - data = dl.download(urls) - - return data - - -def toFlac(track_data: bytes) -> bytes: - process = ( - ffmpeg.input("pipe:0") - .output("pipe:1", format="flac", codec="copy") - .run_async(pipe_stdin=True, pipe_stdout=True, pipe_stderr=True) - ) - - flac_data, stderr = process.communicate(input=track_data) - - if process.returncode != 0: - raise RuntimeError(f"FFmpeg failed: {stderr.decode()}") - - return flac_data - - -def downloadTrackStream( - encoded_manifest: str, - mime_type: ManifestMimeType, -) -> tuple[bytes, str]: - logger.debug(f"mime_type: {mime_type}") - manifest = decodeManifest(encoded_manifest) - - match mime_type: - case "application/dash+xml": - track_urls, codecs = parseManifestXML(manifest) - case "application/vnd.tidal.bts": - data = parseManifest(manifest) - track_urls, codecs = data["urls"], data["codecs"] - case _: - raise ValueError(f"Unknown `mime_type`: {mime_type}") - - logger.debug(f"codecs: {codecs}") - - if len(track_urls) == 1: - track_data = download(track_urls[0]) - else: - track_data = threadDownload(track_urls) - track_data = toFlac(track_data) - - """ - known codecs - flac (master) - mp4a.40.2 (high) - mp4a.40.5 (low) - """ - - if codecs is None: - raise Exception("Missing codecs") - - extension = "flac" - - if codecs.startswith("mp4a"): - extension = "m4a" - elif codecs != "flac": - logger.warning( - f'unknown file codecs: "{codecs}", please submit this as issue on GitHub' - ) - - return track_data, extension - - -def downloadCover(uid: str, path: str, size=1280): - file = f"{path}/cover.jpg" - - if os.path.isfile(file): - logger.debug(f"cover already exists ({file})") - return - - formatted_uid = uid.replace("-", "/") - url = f"https://resources.tidal.com/images/{formatted_uid}/{size}x{size}.jpg" - - req = requests.get(url) - - if req.status_code != 200: - logger.error(f"could not download cover. ({req.status_code}) {url}") - return - - try: - with open(file, "wb") as f: - f.write(req.content) - except FileNotFoundError as e: - logger.error(f"could not save cover. {file} -> {e}") - - -class Cover: - def __init__(self, uid: str, size=1280) -> None: - if size > 1280: - logger.warning( - f"can not set cover size higher than 1280 (user set: {size})" - ) - size = 1280 - - self.uid = uid - - formatted_uid = uid.replace("-", "/") - self.url = ( - f"https://resources.tidal.com/images/{formatted_uid}/{size}x{size}.jpg" - ) - - logger.debug((self.uid, self.url)) - self.content = self.get() - - def get(self) -> bytes: - req = requests.get(self.url) - - if req.status_code != 200: - logger.error(f"could not download cover. ({req.status_code}) {self.url}") - return b"" - - logger.debug("got cover") - - return req.content - - def save(self, path: str): - if not self.content: - logger.error("cover file content is empty") - return - - file = f"{path}/cover.jpg" - - if os.path.isfile(file): - logger.debug(f"cover already exists ({file})") - return - - try: - with open(file, "wb") as f: - logger.debug(file) - f.write(self.content) - except FileNotFoundError as e: - logger.error(f"could not save cover. {file} -> {e}") diff --git a/tiddl/utils.py b/tiddl/utils.py deleted file mode 100644 index 30b7cb1..0000000 --- a/tiddl/utils.py +++ /dev/null @@ -1,182 +0,0 @@ -import re -import os -import json -import logging -import subprocess - -from datetime import datetime -from typing import TypedDict, Literal, List, get_args -from mutagen.flac import FLAC as MutagenFLAC, Picture -from mutagen.easymp4 import EasyMP4 as MutagenEasyMP4 -from mutagen.mp4 import MP4Cover, MP4 as MutagenMP4 - -from .types.track import Track - -RESOURCE = Literal["track", "album", "artist", "playlist"] -RESOURCE_LIST: List[RESOURCE] = list(get_args(RESOURCE)) - - -logger = logging.getLogger("utils") - - -def parseFileInput(file: str) -> list[str]: - _, file_extension = os.path.splitext(file) - logger.debug(file, file_extension) - urls_set: set[str] = set() - - if file_extension == ".txt": - with open(file) as f: - data = f.read() - urls_set.update(data.splitlines()) - elif file_extension == ".json": - with open(file) as f: - data = json.load(f) - urls_set.update(data) - else: - logger.warning(f"a file with '{file_extension}' extension is not supported!") - - filtered_urls = [url for url in urls_set if type(url) == str] - - return filtered_urls - - -def parseURL(url: str) -> tuple[RESOURCE, str]: - # remove trailing slash - url = url.rstrip("/") - # remove params - url = url.split("?")[0] - - fragments = url.split("/") - - if len(fragments) < 2: - raise ValueError(f"Invalid input: {url}") - - parsed_type, parsed_id = fragments[-2], fragments[-1] - - if parsed_type not in RESOURCE_LIST: - raise ValueError(f"Invalid resource type: {parsed_type} ({url})") - - return parsed_type, parsed_id - - -class FormattedTrack(TypedDict): - id: str - title: str - number: str - disc_number: str - artist: str - album: str - artists: str - playlist: str - released: str - year: str - playlist_number: str - - -def formatFilename(template: str, track: Track, playlist=""): - artists = [artist["name"].strip() for artist in track["artists"]] - - release_date = datetime.strptime( - track["streamStartDate"], "%Y-%m-%dT%H:%M:%S.000+0000" - ) - - formatted_track: FormattedTrack = { - "album": re.sub(r'[<>:"|?*/\\]', "_", track["album"]["title"].strip()), - "artist": track["artist"]["name"].strip(), - "artists": ", ".join(artists), - "id": str(track["id"]), - "title": track["title"].strip(), - "number": str(track["trackNumber"]), - "disc_number": str(track["volumeNumber"]), - "playlist": playlist.strip(), - "released": release_date.strftime("%m-%d-%Y"), - "year": release_date.strftime("%Y"), - "playlist_number": str(track.get("playlistNumber", "")), - } - - dirs = template.split("/") - filename = dirs.pop() - - formatted_filename = filename.format(**formatted_track) - formatted_dir = "/".join(dirs).format(**formatted_track) - - return sanitizeDirName(formatted_dir), sanitizeFileName(formatted_filename) - - -def sanitizeDirName(dir_name: str): - # replace invalid characters with an underscore - sanitized = re.sub(r'[<>:"|?*]', "_", dir_name) - # strip whitespace - sanitized = sanitized.strip() - - return sanitized - - -def sanitizeFileName(file_name: str): - # replace invalid characters with an underscore - sanitized = re.sub(r'[<>:"|?*/\\]', "_", file_name) - # strip whitespace - sanitized = sanitized.strip() - - return sanitized - - -def setMetadata(file: str, extension: str, track: Track, cover_data=b""): - if extension == "flac": - metadata = MutagenFLAC(file) - if cover_data: - picture = Picture() - picture.data = cover_data - picture.mime = "image/jpeg" - metadata.add_picture(picture) - elif extension == "m4a": - if cover_data: - metadata = MutagenMP4(file) - metadata["covr"] = [MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG)] - metadata.save(file) - metadata = MutagenEasyMP4(file) - else: - raise ValueError(f"Unknown file extension: {extension}") - - new_metadata: dict[str, str] = { - "title": track["title"], - "trackNumber": str(track["trackNumber"]), - "discnumber": str(track["volumeNumber"]), - "copyright": track["copyright"], - "albumartist": track["artist"]["name"], - "artist": ";".join([artist["name"].strip() for artist in track["artists"]]), - "album": track["album"]["title"], - "date": track["streamStartDate"][:10], - } - - metadata.update(new_metadata) - - try: - metadata.save(file) - except Exception as e: - logger.error(f"Failed to set metadata for {extension}: {e}") - - -def convertFileExtension(source_path: str, file_extension: str, remove_source=True): - source_dir, source_extension = os.path.splitext(source_path) - dest_path = f"{source_dir}.{file_extension}" - - logger.debug((source_path, source_dir, source_extension, dest_path)) - - if source_extension == f".{file_extension}": - return source_path - - logger.debug(f"converting `{source_path}` to `{file_extension}`") - command = ["ffmpeg", "-i", source_path, dest_path] - result = subprocess.run( - command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) - - if result.returncode != 0: - logger.error(result.stderr) - return source_path - - if remove_source: - os.remove(source_path) - - return dest_path From 7d803a929d2c9b80f47a79e5fd762685d7df9112 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Wed, 1 Jan 2025 21:20:03 +0100 Subject: [PATCH 17/83] =?UTF-8?q?=E2=9C=A8=20add=20`TidalResource`=20parse?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_utils.py | 46 +++++++++++++++++++++++++++++++++++++++ tiddl/cli/download/url.py | 46 ++++++++++++++++++++++----------------- tiddl/utils.py | 41 ++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 tests/test_utils.py create mode 100644 tiddl/utils.py diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..3236daa --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,46 @@ +import unittest + +from tiddl.utils import TidalResource + + +class TestTidalResource(unittest.TestCase): + + def test_resource_parsing(self): + positive_cases = [ + ("https://tidal.com/browse/track/12345678", "track", "12345678"), + ("track/12345678", "track", "12345678"), + ("https://tidal.com/browse/album/12345678", "album", "12345678"), + ("album/12345678", "album", "12345678"), + ("https://tidal.com/browse/playlist/12345678", "playlist", "12345678"), + ("playlist/12345678", "playlist", "12345678"), + ("https://tidal.com/browse/artist/12345678", "artist", "12345678"), + ("artist/12345678", "artist", "12345678"), + ] + + for resource, expected_type, expected_id in positive_cases: + with self.subTest(resource=resource): + tidal_url = TidalResource(resource) + self.assertEqual(tidal_url.resource_type, expected_type) + self.assertEqual(tidal_url.resource_id, expected_id) + + def test_failing_cases(self): + failing_cases = [ + "https://tidal.com/browse/invalid/12345678", + "invalid/12345678", + "https://tidal.com/browse/track/invalid", + "track/invalid", + "", + "invalid", + "https://tidal.com/browse/track/", + "track/", + "/12345678", + ] + + for resource in failing_cases: + with self.subTest(resource=resource): + with self.assertRaises(ValueError): + TidalResource(resource) + + +if __name__ == "__main__": + unittest.main() diff --git a/tiddl/cli/download/url.py b/tiddl/cli/download/url.py index d89c4f8..f3b31af 100644 --- a/tiddl/cli/download/url.py +++ b/tiddl/cli/download/url.py @@ -1,36 +1,42 @@ import click from ..ctx import Context, passContext + from tiddl.types import Track -from urllib import parse as urlparse +from tiddl.utils import TidalResource -class URL(click.ParamType): - # TODO: create correct Tidal URL parsing, maybe with regex - name = "url" - - def convert(self, value, param, ctx): - if not isinstance(value, tuple): - value = urlparse.urlparse(value) - if value.scheme not in ("http", "https"): - self.fail( - f"invalid URL scheme ({value.scheme}). Only HTTP URLs are allowed", - param, - ctx, - ) - return value +class TidalURL(click.ParamType): + def convert(self, value: str, param, ctx) -> TidalResource: + try: + return TidalResource(value) + except ValueError as e: + self.fail(message=str(e), param=param, ctx=ctx) @click.group("url") -@click.argument("url", type=URL()) +@click.argument("url", type=TidalURL()) @passContext -def UrlGroup(ctx: Context, url: URL, filename): - """Get Tidal URLs""" +def UrlGroup(ctx: Context, url: TidalResource): + """ + Get Tidal URL. - print(url, filename) + It can be Tidal link or `resource_type/resource_id` format. + The resource can be a track, album, playlist or artist. + """ tracks: list[Track] = [] - # TODO: parse the URL list + # TODO: fetch api + + match url.resource_type: + case "track": + pass + case "album": + pass + case "playlist": + pass + case "artist": + pass ctx.obj.tracks.extend(tracks) diff --git a/tiddl/utils.py b/tiddl/utils.py new file mode 100644 index 0000000..033c1bb --- /dev/null +++ b/tiddl/utils.py @@ -0,0 +1,41 @@ +from urllib.parse import urlparse +from typing import Literal, get_args + + +ResourceTypeLiteral = Literal["track", "album", "playlist", "artist"] + + +class TidalResource: + """ + A parser for Tidal resource URLs or strings. + + Extracts the resource type (e.g., "track", "album") and resource ID + from a given input string. The input string can either be a full URL or a + shorthand string in the format "resource_type/resource_id" (e.g., "track/12345678"). + """ + + resource: str + resource_type: ResourceTypeLiteral + resource_id: str + url: str + + def __init__(self, resource: str) -> None: + self.resource = resource + + path = urlparse(self.resource).path + resource_type, resource_id = path.split("/")[-2:] + + if resource_type not in get_args(ResourceTypeLiteral): + raise ValueError(f"Invalid resource type: {resource_type}") + + self.resource_type = resource_type # type: ignore + + if not resource_id.isdigit() and self.resource_type != "playlist": + raise ValueError(f"Invalid resource id: {resource_id}") + + self.resource_id = resource_id + + self.url = f"https://listen.tidal.com/{self.resource_type}/{self.resource_id}" + + def __str__(self) -> str: + return f"{self.resource_type}/{self.resource_id}" From a2ddeaec2edf246011e6ec44e9dbf7ce2d8492c6 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Fri, 3 Jan 2025 15:39:14 +0100 Subject: [PATCH 18/83] =?UTF-8?q?=E2=9C=A8=20add=20logout=20and=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/auth.py | 15 +++++++++++ tiddl/cli/auth.py | 65 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/tiddl/auth.py b/tiddl/auth.py index bcbadeb..7a71190 100644 --- a/tiddl/auth.py +++ b/tiddl/auth.py @@ -1,3 +1,5 @@ +import logging + from requests import request from .exceptions import ApiError @@ -8,6 +10,9 @@ CLIENT_ID = "zU4XHVVkc2tDPo4t" CLIENT_SECRET = "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4=" +logger = logging.getLogger(__name__) + + def getDeviceAuth(): req = request( "POST", @@ -63,3 +68,13 @@ def refreshToken(refresh_token: str): return auth.AuthResponse(**data) raise ApiError(**data) + + +def removeToken(access_token: str): + req = request( + "POST", + f"https://api.tidal.com/v1/logout", + headers={"authorization": f"Bearer {access_token}"}, + ) + + logger.debug((req.status_code, req.text)) diff --git a/tiddl/cli/auth.py b/tiddl/cli/auth.py index 0dfcc00..9dfa6ab 100644 --- a/tiddl/cli/auth.py +++ b/tiddl/cli/auth.py @@ -1,12 +1,16 @@ import click +import logging from click import style from time import sleep, time -from tiddl.auth import getDeviceAuth, getToken, refreshToken, ApiError +from tiddl.auth import getDeviceAuth, getToken, refreshToken, removeToken, ApiError from .ctx import passContext, Context +logger = logging.getLogger(__name__) + + @click.group("auth") def AuthGroup(): """Manage Tidal token""" @@ -17,8 +21,27 @@ def AuthGroup(): def login(ctx: Context): """Add token to the config""" - if ctx.obj.config.config["auth"].get("token"): - click.echo(style("Already logged in", fg="green")) + access_token, refresh_token, expires = ( + ctx.obj.config.config["auth"].get("token"), + ctx.obj.config.config["auth"].get("refresh_token"), + ctx.obj.config.config["auth"].get("expires", 0), + ) + + if access_token: + if refresh_token and time() > expires: + click.echo(style("Refreshing token...", fg="yellow")) + token = refreshToken(refresh_token) + + ctx.obj.config.update( + { + "auth": { + "expires": token.expires_in + int(time()), + "token": token.access_token, + } + } + ) + + click.echo(style("Authenticated!", fg="green")) return auth = getDeviceAuth() @@ -27,7 +50,7 @@ def login(ctx: Context): click.launch(uri) click.echo(f"Go to {style(uri, fg='cyan')} and complete authentication!") - # TODO: show time left for auth with `expiresIn` + time_left = time() + auth.expiresIn while True: sleep(auth.interval) @@ -36,10 +59,15 @@ def login(ctx: Context): token = getToken(auth.deviceCode) except ApiError as e: if e.error == "authorization_pending": + # FIX: `Time left: 0 secondsss` 🐍 + + click.echo(f"\rTime left: {time_left - time():.0f} seconds", nl=False) continue if e.error == "expired_token": - click.echo(f"Time for authentication {style('has expired', fg='red')}.") + click.echo( + f"\nTime for authentication {style('has expired', fg='red')}." + ) break ctx.obj.config.update( @@ -54,7 +82,7 @@ def login(ctx: Context): } ) - click.echo(style("Authenticated!", fg="green")) + click.echo(style("\nAuthenticated!", fg="green")) break @@ -62,5 +90,26 @@ def login(ctx: Context): @AuthGroup.command("logout") @passContext def logout(ctx: Context): - """* Not implemented *""" - # https://github.com/Fokka-Engineering/TIDAL/wiki/log-out + """Remove token from config""" + + access_token = ctx.obj.config.config["auth"].get("token") + + if not access_token: + click.echo(style("Not logged in", fg="yellow")) + return + + removeToken(access_token) + + ctx.obj.config.update( + { + "auth": { + "country_code": "", + "expires": 0, + "refresh_token": "", + "token": "", + "user_id": "", + } + } + ) + + click.echo(style("Logged out!", fg="green")) From bf70635c948b49c8dcf49922f4f77d625388dffa Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Fri, 3 Jan 2025 15:39:32 +0100 Subject: [PATCH 19/83] =?UTF-8?q?=E2=9C=A8=20add=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/__init__.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tiddl/cli/__init__.py b/tiddl/cli/__init__.py index d1848d0..3ccc1e4 100644 --- a/tiddl/cli/__init__.py +++ b/tiddl/cli/__init__.py @@ -1,16 +1,25 @@ import click +import logging from .ctx import ContextObj, passContext, Context from .auth import AuthGroup from .download import UrlGroup, FavGroup, SearchGroup, FileGroup from .config import ConfigCommand + @click.group() @passContext -@click.option("--verbose", is_flag=True, help="Show debug logs") +@click.option("--verbose", "-v", is_flag=True, help="Show debug logs") def cli(ctx: Context, verbose: bool): """TIDDL - Download Tidal tracks ✨""" - ctx.obj = ContextObj(verbose) + ctx.obj = ContextObj() + + logging.basicConfig( + level=logging.DEBUG if verbose else logging.INFO, + handlers=[logging.StreamHandler()], + format="%(levelname)s [%(name)s.%(funcName)s] %(message)s", + ) + cli.add_command(ConfigCommand) cli.add_command(AuthGroup) From 95f9bee3103e7cc1d0b80e7bfe32ff04951719d5 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Fri, 3 Jan 2025 15:43:09 +0100 Subject: [PATCH 20/83] =?UTF-8?q?=E2=9C=A8=20add=20api=20to=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/ctx.py | 23 +++++++++++++++++++---- tiddl/cli/download/utils.py | 12 ++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 tiddl/cli/download/utils.py diff --git a/tiddl/cli/ctx.py b/tiddl/cli/ctx.py index eae9508..e996b3c 100644 --- a/tiddl/cli/ctx.py +++ b/tiddl/cli/ctx.py @@ -3,16 +3,31 @@ import click from typing import Callable, TypeVar, cast +from tiddl.api import TidalApi from tiddl.config import Config from tiddl.types import Track class ContextObj: - def __init__(self, verbose: bool) -> None: - self.config = Config() - self.tracks: list[Track] = [] + api: TidalApi | None + config: Config + tracks: list[Track] - self.verbose = verbose + def __init__(self) -> None: + self.config = Config() + self.tracks = [] + self.api = None + + config_auth = self.config.config["auth"] + + token, user_id, country_code = ( + config_auth.get("token"), + config_auth.get("user_id"), + config_auth.get("country_code"), + ) + + if token and user_id and country_code: + self.api = TidalApi(token, user_id, country_code) class Context(click.Context): diff --git a/tiddl/cli/download/utils.py b/tiddl/cli/download/utils.py new file mode 100644 index 0000000..42d9395 --- /dev/null +++ b/tiddl/cli/download/utils.py @@ -0,0 +1,12 @@ +import click + +from ..ctx import Context + + +def getReadyApi(ctx: Context): + if ctx.obj.api is None: + raise click.ClickException( + "API is not initialized, please use: tiddl auth login." + ) + + return ctx.obj.api From f42a54df0f056e5c8473343293af62aaf8e28b20 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Fri, 3 Jan 2025 15:45:12 +0100 Subject: [PATCH 21/83] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20move=20getApi=20to?= =?UTF-8?q?=20ctx=20obj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/ctx.py | 6 ++++++ tiddl/cli/download/utils.py | 12 ------------ 2 files changed, 6 insertions(+), 12 deletions(-) delete mode 100644 tiddl/cli/download/utils.py diff --git a/tiddl/cli/ctx.py b/tiddl/cli/ctx.py index e996b3c..32f500e 100644 --- a/tiddl/cli/ctx.py +++ b/tiddl/cli/ctx.py @@ -29,6 +29,12 @@ class ContextObj: if token and user_id and country_code: self.api = TidalApi(token, user_id, country_code) + def getApi(self) -> TidalApi: + if self.api is None: + raise click.UsageError("You must login first") + + return self.api + class Context(click.Context): obj: ContextObj diff --git a/tiddl/cli/download/utils.py b/tiddl/cli/download/utils.py deleted file mode 100644 index 42d9395..0000000 --- a/tiddl/cli/download/utils.py +++ /dev/null @@ -1,12 +0,0 @@ -import click - -from ..ctx import Context - - -def getReadyApi(ctx: Context): - if ctx.obj.api is None: - raise click.ClickException( - "API is not initialized, please use: tiddl auth login." - ) - - return ctx.obj.api From 60038bc30e1c5afbc2c5f8731f258d2ec3df62b8 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 5 Jan 2025 12:37:14 +0100 Subject: [PATCH 22/83] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20convert=20to=20pydan?= =?UTF-8?q?tic=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/api.py | 94 +++++++++++++++++++++++++------------------- tiddl/types/track.py | 25 ++++++------ 2 files changed, 67 insertions(+), 52 deletions(-) diff --git a/tiddl/api.py b/tiddl/api.py index 0ffc729..d63eb26 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -46,64 +46,78 @@ class TidalApi: return data - def getSession(self) -> SessionResponse: - return self._request( - f"sessions", + def getSession(self): + return SessionResponse( + **self._request( + f"sessions", + ) ) - def getTrackStream(self, id: str | int, quality: TrackQuality) -> TrackStream: - return self._request( - f"tracks/{id}/playbackinfo", - { - "audioquality": quality, - "playbackmode": "STREAM", - "assetpresentation": "FULL", - }, + def getTrackStream(self, id: str | int, quality: TrackQuality): + return TrackStream( + **self._request( + f"tracks/{id}/playbackinfo", + { + "audioquality": quality, + "playbackmode": "STREAM", + "assetpresentation": "FULL", + }, + ) ) - def getTrack(self, id: str | int) -> Track: - return self._request(f"tracks/{id}", {"countryCode": self.country_code}) + def getTrack(self, id: str | int): + return Track( + **self._request(f"tracks/{id}", {"countryCode": self.country_code}) + ) def getArtistAlbums( self, id: str | int, limit=ARTIST_ALBUMS_LIMIT, offset=0, onlyNonAlbum=False - ) -> AristAlbumsItems: + ): params = {"countryCode": self.country_code, "limit": limit, "offset": offset} if onlyNonAlbum: params.update({"filter": "EPSANDSINGLES"}) - return self._request( - f"artists/{id}/albums", - params, + return AristAlbumsItems( + **self._request( + f"artists/{id}/albums", + params, + ) ) - def getAlbum(self, id: str | int) -> Album: - return self._request(f"albums/{id}", {"countryCode": self.country_code}) - - def getAlbumItems( - self, id: str | int, limit=ALBUM_ITEMS_LIMIT, offset=0 - ) -> AlbumItems: - return self._request( - f"albums/{id}/items", - {"countryCode": self.country_code, "limit": limit, "offset": offset}, + def getAlbum(self, id: str | int): + return Album( + **self._request(f"albums/{id}", {"countryCode": self.country_code}) ) - def getPlaylist(self, uuid: str) -> Playlist: - return self._request( - f"playlists/{uuid}", - {"countryCode": self.country_code}, + def getAlbumItems(self, id: str | int, limit=ALBUM_ITEMS_LIMIT, offset=0): + return AlbumItems( + **self._request( + f"albums/{id}/items", + {"countryCode": self.country_code, "limit": limit, "offset": offset}, + ) ) - def getPlaylistItems( - self, uuid: str, limit=PLAYLIST_LIMIT, offset=0 - ) -> PlaylistItems: - return self._request( - f"playlists/{uuid}/items", - {"countryCode": self.country_code, "limit": limit, "offset": offset}, + def getPlaylist(self, uuid: str): + return Playlist( + **self._request( + f"playlists/{uuid}", + {"countryCode": self.country_code}, + ) ) - def getFavorites(self) -> Favorites: - return self._request( - f"users/{self.user_id}/favorites/ids", - {"countryCode": self.country_code}, + def getPlaylistItems(self, uuid: str, limit=PLAYLIST_LIMIT, offset=0): + return PlaylistItems( + **self._request( + f"playlists/{uuid}/items", + {"countryCode": self.country_code, "limit": limit, "offset": offset}, + ) + ) + + def getFavorites(self): + return Favorites( + **self._request( + f"users/{self.user_id}/favorites/ids", + {"countryCode": self.country_code}, + ) ) diff --git a/tiddl/types/track.py b/tiddl/types/track.py index 29cc5f4..d2b61a4 100644 --- a/tiddl/types/track.py +++ b/tiddl/types/track.py @@ -1,11 +1,12 @@ -from typing import TypedDict, Optional, List, Dict, Literal, Optional +from pydantic import BaseModel +from typing import Optional, List, Dict, Literal, Optional TrackQuality = Literal["LOW", "HIGH", "LOSSLESS", "HI_RES_LOSSLESS"] ManifestMimeType = Literal["application/dash+xml", "application/vnd.tidal.bts"] -class TrackStream(TypedDict): +class TrackStream(BaseModel): trackId: int assetPresentation: Literal["FULL"] audioMode: Literal["STEREO"] @@ -17,26 +18,26 @@ class TrackStream(TypedDict): albumPeakAmplitude: float trackReplayGain: float trackPeakAmplitude: float - bitDepth: Optional[int] - sampleRate: Optional[int] + bitDepth: Optional[int] = None + sampleRate: Optional[int] = None -class _Artist(TypedDict): +class _Artist(BaseModel): id: int name: str type: str - picture: Optional[str] + picture: Optional[str] = None -class _Album(TypedDict): +class _Album(BaseModel): id: int title: str cover: str vibrantColor: str - videoCover: Optional[str] + videoCover: Optional[str] = None -class Track(TypedDict): +class Track(BaseModel): id: int title: str duration: int @@ -51,10 +52,10 @@ class Track(TypedDict): premiumStreamingOnly: bool trackNumber: int volumeNumber: int - version: Optional[str] + version: Optional[str] = None popularity: int copyright: str - bpm: Optional[int] + bpm: Optional[int] = None url: str isrc: str editable: bool @@ -68,4 +69,4 @@ class Track(TypedDict): mixes: Dict[str, str] # this is used only when downloading playlist - playlistNumber: Optional[int] + playlistNumber: Optional[int] = None From 72063722946ade88f569118c5fa5687a9dd51ffe Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 5 Jan 2025 16:36:53 +0100 Subject: [PATCH 23/83] =?UTF-8?q?=E2=9C=A8=20add=20download=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/download.py | 91 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tiddl/download.py diff --git a/tiddl/download.py b/tiddl/download.py new file mode 100644 index 0000000..4e6bf49 --- /dev/null +++ b/tiddl/download.py @@ -0,0 +1,91 @@ +import logging + +from requests import Session +from pydantic import BaseModel +from base64 import b64decode +from xml.etree.ElementTree import fromstring + +from tiddl.types import TrackStream + + +logger = logging.getLogger(__name__) + + +def parseManifestXML(xml_content: str): + """ + Parses XML manifest file of the track. + """ + + NS = "{urn:mpeg:dash:schema:mpd:2011}" + + tree = fromstring(xml_content) + + representationElement = tree.find( + f"{NS}Period/{NS}AdaptationSet/{NS}Representation" + ) + if representationElement is None: + raise ValueError("Representation element not found") + + codecs = representationElement.get("codecs", "") + + segmentElement = representationElement.find(f"{NS}SegmentTemplate") + if segmentElement is None: + raise ValueError("SegmentTemplate element not found") + + url_template = segmentElement.get("media") + if url_template is None: + raise ValueError("No `media` attribute in SegmentTemplate") + + timelineElements = segmentElement.findall(f"{NS}SegmentTimeline/{NS}S") + if not timelineElements: + raise ValueError("SegmentTimeline elements not found") + + total = 0 + for element in timelineElements: + total += 1 + count = element.get("r") + if count is not None: + total += int(count) + + urls = [url_template.replace("$Number$", str(i)) for i in range(0, total + 1)] + + return urls, codecs + + +class TrackManifest(BaseModel): + mimeType: str + codecs: str + encryptionType: str + urls: list[str] + + +def downloadTrackStream(stream: TrackStream) -> tuple[bytes, str]: + """Download data from track stream and return it with file extension.""" + + decoded_manifest = b64decode(stream.manifest).decode() + + match stream.manifestMimeType: + case "application/vnd.tidal.bts": + track_manifest = TrackManifest.model_validate_json(decoded_manifest) + urls, codecs = track_manifest.urls, track_manifest.codecs + + case "application/dash+xml": + urls, codecs = parseManifestXML(decoded_manifest) + + logger.debug((stream.trackId, stream.audioQuality, codecs, len(urls))) + + if codecs == "flac": + file_extension = "flac" + elif codecs.startswith("mp4"): + file_extension = "m4a" + else: + raise ValueError(f"Unknown codecs: {codecs}") + + with Session() as s: + stream_data = b"" + + for url in urls: + req = s.get(url) + stream_data += req.content + + return stream_data, file_extension From 5bf4868caa13084b87d81d434a54a2b9c96ba62a Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 5 Jan 2025 16:37:15 +0100 Subject: [PATCH 24/83] =?UTF-8?q?=E2=9C=A8=20download=20track?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 31 +++++++++++++------------------ tiddl/cli/download/url.py | 8 ++++++-- tiddl/types/__init__.py | 23 +++++++---------------- 3 files changed, 26 insertions(+), 36 deletions(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 709a1a1..a8eae7a 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -7,21 +7,8 @@ from .url import UrlGroup from ..ctx import Context, passContext -from tiddl.types import TrackArg, Track - - -def downloadTrack(track: Track, quality: TrackArg): - # TODO: create download function - - # it should download track to user specified directory with specified filename - # then add the track id to the database with file path and quality - - # we can cache api responses to avoid requesting the same track multiple times - # then we can use the cached data to download the track - - # we should be able to download multiple tracks at once - - pass +from tiddl.download import downloadTrackStream +from tiddl.types import TrackArg, ARG_TO_QUALITY @click.command("download") @@ -30,7 +17,9 @@ def downloadTrack(track: Track, quality: TrackArg): def DownloadCommand(ctx: Context, quality: TrackArg): """Download the tracks""" - quality = quality or ctx.obj.config.config["download"]["quality"] + download_quality = ARG_TO_QUALITY[ + quality or ctx.obj.config.config["download"]["quality"] + ] tracks = ctx.obj.tracks @@ -38,9 +27,15 @@ def DownloadCommand(ctx: Context, quality: TrackArg): click.echo("No tracks found.") return + api = ctx.obj.getApi() + for track in tracks: - click.echo(f"Downloading {track['title']}") - downloadTrack(track, quality) + click.echo(f"Downloading {track.title}") + track_stream = api.getTrackStream(track.id, download_quality) + stream_data, file_extension = downloadTrackStream(track_stream) + + with open(f"{track.id}.{file_extension}", "wb") as f: + f.write(stream_data) UrlGroup.add_command(DownloadCommand) diff --git a/tiddl/cli/download/url.py b/tiddl/cli/download/url.py index f3b31af..13178e4 100644 --- a/tiddl/cli/download/url.py +++ b/tiddl/cli/download/url.py @@ -27,15 +27,19 @@ def UrlGroup(ctx: Context, url: TidalResource): tracks: list[Track] = [] - # TODO: fetch api + api = ctx.obj.getApi() match url.resource_type: case "track": - pass + track = api.getTrack(url.resource_id) + tracks.append(track) + case "album": pass + case "playlist": pass + case "artist": pass diff --git a/tiddl/types/__init__.py b/tiddl/types/__init__.py index 5117da3..0e030cc 100644 --- a/tiddl/types/__init__.py +++ b/tiddl/types/__init__.py @@ -5,20 +5,11 @@ from .track import * TrackArg = Literal["low", "normal", "high", "master"] - -class QualityDetails(TypedDict): - name: str - details: str - arg: TrackArg - - -TRACK_QUALITY: dict[TrackQuality, QualityDetails] = { - "LOW": {"name": "Low", "details": "96 kbps", "arg": "low"}, - "HIGH": {"name": "Low", "details": "320 kbps", "arg": "normal"}, - "LOSSLESS": {"name": "High", "details": "16-bit, 44.1 kHz", "arg": "high"}, - "HI_RES_LOSSLESS": { - "name": "Max", - "details": "Up to 24-bit, 192 kHz", - "arg": "master", - }, +ARG_TO_QUALITY: dict[TrackArg, TrackQuality] = { + "low": "LOW", + "normal": "HIGH", + "high": "LOSSLESS", + "master": "HI_RES_LOSSLESS", } + +QUALITY_TO_ARG = {v: k for k, v in ARG_TO_QUALITY.items()} From b62bcbadb7d0be68628af921f9d0b0b768b9e732 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 5 Jan 2025 19:26:50 +0100 Subject: [PATCH 25/83] =?UTF-8?q?=E2=9C=A8=20replace=20tracks=20with=20res?= =?UTF-8?q?ources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/ctx.py | 8 ++++---- tiddl/cli/download/__init__.py | 5 ++++- tiddl/cli/download/fav.py | 6 ------ tiddl/cli/download/file.py | 31 +++++++++++++++++++++++++------ tiddl/cli/download/search.py | 5 ----- tiddl/cli/download/url.py | 21 +-------------------- 6 files changed, 34 insertions(+), 42 deletions(-) diff --git a/tiddl/cli/ctx.py b/tiddl/cli/ctx.py index 32f500e..25b444a 100644 --- a/tiddl/cli/ctx.py +++ b/tiddl/cli/ctx.py @@ -5,17 +5,17 @@ from typing import Callable, TypeVar, cast from tiddl.api import TidalApi from tiddl.config import Config -from tiddl.types import Track - +from tiddl.utils import TidalResource class ContextObj: api: TidalApi | None config: Config - tracks: list[Track] + resources: list[TidalResource] + def __init__(self) -> None: self.config = Config() - self.tracks = [] + self.resources = [] self.api = None config_auth = self.config.config["auth"] diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index a8eae7a..1f68a07 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -21,7 +21,10 @@ def DownloadCommand(ctx: Context, quality: TrackArg): quality or ctx.obj.config.config["download"]["quality"] ] - tracks = ctx.obj.tracks + # TODO: fetch tracks from database + # or from api + + tracks = [] if not tracks: click.echo("No tracks found.") diff --git a/tiddl/cli/download/fav.py b/tiddl/cli/download/fav.py index ede83e2..b6706d1 100644 --- a/tiddl/cli/download/fav.py +++ b/tiddl/cli/download/fav.py @@ -2,8 +2,6 @@ import click from ..ctx import Context, passContext -from tiddl.types import Track - @click.group("fav") @click.argument("type") @@ -11,8 +9,4 @@ from tiddl.types import Track def FavGroup(ctx: Context, type: str): """Get your Tidal favorites""" - tracks: list[Track] = [] - # TODO: fetch user favorites - - ctx.obj.tracks.extend(tracks) diff --git a/tiddl/cli/download/file.py b/tiddl/cli/download/file.py index 76945e2..8a3dce6 100644 --- a/tiddl/cli/download/file.py +++ b/tiddl/cli/download/file.py @@ -1,18 +1,37 @@ import click +import json + +from io import TextIOWrapper +from os.path import splitext from ..ctx import Context, passContext -from io import TextIOWrapper -from tiddl.types import Track +from tiddl.utils import TidalResource @click.group("file") @click.argument("filename", type=click.File(mode="r")) @passContext def FileGroup(ctx: Context, filename: TextIOWrapper): - """Parse text or JSON file with urls""" + """Parse txt or JSON file with urls""" - tracks: list[Track] = [] + _, extension = splitext(filename.name) - # TODO: parse the file + resource_strings: list[str] - ctx.obj.tracks.extend(tracks) + match extension: + case ".json": + try: + resource_strings = json.load(filename) + except json.JSONDecodeError as e: + raise click.UsageError(f"Cant decode JSON file - {e.msg}") + + case ".txt": + resource_strings = [line.strip() for line in filename.readlines()] + + case _: + raise click.UsageError(f"Unsupported file extension - {extension}") + + resources = [TidalResource(string) for string in resource_strings] + ctx.obj.resources.extend(resources) + + click.echo(click.style(f"Loaded {len(resources)} resources", "green")) diff --git a/tiddl/cli/download/search.py b/tiddl/cli/download/search.py index 049224e..ad3be53 100644 --- a/tiddl/cli/download/search.py +++ b/tiddl/cli/download/search.py @@ -1,7 +1,6 @@ import click from ..ctx import Context, passContext -from tiddl.types import Track @click.group("search") @@ -10,8 +9,4 @@ from tiddl.types import Track def SearchGroup(ctx: Context, query: str): """Search on Tidal""" - tracks: list[Track] = [] - # TODO: search on Tidal - - ctx.obj.tracks.extend(tracks) diff --git a/tiddl/cli/download/url.py b/tiddl/cli/download/url.py index 13178e4..7552f57 100644 --- a/tiddl/cli/download/url.py +++ b/tiddl/cli/download/url.py @@ -2,7 +2,6 @@ import click from ..ctx import Context, passContext -from tiddl.types import Track from tiddl.utils import TidalResource @@ -25,22 +24,4 @@ def UrlGroup(ctx: Context, url: TidalResource): The resource can be a track, album, playlist or artist. """ - tracks: list[Track] = [] - - api = ctx.obj.getApi() - - match url.resource_type: - case "track": - track = api.getTrack(url.resource_id) - tracks.append(track) - - case "album": - pass - - case "playlist": - pass - - case "artist": - pass - - ctx.obj.tracks.extend(tracks) + ctx.obj.resources.append(url) From 678abd979e2c6cd41030efd5224d13677b08f2fe Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Wed, 8 Jan 2025 22:06:07 +0100 Subject: [PATCH 26/83] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20add=20`None`=20fi?= =?UTF-8?q?elds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/types/api.py | 31 ++++++++++++++++--------------- tiddl/types/track.py | 6 +++--- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/tiddl/types/api.py b/tiddl/types/api.py index 3cdf9d7..86b8af6 100644 --- a/tiddl/types/api.py +++ b/tiddl/types/api.py @@ -1,16 +1,17 @@ -from typing import TypedDict, Optional, List, Literal +from pydantic import BaseModel +from typing import Optional, List, Literal from .track import Track -class Client(TypedDict): +class Client(BaseModel): id: int name: str authorizedForOffline: bool authorizedForOfflineDate: Optional[str] -class SessionResponse(TypedDict): +class SessionResponse(BaseModel): sessionId: str userId: int countryCode: str @@ -19,24 +20,24 @@ class SessionResponse(TypedDict): client: Client -class Items(TypedDict): +class Items(BaseModel): limit: int offset: int totalNumberOfItems: int -class ArtistAlbum(TypedDict): +class ArtistAlbum(BaseModel): id: int name: str - type: Literal["MAIN"] + type: Literal["MAIN", "FEATURED"] -class Album(TypedDict): +class Album(BaseModel): id: int title: str duration: int streamReady: bool - streamStartDate: str + streamStartDate: Optional[str] = None allowStreaming: bool premiumStreamingOnly: bool numberOfTracks: int @@ -47,8 +48,8 @@ class Album(TypedDict): type: str version: Optional[str] url: str - cover: str - videoCover: Optional[str] + cover: Optional[str] = None + videoCover: Optional[str] = None explicit: bool upc: str popularity: int @@ -62,7 +63,7 @@ class AristAlbumsItems(Items): items: List[Album] -class _AlbumTrack(TypedDict): +class _AlbumTrack(BaseModel): item: Track type: Literal["track"] @@ -71,11 +72,11 @@ class AlbumItems(Items): items: List[_AlbumTrack] -class _Creator(TypedDict): +class _Creator(BaseModel): id: int -class Playlist(TypedDict): +class Playlist(BaseModel): uuid: str title: str numberOfTracks: int @@ -95,7 +96,7 @@ class Playlist(TypedDict): lastItemAddedAt: str -class _PlaylistItem(TypedDict): +class _PlaylistItem(BaseModel): item: Track type: Literal["track"] cut: Literal[None] @@ -105,7 +106,7 @@ class PlaylistItems(Items): items: List[_PlaylistItem] -class Favorites(TypedDict): +class Favorites(BaseModel): PLAYLIST: List[str] ALBUM: List[str] VIDEO: List[str] diff --git a/tiddl/types/track.py b/tiddl/types/track.py index d2b61a4..166ba66 100644 --- a/tiddl/types/track.py +++ b/tiddl/types/track.py @@ -32,8 +32,8 @@ class _Artist(BaseModel): class _Album(BaseModel): id: int title: str - cover: str - vibrantColor: str + cover: Optional[str] = None + vibrantColor: Optional[str] = None videoCover: Optional[str] = None @@ -48,7 +48,7 @@ class Track(BaseModel): adSupportedStreamReady: bool djReady: bool stemReady: bool - streamStartDate: str + streamStartDate: Optional[str] = None premiumStreamingOnly: bool trackNumber: int volumeNumber: int From a6383745396d3eab880bf2aaf343a2b8d2851c34 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 13 Jan 2025 13:33:29 +0100 Subject: [PATCH 27/83] =?UTF-8?q?=E2=9C=A8=20add=20autherror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/api.py | 5 ++++- tiddl/exceptions.py | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tiddl/api.py b/tiddl/api.py index d63eb26..6cfc311 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -1,7 +1,7 @@ import logging from requests import Session -from .exceptions import ApiError +from .exceptions import ApiError, AuthError from .types import ( SessionResponse, TrackQuality, @@ -41,6 +41,9 @@ class TidalApi: data = req.json() + if req.status_code == 401: + raise AuthError(**data) + if req.status_code != 200: raise ApiError(**data) diff --git a/tiddl/exceptions.py b/tiddl/exceptions.py index fb4ea27..da8f705 100644 --- a/tiddl/exceptions.py +++ b/tiddl/exceptions.py @@ -1,11 +1,21 @@ class ApiError(Exception): def __init__( - self, status: int, error: str, sub_status: str, error_description: str + self, status: int, error: str, subStatus: str, error_description: str ): self.status = status self.error = error - self.sub_status = sub_status + self.sub_status = subStatus self.error_description = error_description def __str__(self): return f"{self.status}: {self.error} - {self.error_description}" + + +class AuthError(Exception): + def __init__(self, status: int, subStatus: str, userMessage: str): + self.status = status + self.sub_status = subStatus + self.user_message = userMessage + + def __str__(self): + return f"{self.user_message} ({self.status} - {self.sub_status})" From 44d5917006918b40be0e69b534b03efa6d5f6845 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 13 Jan 2025 23:38:23 +0100 Subject: [PATCH 28/83] =?UTF-8?q?=E2=9C=A8=20add=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_api.py | 43 +++++++++++++- tiddl/api.py | 10 +++- tiddl/types/__init__.py | 1 + tiddl/types/api.py | 12 ++-- tiddl/types/search.py | 127 ++++++++++++++++++++++++++++++++++++++++ tiddl/types/track.py | 2 +- 6 files changed, 185 insertions(+), 10 deletions(-) create mode 100644 tiddl/types/search.py diff --git a/tests/test_api.py b/tests/test_api.py index 6595bed..b949906 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -26,8 +26,47 @@ class TestApi(unittest.TestCase): def test_ready(self): session = self.api.getSession() - self.assertEqual(session["userId"], int(self.api.user_id)) - self.assertEqual(session["countryCode"], self.api.country_code) + self.assertEqual(session.userId, int(self.api.user_id)) + self.assertEqual(session.countryCode, self.api.country_code) + + def test_track(self): + track = self.api.getTrack(103805726) + self.assertEqual(track.title, "Stronger") + + def test_artist_albums(self): + self.api.getArtistAlbums(25022) + + def test_album(self): + album = self.api.getAlbum(103805723) + self.assertEqual(album.title, "Graduation") + + def test_album_items(self): + album_items = self.api.getAlbumItems(103805723, limit=10) + self.assertEqual(len(album_items.items), 10) + + album_items = self.api.getAlbumItems(103805723, limit=10, offset=10) + self.assertEqual(len(album_items.items), 4) + + def test_playlist(self): + playlist = self.api.getPlaylist("84974059-76af-406a-aede-ece2b78fa372") + self.assertEqual(playlist.title, "Kanye West Essentials") + + def test_playlist_items(self): + playlist_items = self.api.getPlaylistItems( + "84974059-76af-406a-aede-ece2b78fa372" + ) + self.assertEqual(len(playlist_items.items), 25) + + def test_favorites(self): + favorites = self.api.getFavorites() + self.assertGreaterEqual(len(favorites.PLAYLIST), 0) + self.assertGreaterEqual(len(favorites.ALBUM), 0) + self.assertGreaterEqual(len(favorites.VIDEO), 0) + self.assertGreaterEqual(len(favorites.TRACK), 0) + self.assertGreaterEqual(len(favorites.ARTIST), 0) + + def test_search(self): + self.api.search("Kanye West") if __name__ == "__main__": diff --git a/tiddl/api.py b/tiddl/api.py index 6cfc311..4e56bad 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -13,6 +13,7 @@ from .types import ( Playlist, PlaylistItems, Favorites, + Search, ) API_URL = "https://api.tidal.com/v1" @@ -52,7 +53,7 @@ class TidalApi: def getSession(self): return SessionResponse( **self._request( - f"sessions", + "sessions", ) ) @@ -124,3 +125,10 @@ class TidalApi: {"countryCode": self.country_code}, ) ) + + def search(self, query: str): + return Search( + **self._request( + "search", {"countryCode": self.country_code, "query": query} + ) + ) diff --git a/tiddl/types/__init__.py b/tiddl/types/__init__.py index 0e030cc..7f7eb23 100644 --- a/tiddl/types/__init__.py +++ b/tiddl/types/__init__.py @@ -2,6 +2,7 @@ from typing import TypedDict, Literal from .api import * from .track import * +from .search import * TrackArg = Literal["low", "normal", "high", "master"] diff --git a/tiddl/types/api.py b/tiddl/types/api.py index 86b8af6..cb9b786 100644 --- a/tiddl/types/api.py +++ b/tiddl/types/api.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -from typing import Optional, List, Literal +from typing import Optional, List, Literal, Dict from .track import Track @@ -46,7 +46,7 @@ class Album(BaseModel): releaseDate: str copyright: str type: str - version: Optional[str] + version: Optional[str] = None url: str cover: Optional[str] = None videoCover: Optional[str] = None @@ -81,19 +81,19 @@ class Playlist(BaseModel): title: str numberOfTracks: int numberOfVideos: int - creator: _Creator - description: str + creator: _Creator | Dict + description: Optional[str] = None duration: int lastUpdated: str created: str type: str publicPlaylist: bool url: str - image: str + image: Optional[str] = None popularity: int squareImage: str promotedArtists: List[ArtistAlbum] - lastItemAddedAt: str + lastItemAddedAt: Optional[str] = None class _PlaylistItem(BaseModel): diff --git a/tiddl/types/search.py b/tiddl/types/search.py new file mode 100644 index 0000000..37ff9a7 --- /dev/null +++ b/tiddl/types/search.py @@ -0,0 +1,127 @@ +from pydantic import BaseModel +from typing import Optional, List, Literal, Dict, Union + +from tiddl.types.track import Track +from tiddl.types.api import Items, Playlist + + +class _ArtistRole(BaseModel): + categoryId: int + category: Literal[ + "Artist", + "Songwriter", + "Performer", + "Producer", + "Engineer", + "Production team", + "Misc", + ] + + +class _ArtistMix(BaseModel): + ARTIST_MIX: str + + +class Artist(BaseModel): + id: int + name: str + artistTypes: Optional[List[Literal["ARTIST", "CONTRIBUTOR"]]] = None + url: Optional[str] = None + picture: Optional[str] = None + selectedAlbumCoverFallback: Optional[str] = None + popularity: Optional[int] = None + artistRoles: Optional[List[_ArtistRole]] = None + mixes: Optional[_ArtistMix | Dict] = None + + +class SearchAritsts(Items): + items: List[Artist] + + +class ArtistSearchAlbum(BaseModel): + id: int + name: str + type: Literal["MAIN", "FEATURED"] + picture: str + + +class SearchAlbum(BaseModel): + id: int + title: str + duration: int + streamReady: bool + streamStartDate: Optional[str] = None + allowStreaming: bool + premiumStreamingOnly: bool + numberOfTracks: int + numberOfVideos: int + numberOfVolumes: int + releaseDate: str + copyright: str + type: str + version: Optional[str] = None + url: str + cover: Optional[str] = None + videoCover: Optional[str] = None + explicit: bool + upc: str + popularity: int + audioQuality: str + audioModes: List[str] + artists: List[ArtistSearchAlbum | Dict] + + +class SearchAlbums(Items): + items: List[SearchAlbum] + + +class SearchPlaylists(Items): + items: List[Playlist] + + +class SearchTracks(Items): + items: List[Track] + + +class Video(BaseModel): + id: int + title: str + volumeNumber: int + trackNumber: int + releaseDate: str + imagePath: Optional[str] = None + imageId: str + vibrantColor: str + duration: int + quality: str + streamReady: bool + adSupportedStreamReady: bool + djReady: bool + stemReady: bool + streamStartDate: str + allowStreaming: bool + explicit: bool + popularity: int + type: str + adsUrl: Optional[str] = None + adsPrePaywallOnly: bool + artists: List[Artist] + album: Optional[str] = None + + +class SearchVideo(Items): + items: List[Video] + + +class TopHit(BaseModel): + value: Union[Artist, Track, Playlist, SearchAlbum] + type: Literal["ARTISTS", "TRACKS", "PLAYLISTS", "ALBUMS"] + + +class Search(BaseModel): + artists: SearchAritsts + albums: SearchAlbums + playlists: SearchPlaylists + tracks: SearchTracks + videos: SearchVideo + topHit: TopHit diff --git a/tiddl/types/track.py b/tiddl/types/track.py index 166ba66..8d9532e 100644 --- a/tiddl/types/track.py +++ b/tiddl/types/track.py @@ -63,7 +63,7 @@ class Track(BaseModel): audioQuality: str audioModes: List[str] mediaMetadata: Dict[str, List[str]] - artist: _Artist + artist: Optional[_Artist] = None artists: List[_Artist] album: _Album mixes: Dict[str, str] From 1175d55933fa0805fbff5334c48f9c3519b546eb Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 13 Jan 2025 23:47:17 +0100 Subject: [PATCH 29/83] =?UTF-8?q?=E2=9C=A8=20basic=20download?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 1f68a07..57251b3 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -8,7 +8,7 @@ from .url import UrlGroup from ..ctx import Context, passContext from tiddl.download import downloadTrackStream -from tiddl.types import TrackArg, ARG_TO_QUALITY +from tiddl.types import TrackArg, ARG_TO_QUALITY, Track @click.command("download") @@ -21,23 +21,41 @@ def DownloadCommand(ctx: Context, quality: TrackArg): quality or ctx.obj.config.config["download"]["quality"] ] - # TODO: fetch tracks from database - # or from api + api = ctx.obj.getApi() + tracks: list[Track] = [] - tracks = [] + def addTrack(track: Track): + if track.allowStreaming: + tracks.append(track) + + for resource in ctx.obj.resources: + match resource.resource_type: + case "track": + try: + track = api.getTrack(resource.resource_id) + addTrack(track) + except Exception as e: + print(e) + + case "album": + album_tracks = api.getAlbumItems(resource.resource_id) + for album_item in album_tracks.items: + if album_item.type == "track": + addTrack(album_item.item) if not tracks: click.echo("No tracks found.") return - api = ctx.obj.getApi() - for track in tracks: click.echo(f"Downloading {track.title}") track_stream = api.getTrackStream(track.id, download_quality) stream_data, file_extension = downloadTrackStream(track_stream) - with open(f"{track.id}.{file_extension}", "wb") as f: + with open( + f"{track.id}.{track_stream.audioQuality.lower()}.{file_extension}", + "wb", + ) as f: f.write(stream_data) From 2ac6b83d07bd262f5bce3c9c74022fb43bb561ea Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 13 Jan 2025 23:48:06 +0100 Subject: [PATCH 30/83] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rename=20`types`=20t?= =?UTF-8?q?o=20`models`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/api.py | 2 +- tiddl/auth.py | 2 +- tiddl/cli/download/__init__.py | 2 +- tiddl/config.py | 2 +- tiddl/download.py | 2 +- tiddl/{types => models}/__init__.py | 0 tiddl/{types => models}/api.py | 0 tiddl/{types => models}/auth.py | 0 tiddl/{types => models}/search.py | 4 ++-- tiddl/{types => models}/track.py | 0 10 files changed, 7 insertions(+), 7 deletions(-) rename tiddl/{types => models}/__init__.py (100%) rename tiddl/{types => models}/api.py (100%) rename tiddl/{types => models}/auth.py (100%) rename tiddl/{types => models}/search.py (96%) rename tiddl/{types => models}/track.py (100%) diff --git a/tiddl/api.py b/tiddl/api.py index 4e56bad..68352d6 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -2,7 +2,7 @@ import logging from requests import Session from .exceptions import ApiError, AuthError -from .types import ( +from .models import ( SessionResponse, TrackQuality, Track, diff --git a/tiddl/auth.py b/tiddl/auth.py index 7a71190..0bf127c 100644 --- a/tiddl/auth.py +++ b/tiddl/auth.py @@ -3,7 +3,7 @@ import logging from requests import request from .exceptions import ApiError -from .types import auth +from .models import auth AUTH_URL = "https://auth.tidal.com/v1/oauth2" CLIENT_ID = "zU4XHVVkc2tDPo4t" diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 57251b3..00886ef 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -8,7 +8,7 @@ from .url import UrlGroup from ..ctx import Context, passContext from tiddl.download import downloadTrackStream -from tiddl.types import TrackArg, ARG_TO_QUALITY, Track +from tiddl.models import TrackArg, ARG_TO_QUALITY, Track @click.command("download") diff --git a/tiddl/config.py b/tiddl/config.py index 2f2d130..ee4200b 100644 --- a/tiddl/config.py +++ b/tiddl/config.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from typing import TypedDict from pathlib import Path -from tiddl.types import TrackArg +from tiddl.models import TrackArg CONFIG_PATH = Path.home() / "tiddl.json" diff --git a/tiddl/download.py b/tiddl/download.py index 4e6bf49..0d05e73 100644 --- a/tiddl/download.py +++ b/tiddl/download.py @@ -5,7 +5,7 @@ from pydantic import BaseModel from base64 import b64decode from xml.etree.ElementTree import fromstring -from tiddl.types import TrackStream +from tiddl.models import TrackStream logger = logging.getLogger(__name__) diff --git a/tiddl/types/__init__.py b/tiddl/models/__init__.py similarity index 100% rename from tiddl/types/__init__.py rename to tiddl/models/__init__.py diff --git a/tiddl/types/api.py b/tiddl/models/api.py similarity index 100% rename from tiddl/types/api.py rename to tiddl/models/api.py diff --git a/tiddl/types/auth.py b/tiddl/models/auth.py similarity index 100% rename from tiddl/types/auth.py rename to tiddl/models/auth.py diff --git a/tiddl/types/search.py b/tiddl/models/search.py similarity index 96% rename from tiddl/types/search.py rename to tiddl/models/search.py index 37ff9a7..0305d4e 100644 --- a/tiddl/types/search.py +++ b/tiddl/models/search.py @@ -1,8 +1,8 @@ from pydantic import BaseModel from typing import Optional, List, Literal, Dict, Union -from tiddl.types.track import Track -from tiddl.types.api import Items, Playlist +from tiddl.models.track import Track +from tiddl.models.api import Items, Playlist class _ArtistRole(BaseModel): diff --git a/tiddl/types/track.py b/tiddl/models/track.py similarity index 100% rename from tiddl/types/track.py rename to tiddl/models/track.py From 59e53b2e1418b4303b7246b70fafc31468967ed0 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sat, 18 Jan 2025 13:19:00 +0100 Subject: [PATCH 31/83] =?UTF-8?q?=E2=9C=A8=20add=20`TrackCollector`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 79 ++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 00886ef..d825ab8 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -9,6 +9,47 @@ from ..ctx import Context, passContext from tiddl.download import downloadTrackStream from tiddl.models import TrackArg, ARG_TO_QUALITY, Track +from tiddl.utils import TidalResource +from tiddl.api import TidalApi + + +class TrackCollector: + def __init__(self, api: TidalApi): + self.api = api + self.tracks: list[Track] = [] + + def addResource(self, resource: TidalResource): + try: + match resource.resource_type: + case "track": + track = self.api.getTrack(resource.resource_id) + self._addTrack(track) + + case "album": + album_tracks = self.api.getAlbumItems(resource.resource_id) + self._addItems(album_tracks.items) + + case "playlist": + playlist_tracks = self.api.getPlaylistItems(resource.resource_id) + self._addItems(playlist_tracks.items) + + case "artist": + artist_albums = self.api.getArtistAlbums(resource.resource_id) + for artist_album in artist_albums.items: + album_tracks = self.api.getAlbumItems(artist_album.id) + self._addItems(album_tracks.items) + + except Exception as e: + print(f"Error in adding resource: {resource}, {e}") + + def _addTrack(self, track: Track): + if track.allowStreaming: + self.tracks.append(track) + + def _addItems(self, items): + for item in items: + if item.type == "track": + self._addTrack(item.item) @click.command("download") @@ -17,37 +58,21 @@ from tiddl.models import TrackArg, ARG_TO_QUALITY, Track def DownloadCommand(ctx: Context, quality: TrackArg): """Download the tracks""" + api = ctx.obj.getApi() + track_collector = TrackCollector(api) + + for resource in ctx.obj.resources: + track_collector.addResource(resource) + + if not track_collector.tracks: + click.echo("No tracks found.") + return + download_quality = ARG_TO_QUALITY[ quality or ctx.obj.config.config["download"]["quality"] ] - api = ctx.obj.getApi() - tracks: list[Track] = [] - - def addTrack(track: Track): - if track.allowStreaming: - tracks.append(track) - - for resource in ctx.obj.resources: - match resource.resource_type: - case "track": - try: - track = api.getTrack(resource.resource_id) - addTrack(track) - except Exception as e: - print(e) - - case "album": - album_tracks = api.getAlbumItems(resource.resource_id) - for album_item in album_tracks.items: - if album_item.type == "track": - addTrack(album_item.item) - - if not tracks: - click.echo("No tracks found.") - return - - for track in tracks: + for track in track_collector.tracks: click.echo(f"Downloading {track.title}") track_stream = api.getTrackStream(track.id, download_quality) stream_data, file_extension = downloadTrackStream(track_stream) From 19a5ae4242a9dd8c249962f5cb1cf9950d6a22c6 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sat, 18 Jan 2025 13:25:22 +0100 Subject: [PATCH 32/83] =?UTF-8?q?=F0=9F=90=9B=20fix=20exceptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/api.py | 2 +- tiddl/auth.py | 8 ++++---- tiddl/cli/auth.py | 4 ++-- tiddl/exceptions.py | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tiddl/api.py b/tiddl/api.py index 68352d6..6a288ca 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -1,7 +1,7 @@ import logging from requests import Session -from .exceptions import ApiError, AuthError +from .exceptions import AuthError, ApiError from .models import ( SessionResponse, TrackQuality, diff --git a/tiddl/auth.py b/tiddl/auth.py index 0bf127c..bab4beb 100644 --- a/tiddl/auth.py +++ b/tiddl/auth.py @@ -2,7 +2,7 @@ import logging from requests import request -from .exceptions import ApiError +from .exceptions import AuthError from .models import auth AUTH_URL = "https://auth.tidal.com/v1/oauth2" @@ -25,7 +25,7 @@ def getDeviceAuth(): if req.status_code == 200: return auth.AuthDeviceResponse(**data) - raise ApiError(**data) + raise AuthError(**data) def getToken(device_code: str): @@ -46,7 +46,7 @@ def getToken(device_code: str): if req.status_code == 200: return auth.AuthResponseWithRefresh(**data) - raise ApiError(**data) + raise AuthError(**data) def refreshToken(refresh_token: str): @@ -67,7 +67,7 @@ def refreshToken(refresh_token: str): if req.status_code == 200: return auth.AuthResponse(**data) - raise ApiError(**data) + raise AuthError(**data) def removeToken(access_token: str): diff --git a/tiddl/cli/auth.py b/tiddl/cli/auth.py index 9dfa6ab..fb56c31 100644 --- a/tiddl/cli/auth.py +++ b/tiddl/cli/auth.py @@ -4,7 +4,7 @@ import logging from click import style from time import sleep, time -from tiddl.auth import getDeviceAuth, getToken, refreshToken, removeToken, ApiError +from tiddl.auth import getDeviceAuth, getToken, refreshToken, removeToken, AuthError from .ctx import passContext, Context @@ -57,7 +57,7 @@ def login(ctx: Context): try: token = getToken(auth.deviceCode) - except ApiError as e: + except AuthError as e: if e.error == "authorization_pending": # FIX: `Time left: 0 secondsss` 🐍 diff --git a/tiddl/exceptions.py b/tiddl/exceptions.py index da8f705..7cb9f6f 100644 --- a/tiddl/exceptions.py +++ b/tiddl/exceptions.py @@ -1,17 +1,17 @@ -class ApiError(Exception): +class AuthError(Exception): def __init__( - self, status: int, error: str, subStatus: str, error_description: str + self, status: int, error: str, sub_status: str, error_description: str ): self.status = status self.error = error - self.sub_status = subStatus + self.sub_status = sub_status self.error_description = error_description def __str__(self): return f"{self.status}: {self.error} - {self.error_description}" -class AuthError(Exception): +class ApiError(Exception): def __init__(self, status: int, subStatus: str, userMessage: str): self.status = status self.sub_status = subStatus From f306fb04a99127cd2466773b1871a27def965632 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sat, 18 Jan 2025 14:08:35 +0100 Subject: [PATCH 33/83] =?UTF-8?q?=E2=9C=A8=20migrate=20to=20`pyproject.tom?= =?UTF-8?q?l`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 29 +++++++++++++++++++++++++++++ requirements.txt | 5 ----- setup.py | 15 --------------- 3 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0b9279d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "tiddl" +version = "2.0.0" +description = "TIDDL (Tidal Downloader) is CLI application that allows downloading Tidal tracks." +readme = "README.md" +requires-python = "3.10" +authors = [{ name = "oskvr37" }] +classifiers = [ + "Environment :: Console", + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] +dependencies = [ + "pydantic>=2.9.2", + "requests>=2.20.0", + "click>=8.1.7", + "mutagen>=1.47.0", + "ffmpeg-python>=0.2.0", +] + +[project.urls] +homepage = "https://github.com/oskvr37/tiddl" + +[project.scripts] +tiddl = "tiddl.cli:cli" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 513fe5b..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -requests>=2.20.0 -mutagen>=1.47.0 -ffmpeg-python>=0.2.0 -click>=8.1.7 -pydantic>=2.9.2 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index ac71a2d..0000000 --- a/setup.py +++ /dev/null @@ -1,15 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name="tiddl", - version="2.0.0", - description="TIDDL (Tidal Downloader) is CLI application that allows downloading Tidal tracks.", - long_description=open("README.md", encoding="utf-8").read(), - long_description_content_type="text/markdown", - readme="README.md", - author="oskvr37", - packages=find_packages(), - entry_points={ - "console_scripts": ["tiddl=tiddl.cli:cli"], - }, -) From df53975bf14eba38107657824cfbe9bd0d4b796a Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sat, 18 Jan 2025 14:13:52 +0100 Subject: [PATCH 34/83] =?UTF-8?q?=F0=9F=90=9B=20fix=20invalid=20specifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0b9279d..1772f97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "tiddl" version = "2.0.0" description = "TIDDL (Tidal Downloader) is CLI application that allows downloading Tidal tracks." readme = "README.md" -requires-python = "3.10" +requires-python = ">=3.10" authors = [{ name = "oskvr37" }] classifiers = [ "Environment :: Console", From 62aff72e1d0672da15f7e83c27f86259fea35b4c Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sat, 18 Jan 2025 14:39:13 +0100 Subject: [PATCH 35/83] =?UTF-8?q?=F0=9F=93=9D=20cleanup=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 63 +++++++++++++++++++++++-------------------------------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 48afc24..f78d43c 100644 --- a/README.md +++ b/README.md @@ -18,52 +18,41 @@ Install package using `pip` pip install tiddl ``` -# Usage +Run the package cli with `tiddl` -** In progress ** +```bash +$ tiddl -### File formatting +Usage: tiddl [OPTIONS] COMMAND [ARGS]... -| Key | Example | Comment | -| --------------- | ------------------------- | ------------------------------------------------------------- | -| title | Money Trees | | -| artist | Kendrick Lamar | | -| artists | Kendrick Lamar, Jay Rock | | -| album | good kid, m.A.A.d city | | -| number | 5 | number on album | -| disc_number | 1 | number of album volume | -| released | 10/22/2012 | release date | -| year | 2012 | year of release date | -| playlist | Kendrick Lamar Essentials | title of playlist will only appear when you download playlist | -| playlist_number | 15 | index of track on the playlist | -| id | 20556797 | id on Tidal | + TIDDL - Download Tidal tracks ✨ -# Modules +Options: + -v, --verbose Show debug logs + --help Show this message and exit. -You can also use TIDDL as module, it's fully typed so you will get type hints - -```python -from tiddl import TidalApi, Config - -config = Config() - -api = TidalApi( - config["token"], - config["user"]["user_id"], - config["user"]["country_code"] -) - -album_id = 284165608 - -album = api.getAlbum(album_id) - -print(f"{album["title"]} has {album["numberOfTracks"]} tracks!") +Commands: + ... ``` -# Testing +# Development +Clone the repository + +```bash +git clone https://github.com/oskvr37/tiddl ``` -python -m unittest tiddl/tests.py + +Install package with `--editable` flag + +```bash +pip install -e . +``` + +Run tests + +```bash +python -m unittest ``` # Resources From 5b95dce65454b82f8816502a33d3337ed303336b Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sat, 18 Jan 2025 14:57:34 +0100 Subject: [PATCH 36/83] =?UTF-8?q?=F0=9F=93=9D=20add=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index f78d43c..fdd486d 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ Run tests python -m unittest ``` +> [!WARNING] +> This app is for personal use only and is not affiliated with Tidal. Users must ensure their use complies with Tidal's terms of service and local copyright laws. Downloaded tracks are for personal use and may not be shared or redistributed. The developer assumes no responsibility for misuse of this app. + # Resources [Tidal API wiki](https://github.com/Fokka-Engineering/TIDAL) From 9e4b64e2d282f940125ecc62f6b2563564f1bc80 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sat, 18 Jan 2025 15:40:02 +0100 Subject: [PATCH 37/83] =?UTF-8?q?=E2=9C=A8=20add=20exception=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/file.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tiddl/cli/download/file.py b/tiddl/cli/download/file.py index 8a3dce6..0245aac 100644 --- a/tiddl/cli/download/file.py +++ b/tiddl/cli/download/file.py @@ -31,7 +31,10 @@ def FileGroup(ctx: Context, filename: TextIOWrapper): case _: raise click.UsageError(f"Unsupported file extension - {extension}") - resources = [TidalResource(string) for string in resource_strings] - ctx.obj.resources.extend(resources) + for string in resource_strings: + try: + ctx.obj.resources.append(TidalResource(string)) + except ValueError as e: + click.echo(click.style(e, "red")) - click.echo(click.style(f"Loaded {len(resources)} resources", "green")) + click.echo(click.style(f"Loaded {len(ctx.obj.resources)} resources", "green")) From a49558b40b3365f44e0d40f705847150fc3cf78d Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sat, 18 Jan 2025 15:47:17 +0100 Subject: [PATCH 38/83] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20reduce=20model=20imp?= =?UTF-8?q?orts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/api.py | 36 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/tiddl/api.py b/tiddl/api.py index 6a288ca..55817ff 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -1,20 +1,8 @@ import logging from requests import Session +from tiddl import models from .exceptions import AuthError, ApiError -from .models import ( - SessionResponse, - TrackQuality, - Track, - TrackStream, - AristAlbumsItems, - Album, - AlbumItems, - Playlist, - PlaylistItems, - Favorites, - Search, -) API_URL = "https://api.tidal.com/v1" @@ -51,14 +39,14 @@ class TidalApi: return data def getSession(self): - return SessionResponse( + return models.SessionResponse( **self._request( "sessions", ) ) - def getTrackStream(self, id: str | int, quality: TrackQuality): - return TrackStream( + def getTrackStream(self, id: str | int, quality: models.TrackQuality): + return models.TrackStream( **self._request( f"tracks/{id}/playbackinfo", { @@ -70,7 +58,7 @@ class TidalApi: ) def getTrack(self, id: str | int): - return Track( + return models.Track( **self._request(f"tracks/{id}", {"countryCode": self.country_code}) ) @@ -82,7 +70,7 @@ class TidalApi: if onlyNonAlbum: params.update({"filter": "EPSANDSINGLES"}) - return AristAlbumsItems( + return models.AristAlbumsItems( **self._request( f"artists/{id}/albums", params, @@ -90,12 +78,12 @@ class TidalApi: ) def getAlbum(self, id: str | int): - return Album( + return models.Album( **self._request(f"albums/{id}", {"countryCode": self.country_code}) ) def getAlbumItems(self, id: str | int, limit=ALBUM_ITEMS_LIMIT, offset=0): - return AlbumItems( + return models.AlbumItems( **self._request( f"albums/{id}/items", {"countryCode": self.country_code, "limit": limit, "offset": offset}, @@ -103,7 +91,7 @@ class TidalApi: ) def getPlaylist(self, uuid: str): - return Playlist( + return models.Playlist( **self._request( f"playlists/{uuid}", {"countryCode": self.country_code}, @@ -111,7 +99,7 @@ class TidalApi: ) def getPlaylistItems(self, uuid: str, limit=PLAYLIST_LIMIT, offset=0): - return PlaylistItems( + return models.PlaylistItems( **self._request( f"playlists/{uuid}/items", {"countryCode": self.country_code, "limit": limit, "offset": offset}, @@ -119,7 +107,7 @@ class TidalApi: ) def getFavorites(self): - return Favorites( + return models.Favorites( **self._request( f"users/{self.user_id}/favorites/ids", {"countryCode": self.country_code}, @@ -127,7 +115,7 @@ class TidalApi: ) def search(self, query: str): - return Search( + return models.Search( **self._request( "search", {"countryCode": self.country_code, "query": query} ) From ed000ffffbf6e28a92478dedddfc3e78141187f8 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sat, 18 Jan 2025 16:50:42 +0100 Subject: [PATCH 39/83] =?UTF-8?q?=E2=9C=A8=20add=20Video=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/models/api.py | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/tiddl/models/api.py b/tiddl/models/api.py index cb9b786..52c0fed 100644 --- a/tiddl/models/api.py +++ b/tiddl/models/api.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -from typing import Optional, List, Literal, Dict +from typing import Optional, List, Literal, Dict, Union from .track import Track @@ -96,9 +96,46 @@ class Playlist(BaseModel): lastItemAddedAt: Optional[str] = None +class VideoArtist(BaseModel): + id: int + name: str + type: str + picture: str + + +class Video(BaseModel): + id: int + title: str + volumeNumber: int + trackNumber: int + releaseDate: str + imagePath: Optional[str] = None + imageId: str + vibrantColor: str + duration: int + quality: str + streamReady: bool + adSupportedStreamReady: bool + djReady: bool + stemReady: bool + streamStartDate: str + allowStreaming: bool + explicit: bool + popularity: int + type: str + adsUrl: Optional[str] = None + adsPrePaywallOnly: bool + artist: VideoArtist + artists: List[VideoArtist] + album: Optional[str] = None + dateAdded: str + index: int + itemUuid: str + + class _PlaylistItem(BaseModel): - item: Track - type: Literal["track"] + item: Union[Track, Video] + type: Literal["track", "video"] cut: Literal[None] From 19272ef90d0f398946b138b7f944b6e12b8dbca0 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sat, 18 Jan 2025 16:51:24 +0100 Subject: [PATCH 40/83] =?UTF-8?q?=E2=9C=A8=20click=20echo=20instead=20of?= =?UTF-8?q?=20print?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index d825ab8..93bb74d 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -40,7 +40,7 @@ class TrackCollector: self._addItems(album_tracks.items) except Exception as e: - print(f"Error in adding resource: {resource}, {e}") + click.echo(click.style(f"Error in adding resource: {resource}, {e}", "red")) def _addTrack(self, track: Track): if track.allowStreaming: From 0734789aea4c0fe8f3dd26ee8a4d6e0bbfed571e Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Sun, 19 Jan 2025 22:34:36 +0100 Subject: [PATCH 41/83] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20rename=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/models/api.py | 11 ++++++----- tiddl/models/auth.py | 4 ++-- tiddl/models/track.py | 17 ++++++++--------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/tiddl/models/api.py b/tiddl/models/api.py index 52c0fed..679d540 100644 --- a/tiddl/models/api.py +++ b/tiddl/models/api.py @@ -1,4 +1,5 @@ from pydantic import BaseModel +from datetime import datetime from typing import Optional, List, Literal, Dict, Union from .track import Track @@ -26,7 +27,7 @@ class Items(BaseModel): totalNumberOfItems: int -class ArtistAlbum(BaseModel): +class AlbumArtist(BaseModel): id: int name: str type: Literal["MAIN", "FEATURED"] @@ -37,7 +38,7 @@ class Album(BaseModel): title: str duration: int streamReady: bool - streamStartDate: Optional[str] = None + streamStartDate: Optional[datetime] = None allowStreaming: bool premiumStreamingOnly: bool numberOfTracks: int @@ -55,8 +56,8 @@ class Album(BaseModel): popularity: int audioQuality: str audioModes: List[str] - artist: ArtistAlbum - artists: List[ArtistAlbum] + artist: AlbumArtist + artists: List[AlbumArtist] class AristAlbumsItems(Items): @@ -92,7 +93,7 @@ class Playlist(BaseModel): image: Optional[str] = None popularity: int squareImage: str - promotedArtists: List[ArtistAlbum] + promotedArtists: List[AlbumArtist] lastItemAddedAt: Optional[str] = None diff --git a/tiddl/models/auth.py b/tiddl/models/auth.py index 3b6bc76..c91d5a7 100644 --- a/tiddl/models/auth.py +++ b/tiddl/models/auth.py @@ -2,7 +2,7 @@ from typing import Optional from pydantic import BaseModel -class _User(BaseModel): +class AuthUser(BaseModel): userId: int email: str countryCode: str @@ -31,7 +31,7 @@ class _User(BaseModel): class AuthResponse(BaseModel): - user: _User + user: AuthUser scope: str clientName: str token_type: str diff --git a/tiddl/models/track.py b/tiddl/models/track.py index 8d9532e..1ee0c69 100644 --- a/tiddl/models/track.py +++ b/tiddl/models/track.py @@ -1,4 +1,5 @@ from pydantic import BaseModel +from datetime import datetime from typing import Optional, List, Dict, Literal, Optional @@ -22,14 +23,14 @@ class TrackStream(BaseModel): sampleRate: Optional[int] = None -class _Artist(BaseModel): +class TrackArtist(BaseModel): id: int name: str type: str picture: Optional[str] = None -class _Album(BaseModel): +class TrackAlbum(BaseModel): id: int title: str cover: Optional[str] = None @@ -48,7 +49,7 @@ class Track(BaseModel): adSupportedStreamReady: bool djReady: bool stemReady: bool - streamStartDate: Optional[str] = None + streamStartDate: Optional[datetime] = None premiumStreamingOnly: bool trackNumber: int volumeNumber: int @@ -60,13 +61,11 @@ class Track(BaseModel): isrc: str editable: bool explicit: bool - audioQuality: str + audioQuality: TrackQuality audioModes: List[str] mediaMetadata: Dict[str, List[str]] - artist: Optional[_Artist] = None - artists: List[_Artist] - album: _Album + artist: Optional[TrackArtist] = None + artists: List[TrackArtist] + album: TrackAlbum mixes: Dict[str, str] - - # this is used only when downloading playlist playlistNumber: Optional[int] = None From aec889c57df2e7b3bdf67991d69c6833e9f7e0c6 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 20 Jan 2025 14:18:56 +0100 Subject: [PATCH 42/83] =?UTF-8?q?=E2=9C=A8=20add=20`formatTrack`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_utils.py | 99 ++++++++++++++++++++++++++++++++++++++++++++- tiddl/utils.py | 50 +++++++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 3236daa..81250d3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,8 @@ import unittest +import json -from tiddl.utils import TidalResource +from tiddl.models import Track +from tiddl.utils import TidalResource, formatTrack class TestTidalResource(unittest.TestCase): @@ -42,5 +44,100 @@ class TestTidalResource(unittest.TestCase): TidalResource(resource) +class TestFormatTrack(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.track = Track( + **{ + "id": 66421438, + "title": "Shutdown", + "duration": 189, + "replayGain": -9.95, + "peak": 0.966051, + "allowStreaming": True, + "streamReady": True, + "adSupportedStreamReady": True, + "djReady": True, + "stemReady": False, + "streamStartDate": "2016-11-15T00:00:00.000+0000", + "premiumStreamingOnly": False, + "trackNumber": 9, + "volumeNumber": 1, + "version": None, + "popularity": 24, + "copyright": "(P) 2016 Boy Better Know", + "bpm": 69, + "url": "http://www.tidal.com/track/66421438", + "isrc": "GB7QY1500024", + "editable": False, + "explicit": True, + "audioQuality": "LOSSLESS", + "audioModes": ["STEREO"], + "mediaMetadata": {"tags": ["LOSSLESS", "HIRES_LOSSLESS"]}, + "artist": { + "id": 3566984, + "name": "Skepta", + "type": "MAIN", + "picture": "747af850-fa9c-4178-a3e6-49259b67df86", + }, + "artists": [ + { + "id": 3566984, + "name": "Skepta", + "type": "MAIN", + "picture": "747af850-fa9c-4178-a3e6-49259b67df86", + } + ], + "album": { + "id": 66421429, + "title": "Konnichiwa", + "cover": "e0c2f05e-e21f-47c5-9c37-2993437df27d", + "vibrantColor": "#ae3b31", + "videoCover": None, + }, + "mixes": {"TRACK_MIX": "001aa4abeb471e8f55f5784772b478"}, + "playlistNumber": None, + } + ) + + def test_templating(self): + test_cases = [ + ("{id}", "66421438"), + ("{title}", "Shutdown"), + ("{version}", ""), + ("{artist}", "Skepta"), + ("{artists}", "Skepta"), + ("{album}", "Konnichiwa"), + ("{number}", "9"), + ("{disc}", "1"), + ("{date}", "11-15-16"), + ("{year}", "2016"), + ("{playlist_number}", ""), + ("{bpm}", "69"), + ("{quality}", "high"), + ("{artist}/{album}/{title}", "Skepta/Konnichiwa/Shutdown"), + ] + + for template, expected_result in test_cases: + result = formatTrack(template, self.track) + self.assertEqual(result, expected_result) + + def test_invalid_characters(self): + test_cases = [ + "\\", + ":", + '"', + "?", + "<", + ">", + "|", + ] + + for template in test_cases: + with self.subTest(template=template): + with self.assertRaises(ValueError): + formatTrack(template, self.track) + + if __name__ == "__main__": unittest.main() diff --git a/tiddl/utils.py b/tiddl/utils.py index 033c1bb..53797de 100644 --- a/tiddl/utils.py +++ b/tiddl/utils.py @@ -1,6 +1,8 @@ +import re from urllib.parse import urlparse from typing import Literal, get_args +from tiddl.models import Track, QUALITY_TO_ARG ResourceTypeLiteral = Literal["track", "album", "playlist", "artist"] @@ -39,3 +41,51 @@ class TidalResource: def __str__(self) -> str: return f"{self.resource_type}/{self.resource_id}" + + +def sanitizeString(string: str) -> str: + pattern = r'[\\/:"*?<>|]+' + return re.sub(pattern, "", string) + + +def formatTrack(template: str, track: Track, date_format="%x") -> str: + disallowed_chars = r'[\\:"*?<>|]+' + invalid_chars = re.findall(disallowed_chars, template) + + if invalid_chars: + raise ValueError( + f"Template '{template}' contains disallowed characters: {' '.join(sorted(set(invalid_chars)))}" + ) + + artist = track.artist.name if track.artist else "" + features = [ + track_artist.name + for track_artist in track.artists + if track_artist.name != artist + ] + + track_dict: dict[str, str] = { + "id": str(track.id), + "title": track.title, + "version": track.version or "", + "artist": artist, + "artists": ", ".join(features + [artist]), + "features": ", ".join(features), + "album": track.album.title, + "number": str(track.trackNumber), + "disc": str(track.volumeNumber), + "date": ( + track.streamStartDate.strftime(date_format).replace("/", "-") + if track.streamStartDate + else "" + ), + "year": track.streamStartDate.strftime("%Y") if track.streamStartDate else "", + "playlist_number": str(track.playlistNumber or ""), + "bpm": str(track.bpm or ""), + "quality": QUALITY_TO_ARG[track.audioQuality], + } + + for key, value in track_dict.items(): + track_dict[key] = sanitizeString(value) + + return template.format(**track_dict) From b5813741231e30311b593381a52b6cb16dd66982 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 20 Jan 2025 15:12:55 +0100 Subject: [PATCH 43/83] =?UTF-8?q?=E2=9C=A8=20add=20download=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 93bb74d..24aad0f 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -1,5 +1,7 @@ import click +from pathlib import Path + from .fav import FavGroup from .file import FileGroup from .search import SearchGroup @@ -9,7 +11,7 @@ from ..ctx import Context, passContext from tiddl.download import downloadTrackStream from tiddl.models import TrackArg, ARG_TO_QUALITY, Track -from tiddl.utils import TidalResource +from tiddl.utils import TidalResource, formatTrack from tiddl.api import TidalApi @@ -54,8 +56,9 @@ class TrackCollector: @click.command("download") @click.option("--quality", "-q", type=click.Choice(TrackArg.__args__)) +@click.option("--output", "-o", type=str) @passContext -def DownloadCommand(ctx: Context, quality: TrackArg): +def DownloadCommand(ctx: Context, quality: TrackArg, output: str): """Download the tracks""" api = ctx.obj.getApi() @@ -77,10 +80,11 @@ def DownloadCommand(ctx: Context, quality: TrackArg): track_stream = api.getTrackStream(track.id, download_quality) stream_data, file_extension = downloadTrackStream(track_stream) - with open( - f"{track.id}.{track_stream.audioQuality.lower()}.{file_extension}", - "wb", - ) as f: + file_name = formatTrack(output or "{artist} - {title}", track) + path = Path(f"{file_name}.{file_extension}") + path.parent.mkdir(parents=True, exist_ok=True) + + with path.open("wb") as f: f.write(stream_data) From ce6a29bd1c2aff9b1aac87b9a6d04c0a54bfc796 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 20 Jan 2025 16:48:06 +0100 Subject: [PATCH 44/83] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20config=20with=20pyda?= =?UTF-8?q?ntic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cfg.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tiddl/cfg.py diff --git a/tiddl/cfg.py b/tiddl/cfg.py new file mode 100644 index 0000000..1dace9e --- /dev/null +++ b/tiddl/cfg.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel +from pathlib import Path + +from tiddl.models import TrackArg + + +CONFIG_PATH = Path.home() / "tiddl.json" +CONFIG_INDENT = 2 + + +class DownloadConfig(BaseModel): + quality: TrackArg = "high" + path: Path = Path.home() / "Music" / "Tiddl" + template: str = "{artist} - {title}" + + +class AuthConfig(BaseModel): + token: str = "" + refresh_token: str = "" + expires: int = 0 + user_id: str = "" + country_code: str = "" + + +class ConfigFile(BaseModel): + download: DownloadConfig = DownloadConfig() + auth: AuthConfig = AuthConfig() + + +TEMP = Path("tiddl.json") + +with TEMP.open("w") as f: + f.write(ConfigFile().model_dump_json(indent=CONFIG_INDENT)) + +with TEMP.open() as f: + config = ConfigFile.model_validate_json(f.read()) From 68bda7b325cb7d4c457acc544bc72df0412faa67 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 20 Jan 2025 16:48:23 +0100 Subject: [PATCH 45/83] =?UTF-8?q?=E2=9C=A8=20add=20template=20to=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 4 +++- tiddl/config.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 24aad0f..88d741d 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -75,12 +75,14 @@ def DownloadCommand(ctx: Context, quality: TrackArg, output: str): quality or ctx.obj.config.config["download"]["quality"] ] + template = output or ctx.obj.config.config["download"].get("template", "") + for track in track_collector.tracks: click.echo(f"Downloading {track.title}") track_stream = api.getTrackStream(track.id, download_quality) stream_data, file_extension = downloadTrackStream(track_stream) - file_name = formatTrack(output or "{artist} - {title}", track) + file_name = formatTrack(template, track) path = Path(f"{file_name}.{file_extension}") path.parent.mkdir(parents=True, exist_ok=True) diff --git a/tiddl/config.py b/tiddl/config.py index ee4200b..d00351c 100644 --- a/tiddl/config.py +++ b/tiddl/config.py @@ -15,6 +15,7 @@ DEFAULT_QUALITY: TrackArg = "high" class DownloadConfig(TypedDict, total=False): quality: TrackArg path: str + template: str class AuthConfig(TypedDict, total=False): @@ -36,7 +37,7 @@ class ConfigUpdate(TypedDict, total=False): DEFAULT_CONFIG: ConfigFile = { - "download": {"quality": DEFAULT_QUALITY, "path": str(DOWNLOAD_PATH)}, + "download": {"quality": DEFAULT_QUALITY, "path": str(DOWNLOAD_PATH), "template": "{artist} - {track}"}, "auth": {"token": "", "refresh_token": "", "expires": 0, "country_code": "", "user_id": ""}, } From 115c18829e36af20bcc4d73b286066e0918b090b Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 20 Jan 2025 20:19:20 +0100 Subject: [PATCH 46/83] =?UTF-8?q?=E2=9C=A8=20new=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cfg.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tiddl/cfg.py b/tiddl/cfg.py index 1dace9e..5c3dbde 100644 --- a/tiddl/cfg.py +++ b/tiddl/cfg.py @@ -1,5 +1,6 @@ from pydantic import BaseModel from pathlib import Path +from typing import Self from tiddl.models import TrackArg @@ -22,15 +23,18 @@ class AuthConfig(BaseModel): country_code: str = "" -class ConfigFile(BaseModel): +class Config(BaseModel): download: DownloadConfig = DownloadConfig() auth: AuthConfig = AuthConfig() + def save(self): + with open(CONFIG_PATH, "w") as f: + f.write(self.model_dump_json(indent=CONFIG_INDENT)) -TEMP = Path("tiddl.json") - -with TEMP.open("w") as f: - f.write(ConfigFile().model_dump_json(indent=CONFIG_INDENT)) - -with TEMP.open() as f: - config = ConfigFile.model_validate_json(f.read()) + @classmethod + def fromFile(cls) -> Self: + try: + with CONFIG_PATH.open() as f: + return Config.model_validate_json(f.read()) + except FileNotFoundError: + return Config() From 97692b20a451f4a2302fcb0bb4f2e4ec7302ed26 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 20 Jan 2025 20:20:23 +0100 Subject: [PATCH 47/83] =?UTF-8?q?=F0=9F=9A=80=20update=20description=20and?= =?UTF-8?q?=20python=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1772f97..ca570fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,9 @@ build-backend = "setuptools.build_meta" [project] name = "tiddl" version = "2.0.0" -description = "TIDDL (Tidal Downloader) is CLI application that allows downloading Tidal tracks." +description = "Download Tidal tracks with CLI downloader." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [{ name = "oskvr37" }] classifiers = [ "Environment :: Console", From 53e3b6837b6b5b0fd44193e03d2498a954d048ed Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 20 Jan 2025 22:02:02 +0100 Subject: [PATCH 48/83] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20remove=20type=20a?= =?UTF-8?q?nnotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cfg.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tiddl/cfg.py b/tiddl/cfg.py index 5c3dbde..99d6ef8 100644 --- a/tiddl/cfg.py +++ b/tiddl/cfg.py @@ -1,6 +1,5 @@ from pydantic import BaseModel from pathlib import Path -from typing import Self from tiddl.models import TrackArg @@ -32,7 +31,7 @@ class Config(BaseModel): f.write(self.model_dump_json(indent=CONFIG_INDENT)) @classmethod - def fromFile(cls) -> Self: + def fromFile(cls): try: with CONFIG_PATH.open() as f: return Config.model_validate_json(f.read()) From 958b572b86b9d81c502bb4df9d5658c632c85552 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 20 Jan 2025 22:18:45 +0100 Subject: [PATCH 49/83] =?UTF-8?q?=E2=9C=A8=20update=20code=20to=20new=20co?= =?UTF-8?q?nfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_api.py | 10 ++-- tiddl/cfg.py | 39 -------------- tiddl/cli/auth.py | 59 ++++++++------------- tiddl/cli/ctx.py | 16 ++---- tiddl/cli/download/__init__.py | 7 +-- tiddl/config.py | 94 +++++++++------------------------- 6 files changed, 57 insertions(+), 168 deletions(-) delete mode 100644 tiddl/cfg.py diff --git a/tests/test_api.py b/tests/test_api.py index b949906..0923f23 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -8,13 +8,13 @@ class TestApi(unittest.TestCase): api: TidalApi def setUp(self): - config = Config() - auth = config.config["auth"] + config = Config.fromFile() + auth = config.auth token, user_id, country_code = ( - auth.get("token"), - auth.get("user_id"), - auth.get("country_code"), + auth.token, + auth.user_id, + auth.country_code ) assert token, "No token found in config file" diff --git a/tiddl/cfg.py b/tiddl/cfg.py deleted file mode 100644 index 99d6ef8..0000000 --- a/tiddl/cfg.py +++ /dev/null @@ -1,39 +0,0 @@ -from pydantic import BaseModel -from pathlib import Path - -from tiddl.models import TrackArg - - -CONFIG_PATH = Path.home() / "tiddl.json" -CONFIG_INDENT = 2 - - -class DownloadConfig(BaseModel): - quality: TrackArg = "high" - path: Path = Path.home() / "Music" / "Tiddl" - template: str = "{artist} - {title}" - - -class AuthConfig(BaseModel): - token: str = "" - refresh_token: str = "" - expires: int = 0 - user_id: str = "" - country_code: str = "" - - -class Config(BaseModel): - download: DownloadConfig = DownloadConfig() - auth: AuthConfig = AuthConfig() - - def save(self): - with open(CONFIG_PATH, "w") as f: - f.write(self.model_dump_json(indent=CONFIG_INDENT)) - - @classmethod - def fromFile(cls): - try: - with CONFIG_PATH.open() as f: - return Config.model_validate_json(f.read()) - except FileNotFoundError: - return Config() diff --git a/tiddl/cli/auth.py b/tiddl/cli/auth.py index fb56c31..51e374e 100644 --- a/tiddl/cli/auth.py +++ b/tiddl/cli/auth.py @@ -5,6 +5,7 @@ from click import style from time import sleep, time from tiddl.auth import getDeviceAuth, getToken, refreshToken, removeToken, AuthError +from tiddl.config import AuthConfig from .ctx import passContext, Context @@ -21,25 +22,17 @@ def AuthGroup(): def login(ctx: Context): """Add token to the config""" - access_token, refresh_token, expires = ( - ctx.obj.config.config["auth"].get("token"), - ctx.obj.config.config["auth"].get("refresh_token"), - ctx.obj.config.config["auth"].get("expires", 0), - ) + auth = ctx.obj.config.auth - if access_token: - if refresh_token and time() > expires: + if auth.token: + if auth.refresh_token and time() > auth.expires: click.echo(style("Refreshing token...", fg="yellow")) - token = refreshToken(refresh_token) + token = refreshToken(auth.refresh_token) - ctx.obj.config.update( - { - "auth": { - "expires": token.expires_in + int(time()), - "token": token.access_token, - } - } - ) + ctx.obj.config.auth.expires = token.expires_in + int(time()) + ctx.obj.config.auth.token = token.access_token + + ctx.obj.config.save() click.echo(style("Authenticated!", fg="green")) return @@ -70,18 +63,17 @@ def login(ctx: Context): ) break - ctx.obj.config.update( - { - "auth": { - "token": token.access_token, - "refresh_token": token.refresh_token, - "expires": token.expires_in + int(time()), - "user_id": str(token.user.userId), - "country_code": token.user.countryCode, - } - } + new_auth = AuthConfig( + token=token.access_token, + refresh_token=token.refresh_token, + expires=token.expires_in + int(time()), + user_id=str(token.user.userId), + country_code=token.user.countryCode, ) + ctx.obj.config.auth = new_auth + ctx.obj.config.save() + click.echo(style("\nAuthenticated!", fg="green")) break @@ -92,7 +84,7 @@ def login(ctx: Context): def logout(ctx: Context): """Remove token from config""" - access_token = ctx.obj.config.config["auth"].get("token") + access_token = ctx.obj.config.auth.token if not access_token: click.echo(style("Not logged in", fg="yellow")) @@ -100,16 +92,7 @@ def logout(ctx: Context): removeToken(access_token) - ctx.obj.config.update( - { - "auth": { - "country_code": "", - "expires": 0, - "refresh_token": "", - "token": "", - "user_id": "", - } - } - ) + ctx.obj.config.auth = AuthConfig() + ctx.obj.config.save() click.echo(style("Logged out!", fg="green")) diff --git a/tiddl/cli/ctx.py b/tiddl/cli/ctx.py index 25b444a..d641a0e 100644 --- a/tiddl/cli/ctx.py +++ b/tiddl/cli/ctx.py @@ -7,27 +7,21 @@ from tiddl.api import TidalApi from tiddl.config import Config from tiddl.utils import TidalResource + class ContextObj: api: TidalApi | None config: Config resources: list[TidalResource] - def __init__(self) -> None: - self.config = Config() + self.config = Config.fromFile() self.resources = [] self.api = None - config_auth = self.config.config["auth"] + auth = self.config.auth - token, user_id, country_code = ( - config_auth.get("token"), - config_auth.get("user_id"), - config_auth.get("country_code"), - ) - - if token and user_id and country_code: - self.api = TidalApi(token, user_id, country_code) + if auth.token and auth.user_id and auth.country_code: + self.api = TidalApi(auth.token, auth.user_id, auth.country_code) def getApi(self) -> TidalApi: if self.api is None: diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 88d741d..787a6aa 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -71,11 +71,8 @@ def DownloadCommand(ctx: Context, quality: TrackArg, output: str): click.echo("No tracks found.") return - download_quality = ARG_TO_QUALITY[ - quality or ctx.obj.config.config["download"]["quality"] - ] - - template = output or ctx.obj.config.config["download"].get("template", "") + download_quality = ARG_TO_QUALITY[quality or ctx.obj.config.download.quality] + template = output or ctx.obj.config.download.template for track in track_collector.tracks: click.echo(f"Downloading {track.title}") diff --git a/tiddl/config.py b/tiddl/config.py index d00351c..99d6ef8 100644 --- a/tiddl/config.py +++ b/tiddl/config.py @@ -1,85 +1,39 @@ -import json - -from dataclasses import dataclass, field -from typing import TypedDict +from pydantic import BaseModel from pathlib import Path from tiddl.models import TrackArg CONFIG_PATH = Path.home() / "tiddl.json" -DOWNLOAD_PATH = Path.home() / "Music" / "Tiddl" -DEFAULT_QUALITY: TrackArg = "high" +CONFIG_INDENT = 2 -class DownloadConfig(TypedDict, total=False): - quality: TrackArg - path: str - template: str +class DownloadConfig(BaseModel): + quality: TrackArg = "high" + path: Path = Path.home() / "Music" / "Tiddl" + template: str = "{artist} - {title}" -class AuthConfig(TypedDict, total=False): - token: str - refresh_token: str - expires: int - user_id: str - country_code: str +class AuthConfig(BaseModel): + token: str = "" + refresh_token: str = "" + expires: int = 0 + user_id: str = "" + country_code: str = "" -class ConfigFile(TypedDict): - download: DownloadConfig - auth: AuthConfig - - -class ConfigUpdate(TypedDict, total=False): - download: DownloadConfig - auth: AuthConfig - - -DEFAULT_CONFIG: ConfigFile = { - "download": {"quality": DEFAULT_QUALITY, "path": str(DOWNLOAD_PATH), "template": "{artist} - {track}"}, - "auth": {"token": "", "refresh_token": "", "expires": 0, "country_code": "", "user_id": ""}, -} - - -@dataclass -class Config: - """Configuration class for loading and updating CLI configuration file.""" - - config: ConfigFile = field(default_factory=lambda: DEFAULT_CONFIG) - - def __post_init__(self): - """Merge loaded configuration with defaults after initialization.""" - - try: - with open(CONFIG_PATH, "r") as f: - loaded_config: ConfigFile = json.load(f) - - self.config = merge(loaded_config, self.config) - - except (FileNotFoundError, json.JSONDecodeError): - pass - - def update(self, new_config: ConfigUpdate): - """Update the configuration with the new values and save it to the file.""" - - self.config = merge(new_config, self.config) +class Config(BaseModel): + download: DownloadConfig = DownloadConfig() + auth: AuthConfig = AuthConfig() + def save(self): with open(CONFIG_PATH, "w") as f: - json.dump(self.config, f, indent=2) + f.write(self.model_dump_json(indent=CONFIG_INDENT)) - -def merge(source, destination): - """ - Recursively merge two dictionaries. - https://stackoverflow.com/a/20666342 - """ - - for key, value in source.items(): - if isinstance(value, dict): - node = destination.setdefault(key, {}) - merge(value, node) - else: - destination[key] = value - - return destination + @classmethod + def fromFile(cls): + try: + with CONFIG_PATH.open() as f: + return Config.model_validate_json(f.read()) + except FileNotFoundError: + return Config() From cb7d57be3bed57a72e04582dbf311a691efcff1f Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Tue, 21 Jan 2025 17:57:29 +0100 Subject: [PATCH 50/83] =?UTF-8?q?=E2=9C=A8=20update=20TidalResource=20with?= =?UTF-8?q?=20pydantic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_utils.py | 10 ++++----- tiddl/cli/download/__init__.py | 10 ++++----- tiddl/cli/download/file.py | 2 +- tiddl/cli/download/url.py | 2 +- tiddl/utils.py | 41 +++++++++++++++++----------------- 5 files changed, 31 insertions(+), 34 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 81250d3..34d8d2b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,12 +1,10 @@ import unittest -import json from tiddl.models import Track from tiddl.utils import TidalResource, formatTrack class TestTidalResource(unittest.TestCase): - def test_resource_parsing(self): positive_cases = [ ("https://tidal.com/browse/track/12345678", "track", "12345678"), @@ -21,9 +19,9 @@ class TestTidalResource(unittest.TestCase): for resource, expected_type, expected_id in positive_cases: with self.subTest(resource=resource): - tidal_url = TidalResource(resource) - self.assertEqual(tidal_url.resource_type, expected_type) - self.assertEqual(tidal_url.resource_id, expected_id) + tidal_resource = TidalResource.fromString(resource) + self.assertEqual(tidal_resource.type, expected_type) + self.assertEqual(tidal_resource.id, expected_id) def test_failing_cases(self): failing_cases = [ @@ -41,7 +39,7 @@ class TestTidalResource(unittest.TestCase): for resource in failing_cases: with self.subTest(resource=resource): with self.assertRaises(ValueError): - TidalResource(resource) + TidalResource.fromString(resource) class TestFormatTrack(unittest.TestCase): diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 787a6aa..8ee08a9 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -22,21 +22,21 @@ class TrackCollector: def addResource(self, resource: TidalResource): try: - match resource.resource_type: + match resource.type: case "track": - track = self.api.getTrack(resource.resource_id) + track = self.api.getTrack(resource.id) self._addTrack(track) case "album": - album_tracks = self.api.getAlbumItems(resource.resource_id) + album_tracks = self.api.getAlbumItems(resource.id) self._addItems(album_tracks.items) case "playlist": - playlist_tracks = self.api.getPlaylistItems(resource.resource_id) + playlist_tracks = self.api.getPlaylistItems(resource.id) self._addItems(playlist_tracks.items) case "artist": - artist_albums = self.api.getArtistAlbums(resource.resource_id) + artist_albums = self.api.getArtistAlbums(resource.id) for artist_album in artist_albums.items: album_tracks = self.api.getAlbumItems(artist_album.id) self._addItems(album_tracks.items) diff --git a/tiddl/cli/download/file.py b/tiddl/cli/download/file.py index 0245aac..9204101 100644 --- a/tiddl/cli/download/file.py +++ b/tiddl/cli/download/file.py @@ -33,7 +33,7 @@ def FileGroup(ctx: Context, filename: TextIOWrapper): for string in resource_strings: try: - ctx.obj.resources.append(TidalResource(string)) + ctx.obj.resources.append(TidalResource.fromString(string)) except ValueError as e: click.echo(click.style(e, "red")) diff --git a/tiddl/cli/download/url.py b/tiddl/cli/download/url.py index 7552f57..265987d 100644 --- a/tiddl/cli/download/url.py +++ b/tiddl/cli/download/url.py @@ -8,7 +8,7 @@ from tiddl.utils import TidalResource class TidalURL(click.ParamType): def convert(self, value: str, param, ctx) -> TidalResource: try: - return TidalResource(value) + return TidalResource.fromString(value) except ValueError as e: self.fail(message=str(e), param=param, ctx=ctx) diff --git a/tiddl/utils.py b/tiddl/utils.py index 53797de..b6ba52e 100644 --- a/tiddl/utils.py +++ b/tiddl/utils.py @@ -1,4 +1,6 @@ import re + +from pydantic import BaseModel from urllib.parse import urlparse from typing import Literal, get_args @@ -7,40 +9,37 @@ from tiddl.models import Track, QUALITY_TO_ARG ResourceTypeLiteral = Literal["track", "album", "playlist", "artist"] -class TidalResource: - """ - A parser for Tidal resource URLs or strings. +class TidalResource(BaseModel): + type: ResourceTypeLiteral + id: str - Extracts the resource type (e.g., "track", "album") and resource ID - from a given input string. The input string can either be a full URL or a - shorthand string in the format "resource_type/resource_id" (e.g., "track/12345678"). - """ + @property + def url(self) -> str: + return f"https://listen.tidal.com/{self.type}/{self.id}" - resource: str - resource_type: ResourceTypeLiteral - resource_id: str - url: str + @classmethod + def fromString(cls, string: str): + """ + Extracts the resource type (e.g., "track", "album") + and resource ID from a given input string. - def __init__(self, resource: str) -> None: - self.resource = resource + The input string can either be a full URL or a shorthand string + in the format `resource_type/resource_id` (e.g., `track/12345678`). + """ - path = urlparse(self.resource).path + path = urlparse(string).path resource_type, resource_id = path.split("/")[-2:] if resource_type not in get_args(ResourceTypeLiteral): raise ValueError(f"Invalid resource type: {resource_type}") - self.resource_type = resource_type # type: ignore - - if not resource_id.isdigit() and self.resource_type != "playlist": + if not resource_id.isdigit() and resource_type != "playlist": raise ValueError(f"Invalid resource id: {resource_id}") - self.resource_id = resource_id - - self.url = f"https://listen.tidal.com/{self.resource_type}/{self.resource_id}" + return cls(type=resource_type, id=resource_id) # type: ignore def __str__(self) -> str: - return f"{self.resource_type}/{self.resource_id}" + return f"{self.type}/{self.id}" def sanitizeString(string: str) -> str: From 1279331dc789466800533870d29f13233e65acd8 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Tue, 21 Jan 2025 18:47:54 +0100 Subject: [PATCH 51/83] =?UTF-8?q?=E2=9C=A8=20add=20fav=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/fav.py | 40 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/tiddl/cli/download/fav.py b/tiddl/cli/download/fav.py index b6706d1..5a38259 100644 --- a/tiddl/cli/download/fav.py +++ b/tiddl/cli/download/fav.py @@ -1,12 +1,46 @@ import click +from tiddl.utils import TidalResource, ResourceTypeLiteral from ..ctx import Context, passContext +ResourceTypeList: list[ResourceTypeLiteral] = ["track", "album", "artist", "playlist"] + @click.group("fav") -@click.argument("type") +@click.option( + "--resource", + "-r", + "resource_types", + multiple=True, + type=click.Choice(ResourceTypeList), +) @passContext -def FavGroup(ctx: Context, type: str): +def FavGroup(ctx: Context, resource_types: list[ResourceTypeLiteral]): """Get your Tidal favorites""" - # TODO: fetch user favorites + api = ctx.obj.getApi() + + favorites = api.getFavorites() + favorites_dict = favorites.model_dump() + + click.echo(type(resource_types)) + + if not resource_types: + resource_types = ResourceTypeList + + stats: dict[ResourceTypeLiteral, int] = dict() + + for resource_type in resource_types: + resources = favorites_dict[resource_type.upper()] + + stats[resource_type] = len(resources) + + for resource_id in resources: + ctx.obj.resources.append(TidalResource(id=resource_id, type=resource_type)) + + # TODO: show pretty message + + click.echo(click.style(f"Loaded {len(ctx.obj.resources)} resources", "green")) + + for resource_type, count in stats.items(): + click.echo(f"{resource_type} - {count}") From fe10c0b7a972f643cc04dc4b585b33ad3d1791b8 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Wed, 22 Jan 2025 19:33:15 +0100 Subject: [PATCH 52/83] =?UTF-8?q?=F0=9F=92=A1=20important=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/models/track.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tiddl/models/track.py b/tiddl/models/track.py index 1ee0c69..6a5d612 100644 --- a/tiddl/models/track.py +++ b/tiddl/models/track.py @@ -64,6 +64,7 @@ class Track(BaseModel): audioQuality: TrackQuality audioModes: List[str] mediaMetadata: Dict[str, List[str]] + # for real, artist can be None? artist: Optional[TrackArtist] = None artists: List[TrackArtist] album: TrackAlbum From 791f100300baebeecc212bd988c9bcc8f42408bd Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Wed, 22 Jan 2025 19:33:42 +0100 Subject: [PATCH 53/83] =?UTF-8?q?=E2=9C=A8=20add=20max=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/api.py | 6 ++++++ tiddl/cli/download/__init__.py | 9 +++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tiddl/api.py b/tiddl/api.py index 55817ff..e16cb67 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -83,6 +83,12 @@ class TidalApi: ) def getAlbumItems(self, id: str | int, limit=ALBUM_ITEMS_LIMIT, offset=0): + MAX_LIMIT = 100 + + if limit > MAX_LIMIT: + logging.warning(f"Too big page, max page size is {MAX_LIMIT}") + limit = MAX_LIMIT + return models.AlbumItems( **self._request( f"albums/{id}/items", diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 8ee08a9..19e1f23 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -28,7 +28,7 @@ class TrackCollector: self._addTrack(track) case "album": - album_tracks = self.api.getAlbumItems(resource.id) + album_tracks = self.api.getAlbumItems(resource.id, limit=100) self._addItems(album_tracks.items) case "playlist": @@ -76,11 +76,12 @@ def DownloadCommand(ctx: Context, quality: TrackArg, output: str): for track in track_collector.tracks: click.echo(f"Downloading {track.title}") - track_stream = api.getTrackStream(track.id, download_quality) - stream_data, file_extension = downloadTrackStream(track_stream) + # track_stream = api.getTrackStream(track.id, download_quality) + # stream_data, file_extension = downloadTrackStream(track_stream) + stream_data, file_extension = b"", "m4a" file_name = formatTrack(template, track) - path = Path(f"{file_name}.{file_extension}") + path = ctx.obj.config.download.path / f"{file_name}.{file_extension}" path.parent.mkdir(parents=True, exist_ok=True) with path.open("wb") as f: From 5eac4598f5681d34ff9b42bd6afac7a361245324 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Thu, 23 Jan 2025 19:26:03 +0100 Subject: [PATCH 54/83] =?UTF-8?q?=E2=9C=A8=20#68=20#69?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_utils.py | 19 ++++++----------- tiddl/utils.py | 52 ++++++++++++++++++++++----------------------- 2 files changed, 32 insertions(+), 39 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 34d8d2b..24e5aeb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -108,28 +108,23 @@ class TestFormatTrack(unittest.TestCase): ("{album}", "Konnichiwa"), ("{number}", "9"), ("{disc}", "1"), - ("{date}", "11-15-16"), + ("{date:%m-%d-%y}", "11-15-16"), + ("{date:%Y}", "2016"), ("{year}", "2016"), ("{playlist_number}", ""), ("{bpm}", "69"), ("{quality}", "high"), ("{artist}/{album}/{title}", "Skepta/Konnichiwa/Shutdown"), + ("{number:02d}. {title}", "09. Shutdown"), ] for template, expected_result in test_cases: - result = formatTrack(template, self.track) - self.assertEqual(result, expected_result) + with self.subTest(template=template, expected_result=expected_result): + result = formatTrack(template, self.track) + self.assertEqual(result, expected_result) def test_invalid_characters(self): - test_cases = [ - "\\", - ":", - '"', - "?", - "<", - ">", - "|", - ] + test_cases = ["\\", ":", '"', "?", "<", ">", "|", "{number}:{title}"] for template in test_cases: with self.subTest(template=template): diff --git a/tiddl/utils.py b/tiddl/utils.py index b6ba52e..913543b 100644 --- a/tiddl/utils.py +++ b/tiddl/utils.py @@ -47,44 +47,42 @@ def sanitizeString(string: str) -> str: return re.sub(pattern, "", string) -def formatTrack(template: str, track: Track, date_format="%x") -> str: - disallowed_chars = r'[\\:"*?<>|]+' - invalid_chars = re.findall(disallowed_chars, template) - - if invalid_chars: - raise ValueError( - f"Template '{template}' contains disallowed characters: {' '.join(sorted(set(invalid_chars)))}" - ) - - artist = track.artist.name if track.artist else "" +def formatTrack(template: str, track: Track, album_artist="", playlist_title="") -> str: + artist = sanitizeString(track.artist.name) if track.artist else "" features = [ - track_artist.name + sanitizeString(track_artist.name) for track_artist in track.artists if track_artist.name != artist ] - track_dict: dict[str, str] = { + track_dict = { "id": str(track.id), - "title": track.title, - "version": track.version or "", + "title": sanitizeString(track.title), + "version": sanitizeString(track.version or ""), "artist": artist, "artists": ", ".join(features + [artist]), "features": ", ".join(features), - "album": track.album.title, - "number": str(track.trackNumber), - "disc": str(track.volumeNumber), - "date": ( - track.streamStartDate.strftime(date_format).replace("/", "-") - if track.streamStartDate - else "" - ), + "album": sanitizeString(track.album.title), + "number": track.trackNumber, + "disc": track.volumeNumber, + "date": (track.streamStartDate if track.streamStartDate else ""), + # i think we can remove year as we are able to format date "year": track.streamStartDate.strftime("%Y") if track.streamStartDate else "", - "playlist_number": str(track.playlistNumber or ""), - "bpm": str(track.bpm or ""), + "playlist": sanitizeString(playlist_title), + "bpm": track.bpm or "", "quality": QUALITY_TO_ARG[track.audioQuality], + "album_artist": sanitizeString(album_artist), + "playlist_number": track.playlistNumber or "", } - for key, value in track_dict.items(): - track_dict[key] = sanitizeString(value) + formatted_track = template.format(**track_dict) - return template.format(**track_dict) + disallowed_chars = r'[\\:"*?<>|]+' + invalid_chars = re.findall(disallowed_chars, formatted_track) + + if invalid_chars: + raise ValueError( + f"Template '{template}' and formatted track '{formatted_track}' contains disallowed characters: {' '.join(sorted(set(invalid_chars)))}" + ) + + return formatted_track From 1e5896b20a0f07352c84bb9cabc2376e6f0aa778 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Thu, 23 Jan 2025 20:38:35 +0100 Subject: [PATCH 55/83] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20update=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/models/api.py | 105 ++++++++++++++++++++++++++---------------- tiddl/models/track.py | 2 +- 2 files changed, 66 insertions(+), 41 deletions(-) diff --git a/tiddl/models/api.py b/tiddl/models/api.py index 679d540..f5f2ccf 100644 --- a/tiddl/models/api.py +++ b/tiddl/models/api.py @@ -27,6 +27,48 @@ class Items(BaseModel): totalNumberOfItems: int +class BaseVideoArtist(BaseModel): + id: int + name: str + type: str + picture: Optional[str] = None + + +class BaseVideoAlbum(BaseModel): + id: int + title: str + cover: str + vibrantColor: str + videoCover: Optional[str] = None + + +class BaseVideo(BaseModel): + id: int + title: str + volumeNumber: int + trackNumber: int + releaseDate: str + imagePath: Optional[str] = None + imageId: str + vibrantColor: str + duration: int + quality: str + streamReady: bool + adSupportedStreamReady: bool + djReady: bool + stemReady: bool + streamStartDate: str + allowStreaming: bool + explicit: bool + popularity: int + type: str + adsUrl: Optional[str] = None + adsPrePaywallOnly: bool + artist: BaseVideoArtist + artists: List[BaseVideoArtist] + album: Optional[BaseVideoAlbum] = None + + class AlbumArtist(BaseModel): id: int name: str @@ -64,13 +106,21 @@ class AristAlbumsItems(Items): items: List[Album] -class _AlbumTrack(BaseModel): +ItemType = Literal["track", "video"] + + +class VideoItem(BaseModel): + item: BaseVideo + type: ItemType = "video" + + +class TrackItem(BaseModel): item: Track - type: Literal["track"] + type: ItemType = "track" class AlbumItems(Items): - items: List[_AlbumTrack] + items: List[Union[TrackItem, VideoItem]] class _Creator(BaseModel): @@ -97,51 +147,26 @@ class Playlist(BaseModel): lastItemAddedAt: Optional[str] = None -class VideoArtist(BaseModel): - id: int - name: str - type: str - picture: str - - -class Video(BaseModel): - id: int - title: str - volumeNumber: int - trackNumber: int - releaseDate: str - imagePath: Optional[str] = None - imageId: str - vibrantColor: str - duration: int - quality: str - streamReady: bool - adSupportedStreamReady: bool - djReady: bool - stemReady: bool - streamStartDate: str - allowStreaming: bool - explicit: bool - popularity: int - type: str - adsUrl: Optional[str] = None - adsPrePaywallOnly: bool - artist: VideoArtist - artists: List[VideoArtist] - album: Optional[str] = None +class PlaylistVideo(BaseVideo): dateAdded: str index: int itemUuid: str -class _PlaylistItem(BaseModel): - item: Union[Track, Video] - type: Literal["track", "video"] - cut: Literal[None] +class PlaylistVideoItem(BaseModel): + item: PlaylistVideo + type: ItemType = "video" + cut: None + + +class PlaylistTrackItem(BaseModel): + item: Track + type: ItemType = "track" + cut: None class PlaylistItems(Items): - items: List[_PlaylistItem] + items: List[Union[PlaylistTrackItem, PlaylistVideoItem]] class Favorites(BaseModel): diff --git a/tiddl/models/track.py b/tiddl/models/track.py index 6a5d612..8990a7f 100644 --- a/tiddl/models/track.py +++ b/tiddl/models/track.py @@ -1,6 +1,6 @@ from pydantic import BaseModel from datetime import datetime -from typing import Optional, List, Dict, Literal, Optional +from typing import Optional, List, Dict, Literal TrackQuality = Literal["LOW", "HIGH", "LOSSLESS", "HI_RES_LOSSLESS"] From 261e5f3c28b35c6ac2dfaa4d15006a94b50c71db Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Thu, 23 Jan 2025 21:20:48 +0100 Subject: [PATCH 56/83] =?UTF-8?q?=E2=9C=A8=20update=20DownloadCommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 136 ++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 63 deletions(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 19e1f23..f5c26bd 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -1,7 +1,5 @@ import click -from pathlib import Path - from .fav import FavGroup from .file import FileGroup from .search import SearchGroup @@ -11,82 +9,94 @@ from ..ctx import Context, passContext from tiddl.download import downloadTrackStream from tiddl.models import TrackArg, ARG_TO_QUALITY, Track -from tiddl.utils import TidalResource, formatTrack -from tiddl.api import TidalApi - - -class TrackCollector: - def __init__(self, api: TidalApi): - self.api = api - self.tracks: list[Track] = [] - - def addResource(self, resource: TidalResource): - try: - match resource.type: - case "track": - track = self.api.getTrack(resource.id) - self._addTrack(track) - - case "album": - album_tracks = self.api.getAlbumItems(resource.id, limit=100) - self._addItems(album_tracks.items) - - case "playlist": - playlist_tracks = self.api.getPlaylistItems(resource.id) - self._addItems(playlist_tracks.items) - - case "artist": - artist_albums = self.api.getArtistAlbums(resource.id) - for artist_album in artist_albums.items: - album_tracks = self.api.getAlbumItems(artist_album.id) - self._addItems(album_tracks.items) - - except Exception as e: - click.echo(click.style(f"Error in adding resource: {resource}, {e}", "red")) - - def _addTrack(self, track: Track): - if track.allowStreaming: - self.tracks.append(track) - - def _addItems(self, items): - for item in items: - if item.type == "track": - self._addTrack(item.item) +from tiddl.utils import formatTrack @click.command("download") @click.option("--quality", "-q", type=click.Choice(TrackArg.__args__)) -@click.option("--output", "-o", type=str) +@click.option("--output", "-o", "template", type=str) @passContext -def DownloadCommand(ctx: Context, quality: TrackArg, output: str): +def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None): """Download the tracks""" - api = ctx.obj.getApi() - track_collector = TrackCollector(api) - - for resource in ctx.obj.resources: - track_collector.addResource(resource) - - if not track_collector.tracks: - click.echo("No tracks found.") - return - download_quality = ARG_TO_QUALITY[quality or ctx.obj.config.download.quality] - template = output or ctx.obj.config.download.template + download_path = ctx.obj.config.download.path - for track in track_collector.tracks: - click.echo(f"Downloading {track.title}") - # track_stream = api.getTrackStream(track.id, download_quality) - # stream_data, file_extension = downloadTrackStream(track_stream) - stream_data, file_extension = b"", "m4a" + # TODO: add track/album/playlist templates to config + format_template = template or ctx.obj.config.download.template - file_name = formatTrack(template, track) - path = ctx.obj.config.download.path / f"{file_name}.{file_extension}" + api = ctx.obj.getApi() + + def downloadTrack(track: Track, file_name: str) -> None: + if not track.allowStreaming: + click.echo( + f"{click.style('✖', 'yellow')} Track {click.style(file_name, 'yellow')} does not allow streaming" + ) + return + + click.echo( + f"{click.style('✔', 'green')} Downloading track {click.style(file_name, 'green')}" + ) + + track_stream = api.getTrackStream(track.id, download_quality) + stream_data, file_extension = downloadTrackStream(track_stream) + + path = download_path / f"{file_name}.{file_extension}" path.parent.mkdir(parents=True, exist_ok=True) with path.open("wb") as f: f.write(stream_data) + # TODO: check for artists in resources + # then add their resources to the list + + for resource in ctx.obj.resources: + match resource.type: + case "track": + track = api.getTrack(resource.id) + file_name = formatTrack(template=format_template, track=track) + + downloadTrack( + track=track, + file_name=file_name, + ) + + case "album": + album = api.getAlbum(resource.id) + click.echo(f"★ Album {album.title}") + + # TODO: fetch all items + album_items = api.getAlbumItems(resource.id, limit=100) + + for item in album_items.items: + if isinstance(item.item, Track): + track = item.item + + file_name = formatTrack( + template=format_template, + track=track, + album_artist=album.artist.name, + ) + + downloadTrack(track=track, file_name=file_name) + + case "playlist": + playlist = api.getPlaylist(resource.id) + click.echo(f"★ Playlist {playlist.title}") + + # TODO: fetch all items + playlist_items = api.getPlaylistItems(resource.id) + + for item in playlist_items.items: + if isinstance(item.item, Track): + file_name = formatTrack( + template=format_template, + track=item.item, + playlist_title=playlist.title, + ) + + downloadTrack(track=item.item, file_name=file_name) + UrlGroup.add_command(DownloadCommand) SearchGroup.add_command(DownloadCommand) From 410bf26c0b490303ede27115bd3e6956ec789b1f Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Thu, 23 Jan 2025 21:21:24 +0100 Subject: [PATCH 57/83] =?UTF-8?q?=F0=9F=93=9D=20move=20warning=20higher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fdd486d..7589c69 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ TIDDL is Python CLI application that allows downloading Tidal tracks. It's inspired by [Tidal-Media-Downloader](https://github.com/yaronzz/Tidal-Media-Downloader) - currently not mantained project. This repository will contain features requests from that project and will be the enhanced version. +> [!WARNING] +> This app is for personal use only and is not affiliated with Tidal. Users must ensure their use complies with Tidal's terms of service and local copyright laws. Downloaded tracks are for personal use and may not be shared or redistributed. The developer assumes no responsibility for misuse of this app. + # Installation Install package using `pip` @@ -55,9 +58,6 @@ Run tests python -m unittest ``` -> [!WARNING] -> This app is for personal use only and is not affiliated with Tidal. Users must ensure their use complies with Tidal's terms of service and local copyright laws. Downloaded tracks are for personal use and may not be shared or redistributed. The developer assumes no responsibility for misuse of this app. - # Resources [Tidal API wiki](https://github.com/Fokka-Engineering/TIDAL) From 37e0eeb4fd6a20623eaed00f4068c7dfb2fbc6bd Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Thu, 23 Jan 2025 23:04:34 +0100 Subject: [PATCH 58/83] =?UTF-8?q?=E2=9C=A8=20add=20templates=20and=20playl?= =?UTF-8?q?ist=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 23 ++++++++++++++--------- tiddl/config.py | 15 ++++++++++++--- tiddl/models/api.py | 8 +++++++- tiddl/models/track.py | 1 - tiddl/utils.py | 4 ++-- 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index f5c26bd..9a39a0c 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -8,7 +8,7 @@ from .url import UrlGroup from ..ctx import Context, passContext from tiddl.download import downloadTrackStream -from tiddl.models import TrackArg, ARG_TO_QUALITY, Track +from tiddl.models import TrackArg, ARG_TO_QUALITY, Track, PlaylistTrack from tiddl.utils import formatTrack @@ -22,9 +22,6 @@ def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None download_quality = ARG_TO_QUALITY[quality or ctx.obj.config.download.quality] download_path = ctx.obj.config.download.path - # TODO: add track/album/playlist templates to config - format_template = template or ctx.obj.config.download.template - api = ctx.obj.getApi() def downloadTrack(track: Track, file_name: str) -> None: @@ -38,6 +35,9 @@ def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None f"{click.style('✔', 'green')} Downloading track {click.style(file_name, 'green')}" ) + # TODO: check if file already exists. + # will need to predict file extension + track_stream = api.getTrackStream(track.id, download_quality) stream_data, file_extension = downloadTrackStream(track_stream) @@ -54,7 +54,9 @@ def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None match resource.type: case "track": track = api.getTrack(resource.id) - file_name = formatTrack(template=format_template, track=track) + file_name = formatTrack( + template=template or ctx.obj.config.template.track, track=track + ) downloadTrack( track=track, @@ -73,7 +75,7 @@ def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None track = item.item file_name = formatTrack( - template=format_template, + template=template or ctx.obj.config.template.album, track=track, album_artist=album.artist.name, ) @@ -88,11 +90,14 @@ def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None playlist_items = api.getPlaylistItems(resource.id) for item in playlist_items.items: - if isinstance(item.item, Track): + if isinstance(item.item, PlaylistTrack): + track = item.item + file_name = formatTrack( - template=format_template, - track=item.item, + template=template or ctx.obj.config.template.playlist, + track=track, playlist_title=playlist.title, + playlist_index=track.index // 100000, ) downloadTrack(track=item.item, file_name=file_name) diff --git a/tiddl/config.py b/tiddl/config.py index 99d6ef8..5b52700 100644 --- a/tiddl/config.py +++ b/tiddl/config.py @@ -8,10 +8,15 @@ CONFIG_PATH = Path.home() / "tiddl.json" CONFIG_INDENT = 2 +class TemplateConfig(BaseModel): + track: str = "{artist} - {title}" + album: str = "{album_artist}/{album}/{number:02d}. {title}" + playlist: str = "{playlist}/{playlist_number:02d}. {artist} - {title}" + + class DownloadConfig(BaseModel): quality: TrackArg = "high" path: Path = Path.home() / "Music" / "Tiddl" - template: str = "{artist} - {title}" class AuthConfig(BaseModel): @@ -23,6 +28,7 @@ class AuthConfig(BaseModel): class Config(BaseModel): + template: TemplateConfig = TemplateConfig() download: DownloadConfig = DownloadConfig() auth: AuthConfig = AuthConfig() @@ -34,6 +40,9 @@ class Config(BaseModel): def fromFile(cls): try: with CONFIG_PATH.open() as f: - return Config.model_validate_json(f.read()) + config = cls.model_validate_json(f.read()) except FileNotFoundError: - return Config() + config = cls() + + config.save() + return config diff --git a/tiddl/models/api.py b/tiddl/models/api.py index f5f2ccf..d5e1e56 100644 --- a/tiddl/models/api.py +++ b/tiddl/models/api.py @@ -159,8 +159,14 @@ class PlaylistVideoItem(BaseModel): cut: None +class PlaylistTrack(Track): + dateAdded: str + index: int + itemUuid: str + + class PlaylistTrackItem(BaseModel): - item: Track + item: PlaylistTrack type: ItemType = "track" cut: None diff --git a/tiddl/models/track.py b/tiddl/models/track.py index 8990a7f..605277a 100644 --- a/tiddl/models/track.py +++ b/tiddl/models/track.py @@ -69,4 +69,3 @@ class Track(BaseModel): artists: List[TrackArtist] album: TrackAlbum mixes: Dict[str, str] - playlistNumber: Optional[int] = None diff --git a/tiddl/utils.py b/tiddl/utils.py index 913543b..d07b0eb 100644 --- a/tiddl/utils.py +++ b/tiddl/utils.py @@ -47,7 +47,7 @@ def sanitizeString(string: str) -> str: return re.sub(pattern, "", string) -def formatTrack(template: str, track: Track, album_artist="", playlist_title="") -> str: +def formatTrack(template: str, track: Track, album_artist="", playlist_title="", playlist_index=0) -> str: artist = sanitizeString(track.artist.name) if track.artist else "" features = [ sanitizeString(track_artist.name) @@ -72,7 +72,7 @@ def formatTrack(template: str, track: Track, album_artist="", playlist_title="") "bpm": track.bpm or "", "quality": QUALITY_TO_ARG[track.audioQuality], "album_artist": sanitizeString(album_artist), - "playlist_number": track.playlistNumber or "", + "playlist_number": playlist_index or 0, } formatted_track = template.format(**track_dict) From 106fe0609a0c421dd74fd391baaf67f64d0e8be4 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Thu, 23 Jan 2025 23:40:11 +0100 Subject: [PATCH 59/83] =?UTF-8?q?=E2=9C=85=20add=20more=20`FormatTrack`=20?= =?UTF-8?q?tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 24e5aeb..4fbe5f5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -111,7 +111,8 @@ class TestFormatTrack(unittest.TestCase): ("{date:%m-%d-%y}", "11-15-16"), ("{date:%Y}", "2016"), ("{year}", "2016"), - ("{playlist_number}", ""), + ("{playlist_number}", "0"), + ("{playlist_number:02d}", "00"), ("{bpm}", "69"), ("{quality}", "high"), ("{artist}/{album}/{title}", "Skepta/Konnichiwa/Shutdown"), @@ -124,7 +125,7 @@ class TestFormatTrack(unittest.TestCase): self.assertEqual(result, expected_result) def test_invalid_characters(self): - test_cases = ["\\", ":", '"', "?", "<", ">", "|", "{number}:{title}"] + test_cases = ["\\", ":", '"', "?", "<", ">", "|", "{number}:{title}", "{date}"] for template in test_cases: with self.subTest(template=template): From 987170fdec8182db98107892b691de171b9f3126 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Fri, 24 Jan 2025 19:50:58 +0100 Subject: [PATCH 60/83] =?UTF-8?q?=F0=9F=93=9D=20add=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 9a39a0c..c42a033 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -14,7 +14,9 @@ from tiddl.utils import formatTrack @click.command("download") @click.option("--quality", "-q", type=click.Choice(TrackArg.__args__)) -@click.option("--output", "-o", "template", type=str) +@click.option( + "--output", "-o", "template", type=str, help="Format track file template." +) @passContext def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None): """Download the tracks""" From b3e2056dac5f36d6df4d3137f14a7f7a62cc050f Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Fri, 24 Jan 2025 20:01:41 +0100 Subject: [PATCH 61/83] =?UTF-8?q?=E2=9C=A8=20get=20artist=20albums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 41 +++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index c42a033..671c201 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -8,7 +8,7 @@ from .url import UrlGroup from ..ctx import Context, passContext from tiddl.download import downloadTrackStream -from tiddl.models import TrackArg, ARG_TO_QUALITY, Track, PlaylistTrack +from tiddl.models import TrackArg, ARG_TO_QUALITY, Track, PlaylistTrack, Album from tiddl.utils import formatTrack @@ -49,8 +49,23 @@ def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None with path.open("wb") as f: f.write(stream_data) - # TODO: check for artists in resources - # then add their resources to the list + def downloadAlbum(album: Album): + click.echo(f"★ Album {album.title}") + + # TODO: fetch all items + album_items = api.getAlbumItems(album.id, limit=100) + + for item in album_items.items: + if isinstance(item.item, Track): + track = item.item + + file_name = formatTrack( + template=template or ctx.obj.config.template.album, + track=track, + album_artist=album.artist.name, + ) + + downloadTrack(track=track, file_name=file_name) for resource in ctx.obj.resources: match resource.type: @@ -67,22 +82,16 @@ def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None case "album": album = api.getAlbum(resource.id) - click.echo(f"★ Album {album.title}") + downloadAlbum(album) + + case "artist": + # TODO: add `include_singles` # TODO: fetch all items - album_items = api.getAlbumItems(resource.id, limit=100) + artist_albums = api.getArtistAlbums(resource.id) - for item in album_items.items: - if isinstance(item.item, Track): - track = item.item - - file_name = formatTrack( - template=template or ctx.obj.config.template.album, - track=track, - album_artist=album.artist.name, - ) - - downloadTrack(track=track, file_name=file_name) + for album in artist_albums.items: + downloadAlbum(album) case "playlist": playlist = api.getPlaylist(resource.id) From 986d5e491fa509aa3daff0a64836d4099ce0999f Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Fri, 24 Jan 2025 20:58:58 +0100 Subject: [PATCH 62/83] =?UTF-8?q?=E2=9C=A8=20add=20dot=20to=20file=20exten?= =?UTF-8?q?sion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/download.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tiddl/download.py b/tiddl/download.py index 0d05e73..1e0a966 100644 --- a/tiddl/download.py +++ b/tiddl/download.py @@ -75,9 +75,9 @@ def downloadTrackStream(stream: TrackStream) -> tuple[bytes, str]: logger.debug((stream.trackId, stream.audioQuality, codecs, len(urls))) if codecs == "flac": - file_extension = "flac" + file_extension = ".flac" elif codecs.startswith("mp4"): - file_extension = "m4a" + file_extension = ".m4a" else: raise ValueError(f"Unknown codecs: {codecs}") From cb727e128016f283a13fbecae38ca7f5ad96a0af Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Fri, 24 Jan 2025 21:00:19 +0100 Subject: [PATCH 63/83] =?UTF-8?q?=E2=9C=A8=20add=20`trackExists`=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 27 +++++++++++++++++---------- tiddl/utils.py | 23 ++++++++++++++++++++++- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 671c201..e3dc903 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -9,7 +9,7 @@ from ..ctx import Context, passContext from tiddl.download import downloadTrackStream from tiddl.models import TrackArg, ARG_TO_QUALITY, Track, PlaylistTrack, Album -from tiddl.utils import formatTrack +from tiddl.utils import formatTrack, trackExists @click.command("download") @@ -21,9 +21,6 @@ from tiddl.utils import formatTrack def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None): """Download the tracks""" - download_quality = ARG_TO_QUALITY[quality or ctx.obj.config.download.quality] - download_path = ctx.obj.config.download.path - api = ctx.obj.getApi() def downloadTrack(track: Track, file_name: str) -> None: @@ -33,20 +30,30 @@ def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None ) return + download_quality = ARG_TO_QUALITY[quality or ctx.obj.config.download.quality] + + # .suffix is needed because the Path.with_suffix method will replace any content after dot + # for example: 'album/01. title' becomes 'album/01.m4a' + path = ctx.obj.config.download.path / f"{file_name}.suffix" + + if trackExists(track.audioQuality, download_quality, path): + click.echo( + f"{click.style('✔', 'cyan')} Skipping track {click.style(file_name, 'cyan')}" + ) + return + click.echo( f"{click.style('✔', 'green')} Downloading track {click.style(file_name, 'green')}" ) - # TODO: check if file already exists. - # will need to predict file extension - track_stream = api.getTrackStream(track.id, download_quality) + stream_data, file_extension = downloadTrackStream(track_stream) - path = download_path / f"{file_name}.{file_extension}" - path.parent.mkdir(parents=True, exist_ok=True) + full_path = path.with_suffix(file_extension) + full_path.parent.mkdir(parents=True, exist_ok=True) - with path.open("wb") as f: + with full_path.open("wb") as f: f.write(stream_data) def downloadAlbum(album: Album): diff --git a/tiddl/utils.py b/tiddl/utils.py index d07b0eb..852d4a9 100644 --- a/tiddl/utils.py +++ b/tiddl/utils.py @@ -2,9 +2,11 @@ import re from pydantic import BaseModel from urllib.parse import urlparse +from pathlib import Path + from typing import Literal, get_args -from tiddl.models import Track, QUALITY_TO_ARG +from tiddl.models import Track, TrackQuality, QUALITY_TO_ARG ResourceTypeLiteral = Literal["track", "album", "playlist", "artist"] @@ -86,3 +88,22 @@ def formatTrack(template: str, track: Track, album_artist="", playlist_title="", ) return formatted_track + + +def trackExists( + track_quality: TrackQuality, download_quality: TrackQuality, file_name: Path +): + """ + Predict track extension and check if track file exists. + """ + + FLAC_QUALITIES: list[TrackQuality] = ["LOSSLESS", "HI_RES_LOSSLESS"] + + if download_quality in FLAC_QUALITIES and track_quality in FLAC_QUALITIES: + extension = ".flac" + else: + extension = ".m4a" + + full_file_name = file_name.with_suffix(extension) + + return full_file_name.exists() From ba1f9ef1e6fe48f91c1dd7a8aa2883ff236ef131 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Fri, 24 Jan 2025 21:04:39 +0100 Subject: [PATCH 64/83] =?UTF-8?q?=E2=9C=A8=20add=20noskip=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index e3dc903..ceeb81e 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -13,12 +13,22 @@ from tiddl.utils import formatTrack, trackExists @click.command("download") -@click.option("--quality", "-q", type=click.Choice(TrackArg.__args__)) +@click.option("--quality", "-q", "quality", type=click.Choice(TrackArg.__args__)) @click.option( "--output", "-o", "template", type=str, help="Format track file template." ) +@click.option( + "--noskip", + "-ns", + "noskip", + is_flag=True, + default=False, + help="Dont skip downloaded tracks.", +) @passContext -def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None): +def DownloadCommand( + ctx: Context, quality: TrackArg | None, template: str | None, noskip: bool +): """Download the tracks""" api = ctx.obj.getApi() @@ -36,7 +46,7 @@ def DownloadCommand(ctx: Context, quality: TrackArg | None, template: str | None # for example: 'album/01. title' becomes 'album/01.m4a' path = ctx.obj.config.download.path / f"{file_name}.suffix" - if trackExists(track.audioQuality, download_quality, path): + if not noskip and trackExists(track.audioQuality, download_quality, path): click.echo( f"{click.style('✔', 'cyan')} Skipping track {click.style(file_name, 'cyan')}" ) From f7d35e5faffd3a7a7bfee1b7dec930f6a3859814 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Fri, 24 Jan 2025 22:32:52 +0100 Subject: [PATCH 65/83] =?UTF-8?q?=E2=9C=A8=20add=20metadata=20and=20covers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 14 ++++- tiddl/metadata.py | 100 +++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 tiddl/metadata.py diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index ceeb81e..886fbb9 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -10,6 +10,7 @@ from ..ctx import Context, passContext from tiddl.download import downloadTrackStream from tiddl.models import TrackArg, ARG_TO_QUALITY, Track, PlaylistTrack, Album from tiddl.utils import formatTrack, trackExists +from tiddl.metadata import addMetadata, Cover @click.command("download") @@ -33,7 +34,7 @@ def DownloadCommand( api = ctx.obj.getApi() - def downloadTrack(track: Track, file_name: str) -> None: + def downloadTrack(track: Track, file_name: str, cover_data=b"") -> None: if not track.allowStreaming: click.echo( f"{click.style('✖', 'yellow')} Track {click.style(file_name, 'yellow')} does not allow streaming" @@ -66,12 +67,21 @@ def DownloadCommand( with full_path.open("wb") as f: f.write(stream_data) + # TODO: add track credits fetching to fill more metadata + + if not cover_data and track.album.cover: + cover_data = Cover(track.album.cover).content + + addMetadata(full_path, track, cover_data) + def downloadAlbum(album: Album): click.echo(f"★ Album {album.title}") # TODO: fetch all items album_items = api.getAlbumItems(album.id, limit=100) + cover_data = Cover(album.cover).content if album.cover else b"" + for item in album_items.items: if isinstance(item.item, Track): track = item.item @@ -82,7 +92,7 @@ def DownloadCommand( album_artist=album.artist.name, ) - downloadTrack(track=track, file_name=file_name) + downloadTrack(track=track, file_name=file_name, cover_data=cover_data) for resource in ctx.obj.resources: match resource.type: diff --git a/tiddl/metadata.py b/tiddl/metadata.py new file mode 100644 index 0000000..8f8a3b7 --- /dev/null +++ b/tiddl/metadata.py @@ -0,0 +1,100 @@ +import logging +import requests + +from pathlib import Path + +from mutagen.flac import FLAC as MutagenFLAC, Picture +from mutagen.easymp4 import EasyMP4 as MutagenEasyMP4 +from mutagen.mp4 import MP4Cover, MP4 as MutagenMP4 + +from tiddl.models import Track + + +logger = logging.getLogger(__name__) + + +def addMetadata(track_path: Path, track: Track, cover_data=b""): + extension = track_path.suffix + + if extension == ".flac": + metadata = MutagenFLAC(track_path) + if cover_data: + picture = Picture() + picture.data = cover_data + picture.mime = "image/jpeg" + metadata.add_picture(picture) + elif extension == ".m4a": + if cover_data: + metadata = MutagenMP4(track_path) + metadata["covr"] = [MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG)] + metadata.save(track_path) + metadata = MutagenEasyMP4(track_path) + else: + raise ValueError(f"Unknown file extension: {extension}") + + new_metadata: dict[str, str] = { + "title": track.title, + "trackNumber": str(track.trackNumber), + "discnumber": str(track.volumeNumber), + "copyright": track.copyright, + "albumartist": track.artist.name if track.artist else "", + "artist": ";".join([artist.name.strip() for artist in track.artists]), + "album": track.album.title, + "date": str(track.streamStartDate) if track.streamStartDate else "", + } + + metadata.update(new_metadata) + + try: + metadata.save(track_path) + except Exception as e: + logger.error(f"Failed to set metadata for {extension}: {e}") + + +class Cover: + def __init__(self, uid: str, size=1280) -> None: + if size > 1280: + logger.warning( + f"can not set cover size higher than 1280 (user set: {size})" + ) + size = 1280 + + self.uid = uid + + formatted_uid = uid.replace("-", "/") + self.url = ( + f"https://resources.tidal.com/images/{formatted_uid}/{size}x{size}.jpg" + ) + + logger.debug((self.uid, self.url)) + + self.content = self._get() + + def _get(self) -> bytes: + req = requests.get(self.url) + + if req.status_code != 200: + logger.error(f"could not download cover. ({req.status_code}) {self.url}") + return b"" + + logger.debug(f"got cover: {self.uid}") + + return req.content + + def save(self, directory_path: Path): + if not self.content: + logger.error("cover file content is empty") + return + + file = directory_path / "cover.jpg" + + if file.exists(): + logger.debug(f"cover already exists ({file})") + return + + try: + with file.open("wb") as f: + f.write(self.content) + + except FileNotFoundError as e: + logger.error(f"could not save cover. {file} -> {e}") From 6a737af17fa2f6029e11bf20448a686dd948f016 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Fri, 24 Jan 2025 22:55:55 +0100 Subject: [PATCH 66/83] =?UTF-8?q?=E2=9C=A8=20handle=20api=20and=20auth=20e?= =?UTF-8?q?rrors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/__init__.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 886fbb9..3b5c82b 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -9,8 +9,9 @@ from ..ctx import Context, passContext from tiddl.download import downloadTrackStream from tiddl.models import TrackArg, ARG_TO_QUALITY, Track, PlaylistTrack, Album -from tiddl.utils import formatTrack, trackExists +from tiddl.utils import formatTrack, trackExists, TidalResource from tiddl.metadata import addMetadata, Cover +from tiddl.exceptions import ApiError, AuthError @click.command("download") @@ -34,7 +35,7 @@ def DownloadCommand( api = ctx.obj.getApi() - def downloadTrack(track: Track, file_name: str, cover_data=b"") -> None: + def downloadTrack(track: Track, file_name: str, cover_data=b""): if not track.allowStreaming: click.echo( f"{click.style('✖', 'yellow')} Track {click.style(file_name, 'yellow')} does not allow streaming" @@ -94,7 +95,7 @@ def DownloadCommand( downloadTrack(track=track, file_name=file_name, cover_data=cover_data) - for resource in ctx.obj.resources: + def handleResource(resource: TidalResource): match resource.type: case "track": track = api.getTrack(resource.id) @@ -140,6 +141,16 @@ def DownloadCommand( downloadTrack(track=item.item, file_name=file_name) + for resource in ctx.obj.resources: + try: + handleResource(resource) + + except ApiError as e: + click.echo(click.style(f"✖ {e}", "red")) + + except AuthError as e: + click.echo(click.style(f"✖ {e}", "red")) + UrlGroup.add_command(DownloadCommand) SearchGroup.add_command(DownloadCommand) From 45646283b6e683f90990855dd18df57a48352342 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 12:24:29 +0100 Subject: [PATCH 67/83] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20cleanup=20some=20?= =?UTF-8?q?models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/models/api.py | 169 ++++++++------------------------------- tiddl/models/resource.py | 101 +++++++++++++++++++++++ tiddl/models/search.py | 7 +- 3 files changed, 140 insertions(+), 137 deletions(-) create mode 100644 tiddl/models/resource.py diff --git a/tiddl/models/api.py b/tiddl/models/api.py index d5e1e56..1b10cdd 100644 --- a/tiddl/models/api.py +++ b/tiddl/models/api.py @@ -1,8 +1,8 @@ from pydantic import BaseModel -from datetime import datetime -from typing import Optional, List, Literal, Dict, Union +from typing import Optional, List, Literal, Union from .track import Track +from .resource import Video, Album class Client(BaseModel): @@ -27,81 +27,6 @@ class Items(BaseModel): totalNumberOfItems: int -class BaseVideoArtist(BaseModel): - id: int - name: str - type: str - picture: Optional[str] = None - - -class BaseVideoAlbum(BaseModel): - id: int - title: str - cover: str - vibrantColor: str - videoCover: Optional[str] = None - - -class BaseVideo(BaseModel): - id: int - title: str - volumeNumber: int - trackNumber: int - releaseDate: str - imagePath: Optional[str] = None - imageId: str - vibrantColor: str - duration: int - quality: str - streamReady: bool - adSupportedStreamReady: bool - djReady: bool - stemReady: bool - streamStartDate: str - allowStreaming: bool - explicit: bool - popularity: int - type: str - adsUrl: Optional[str] = None - adsPrePaywallOnly: bool - artist: BaseVideoArtist - artists: List[BaseVideoArtist] - album: Optional[BaseVideoAlbum] = None - - -class AlbumArtist(BaseModel): - id: int - name: str - type: Literal["MAIN", "FEATURED"] - - -class Album(BaseModel): - id: int - title: str - duration: int - streamReady: bool - streamStartDate: Optional[datetime] = None - allowStreaming: bool - premiumStreamingOnly: bool - numberOfTracks: int - numberOfVideos: int - numberOfVolumes: int - releaseDate: str - copyright: str - type: str - version: Optional[str] = None - url: str - cover: Optional[str] = None - videoCover: Optional[str] = None - explicit: bool - upc: str - popularity: int - audioQuality: str - audioModes: List[str] - artist: AlbumArtist - artists: List[AlbumArtist] - - class AristAlbumsItems(Items): items: List[Album] @@ -109,69 +34,43 @@ class AristAlbumsItems(Items): ItemType = Literal["track", "video"] -class VideoItem(BaseModel): - item: BaseVideo - type: ItemType = "video" - - -class TrackItem(BaseModel): - item: Track - type: ItemType = "track" - - class AlbumItems(Items): + + class VideoItem(BaseModel): + item: Video + type: ItemType = "video" + + class TrackItem(BaseModel): + item: Track + type: ItemType = "track" + items: List[Union[TrackItem, VideoItem]] -class _Creator(BaseModel): - id: int - - -class Playlist(BaseModel): - uuid: str - title: str - numberOfTracks: int - numberOfVideos: int - creator: _Creator | Dict - description: Optional[str] = None - duration: int - lastUpdated: str - created: str - type: str - publicPlaylist: bool - url: str - image: Optional[str] = None - popularity: int - squareImage: str - promotedArtists: List[AlbumArtist] - lastItemAddedAt: Optional[str] = None - - -class PlaylistVideo(BaseVideo): - dateAdded: str - index: int - itemUuid: str - - -class PlaylistVideoItem(BaseModel): - item: PlaylistVideo - type: ItemType = "video" - cut: None - - -class PlaylistTrack(Track): - dateAdded: str - index: int - itemUuid: str - - -class PlaylistTrackItem(BaseModel): - item: PlaylistTrack - type: ItemType = "track" - cut: None - - class PlaylistItems(Items): + + class PlaylistVideoItem(BaseModel): + + class PlaylistVideo(Video): + dateAdded: str + index: int + itemUuid: str + + item: PlaylistVideo + type: ItemType = "video" + cut: None + + class PlaylistTrackItem(BaseModel): + + class PlaylistTrack(Track): + dateAdded: str + index: int + itemUuid: str + + item: PlaylistTrack + type: ItemType = "track" + cut: None + items: List[Union[PlaylistTrackItem, PlaylistVideoItem]] diff --git a/tiddl/models/resource.py b/tiddl/models/resource.py new file mode 100644 index 0000000..a8f6664 --- /dev/null +++ b/tiddl/models/resource.py @@ -0,0 +1,101 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, List, Literal, Dict + + +class Video(BaseModel): + + class Arist(BaseModel): + id: int + name: str + type: str + picture: Optional[str] = None + + class Album(BaseModel): + id: int + title: str + cover: str + vibrantColor: str + videoCover: Optional[str] = None + + id: int + title: str + volumeNumber: int + trackNumber: int + releaseDate: str + imagePath: Optional[str] = None + imageId: str + vibrantColor: str + duration: int + quality: str + streamReady: bool + adSupportedStreamReady: bool + djReady: bool + stemReady: bool + streamStartDate: str + allowStreaming: bool + explicit: bool + popularity: int + type: str + adsUrl: Optional[str] = None + adsPrePaywallOnly: bool + artist: Arist + artists: List[Arist] + album: Optional[Album] = None + + +class Album(BaseModel): + + class Artist(BaseModel): + id: int + name: str + type: Literal["MAIN", "FEATURED"] + + id: int + title: str + duration: int + streamReady: bool + streamStartDate: Optional[datetime] = None + allowStreaming: bool + premiumStreamingOnly: bool + numberOfTracks: int + numberOfVideos: int + numberOfVolumes: int + releaseDate: str + copyright: str + type: str + version: Optional[str] = None + url: str + cover: Optional[str] = None + videoCover: Optional[str] = None + explicit: bool + upc: str + popularity: int + audioQuality: str + audioModes: List[str] + artist: Artist + artists: List[Artist] + + +class Playlist(BaseModel): + + class Creator(BaseModel): + id: int + + uuid: str + title: str + numberOfTracks: int + numberOfVideos: int + creator: Creator | Dict + description: Optional[str] = None + duration: int + lastUpdated: str + created: str + type: str + publicPlaylist: bool + url: str + image: Optional[str] = None + popularity: int + squareImage: str + promotedArtists: List[Album.Artist] + lastItemAddedAt: Optional[str] = None diff --git a/tiddl/models/search.py b/tiddl/models/search.py index 0305d4e..7f72731 100644 --- a/tiddl/models/search.py +++ b/tiddl/models/search.py @@ -1,8 +1,11 @@ from pydantic import BaseModel from typing import Optional, List, Literal, Dict, Union -from tiddl.models.track import Track -from tiddl.models.api import Items, Playlist +from .track import Track +from .resource import Playlist +from .api import Items + +# TODO: cleanup this mess class _ArtistRole(BaseModel): From 19bc615155b7b7e7133cb33a90fe29ff8344a5fc Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 12:25:08 +0100 Subject: [PATCH 68/83] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20linting=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/auth.py | 2 +- tiddl/cli/download/__init__.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tiddl/auth.py b/tiddl/auth.py index bab4beb..560b9dc 100644 --- a/tiddl/auth.py +++ b/tiddl/auth.py @@ -73,7 +73,7 @@ def refreshToken(refresh_token: str): def removeToken(access_token: str): req = request( "POST", - f"https://api.tidal.com/v1/logout", + "https://api.tidal.com/v1/logout", headers={"authorization": f"Bearer {access_token}"}, ) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 3b5c82b..07b6c39 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -8,7 +8,7 @@ from .url import UrlGroup from ..ctx import Context, passContext from tiddl.download import downloadTrackStream -from tiddl.models import TrackArg, ARG_TO_QUALITY, Track, PlaylistTrack, Album +from tiddl.models import TrackArg, ARG_TO_QUALITY, Track, PlaylistItems, Album from tiddl.utils import formatTrack, trackExists, TidalResource from tiddl.metadata import addMetadata, Cover from tiddl.exceptions import ApiError, AuthError @@ -129,7 +129,9 @@ def DownloadCommand( playlist_items = api.getPlaylistItems(resource.id) for item in playlist_items.items: - if isinstance(item.item, PlaylistTrack): + if isinstance( + item.item, PlaylistItems.PlaylistTrackItem.PlaylistTrack + ): track = item.item file_name = formatTrack( From 3306dbf7ecce63c027d50f2917ca7168ab3f1882 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 14:30:38 +0100 Subject: [PATCH 69/83] =?UTF-8?q?=E2=9C=A8=20add=20data=20debug=20for=20ap?= =?UTF-8?q?i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/api.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tiddl/api.py b/tiddl/api.py index e16cb67..ec8228a 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -1,9 +1,13 @@ import logging +import json + +from pathlib import Path from requests import Session from tiddl import models from .exceptions import AuthError, ApiError +DEBUG = False API_URL = "https://api.tidal.com/v1" # Tidal default limits @@ -36,6 +40,15 @@ class TidalApi: if req.status_code != 200: raise ApiError(**data) + if DEBUG: + debug_data = {"endpoint": endpoint, "params": params, "data": data} + + path = Path(f"debug_data/{endpoint}.json") + path.parent.mkdir(parents=True, exist_ok=True) + + with path.open("w", encoding="utf-8") as f: + json.dump(debug_data, f, indent=2) + return data def getSession(self): From d7592afd161167c95ba113314c2c9befbe4a2ea2 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 14:31:30 +0100 Subject: [PATCH 70/83] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20models=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/models/resource.py | 11 ++- tiddl/models/search.py | 155 ++++++++++++--------------------------- 2 files changed, 56 insertions(+), 110 deletions(-) diff --git a/tiddl/models/resource.py b/tiddl/models/resource.py index a8f6664..0715f68 100644 --- a/tiddl/models/resource.py +++ b/tiddl/models/resource.py @@ -39,7 +39,7 @@ class Video(BaseModel): type: str adsUrl: Optional[str] = None adsPrePaywallOnly: bool - artist: Arist + artist: Optional[Arist] = None artists: List[Arist] album: Optional[Album] = None @@ -50,11 +50,18 @@ class Album(BaseModel): id: int name: str type: Literal["MAIN", "FEATURED"] + picture: Optional[str] = None + + class MediaMetadata(BaseModel): + tags: List[Literal['LOSSLESS', 'HIRES_LOSSLESS']] id: int title: str duration: int streamReady: bool + adSupportedStreamReady: bool + djReady: bool + stemReady: bool streamStartDate: Optional[datetime] = None allowStreaming: bool premiumStreamingOnly: bool @@ -67,12 +74,14 @@ class Album(BaseModel): version: Optional[str] = None url: str cover: Optional[str] = None + vibrantColor: Optional[str] = None videoCover: Optional[str] = None explicit: bool upc: str popularity: int audioQuality: str audioModes: List[str] + mediaMetadata: MediaMetadata artist: Artist artists: List[Artist] diff --git a/tiddl/models/search.py b/tiddl/models/search.py index 7f72731..148b6b3 100644 --- a/tiddl/models/search.py +++ b/tiddl/models/search.py @@ -2,30 +2,32 @@ from pydantic import BaseModel from typing import Optional, List, Literal, Dict, Union from .track import Track -from .resource import Playlist +from .resource import Playlist, Album, Video from .api import Items -# TODO: cleanup this mess - -class _ArtistRole(BaseModel): - categoryId: int - category: Literal[ - "Artist", - "Songwriter", - "Performer", - "Producer", - "Engineer", - "Production team", - "Misc", - ] - - -class _ArtistMix(BaseModel): - ARTIST_MIX: str +class SearchAlbum(Album): + # TODO: remove the artist field instead of making it None + artist: None = None class Artist(BaseModel): + + class Role(BaseModel): + categoryId: int + category: Literal[ + "Artist", + "Songwriter", + "Performer", + "Producer", + "Engineer", + "Production team", + "Misc", + ] + + class Mix(BaseModel): + ARTIST_MIX: str + id: int name: str artistTypes: Optional[List[Literal["ARTIST", "CONTRIBUTOR"]]] = None @@ -33,98 +35,33 @@ class Artist(BaseModel): picture: Optional[str] = None selectedAlbumCoverFallback: Optional[str] = None popularity: Optional[int] = None - artistRoles: Optional[List[_ArtistRole]] = None - mixes: Optional[_ArtistMix | Dict] = None - - -class SearchAritsts(Items): - items: List[Artist] - - -class ArtistSearchAlbum(BaseModel): - id: int - name: str - type: Literal["MAIN", "FEATURED"] - picture: str - - -class SearchAlbum(BaseModel): - id: int - title: str - duration: int - streamReady: bool - streamStartDate: Optional[str] = None - allowStreaming: bool - premiumStreamingOnly: bool - numberOfTracks: int - numberOfVideos: int - numberOfVolumes: int - releaseDate: str - copyright: str - type: str - version: Optional[str] = None - url: str - cover: Optional[str] = None - videoCover: Optional[str] = None - explicit: bool - upc: str - popularity: int - audioQuality: str - audioModes: List[str] - artists: List[ArtistSearchAlbum | Dict] - - -class SearchAlbums(Items): - items: List[SearchAlbum] - - -class SearchPlaylists(Items): - items: List[Playlist] - - -class SearchTracks(Items): - items: List[Track] - - -class Video(BaseModel): - id: int - title: str - volumeNumber: int - trackNumber: int - releaseDate: str - imagePath: Optional[str] = None - imageId: str - vibrantColor: str - duration: int - quality: str - streamReady: bool - adSupportedStreamReady: bool - djReady: bool - stemReady: bool - streamStartDate: str - allowStreaming: bool - explicit: bool - popularity: int - type: str - adsUrl: Optional[str] = None - adsPrePaywallOnly: bool - artists: List[Artist] - album: Optional[str] = None - - -class SearchVideo(Items): - items: List[Video] - - -class TopHit(BaseModel): - value: Union[Artist, Track, Playlist, SearchAlbum] - type: Literal["ARTISTS", "TRACKS", "PLAYLISTS", "ALBUMS"] + artistRoles: Optional[List[Role]] = None + mixes: Optional[Mix | Dict] = None class Search(BaseModel): - artists: SearchAritsts - albums: SearchAlbums - playlists: SearchPlaylists - tracks: SearchTracks - videos: SearchVideo + class Artists(Items): + items: List[Artist] + + class Albums(Items): + items: List[SearchAlbum] + + class Playlists(Items): + items: List[Playlist] + + class Tracks(Items): + items: List[Track] + + class Videos(Items): + items: List[Video] + + class TopHit(BaseModel): + value: Union[Artist, Track, Playlist, Album] + type: Literal["ARTISTS", "TRACKS", "PLAYLISTS", "ALBUMS"] + + artists: Artists + albums: Albums + playlists: Playlists + tracks: Tracks + videos: Videos topHit: TopHit From 0eb68e2d0320e44d5aff2dbee4ec3d999f6cab92 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 17:42:39 +0100 Subject: [PATCH 71/83] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20remove=20models.t?= =?UTF-8?q?rack,=20add=20models.constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_utils.py | 2 +- tiddl/api.py | 36 ++++++++++------- tiddl/cli/download/__init__.py | 4 +- tiddl/config.py | 2 +- tiddl/download.py | 2 +- tiddl/metadata.py | 2 +- tiddl/models/__init__.py | 16 -------- tiddl/models/api.py | 31 +++++++++++++-- tiddl/models/constants.py | 13 +++++++ tiddl/models/resource.py | 53 ++++++++++++++++++++++++- tiddl/models/search.py | 13 +++---- tiddl/models/track.py | 71 ---------------------------------- tiddl/utils.py | 7 +++- 13 files changed, 134 insertions(+), 118 deletions(-) create mode 100644 tiddl/models/constants.py delete mode 100644 tiddl/models/track.py diff --git a/tests/test_utils.py b/tests/test_utils.py index 4fbe5f5..a255c21 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ import unittest -from tiddl.models import Track +from tiddl.models.resource import Track from tiddl.utils import TidalResource, formatTrack diff --git a/tiddl/api.py b/tiddl/api.py index ec8228a..7113252 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -4,8 +4,18 @@ import json from pathlib import Path from requests import Session -from tiddl import models -from .exceptions import AuthError, ApiError +from tiddl.exceptions import AuthError, ApiError +from tiddl.models.api import ( + AlbumItems, + ArtistAlbumsItems, + Favorites, + PlaylistItems, + SessionResponse, + TrackStream, +) +from tiddl.models.constants import TrackQuality +from tiddl.models.resource import Track, Album, Playlist +from tiddl.models.search import Search DEBUG = False API_URL = "https://api.tidal.com/v1" @@ -52,14 +62,14 @@ class TidalApi: return data def getSession(self): - return models.SessionResponse( + return SessionResponse( **self._request( "sessions", ) ) - def getTrackStream(self, id: str | int, quality: models.TrackQuality): - return models.TrackStream( + def getTrackStream(self, id: str | int, quality: TrackQuality): + return TrackStream( **self._request( f"tracks/{id}/playbackinfo", { @@ -71,7 +81,7 @@ class TidalApi: ) def getTrack(self, id: str | int): - return models.Track( + return Track( **self._request(f"tracks/{id}", {"countryCode": self.country_code}) ) @@ -83,7 +93,7 @@ class TidalApi: if onlyNonAlbum: params.update({"filter": "EPSANDSINGLES"}) - return models.AristAlbumsItems( + return ArtistAlbumsItems( **self._request( f"artists/{id}/albums", params, @@ -91,7 +101,7 @@ class TidalApi: ) def getAlbum(self, id: str | int): - return models.Album( + return Album( **self._request(f"albums/{id}", {"countryCode": self.country_code}) ) @@ -102,7 +112,7 @@ class TidalApi: logging.warning(f"Too big page, max page size is {MAX_LIMIT}") limit = MAX_LIMIT - return models.AlbumItems( + return AlbumItems( **self._request( f"albums/{id}/items", {"countryCode": self.country_code, "limit": limit, "offset": offset}, @@ -110,7 +120,7 @@ class TidalApi: ) def getPlaylist(self, uuid: str): - return models.Playlist( + return Playlist( **self._request( f"playlists/{uuid}", {"countryCode": self.country_code}, @@ -118,7 +128,7 @@ class TidalApi: ) def getPlaylistItems(self, uuid: str, limit=PLAYLIST_LIMIT, offset=0): - return models.PlaylistItems( + return PlaylistItems( **self._request( f"playlists/{uuid}/items", {"countryCode": self.country_code, "limit": limit, "offset": offset}, @@ -126,7 +136,7 @@ class TidalApi: ) def getFavorites(self): - return models.Favorites( + return Favorites( **self._request( f"users/{self.user_id}/favorites/ids", {"countryCode": self.country_code}, @@ -134,7 +144,7 @@ class TidalApi: ) def search(self, query: str): - return models.Search( + return Search( **self._request( "search", {"countryCode": self.country_code, "query": query} ) diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py index 07b6c39..34ba127 100644 --- a/tiddl/cli/download/__init__.py +++ b/tiddl/cli/download/__init__.py @@ -8,10 +8,12 @@ from .url import UrlGroup from ..ctx import Context, passContext from tiddl.download import downloadTrackStream -from tiddl.models import TrackArg, ARG_TO_QUALITY, Track, PlaylistItems, Album from tiddl.utils import formatTrack, trackExists, TidalResource from tiddl.metadata import addMetadata, Cover from tiddl.exceptions import ApiError, AuthError +from tiddl.models.constants import TrackArg, ARG_TO_QUALITY +from tiddl.models.resource import Track, Album +from tiddl.models.api import PlaylistItems @click.command("download") diff --git a/tiddl/config.py b/tiddl/config.py index 5b52700..cde1a84 100644 --- a/tiddl/config.py +++ b/tiddl/config.py @@ -1,7 +1,7 @@ from pydantic import BaseModel from pathlib import Path -from tiddl.models import TrackArg +from tiddl.models.constants import TrackArg CONFIG_PATH = Path.home() / "tiddl.json" diff --git a/tiddl/download.py b/tiddl/download.py index 1e0a966..0018a36 100644 --- a/tiddl/download.py +++ b/tiddl/download.py @@ -5,7 +5,7 @@ from pydantic import BaseModel from base64 import b64decode from xml.etree.ElementTree import fromstring -from tiddl.models import TrackStream +from tiddl.models.api import TrackStream logger = logging.getLogger(__name__) diff --git a/tiddl/metadata.py b/tiddl/metadata.py index 8f8a3b7..8a2673e 100644 --- a/tiddl/metadata.py +++ b/tiddl/metadata.py @@ -7,7 +7,7 @@ from mutagen.flac import FLAC as MutagenFLAC, Picture from mutagen.easymp4 import EasyMP4 as MutagenEasyMP4 from mutagen.mp4 import MP4Cover, MP4 as MutagenMP4 -from tiddl.models import Track +from tiddl.models.resource import Track logger = logging.getLogger(__name__) diff --git a/tiddl/models/__init__.py b/tiddl/models/__init__.py index 7f7eb23..e69de29 100644 --- a/tiddl/models/__init__.py +++ b/tiddl/models/__init__.py @@ -1,16 +0,0 @@ -from typing import TypedDict, Literal - -from .api import * -from .track import * -from .search import * - -TrackArg = Literal["low", "normal", "high", "master"] - -ARG_TO_QUALITY: dict[TrackArg, TrackQuality] = { - "low": "LOW", - "normal": "HIGH", - "high": "LOSSLESS", - "master": "HI_RES_LOSSLESS", -} - -QUALITY_TO_ARG = {v: k for k, v in ARG_TO_QUALITY.items()} diff --git a/tiddl/models/api.py b/tiddl/models/api.py index 1b10cdd..e8eb95d 100644 --- a/tiddl/models/api.py +++ b/tiddl/models/api.py @@ -1,8 +1,17 @@ from pydantic import BaseModel from typing import Optional, List, Literal, Union -from .track import Track -from .resource import Video, Album +from .resource import Video, Album, Track, TrackQuality + +__all__ = [ + "Client", + "SessionResponse", + "ArtistAlbumsItems", + "AlbumItems", + "PlaylistItems", + "Favorites", + "TrackStream", +] class Client(BaseModel): @@ -27,7 +36,7 @@ class Items(BaseModel): totalNumberOfItems: int -class AristAlbumsItems(Items): +class ArtistAlbumsItems(Items): items: List[Album] @@ -80,3 +89,19 @@ class Favorites(BaseModel): VIDEO: List[str] TRACK: List[str] ARTIST: List[str] + + +class TrackStream(BaseModel): + trackId: int + assetPresentation: Literal["FULL"] + audioMode: Literal["STEREO"] + audioQuality: TrackQuality + manifestMimeType: Literal["application/dash+xml", "application/vnd.tidal.bts"] + manifestHash: str + manifest: str + albumReplayGain: float + albumPeakAmplitude: float + trackReplayGain: float + trackPeakAmplitude: float + bitDepth: Optional[int] = None + sampleRate: Optional[int] = None diff --git a/tiddl/models/constants.py b/tiddl/models/constants.py new file mode 100644 index 0000000..94b65b9 --- /dev/null +++ b/tiddl/models/constants.py @@ -0,0 +1,13 @@ +from typing import Literal + +TrackQuality = Literal["LOW", "HIGH", "LOSSLESS", "HI_RES_LOSSLESS"] +TrackArg = Literal["low", "normal", "high", "master"] + +ARG_TO_QUALITY: dict[TrackArg, TrackQuality] = { + "low": "LOW", + "normal": "HIGH", + "high": "LOSSLESS", + "master": "HI_RES_LOSSLESS", +} + +QUALITY_TO_ARG = {v: k for k, v in ARG_TO_QUALITY.items()} diff --git a/tiddl/models/resource.py b/tiddl/models/resource.py index 0715f68..bd44df8 100644 --- a/tiddl/models/resource.py +++ b/tiddl/models/resource.py @@ -1,6 +1,57 @@ from pydantic import BaseModel from datetime import datetime from typing import Optional, List, Literal, Dict +from .constants import TrackQuality + + +__all__ = ["Track", "Video", "Album", "Playlist"] + + +class Track(BaseModel): + + class Artist(BaseModel): + id: int + name: str + type: str + picture: Optional[str] = None + + class Album(BaseModel): + id: int + title: str + cover: Optional[str] = None + vibrantColor: Optional[str] = None + videoCover: Optional[str] = None + + id: int + title: str + duration: int + replayGain: float + peak: float + allowStreaming: bool + streamReady: bool + adSupportedStreamReady: bool + djReady: bool + stemReady: bool + streamStartDate: Optional[datetime] = None + premiumStreamingOnly: bool + trackNumber: int + volumeNumber: int + version: Optional[str] = None + popularity: int + copyright: str + bpm: Optional[int] = None + url: str + isrc: str + editable: bool + explicit: bool + audioQuality: TrackQuality + audioModes: List[str] + mediaMetadata: Dict[str, List[str]] + # for real, artist can be None? + artist: Optional[Artist] = None + artists: List[Artist] + album: Album + mixes: Dict[str, str] class Video(BaseModel): @@ -53,7 +104,7 @@ class Album(BaseModel): picture: Optional[str] = None class MediaMetadata(BaseModel): - tags: List[Literal['LOSSLESS', 'HIRES_LOSSLESS']] + tags: List[Literal["LOSSLESS", "HIRES_LOSSLESS"]] id: int title: str diff --git a/tiddl/models/search.py b/tiddl/models/search.py index 148b6b3..a70b979 100644 --- a/tiddl/models/search.py +++ b/tiddl/models/search.py @@ -1,16 +1,10 @@ from pydantic import BaseModel from typing import Optional, List, Literal, Dict, Union -from .track import Track -from .resource import Playlist, Album, Video +from .resource import Track, Playlist, Album, Video from .api import Items -class SearchAlbum(Album): - # TODO: remove the artist field instead of making it None - artist: None = None - - class Artist(BaseModel): class Role(BaseModel): @@ -40,10 +34,15 @@ class Artist(BaseModel): class Search(BaseModel): + class Artists(Items): items: List[Artist] class Albums(Items): + class SearchAlbum(Album): + # TODO: remove the artist field instead of making it None + artist: None = None + items: List[SearchAlbum] class Playlists(Items): diff --git a/tiddl/models/track.py b/tiddl/models/track.py deleted file mode 100644 index 605277a..0000000 --- a/tiddl/models/track.py +++ /dev/null @@ -1,71 +0,0 @@ -from pydantic import BaseModel -from datetime import datetime -from typing import Optional, List, Dict, Literal - - -TrackQuality = Literal["LOW", "HIGH", "LOSSLESS", "HI_RES_LOSSLESS"] -ManifestMimeType = Literal["application/dash+xml", "application/vnd.tidal.bts"] - - -class TrackStream(BaseModel): - trackId: int - assetPresentation: Literal["FULL"] - audioMode: Literal["STEREO"] - audioQuality: TrackQuality - manifestMimeType: ManifestMimeType - manifestHash: str - manifest: str - albumReplayGain: float - albumPeakAmplitude: float - trackReplayGain: float - trackPeakAmplitude: float - bitDepth: Optional[int] = None - sampleRate: Optional[int] = None - - -class TrackArtist(BaseModel): - id: int - name: str - type: str - picture: Optional[str] = None - - -class TrackAlbum(BaseModel): - id: int - title: str - cover: Optional[str] = None - vibrantColor: Optional[str] = None - videoCover: Optional[str] = None - - -class Track(BaseModel): - id: int - title: str - duration: int - replayGain: float - peak: float - allowStreaming: bool - streamReady: bool - adSupportedStreamReady: bool - djReady: bool - stemReady: bool - streamStartDate: Optional[datetime] = None - premiumStreamingOnly: bool - trackNumber: int - volumeNumber: int - version: Optional[str] = None - popularity: int - copyright: str - bpm: Optional[int] = None - url: str - isrc: str - editable: bool - explicit: bool - audioQuality: TrackQuality - audioModes: List[str] - mediaMetadata: Dict[str, List[str]] - # for real, artist can be None? - artist: Optional[TrackArtist] = None - artists: List[TrackArtist] - album: TrackAlbum - mixes: Dict[str, str] diff --git a/tiddl/utils.py b/tiddl/utils.py index 852d4a9..b58f886 100644 --- a/tiddl/utils.py +++ b/tiddl/utils.py @@ -6,7 +6,8 @@ from pathlib import Path from typing import Literal, get_args -from tiddl.models import Track, TrackQuality, QUALITY_TO_ARG +from tiddl.models.constants import TrackQuality, QUALITY_TO_ARG +from tiddl.models.resource import Track ResourceTypeLiteral = Literal["track", "album", "playlist", "artist"] @@ -49,7 +50,9 @@ def sanitizeString(string: str) -> str: return re.sub(pattern, "", string) -def formatTrack(template: str, track: Track, album_artist="", playlist_title="", playlist_index=0) -> str: +def formatTrack( + template: str, track: Track, album_artist="", playlist_title="", playlist_index=0 +) -> str: artist = sanitizeString(track.artist.name) if track.artist else "" features = [ sanitizeString(track_artist.name) From bf2bc78f3d47091ce67cc3833d552235a815301a Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 17:57:08 +0100 Subject: [PATCH 72/83] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20move=20search=20t?= =?UTF-8?q?o=20api,=20artist=20to=20resource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/api.py | 9 ++++-- tiddl/models/api.py | 36 +++++++++++++++++++++- tiddl/models/resource.py | 32 ++++++++++++++++++- tiddl/models/search.py | 66 ---------------------------------------- 4 files changed, 73 insertions(+), 70 deletions(-) delete mode 100644 tiddl/models/search.py diff --git a/tiddl/api.py b/tiddl/api.py index 7113252..9386dcf 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -11,11 +11,11 @@ from tiddl.models.api import ( Favorites, PlaylistItems, SessionResponse, + Search, TrackStream, ) from tiddl.models.constants import TrackQuality -from tiddl.models.resource import Track, Album, Playlist -from tiddl.models.search import Search +from tiddl.models.resource import Track, Album, Playlist, Artist DEBUG = False API_URL = "https://api.tidal.com/v1" @@ -85,6 +85,11 @@ class TidalApi: **self._request(f"tracks/{id}", {"countryCode": self.country_code}) ) + def getArtist(self, id: str | int): + return Artist( + **self._request(f"artists/{id}", {"countryCode": self.country_code}) + ) + def getArtistAlbums( self, id: str | int, limit=ARTIST_ALBUMS_LIMIT, offset=0, onlyNonAlbum=False ): diff --git a/tiddl/models/api.py b/tiddl/models/api.py index e8eb95d..7961de4 100644 --- a/tiddl/models/api.py +++ b/tiddl/models/api.py @@ -1,7 +1,7 @@ from pydantic import BaseModel from typing import Optional, List, Literal, Union -from .resource import Video, Album, Track, TrackQuality +from .resource import Album, Artist, Playlist, Track, TrackQuality, Video __all__ = [ "Client", @@ -11,6 +11,7 @@ __all__ = [ "PlaylistItems", "Favorites", "TrackStream", + "Search" ] @@ -105,3 +106,36 @@ class TrackStream(BaseModel): trackPeakAmplitude: float bitDepth: Optional[int] = None sampleRate: Optional[int] = None + + +class Search(BaseModel): + + class Artists(Items): + items: List[Artist] + + class Albums(Items): + class SearchAlbum(Album): + # TODO: remove the artist field instead of making it None + artist: None = None + + items: List[SearchAlbum] + + class Playlists(Items): + items: List[Playlist] + + class Tracks(Items): + items: List[Track] + + class Videos(Items): + items: List[Video] + + class TopHit(BaseModel): + value: Union[Artist, Track, Playlist, Album] + type: Literal["ARTISTS", "TRACKS", "PLAYLISTS", "ALBUMS"] + + artists: Artists + albums: Albums + playlists: Playlists + tracks: Tracks + videos: Videos + topHit: TopHit diff --git a/tiddl/models/resource.py b/tiddl/models/resource.py index bd44df8..6ed5a68 100644 --- a/tiddl/models/resource.py +++ b/tiddl/models/resource.py @@ -4,7 +4,7 @@ from typing import Optional, List, Literal, Dict from .constants import TrackQuality -__all__ = ["Track", "Video", "Album", "Playlist"] +__all__ = ["Track", "Video", "Album", "Playlist", "Artist"] class Track(BaseModel): @@ -159,3 +159,33 @@ class Playlist(BaseModel): squareImage: str promotedArtists: List[Album.Artist] lastItemAddedAt: Optional[str] = None + + +class Artist(BaseModel): + + class Role(BaseModel): + categoryId: int + category: Literal[ + "Artist", + "Songwriter", + "Performer", + "Producer", + "Engineer", + "Production team", + "Misc", + ] + + class Mix(BaseModel): + ARTIST_MIX: str + MASTER_ARTIST_MIX: Optional[str] = None + + id: int + name: str + artistTypes: Optional[List[Literal["ARTIST", "CONTRIBUTOR"]]] = None + url: Optional[str] = None + picture: Optional[str] = None + # only in search i guess + selectedAlbumCoverFallback: Optional[str] = None + popularity: Optional[int] = None + artistRoles: Optional[List[Role]] = None + mixes: Optional[Mix | Dict] = None diff --git a/tiddl/models/search.py b/tiddl/models/search.py deleted file mode 100644 index a70b979..0000000 --- a/tiddl/models/search.py +++ /dev/null @@ -1,66 +0,0 @@ -from pydantic import BaseModel -from typing import Optional, List, Literal, Dict, Union - -from .resource import Track, Playlist, Album, Video -from .api import Items - - -class Artist(BaseModel): - - class Role(BaseModel): - categoryId: int - category: Literal[ - "Artist", - "Songwriter", - "Performer", - "Producer", - "Engineer", - "Production team", - "Misc", - ] - - class Mix(BaseModel): - ARTIST_MIX: str - - id: int - name: str - artistTypes: Optional[List[Literal["ARTIST", "CONTRIBUTOR"]]] = None - url: Optional[str] = None - picture: Optional[str] = None - selectedAlbumCoverFallback: Optional[str] = None - popularity: Optional[int] = None - artistRoles: Optional[List[Role]] = None - mixes: Optional[Mix | Dict] = None - - -class Search(BaseModel): - - class Artists(Items): - items: List[Artist] - - class Albums(Items): - class SearchAlbum(Album): - # TODO: remove the artist field instead of making it None - artist: None = None - - items: List[SearchAlbum] - - class Playlists(Items): - items: List[Playlist] - - class Tracks(Items): - items: List[Track] - - class Videos(Items): - items: List[Video] - - class TopHit(BaseModel): - value: Union[Artist, Track, Playlist, Album] - type: Literal["ARTISTS", "TRACKS", "PLAYLISTS", "ALBUMS"] - - artists: Artists - albums: Albums - playlists: Playlists - tracks: Tracks - videos: Videos - topHit: TopHit From a702043d378aeb8ebd75b3c4eb8af8973acc7ef2 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 18:01:08 +0100 Subject: [PATCH 73/83] =?UTF-8?q?=E2=9C=85=20add=20getArtist=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_api.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index 0923f23..27dfdb0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -33,6 +33,10 @@ class TestApi(unittest.TestCase): track = self.api.getTrack(103805726) self.assertEqual(track.title, "Stronger") + def test_artist(self): + artist = self.api.getArtist(25022) + self.assertEqual(artist.name, "Kanye West") + def test_artist_albums(self): self.api.getArtistAlbums(25022) From dfa4e1bcb943518d44b7dc88136b5b38c92ac83f Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 18:04:28 +0100 Subject: [PATCH 74/83] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20add=20missing=20t?= =?UTF-8?q?ag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/models/resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tiddl/models/resource.py b/tiddl/models/resource.py index 6ed5a68..a84f6a2 100644 --- a/tiddl/models/resource.py +++ b/tiddl/models/resource.py @@ -104,7 +104,7 @@ class Album(BaseModel): picture: Optional[str] = None class MediaMetadata(BaseModel): - tags: List[Literal["LOSSLESS", "HIRES_LOSSLESS"]] + tags: List[Literal["LOSSLESS", "HIRES_LOSSLESS", "DOLBY_ATMOS"]] id: int title: str From ef231d7c1bff1b8aa9a14dbf20859e3af6f2f1e7 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 18:05:06 +0100 Subject: [PATCH 75/83] =?UTF-8?q?=E2=9C=85=20add=20get=20eps=20and=20singl?= =?UTF-8?q?es=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_api.py b/tests/test_api.py index 27dfdb0..ae6d560 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -39,6 +39,7 @@ class TestApi(unittest.TestCase): def test_artist_albums(self): self.api.getArtistAlbums(25022) + self.api.getArtistAlbums(25022, onlyNonAlbum=True) def test_album(self): album = self.api.getAlbum(103805723) From 39b3d38db5f1b2805fad4197738a980e3163a79a Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 21:27:55 +0100 Subject: [PATCH 76/83] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20move=20client=20t?= =?UTF-8?q?o=20sessionResponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/models/api.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/tiddl/models/api.py b/tiddl/models/api.py index 7961de4..2086cfb 100644 --- a/tiddl/models/api.py +++ b/tiddl/models/api.py @@ -4,25 +4,23 @@ from typing import Optional, List, Literal, Union from .resource import Album, Artist, Playlist, Track, TrackQuality, Video __all__ = [ - "Client", "SessionResponse", "ArtistAlbumsItems", "AlbumItems", "PlaylistItems", "Favorites", "TrackStream", - "Search" + "Search", ] -class Client(BaseModel): - id: int - name: str - authorizedForOffline: bool - authorizedForOfflineDate: Optional[str] - - class SessionResponse(BaseModel): + class Client(BaseModel): + id: int + name: str + authorizedForOffline: bool + authorizedForOfflineDate: Optional[str] + sessionId: str userId: int countryCode: str @@ -45,7 +43,6 @@ ItemType = Literal["track", "video"] class AlbumItems(Items): - class VideoItem(BaseModel): item: Video type: ItemType = "video" @@ -58,9 +55,7 @@ class AlbumItems(Items): class PlaylistItems(Items): - class PlaylistVideoItem(BaseModel): - class PlaylistVideo(Video): dateAdded: str index: int @@ -71,7 +66,6 @@ class PlaylistItems(Items): cut: None class PlaylistTrackItem(BaseModel): - class PlaylistTrack(Track): dateAdded: str index: int @@ -97,7 +91,9 @@ class TrackStream(BaseModel): assetPresentation: Literal["FULL"] audioMode: Literal["STEREO"] audioQuality: TrackQuality - manifestMimeType: Literal["application/dash+xml", "application/vnd.tidal.bts"] + manifestMimeType: Literal[ + "application/dash+xml", "application/vnd.tidal.bts" + ] manifestHash: str manifest: str albumReplayGain: float @@ -109,7 +105,6 @@ class TrackStream(BaseModel): class Search(BaseModel): - class Artists(Items): items: List[Artist] From e5b38fb53779ea50dba8059132287b39e90d880e Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 21:38:47 +0100 Subject: [PATCH 77/83] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20close=20#57?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_api.py | 6 +- tiddl/api.py | 243 ++++++++++++++++++++++++++-------------------- 2 files changed, 140 insertions(+), 109 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index ae6d560..fe448d8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -38,8 +38,8 @@ class TestApi(unittest.TestCase): self.assertEqual(artist.name, "Kanye West") def test_artist_albums(self): - self.api.getArtistAlbums(25022) - self.api.getArtistAlbums(25022, onlyNonAlbum=True) + self.api.getArtistAlbums(25022, filter="ALBUMS") + self.api.getArtistAlbums(25022, filter="EPSANDSINGLES") def test_album(self): album = self.api.getAlbum(103805723) @@ -71,7 +71,7 @@ class TestApi(unittest.TestCase): self.assertGreaterEqual(len(favorites.ARTIST), 0) def test_search(self): - self.api.search("Kanye West") + self.api.getSearch("Kanye West") if __name__ == "__main__": diff --git a/tiddl/api.py b/tiddl/api.py index 9386dcf..6006e9e 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -1,57 +1,82 @@ -import logging import json - +import logging from pathlib import Path +from typing import Any, Literal, Type, TypeVar + +from pydantic import BaseModel from requests import Session -from tiddl.exceptions import AuthError, ApiError from tiddl.models.api import ( + Album, AlbumItems, + Artist, ArtistAlbumsItems, Favorites, + Playlist, PlaylistItems, - SessionResponse, Search, + SessionResponse, + Track, TrackStream, + Video, ) + from tiddl.models.constants import TrackQuality -from tiddl.models.resource import Track, Album, Playlist, Artist +from tiddl.exceptions import ApiError DEBUG = False -API_URL = "https://api.tidal.com/v1" +T = TypeVar("T", bound=BaseModel) -# Tidal default limits -ARTIST_ALBUMS_LIMIT = 50 -ALBUM_ITEMS_LIMIT = 10 -PLAYLIST_LIMIT = 50 +logger = logging.getLogger(__name__) + + +def ensureLimit(limit: int, max_limit: int) -> int: + if limit > max_limit: + logger.warning(f"Max limit is {max_limit}") + return max_limit + + return limit + + +class Limits: + ARTIST_ALBUMS = 50 + ALBUM_ITEMS = 10 + ALBUM_ITEMS_MAX = 100 + PLAYLIST = 50 class TidalApi: + URL = "https://api.tidal.com/v1" + LIMITS = Limits + def __init__(self, token: str, user_id: str, country_code: str) -> None: - self.token = token self.user_id = user_id self.country_code = country_code - self._session = Session() - self._session.headers = {"authorization": f"Bearer {token}"} - self._logger = logging.getLogger("TidalApi") + self.session = Session() + self.session.headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } - def _request(self, endpoint: str, params={}): - self._logger.debug(f"{endpoint} {params}") - req = self._session.request( - method="GET", url=f"{API_URL}/{endpoint}", params=params - ) + def fetch( + self, model: Type[T], endpoint: str, params: dict[str, Any] = {} + ) -> T: + """Fetch data from the API and parse it into the given Pydantic model.""" + + req = self.session.get(f"{self.URL}/{endpoint}", params=params) + + logger.debug((endpoint, params, req.status_code)) data = req.json() - if req.status_code == 401: - raise AuthError(**data) - - if req.status_code != 200: - raise ApiError(**data) - if DEBUG: - debug_data = {"endpoint": endpoint, "params": params, "data": data} + debug_data = { + "status_code": req.status_code, + "endpoint": endpoint, + "params": params, + "data": data, + } path = Path(f"debug_data/{endpoint}.json") path.parent.mkdir(parents=True, exist_ok=True) @@ -59,98 +84,104 @@ class TidalApi: with path.open("w", encoding="utf-8") as f: json.dump(debug_data, f, indent=2) - return data + if req.status_code != 200: + raise ApiError(**data) - def getSession(self): - return SessionResponse( - **self._request( - "sessions", - ) + return model.model_validate(data) + + def getAlbum(self, album_id: str | int): + return self.fetch( + Album, f"albums/{album_id}", {"countryCode": self.country_code} ) - def getTrackStream(self, id: str | int, quality: TrackQuality): - return TrackStream( - **self._request( - f"tracks/{id}/playbackinfo", - { - "audioquality": quality, - "playbackmode": "STREAM", - "assetpresentation": "FULL", - }, - ) + def getAlbumItems( + self, album_id: str | int, limit=LIMITS.ALBUM_ITEMS, offset=0 + ): + return self.fetch( + AlbumItems, + f"albums/{album_id}/items", + { + "countryCode": self.country_code, + "limit": ensureLimit(limit, self.LIMITS.ALBUM_ITEMS_MAX), + "offset": offset, + }, ) - def getTrack(self, id: str | int): - return Track( - **self._request(f"tracks/{id}", {"countryCode": self.country_code}) - ) - - def getArtist(self, id: str | int): - return Artist( - **self._request(f"artists/{id}", {"countryCode": self.country_code}) + def getArtist(self, artist_id: str | int): + return self.fetch( + Artist, f"artists/{artist_id}", {"countryCode": self.country_code} ) def getArtistAlbums( - self, id: str | int, limit=ARTIST_ALBUMS_LIMIT, offset=0, onlyNonAlbum=False + self, + artist_id: str | int, + limit=LIMITS.ARTIST_ALBUMS, + offset=0, + filter: Literal["ALBUMS", "EPSANDSINGLES"] = "ALBUMS", ): - params = {"countryCode": self.country_code, "limit": limit, "offset": offset} - - if onlyNonAlbum: - params.update({"filter": "EPSANDSINGLES"}) - - return ArtistAlbumsItems( - **self._request( - f"artists/{id}/albums", - params, - ) - ) - - def getAlbum(self, id: str | int): - return Album( - **self._request(f"albums/{id}", {"countryCode": self.country_code}) - ) - - def getAlbumItems(self, id: str | int, limit=ALBUM_ITEMS_LIMIT, offset=0): - MAX_LIMIT = 100 - - if limit > MAX_LIMIT: - logging.warning(f"Too big page, max page size is {MAX_LIMIT}") - limit = MAX_LIMIT - - return AlbumItems( - **self._request( - f"albums/{id}/items", - {"countryCode": self.country_code, "limit": limit, "offset": offset}, - ) - ) - - def getPlaylist(self, uuid: str): - return Playlist( - **self._request( - f"playlists/{uuid}", - {"countryCode": self.country_code}, - ) - ) - - def getPlaylistItems(self, uuid: str, limit=PLAYLIST_LIMIT, offset=0): - return PlaylistItems( - **self._request( - f"playlists/{uuid}/items", - {"countryCode": self.country_code, "limit": limit, "offset": offset}, - ) + return self.fetch( + ArtistAlbumsItems, + f"artists/{artist_id}/albums", + { + "countryCode": self.country_code, + "limit": limit, # tested limit 10,000 + "offset": offset, + "filter": filter, + }, ) def getFavorites(self): - return Favorites( - **self._request( - f"users/{self.user_id}/favorites/ids", - {"countryCode": self.country_code}, - ) + return self.fetch( + Favorites, + f"users/{self.user_id}/favorites/ids", + {"countryCode": self.country_code}, ) - def search(self, query: str): - return Search( - **self._request( - "search", {"countryCode": self.country_code, "query": query} - ) + def getPlaylist(self, playlist_uuid: str): + return self.fetch( + Playlist, + f"playlists/{playlist_uuid}", + {"countryCode": self.country_code}, + ) + + def getPlaylistItems( + self, playlist_uuid: str, limit=LIMITS.PLAYLIST, offset=0 + ): + return self.fetch( + PlaylistItems, + f"playlists/{playlist_uuid}/items", + { + "countryCode": self.country_code, + "limit": limit, + "offset": offset, + }, + ) + + def getSearch(self, query: str): + return self.fetch( + Search, "search", {"countryCode": self.country_code, "query": query} + ) + + def getSession(self): + return self.fetch(SessionResponse, "sessions") + + def getTrack(self, track_id: str | int): + return self.fetch( + Track, f"tracks/{track_id}", {"countryCode": self.country_code} + ) + + def getTrackStream(self, track_id: str | int, quality: TrackQuality): + return self.fetch( + TrackStream, + f"tracks/{track_id}/playbackinfo", + { + "audioquality": quality, + "playbackmode": "STREAM", + "assetpresentation": "FULL", + }, + ) + + def getVideo(self, video_id: str | int): + return self.fetch( + Video, f"videos/{video_id}", {"countryCode": self.country_code} ) From 829c596b7c104692863421e0716427e0276dc8a0 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 21:41:25 +0100 Subject: [PATCH 78/83] =?UTF-8?q?=F0=9F=94=A7=20ruff=20configuration=20in?= =?UTF-8?q?=20.vscode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index e0009a2..ba9a9b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,9 @@ { - "python.analysis.typeCheckingMode": "basic" -} \ No newline at end of file + "python.analysis.typeCheckingMode": "basic", + "[python]": { + "editor.codeActionsOnSave": { + "source.organizeImports.ruff": "explicit", + } + }, + "ruff.lineLength": 80, +} From 53b1aea4ac215b20bec2499e9c37030211bf600c Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 22:39:40 +0100 Subject: [PATCH 79/83] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20models=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/models/api.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tiddl/models/api.py b/tiddl/models/api.py index 2086cfb..4326d1b 100644 --- a/tiddl/models/api.py +++ b/tiddl/models/api.py @@ -104,15 +104,16 @@ class TrackStream(BaseModel): sampleRate: Optional[int] = None +class SearchAlbum(Album): + # TODO: remove the artist field instead of making it None + artist: None = None + + class Search(BaseModel): class Artists(Items): items: List[Artist] class Albums(Items): - class SearchAlbum(Album): - # TODO: remove the artist field instead of making it None - artist: None = None - items: List[SearchAlbum] class Playlists(Items): @@ -125,7 +126,7 @@ class Search(BaseModel): items: List[Video] class TopHit(BaseModel): - value: Union[Artist, Track, Playlist, Album] + value: Union[Artist, Track, Playlist, SearchAlbum] type: Literal["ARTISTS", "TRACKS", "PLAYLISTS", "ALBUMS"] artists: Artists From 1036cd9e6c3f795508366dca15e1ad6b8449093a Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 22:46:30 +0100 Subject: [PATCH 80/83] =?UTF-8?q?=E2=9C=A8=20add=20search=20on=20Tidal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/download/search.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tiddl/cli/download/search.py b/tiddl/cli/download/search.py index ad3be53..db8c82a 100644 --- a/tiddl/cli/download/search.py +++ b/tiddl/cli/download/search.py @@ -1,5 +1,8 @@ import click +from tiddl.utils import TidalResource +from tiddl.models.resource import Artist, Album, Playlist, Track, Video + from ..ctx import Context, passContext @@ -9,4 +12,33 @@ from ..ctx import Context, passContext def SearchGroup(ctx: Context, query: str): """Search on Tidal""" - # TODO: search on Tidal + # TODO: give user interactive choice what to select + + api = ctx.obj.getApi() + + search = api.getSearch(query) + + # issue is that we get resource data in search api call, + # in download we refetch that data. + # it's not that big deal as we refetch one resource at most, + # but it should be redesigned + + value = search.topHit.value + icon = click.style("\u2bcc", "magenta") + + if isinstance(value, Album): + resource = TidalResource(type="album", id=str(value.id)) + click.echo(f"{icon} Album {value.title}") + elif isinstance(value, Artist): + resource = TidalResource(type="artist", id=str(value.id)) + click.echo(f"{icon} Artist {value.name}") + elif isinstance(value, Track): + resource = TidalResource(type="track", id=str(value.id)) + click.echo(f"{icon} Track {value.title}") + elif isinstance(value, Playlist): + resource = TidalResource(type="playlist", id=str(value.uuid)) + click.echo(f"{icon} Playlist {value.title}") + elif isinstance(value, Video): + click.echo(f"{icon} Video {value.title} (currently not supported)") + + ctx.obj.resources.append(resource) From f4fc8edb1137ea661ea14c96a2d8ede6041ea185 Mon Sep 17 00:00:00 2001 From: oskvr37 Date: Mon, 27 Jan 2025 22:49:20 +0100 Subject: [PATCH 81/83] =?UTF-8?q?=E2=9C=A8=20add=20note=20unicode,=20suppr?= =?UTF-8?q?ess=20urllib3=20debug=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tiddl/cli/__init__.py b/tiddl/cli/__init__.py index 3ccc1e4..2583182 100644 --- a/tiddl/cli/__init__.py +++ b/tiddl/cli/__init__.py @@ -11,7 +11,7 @@ from .config import ConfigCommand @passContext @click.option("--verbose", "-v", is_flag=True, help="Show debug logs") def cli(ctx: Context, verbose: bool): - """TIDDL - Download Tidal tracks ✨""" + """TIDDL - Download Tidal tracks \u266b""" ctx.obj = ContextObj() logging.basicConfig( @@ -20,6 +20,8 @@ def cli(ctx: Context, verbose: bool): format="%(levelname)s [%(name)s.%(funcName)s] %(message)s", ) + logging.getLogger("urllib3").setLevel(logging.ERROR) + cli.add_command(ConfigCommand) cli.add_command(AuthGroup) From 66f1b500a2903d59999163b6b6f43d817e399ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Dudzi=C5=84ski?= <56404247+oskvr37@users.noreply.github.com> Date: Mon, 27 Jan 2025 23:08:45 +0100 Subject: [PATCH 82/83] =?UTF-8?q?=F0=9F=93=9D=20add=20basic=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index 7589c69..00602ad 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,36 @@ Commands: ... ``` +# Basic usage + +Login with Tidal account + +```bash +tiddl auth login +``` + +Download track / album / artist / playlist + +```bash +tiddl url https://listen.tidal.com/track/103805726 download +tiddl url https://listen.tidal.com/album/103805723 download +tiddl url https://listen.tidal.com/artist/25022 download +tiddl url https://listen.tidal.com/playlist/84974059-76af-406a-aede-ece2b78fa372 download +``` + +> [!TIP] +> You don't have to paste full urls, track/103805726, album/103805723 etc. will also work + +Set download quality and output format + +```bash +tiddl ... download -q master -o "{artist}/{title} ({album})" +``` + +This command will: +- download with highest quality +- save track with title and album name in artist folder + # Development Clone the repository From 8247a74757e6bd2f0875c91ffd4f00f50cf279ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Dudzi=C5=84ski?= <56404247+oskvr37@users.noreply.github.com> Date: Mon, 27 Jan 2025 23:39:26 +0100 Subject: [PATCH 83/83] =?UTF-8?q?=F0=9F=93=9D=20add=20wiki=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 00602ad..7b9b5ef 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ This command will: - download with highest quality - save track with title and album name in artist folder +> [!NOTE] +> More about file templating [on wiki](https://github.com/oskvr37/tiddl/wiki/Template-formatting). + # Development Clone the repository