from __future__ import annotations
from abc import abstractmethod
from datetime import datetime
import time
from typing import TYPE_CHECKING
import warnings
from .._shared import TTLCache, OAuth2APIClient, ResourceAPI
from ._api.albums import AlbumsAPI
from ._api.artists import ArtistsAPI
from ._api.artworks import ArtworksAPI
from ._api.playlists import PlaylistsAPI
from ._api.providers import ProvidersAPI
from ._api.rules import RulesAPI
from ._api.search import SearchAPI
from ._api.tracks import TracksAPI
from ._api.users import UsersAPI
from ._api.videos import VideosAPI
from ._private_api.albums import PrivateAlbumsAPI
from ._private_api.artists import PrivateArtistsAPI
from ._private_api.feed import PrivateFeedAPI
from ._private_api.mixes import PrivateMixesAPI
from ._private_api.pages import PrivatePagesAPI
from ._private_api.playlists import PrivatePlaylistsAPI
from ._private_api.search import PrivateSearchAPI
from ._private_api.tracks import PrivateTracksAPI
from ._private_api.users import PrivateUsersAPI
from ._private_api.videos import PrivateVideosAPI
if TYPE_CHECKING:
from typing import Any
import httpx
from ..._types import Collection
class BaseTIDALAPIClient(OAuth2APIClient):
"""
Base class for TIDAL API clients.
"""
_ALLOWED_AUTH_FLOWS: set[str]
_ALLOWED_SCOPES: set[str]
_ENV_VAR_PREFIX: str
_QUAL_NAME: str
_VERSION: str
BASE_URL: str
_PROVIDER = "TIDAL"
AUTH_URL = "https://login.tidal.com/authorize"
TOKEN_URL = "https://auth.tidal.com/v1/oauth2/token"
__slots__ = ()
@classmethod
def resolve_scopes(
cls, matches: str | Collection[str] | None = None
) -> set[str]:
"""
Resolve one or more scope categories or substrings into a set of
scopes.
Parameters
----------
matches : str or Collection[str]; optional
Substrings to match in the available scopes. If not
specified, all available scopes are returned.
Returns
-------
scopes : set[str]
Authorization scopes.
"""
# Return all scopes if no matches are provided
if matches is None:
return cls._ALLOWED_SCOPES.copy()
# Return scopes containing a substring
if isinstance(matches, str):
return {scope for scope in cls._ALLOWED_SCOPES if matches in scope}
# Recursively gather scopes for multiple categories/substrings
return {
scope for match in matches for scope in cls.resolve_scopes(match)
}
@property
@abstractmethod
def _my_country_code(self) -> str:
"""
Current user's country code.
"""
...
@property
@abstractmethod
def _my_profile(self) -> dict[str, Any]:
"""
Current user's profile.
"""
...
@abstractmethod
def _request(
self,
method: str,
endpoint: str,
/,
*,
retry: bool = True,
**kwargs: dict[str, Any],
) -> "httpx.Response":
"""
Make an HTTP request to a TIDAL API endpoint.
Parameters
----------
method : str; positional-only
HTTP method.
endpoint : str; positional-only
TIDAL API endpoint.
retry : bool; keyword-only; default: :code:`True`
Whether to retry the request if the first attempt returns a
:code:`401 Unauthorized` or :code:`429 Too Many Requests`.
**kwargs : dict[str, Any]
Keyword parameters to pass to :meth:`httpx.Client.request`.
Returns
-------
response : httpx.Response
HTTP response.
"""
...
@abstractmethod
def _resolve_user_identifier(self) -> str:
"""
Return the TIDAL user ID as the user identifier for the
current account.
"""
...
def _resolve_country_code(
self, country_code: str | None, /, params: dict[str, Any]
) -> None:
"""
Resolve or validate the country code for a TIDAL API request.
Parameters
----------
country_code : str; positional-only
ISO 3166-1 alpha-2 country code. If not provided, the
country associated with the current user account or IP
address is used.
"""
if country_code is None:
params["countryCode"] = self._my_country_code
else:
ResourceAPI._validate_country_code(country_code)
params["countryCode"] = country_code
[docs]
class TIDALAPIClient(BaseTIDALAPIClient):
"""
TIDAL API client.
"""
_ALLOWED_AUTH_FLOWS = {"pkce", "client_credentials"}
_ALLOWED_SCOPES = {
"collection.read",
"collection.write",
"playlists.read",
"playlists.write",
"playback",
"user.read",
"recommendations.read",
"entitlements.read",
"search.read",
"search.write",
}
_ENV_VAR_PREFIX = "TIDAL_API"
_QUAL_NAME = (
f"minim.api.{BaseTIDALAPIClient._PROVIDER.lower()}.{__qualname__}"
)
_VERSION = "1.4.17"
BASE_URL = "https://openapi.tidal.com/v2"
__slots__ = (
"albums",
"artists",
"artworks",
"playlists",
"providers",
"rules",
"search",
"tracks",
"users",
"videos",
)
def __init__(
self,
*,
auth_flow: str,
client_id: str | None = None,
client_secret: str | None = None,
user_identifier: str | None = None,
redirect_uri: str | None = None,
scopes: str | Collection[str] = "",
access_token: str | None = None,
refresh_token: str | None = None,
expires_at: str | datetime | None = None,
redirect_handler: str | None = None,
open_browser: bool = False,
enable_cache: bool = True,
store_tokens: bool = True,
user_agent: str | None = None,
) -> None:
"""
Parameters
----------
auth_flow : str; keyword-only
Authorization flow.
**Valid values**:
* :code:`"pkce"` – Authorization Code Flow with Proof Key
for Code Exchange (PKCE).
* :code:`"client_credentials"` – Client Credentials Flow.
client_id : str; keyword-only; optional
Client ID. Required unless set as system environment
variable :code:`TIDAL_API_CLIENT_ID` or stored in the local
token storage.
client_secret : str; keyword-only; optional
Client secret. Required for the Client Credentials flow
unless set as system environment variable
:code:`TIDAL_API_CLIENT_SECRET` or stored in the local token
storage.
user_identifier : str; keyword-only; optional
Identifier for the user account. Used when
:code:`store_tokens=True` to distinguish between multiple
accounts for the same client ID and authorization flow.
If specified, it is used with the client ID and authorization
flow to locate a matching stored token. If none is found, a
new token is obtained and stored under this identifier.
If not specified, the most recently accessed token for the
client ID and authorization flow is used. If none exists, a
new token is obtained and stored using the TIDAL user ID
acquired from a successful authorization.
Prefixing the identifier with a tilde (:code:`~`) bypasses
token retrieval, forces reauthorization, and stores the new
token under the suffix.
redirect_uri : str; keyword-only; optional
Redirect URI. Required for the Authorization Code with PKCE
flow.
scopes : str or Collection[str]; keyword-only; optional
Authorization scopes requested by the client to access user
resources.
.. seealso::
:meth:`resolve_scopes` – Resolve scope categories and/or
substrings into a set of scopes.
access_token : str; keyword-only; optional
Access token. If provided, the authorization process is
bypassed, and automatic token refresh is enabled when
relevant metadata (refresh token, expiry, etc.) is also
supplied.
refresh_token : str; keyword-only; optional
Refresh token for renewing the access token. If not
provided, the user will be reauthorized via the specified
authorization flow when the access token expires.
expires_at : str or datetime.datetime; keyword-only; optional
Expiration time of the access token. If a string, it must be
in ISO 8601 format (:code:`%Y-%m-%dT%H:%M:%SZ`).
redirect_handler : str or None; keyword-only; optional
Backend for handling redirects during the authorization
flow. Redirect handling is only available for hosts
:code:`localhost`, :code:`127.0.0.1`, or :code:`::1`.
**Valid values**:
* :code:`None` – Show authorization URL in and have the user
manually paste the redirect URL into the terminal.
* :code:`"http.server"` – Run a HTTP server to intercept the
redirect after user authorization in any local browser.
* :code:`"playwright"` – Use a Playwright Firefox browser to
complete the user authorization.
open_browser : bool; keyword-only; default: :code:`False`
Whether to automatically open the authorization URL in the
default web browser for the Authorization Code with PKCE
flow. If :code:`False`, the URL is printed to the terminal.
enable_cache : bool; keyword-only; default: :code:`True`
Whether to enable an in-memory time-to-live (TTL) cache with
a least recently used (LRU) eviction policy for this client.
If :code:`True`, responses from semi-static endpoints are
cached for one minute to one day, depending on their
expected update frequency.
.. seealso::
:meth:`clear_cache` – Clear specific or all cache entries
for this client.
store_tokens : bool; keyword-only; default: :code:`True`
Whether to enable the local token storage for this client.
If :code:`True`, existing access tokens are retrieved when
found in local storage, and newly acquired tokens and their
metadata are stored for future retrieval. If :code:`False`,
the client neither retrieves nor stores access tokens.
.. seealso::
:meth:`get_tokens` – Retrieve specific or all stored
access tokens for this client.
:meth:`remove_tokens` – Remove specific or all stored
access tokens for this client.
user_agent : str; keyword-only; optional
:code:`User-Agent` value to include in the headers of HTTP
requests.
"""
# Initialize subclasses for endpoint groups
#: Albums API endpoints for the TIDAL API.
self.albums: AlbumsAPI = AlbumsAPI(self)
#: Artists and Artist Roles API endpoints for the TIDAL API.
self.artists: ArtistsAPI = ArtistsAPI(self)
#: Artworks API endpoints for the TIDAL API.
self.artworks: ArtworksAPI = ArtworksAPI(self)
#: Playlists API endpoints for the TIDAL API.
self.playlists: PlaylistsAPI = PlaylistsAPI(self)
#: Providers API endpoints for the TIDAL API.
self.providers: ProvidersAPI = ProvidersAPI(self)
#: Usage Rules API endpoints for the TIDAL API.
self.rules: RulesAPI = RulesAPI(self)
#: Search Results and Search Suggestions API endpoints for the
#: TIDAL API.
self.search: SearchAPI = SearchAPI(self)
#: Tracks API endpoints for the TIDAL API.
self.tracks: TracksAPI = TracksAPI(self)
#: User Collections, User Entitlements, User Recommendations,
#: and Users API endpoints for the TIDAL API.
self.users: UsersAPI = UsersAPI(self)
#: Videos API endpoints for the TIDAL API.
self.videos: VideosAPI = VideosAPI(self)
super().__init__(
auth_flow=auth_flow,
client_id=client_id,
client_secret=client_secret,
user_identifier=user_identifier,
redirect_uri=redirect_uri,
scopes=scopes,
access_token=access_token,
refresh_token=refresh_token,
expires_at=expires_at,
redirect_handler=redirect_handler,
open_browser=open_browser,
enable_cache=enable_cache,
store_tokens=store_tokens,
user_agent=user_agent,
)
@property
def _my_country_code(self) -> str:
"""
Current user's country code.
.. note::
Accessing this property may call
:meth:`~minim.api.tidal.UsersAPI.get_user` and make a request
to the TIDAL API.
"""
country_code = self._my_profile.get("attributes", {}).get("country")
if country_code is None:
raise RuntimeError(
"Unable to determine the country associated with the "
"current user account. A ISO 3166-1 alpha-2 country "
"code must be provided explicitly via the "
"`country_code` parameter."
)
return country_code
@property
def _my_profile(self) -> dict[str, Any]:
"""
Current user's profile.
.. note::
Accessing this property may call
:meth:`~minim.api.tidal.UsersAPI.get_user` and make a request
to the TIDAL API.
"""
return (
{}
if self._auth_flow == "client_credentials"
else self.users.get_user()["data"]
)
def _request(
self,
method: str,
endpoint: str,
/,
*,
retry: bool = True,
**kwargs: dict[str, Any],
) -> "httpx.Response":
"""
Make an HTTP request to a TIDAL API endpoint.
Parameters
----------
method : str; positional-only
HTTP method.
endpoint : str; positional-only
TIDAL API endpoint.
retry : bool; keyword-only; default: :code:`True`
Whether to retry the request if the first attempt returns a
:code:`401 Unauthorized` or :code:`429 Too Many Requests`.
**kwargs : dict[str, Any]
Keyword parameters to pass to :meth:`httpx.Client.request`.
Returns
-------
response : httpx.Response
HTTP response.
"""
if True or self._expires_at and datetime.now() > self._expires_at:
self._refresh_access_token()
resp = self._client.request(method, endpoint, **kwargs)
status = resp.status_code
if 200 <= status < 300:
return resp
if status == 401 and not self._expires_at and retry:
self._refresh_access_token()
return self._request(method, endpoint, retry=False, **kwargs)
if status == 429 and retry:
try:
retry_after = float(resp.headers["retry-after"]) + 1.0
except (KeyError, ValueError):
retry_after = 1.0
warnings.warn(
"Rate limit exceeded. Retrying after "
f"{retry_after:.3f} second(s)."
)
time.sleep(retry_after)
return self._request(method, endpoint, retry=False, **kwargs)
error = resp.json()["errors"][0]
raise RuntimeError(
f"{status} {resp.reason_phrase} "
f"({error['code']}) – {error['detail']}"
)
def _resolve_user_identifier(self) -> str:
"""
Return the TIDAL user ID as the user identifier for the current
account.
.. note::
Invoking this method may call
:meth:`~minim.api.tidal.UsersAPI.get_user` and make a request
to the TIDAL API.
"""
return self._my_profile.get("id")
[docs]
class PrivateTIDALAPIClient(BaseTIDALAPIClient):
"""
Private TIDAL API client.
.. attention::
As the private TIDAL API is not designed to be publicly
accessible, this client may break without warning if TIDAL makes
internal changes or be disabled and removed at any time to ensure
compliance with the `TIDAL Developer Terms of Service
<https://developer.tidal.com/documentation/guidelines
/guidelines-developer-terms>`_.
"""
_ALLOWED_AUTH_FLOWS = {None, "pkce", "device"}
_ALLOWED_SCOPES = {"r_usr", "w_usr", "w_sub"}
_DEVICE_TYPES = {"BROWSER", "DESKTOP", "PHONE", "TV"}
_ENV_VAR_PREFIX = "PRIVATE_TIDAL_API"
_IMAGE_SIZES = {
"album": {
"1280x1280",
"1080x1080",
"750x750",
"640x640",
"320x320",
"160x160",
"80x80",
},
"artist": {"750x750", "480x480", "320x320", "160x160"},
"playlist": {"1280x1280", "480x480", "320x320", "160x160"},
"video": {"1280x720", "800x450", "640x360", "320x180", "160x90"},
}
_IMAGE_TYPES = {
"album": "cover art",
"artist": "profile art",
"playlist": "cover art",
"video": "thumbnail",
}
_IS_TRUSTED_DEVICE = True
_OPTIONAL_AUTH = True
_QUAL_NAME = (
f"minim.api.{BaseTIDALAPIClient._PROVIDER.lower()}.{__qualname__}"
)
_REDIRECT_HANDLERS = {}
_REDIRECT_URIS = {"tidal://login/auth", "https://tidal.com/login/auth"}
_VERSION = "2025.12.18"
BASE_URL = "https://api.tidal.com"
DEVICE_AUTH_URL = "https://auth.tidal.com/v1/oauth2/device_authorization"
#: URL for image resources.
RESOURCE_URL = "https://resources.tidal.com"
__slots__ = (
"albums",
"artists",
"feed",
"mixes",
"pages",
"playlists",
"search",
"tracks",
"users",
"videos",
)
def __init__(
self,
*,
auth_flow: str | None,
client_id: str | None = None,
client_secret: str | None = None,
user_identifier: str | None = None,
redirect_uri: str | None = None,
scopes: str | Collection[str] = "",
access_token: str | None = None,
refresh_token: str | None = None,
expires_at: str | datetime | None = None,
redirect_handler: str | None = None,
open_browser: bool = False,
enable_cache: bool = True,
store_tokens: bool = True,
user_agent: str | None = None,
) -> None:
"""
Parameters
----------
auth_flow : str or None; keyword-only
Authorization flow.
**Valid values**:
* :code:`None` – No authentication.
* :code:`"pkce"` – Authorization Code Flow with Proof Key
for Code Exchange (PKCE).
* :code:`"device"` – Device Authorization Flow.
client_id : str; keyword-only; optional
Client ID. Required unless set as system environment
variable :code:`PRIVATE_TIDAL_API_CLIENT_ID` or stored in
the local token storage.
client_secret : str; keyword-only; optional
Client secret. Required for the Client Credentials flow
unless set as system environment variable
:code:`PRIVATE_TIDAL_API_CLIENT_SECRET` or stored in the
local token storage.
user_identifier : str; keyword-only; optional
Identifier for the user account. Used when
:code:`store_tokens=True` to distinguish between multiple
accounts for the same client ID and authorization flow.
If specified, it is used with the client ID and authorization
flow to locate a matching stored token. If none is found, a
new token is obtained and stored under this identifier.
If not specified, the most recently accessed token for the
client ID and authorization flow is used. If none exists, a
new token is obtained and stored using the TIDAL user ID
acquired from a successful authorization.
Prefixing the identifier with a tilde (:code:`~`) bypasses
token retrieval, forces reauthorization, and stores the new
token under the suffix.
redirect_uri : str; keyword-only; optional
Redirect URI. Required for the Authorization Code with PKCE
flow.
**Valid values**: :code:`"tidal://login/auth"`,
:code:`"https://tidal.com/login/auth"`.
scopes : str or Collection[str]; keyword-only; optional
Authorization scopes requested by the client to access user
resources.
.. seealso::
:meth:`resolve_scopes` – Resolve scope categories and/or
substrings into a set of scopes.
access_token : str; keyword-only; optional
Access token. If provided, the authorization process is
bypassed, and automatic token refresh is enabled when
relevant metadata (refresh token, expiry, etc.) is also
supplied.
refresh_token : str; keyword-only; optional
Refresh token for renewing the access token. If not
provided, the user will be reauthorized via the specified
authorization flow when the access token expires.
expires_at : str or datetime.datetime; keyword-only; optional
Expiration time of the access token. If a string, it must be
in ISO 8601 format (:code:`%Y-%m-%dT%H:%M:%SZ`).
redirect_handler : None; keyword-only; optional
Backend for handling redirects during the authorization
flow. Redirect handling is only available for hosts
:code:`localhost`, :code:`127.0.0.1`, or :code:`::1`.
**Valid value**: :code:`None` – Manually paste the redirect
URL into the terminal.
open_browser : bool; keyword-only; default: :code:`False`
Whether to automatically open the authorization URL in the
default web browser for the Authorization Code with PKCE
flow. If :code:`False`, the URL is printed to the terminal.
enable_cache : bool; keyword-only; default: :code:`True`
Whether to enable an in-memory time-to-live (TTL) cache with
a least recently used (LRU) eviction policy for this client.
If :code:`True`, responses from semi-static endpoints are
cached for one minute to one day, depending on their
expected update frequency.
.. seealso::
:meth:`clear_cache` – Clear specific or all cache entries
for this client.
store_tokens : bool; keyword-only; default: :code:`True`
Whether to enable the local token storage for this client.
If :code:`True`, existing access tokens are retrieved when
found in local storage, and newly acquired tokens and their
metadata are stored for future retrieval. If :code:`False`,
the client neither retrieves nor stores access tokens.
.. seealso::
:meth:`get_tokens` – Retrieve specific or all stored
access tokens for this client.
:meth:`remove_tokens` – Remove specific or all stored
access tokens for this client.
user_agent : str; keyword-only; optional
:code:`User-Agent` value to include in the headers of HTTP
requests.
"""
# Initialize subclasses for endpoint groups
#: Albums API endpoints for the private TIDAL API.
self.albums: PrivateAlbumsAPI = PrivateAlbumsAPI(self)
#: Artists API endpoints for the private TIDAL API.
self.artists: PrivateArtistsAPI = PrivateArtistsAPI(self)
#: Feed API endpoints for the private TIDAL API.
self.feed: PrivateFeedAPI = PrivateFeedAPI(self)
#: Mixes API endpoints for the private TIDAL API.
self.mixes: PrivateMixesAPI = PrivateMixesAPI(self)
#: Pages API endpoints for the private TIDAL API.
self.pages: PrivatePagesAPI = PrivatePagesAPI(self)
#: Playlists API endpoints for the private TIDAL API.
self.playlists: PrivatePlaylistsAPI = PrivatePlaylistsAPI(self)
#: Search API endpoints for the private TIDAL API.
self.search: PrivateSearchAPI = PrivateSearchAPI(self)
#: Tracks API endpoints for the private TIDAL API.
self.tracks: PrivateTracksAPI = PrivateTracksAPI(self)
#: Users API endpoints for the private TIDAL API.
self.users: PrivateUsersAPI = PrivateUsersAPI(self)
#: Videos API endpoint for the private TIDAL API.
self.videos: PrivateVideosAPI = PrivateVideosAPI(self)
super().__init__(
auth_flow=auth_flow,
client_id=client_id,
client_secret=client_secret,
user_identifier=user_identifier,
redirect_uri=redirect_uri,
scopes=scopes,
access_token=access_token,
refresh_token=refresh_token,
expires_at=expires_at,
redirect_handler=redirect_handler,
open_browser=open_browser,
enable_cache=enable_cache,
store_tokens=store_tokens,
user_agent=user_agent,
)
self._client.headers["x-tidal-client-version"] = self._VERSION
[docs]
@classmethod
def build_artwork_url(
cls,
artwork_uuid: str,
/,
item_type: str | None = None,
*,
animated: bool = False,
dimensions: int | str | tuple[int | str, int | str] | None = None,
) -> str:
"""
Build the URL for a TIDAL artwork.
Parameters
----------
artwork_uuid : str; positional-only
TIDAL artwork UUID.
item_type : str; positional-only; optional
Type of item the artwork belongs to. If provided, the
specified dimensions are validated against the allowed
dimensions for the item type.
**Valid values**: :code:`"artist"`, :code:`"album"`,
:code:`"playlist"`, :code:`"userProfile"`, :code:`"video"`.
animated : bool; keyword-only; default: :code:`False`
Whether the artwork is animated.
dimensions : int, str, or tuple[int | str, int | str]; \
keyword-only; optional
Dimensions of the artwork. Use :code:`"origin"` or leave
blank to get artwork in its original dimensions.
**Examples**: :code:`"origin"`, :code:`1_280`,
:code:`"1280"`, :code:`"1280x1280"`, :code:`(640, 360)`,
:code:`("640", "360")`.
"""
if animated:
extension = ".mp4"
media_type = "videos"
else:
extension = ".jpg"
media_type = "images"
if dimensions is None:
dimensions = "origin"
elif isinstance(dimensions, int):
dimensions = f"{dimensions}x{dimensions}"
elif isinstance(dimensions, str):
if "x" in dimensions:
if len(
split_dimensions := dimensions.split("x", maxsplit=1)
) != 2 or any(not dim.isdecimal() for dim in split_dimensions):
raise ValueError(f"Invalid dimensions {dimensions!r}.")
elif dimensions.isdecimal():
dimensions = f"{dimensions}x{dimensions}"
else:
raise ValueError(f"Invalid dimensions {dimensions!r}.")
elif isinstance(dimensions, tuple | list) and len(dimensions) == 2:
for ax, dim in zip(("width", "height"), dimensions):
if isinstance(dim, str):
if not dim.isdecimal():
raise ValueError(f"Invalid {ax} {dim!r}.")
elif not isinstance(dim, int):
raise ValueError(f"Invalid {ax} {dim!r}.")
dimensions = f"{dimensions[0]}x{dimensions[1]}"
else:
raise ValueError(f"Invalid dimensions {dimensions!r}.")
if item_type is not None:
if item_type[-1] == "s":
item_type = item_type[:-1]
if item_type not in cls._IMAGE_SIZES:
raise ValueError(
f"Invalid resource type {item_type!r}. Valid "
f"values: {cls._join_values(cls._IMAGE_SIZES)}."
)
if dimensions not in (sizes := cls._IMAGE_SIZES[item_type]):
raise ValueError(
f"Invalid dimensions {dimensions!r} for a(n) "
f"{item_type} {cls._IMAGE_TYPES[item_type]}. "
f"Valid values: {cls._join_values(sizes)}."
)
return (
f"{PrivateTIDALAPIClient.RESOURCE_URL}/{media_type}"
f"/{artwork_uuid.replace('-', '/')}/{dimensions}{extension}"
)
@property
def _my_country_code(self) -> str:
"""
Current user's country code.
.. note::
Accessing this property may call :meth:`get_country_code` or
:meth:`~minim.api.tidal.PrivateUsersAPI.get_me` and make
requests to the private TIDAL API.
"""
if self._auth_flow is None:
return self.get_country_code()["countryCode"]
return self._my_profile.get(
"countryCode", self.get_country_code()["countryCode"]
)
@property
def _my_profile(self) -> dict[str, Any] | None:
"""
Current user's profile.
.. note::
Accessing this property may call
:meth:`~minim.api.tidal.PrivateUsersAPI.get_me` and make a
request to the private TIDAL API.
"""
if self._auth_flow is not None:
return self.users.get_me()
def _request(
self,
method: str,
endpoint: str,
/,
*,
retry: bool = True,
**kwargs: dict[str, Any],
) -> "httpx.Response":
"""
Make an HTTP request to a private TIDAL API endpoint.
Parameters
----------
method : str; positional-only
HTTP method.
endpoint : str; positional-only
Private TIDAL API endpoint.
retry : bool; keyword-only; default: :code:`True`
Whether to retry the request if the first attempt returns a
:code:`401 Unauthorized`.
**kwargs : dict[str, Any]
Keyword parameters to pass to :meth:`httpx.Client.request`.
Returns
-------
response : httpx.Response
HTTP response.
"""
if self._expires_at and datetime.now() > self._expires_at:
self._refresh_access_token()
if self._auth_flow == "device" and "r_usr" not in self._scopes:
raise RuntimeError(
"The 'r_usr' scope is required when using the private "
"TIDAL API with the Device Authorization Flow."
)
resp = self._client.request(method, endpoint, **kwargs)
status = resp.status_code
if 200 <= status < 300:
return resp
if status == 401 and not self._expires_at and retry:
self._refresh_access_token()
return self._request(method, endpoint, retry=False, **kwargs)
error = resp.json()
if isinstance(error, str):
raise RuntimeError(f"{resp.status_code} {error}")
if "status" in error:
if "subStatus" in error:
raise RuntimeError(
f"{error['status']}.{error['subStatus']} "
f"– {error['userMessage']}"
)
if "path" in error:
raise RuntimeError(
f"{error['status']} {error['error']} – {error['path']}"
)
raise RuntimeError(
f"{error['status']} {error['title']} – {error['detail']}"
)
raise RuntimeError(
f"{error['httpStatus']}.{error['subStatus']} "
f"{error['error']} – {error['description']}"
)
def _require_subscription(self, endpoint_method: str, /) -> None:
"""
Ensure that a TIDAL streaming plan is active for an endpoint
method that requires it.
.. note::
Invoking this method may call
:meth:`~minim.api.tidal.PrivateUsersAPI.get_subscription` and
make a request to the private TIDAL API.
Parameters
----------
endpoint_method : str; positional-only
Name of the endpoint method.
"""
if not self.users.get_subscription().get("premiumAccess", False):
raise RuntimeError(
f"{self._QUAL_NAME}.{endpoint_method}() requires "
"an active TIDAL streaming plan."
)
def _resolve_user_identifier(self) -> str:
"""
Return the TIDAL user ID as the user identifier for the current
account.
.. note::
Invoking this method may call
:meth:`~minim.api.tidal.PrivateUsersAPI.get_me` and make a
request to the private TIDAL API.
"""
return self._token_extras.get("user_id", self._my_profile["userId"])
[docs]
@TTLCache.cached_method(ttl="static")
def get_country_code(self) -> dict[str, str]:
"""
Get the country code associated with the current IP address or
user account.
Returns
-------
country_code : dict[str, str]
Country code.
**Sample response**: :code:`{"countryCode": "US"}`.
"""
return self._request("GET", "v1/country").json()
[docs]
def set_auth_flow(
self,
auth_flow: str | None,
/,
*,
client_id: str | None = None,
client_secret: str | None = None,
user_identifier: str | None = None,
redirect_uri: str | None = None,
scopes: str | Collection[str] = "",
redirect_handler: str | None = None,
open_browser: bool = False,
store_tokens: bool = True,
authenticate: bool = True,
) -> None:
"""
Set or update the authorization flow and related information.
.. warning::
Calling this method replaces all existing values with the
specified parameters. Parameters not specified explicitly
will be overwritten by their default values.
Parameters
----------
auth_flow : str or None; keyword-only
Authorization flow.
**Valid values**:
* :code:`None` – No authentication.
* :code:`"pkce"` – Authorization Code Flow with Proof Key
for Code Exchange (PKCE).
* :code:`"device"` – Device Authorization Flow.
client_id : str; keyword-only; optional
Client ID. Required unless set as system environment
variable :code:`PRIVATE_TIDAL_API_CLIENT_ID`.
client_secret : str; keyword-only; optional
Client secret. Required for the Client Credentials flow
unless set as system environment variable
:code:`PRIVATE_TIDAL_API_CLIENT_SECRET`.
user_identifier : str; keyword-only; optional
Identifier for the user account. Used when
:code:`store_tokens=True` to distinguish between multiple
accounts for the same client ID and authorization flow.
If specified, it is used with the client ID and authorization
flow to locate a matching stored token. If none is found, a
new token is obtained and stored under this identifier.
If not specified, the most recently accessed token for the
client ID and authorization flow is used. If none exists, a
new token is obtained and stored using the TIDAL user ID
acquired from a successful authorization.
Prefixing the identifier with a tilde (:code:`~`) bypasses
token retrieval, forces reauthorization, and stores the new
token under the suffix.
redirect_uri : str; keyword-only; optional
Redirect URI. Required for the Authorization Code with PKCE
flow.
**Valid values**: :code:`"tidal://login/auth"`,
:code:`"https://tidal.com/login/auth"`.
scopes : str or Collection[str]; keyword-only; optional
Authorization scopes requested by the client to access user
resources.
.. seealso::
:meth:`resolve_scopes` – Resolve scope categories and/or
substrings into a set of scopes.
redirect_handler : None; keyword-only; optional
Backend for handling redirects during the authorization
flow. Redirect handling is only available for hosts
:code:`localhost`, :code:`127.0.0.1`, or :code:`::1`.
**Valid value**: :code:`None` – Manually paste the redirect
URL into the terminal.
open_browser : bool; keyword-only; default: :code:`False`
Whether to automatically open the authorization URL in the
default web browser for the Authorization Code with PKCE
flow. If :code:`False`, the URL is printed to the terminal.
store_tokens : bool; keyword-only; default: :code:`True`
Whether to enable the local token storage for this client.
If :code:`True`, existing access tokens are retrieved when
found in local storage, and newly acquired tokens and their
metadata are stored for future retrieval. If :code:`False`,
the client neither retrieves nor stores access tokens.
.. seealso::
:meth:`get_tokens` – Retrieve specific or all stored
access tokens for this client.
:meth:`remove_tokens` – Remove specific or all stored
access tokens for this client.
authenticate : bool; keyword-only; default: :code:`True`
Whether to immediately initiate the authorization flow to
acquire an access token.
.. important::
Unless :meth:`set_access_token` is called immediately
after, this should be left as :code:`True` to ensure the
client's existing token is compatible with the new
authorization flow and/or scopes.
"""
if auth_flow is None:
self._client.headers["x-tidal-token"] = client_id
if "Authorization" in self._client.headers:
del self._client.headers["Authorization"]
self._expires_at = datetime.max
else:
if "x-tidal-token" in self._client.headers:
del self._client.headers["x-tidal-token"]
super().set_auth_flow(
auth_flow,
client_id=client_id,
client_secret=client_secret,
user_identifier=user_identifier,
redirect_uri=redirect_uri,
scopes=scopes,
redirect_handler=redirect_handler,
open_browser=open_browser,
store_tokens=store_tokens,
authenticate=authenticate,
)