add Tidal API cache (#84)

*  add cache to api

*  add cache omit option

*  add `expire_after` to `TidalApi.fetch`

* ♻️ prepare new download command
This commit is contained in:
Oskar Dudziński
2025-02-08 14:57:52 +01:00
committed by GitHub
parent a9e105150f
commit 993aa08e7e
6 changed files with 163 additions and 16 deletions
+1
View File
@@ -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",
+43 -9
View File
@@ -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,
)
+9 -4
View File
@@ -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()
+7 -2
View File
@@ -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:
+100
View File
@@ -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`
+3 -1
View File
@@ -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: