mirror of
https://github.com/oskvr37/tiddl.git
synced 2026-06-13 04:05:08 +03:00
✨ 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:
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user