diff --git a/pyproject.toml b/pyproject.toml index 9717fac..896f034 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ dependencies = [ "pydantic>=2.9.2", "requests>=2.20.0", + "requests-cache>=1.2.1", "click>=8.1.7", "mutagen>=1.47.0", "ffmpeg-python>=0.2.0", diff --git a/tiddl/api.py b/tiddl/api.py index 83bebc6..3144d74 100644 --- a/tiddl/api.py +++ b/tiddl/api.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Any, Literal, Type, TypeVar from pydantic import BaseModel -from requests import Session +from requests_cache import CachedSession, EXPIRE_IMMEDIATELY, NEVER_EXPIRE from tiddl.models.api import ( Album, @@ -25,6 +25,7 @@ from tiddl.models.api import ( from tiddl.models.constants import TrackQuality from tiddl.exceptions import ApiError +from tiddl.config import HOME_PATH DEBUG = False T = TypeVar("T", bound=BaseModel) @@ -51,24 +52,44 @@ class TidalApi: URL = "https://api.tidal.com/v1" LIMITS = Limits - def __init__(self, token: str, user_id: str, country_code: str) -> None: + def __init__( + self, token: str, user_id: str, country_code: str, omit_cache=False + ) -> None: self.user_id = user_id self.country_code = country_code - self.session = Session() + # 3.0 TODO: change cache path + CACHE_NAME = "tiddl_api_cache" + + self.session = CachedSession( + cache_name=HOME_PATH / CACHE_NAME, always_revalidate=omit_cache + ) self.session.headers = { "Authorization": f"Bearer {token}", "Accept": "application/json", } def fetch( - self, model: Type[T], endpoint: str, params: dict[str, Any] = {} + self, + model: Type[T], + endpoint: str, + params: dict[str, Any] = {}, + expire_after=NEVER_EXPIRE, ) -> T: """Fetch data from the API and parse it into the given Pydantic model.""" - req = self.session.get(f"{self.URL}/{endpoint}", params=params) + req = self.session.get( + f"{self.URL}/{endpoint}", params=params, expire_after=expire_after + ) - logger.debug((endpoint, params, req.status_code)) + logger.debug( + ( + endpoint, + params, + req.status_code, + "HIT" if req.from_cache else "MISS", + ) + ) data = req.json() @@ -124,7 +145,10 @@ class TidalApi: def getArtist(self, artist_id: str | int): return self.fetch( - Artist, f"artists/{artist_id}", {"countryCode": self.country_code} + Artist, + f"artists/{artist_id}", + {"countryCode": self.country_code}, + expire_after=3600, ) def getArtistAlbums( @@ -143,6 +167,7 @@ class TidalApi: "offset": offset, "filter": filter, }, + expire_after=3600, ) def getFavorites(self): @@ -150,6 +175,7 @@ class TidalApi: Favorites, f"users/{self.user_id}/favorites/ids", {"countryCode": self.country_code}, + expire_after=EXPIRE_IMMEDIATELY, ) def getPlaylist(self, playlist_uuid: str): @@ -170,15 +196,21 @@ class TidalApi: "limit": limit, "offset": offset, }, + expire_after=EXPIRE_IMMEDIATELY, ) def getSearch(self, query: str): return self.fetch( - Search, "search", {"countryCode": self.country_code, "query": query} + Search, + "search", + {"countryCode": self.country_code, "query": query}, + expire_after=EXPIRE_IMMEDIATELY, ) def getSession(self): - return self.fetch(SessionResponse, "sessions") + return self.fetch( + SessionResponse, "sessions", expire_after=EXPIRE_IMMEDIATELY + ) def getTrack(self, track_id: str | int): return self.fetch( @@ -194,6 +226,7 @@ class TidalApi: "playbackmode": "STREAM", "assetpresentation": "FULL", }, + expire_after=3600, ) def getVideo(self, video_id: str | int): @@ -210,4 +243,5 @@ class TidalApi: "playbackmode": "STREAM", "assetpresentation": "FULL", }, + expire_after=3600, ) diff --git a/tiddl/cli/__init__.py b/tiddl/cli/__init__.py index cab82cc..9b3651b 100644 --- a/tiddl/cli/__init__.py +++ b/tiddl/cli/__init__.py @@ -7,17 +7,21 @@ from .ctx import ContextObj, passContext, Context from .auth import AuthGroup from .download import UrlGroup, FavGroup, SearchGroup, FileGroup from .config import ConfigCommand +from .download_concurrent import DownloadCommand from tiddl.config import HOME_PATH @click.group() @passContext -@click.option("--verbose", "-v", is_flag=True, help="Show debug logs") -@click.option("--quiet", "-q", is_flag=True, help="Suppress logs") -def cli(ctx: Context, verbose: bool, quiet: bool): +@click.option("--verbose", "-v", is_flag=True, help="Show debug logs.") +@click.option("--quiet", "-q", is_flag=True, help="Suppress logs.") +@click.option( + "--no-cache", "-nc", is_flag=True, help="Omit Tidal API requests caching." +) +def cli(ctx: Context, verbose: bool, quiet: bool, no_cache: bool): """TIDDL - Download Tidal tracks \u266b""" - ctx.obj = ContextObj() + ctx.obj = ContextObj(omit_cache=no_cache) # latest logs file_handler = logging.FileHandler( @@ -55,6 +59,7 @@ cli.add_command(UrlGroup) cli.add_command(FavGroup) cli.add_command(SearchGroup) cli.add_command(FileGroup) +cli.add_command(DownloadCommand) if __name__ == "__main__": cli() diff --git a/tiddl/cli/ctx.py b/tiddl/cli/ctx.py index 1913806..3f6559d 100644 --- a/tiddl/cli/ctx.py +++ b/tiddl/cli/ctx.py @@ -16,7 +16,7 @@ class ContextObj: resources: list[TidalResource] console: Console - def __init__(self) -> None: + def __init__(self, omit_cache=False) -> None: self.config = Config.fromFile() self.resources = [] self.api = None @@ -25,7 +25,12 @@ class ContextObj: auth = self.config.auth if auth.token and auth.user_id and auth.country_code: - self.api = TidalApi(auth.token, auth.user_id, auth.country_code) + self.api = TidalApi( + auth.token, + auth.user_id, + auth.country_code, + omit_cache=omit_cache or self.config.omit_cache, + ) def getApi(self) -> TidalApi: if self.api is None: diff --git a/tiddl/cli/download_concurrent/__init__.py b/tiddl/cli/download_concurrent/__init__.py new file mode 100644 index 0000000..1d551cb --- /dev/null +++ b/tiddl/cli/download_concurrent/__init__.py @@ -0,0 +1,100 @@ +import logging +import click + +from ..ctx import Context, passContext + +from typing import List, Literal + +from concurrent.futures import ThreadPoolExecutor + +from rich.console import Console +from rich.logging import RichHandler +from rich.progress import ( + BarColumn, + Progress, + TextColumn, +) + +from tiddl.download import downloadTrackStream +from tiddl.utils import ( + formatTrack, + trackExists, + TidalResource, + convertFileExtension, +) +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, AlbumItemsCredits + +SinglesFilter = Literal["none", "only", "include"] + + +@click.command("download") +@click.option( + "--quality", "-q", "QUALITY", type=click.Choice(TrackArg.__args__) +) +@click.option( + "--output", "-o", "TEMPLATE", type=str, help="Format track file template." +) +@click.option( + "--threads", + "-t", + "THREADS_COUNT", + type=int, + help="Number of threads to use in concurrent download; use with caution.", + default=1, +) +@click.option( + "--noskip", + "-ns", + "DO_NOT_SKIP", + is_flag=True, + default=False, + help="Do not skip already downloaded tracks.", +) +@click.option( + "--singles", + "-s", + "SINGLES_FILTER", + type=click.Choice(SinglesFilter.__args__), + default="none", + help="Defines how to treat artist EPs and singles.", +) +@passContext +def DownloadCommand( + ctx: Context, + QUALITY: TrackArg | None, + TEMPLATE: str | None, + THREADS_COUNT: int, + DO_NOT_SKIP: bool, + SINGLES_FILTER: SinglesFilter, +): + """Download resources""" + + logging.debug( + (QUALITY, TEMPLATE, THREADS_COUNT, DO_NOT_SKIP, SINGLES_FILTER) + ) + + api = ctx.obj.getApi() + + def handleResource(resource: TidalResource) -> None: + pass + + failed_resources: list[TidalResource] = [] + + for resource in ctx.obj.resources: + try: + handleResource(resource) + + except ApiError as e: + # TODO: handle rate limit + logging.error(e) + failed_resources.append(resource) + + except AuthError as e: + logging.error(e) + return + + # TODO: do something with `failed_resources` diff --git a/tiddl/config.py b/tiddl/config.py index 8203dad..c5300ce 100644 --- a/tiddl/config.py +++ b/tiddl/config.py @@ -1,4 +1,4 @@ -# TODO: 3.0 change config path to ~/.config/tiddl.json +# 3.0 TODO: change config path to ~/.config/tiddl.json from pydantic import BaseModel from pathlib import Path @@ -20,6 +20,7 @@ class TemplateConfig(BaseModel): class DownloadConfig(BaseModel): quality: TrackArg = "high" path: Path = Path.home() / "Music" / "Tiddl" + threads: int = 1 class AuthConfig(BaseModel): @@ -34,6 +35,7 @@ class Config(BaseModel): template: TemplateConfig = TemplateConfig() download: DownloadConfig = DownloadConfig() auth: AuthConfig = AuthConfig() + omit_cache: bool = False def save(self): with open(CONFIG_PATH, "w") as f: