From e37338b7db3dac76bb5fc0fddb63db1c04099c76 Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Wed, 7 Oct 2026 23:17:55 +0200 Subject: [PATCH 01/12] Bugfix: Keep rotated refresh token in token_refresh TIDAL rotates refresh tokens: a refresh response can carry a new refresh_token and the previous one is invalidated server-side. token_refresh() updated the access token and expiry but never read the refresh_token field, so a session saved afterwards kept re-persisting the old token. Once TIDAL killed it, the next refresh failed and the user had to log in again. --- tests/test_token_refresh.py | 71 +++++++++++++++++++++++++++++++++++++ tidalapi/session.py | 7 ++++ 2 files changed, 78 insertions(+) create mode 100644 tests/test_token_refresh.py diff --git a/tests/test_token_refresh.py b/tests/test_token_refresh.py new file mode 100644 index 00000000..0b14371b --- /dev/null +++ b/tests/test_token_refresh.py @@ -0,0 +1,71 @@ +# Copyright (C) 2023- The Tidalapi Developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""Offline tests for the OAuth token refresh logic (no TIDAL account needed).""" + +from unittest import mock + +import tidalapi + + +def fake_response(status_code=200, json_data=None): + response = mock.Mock() + response.status_code = status_code + response.ok = status_code < 400 + response.json.return_value = json_data if json_data is not None else {} + return response + + +def make_session(refresh_token="old-refresh"): + session = tidalapi.Session(tidalapi.Config()) + session.token_type = "Bearer" + session.access_token = "old-access" + session.refresh_token = refresh_token + return session + + +def test_token_refresh_keeps_rotated_refresh_token(): + session = make_session() + session.request_session = mock.Mock() + session.request_session.post.return_value = fake_response( + json_data={ + "access_token": "new-access", + "expires_in": 3600, + "token_type": "Bearer", + "refresh_token": "rotated-refresh", + } + ) + + assert session.token_refresh("old-refresh") + + assert session.access_token == "new-access" + assert session.refresh_token == "rotated-refresh" + + +def test_token_refresh_keeps_refresh_token_when_not_rotated(): + session = make_session() + session.request_session = mock.Mock() + session.request_session.post.return_value = fake_response( + json_data={ + "access_token": "new-access", + "expires_in": 3600, + "token_type": "Bearer", + } + ) + + assert session.token_refresh("old-refresh") + + assert session.access_token == "new-access" + assert session.refresh_token == "old-refresh" diff --git a/tidalapi/session.py b/tidalapi/session.py index 6fb4d8b6..d5253804 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -750,6 +750,13 @@ def token_refresh(self, refresh_token: str) -> bool: seconds=json["expires_in"] ) self.token_type = json["token_type"] + # TIDAL rotates refresh tokens: the response may carry a new one and the + # old one is then invalidated. Keep it, otherwise a persisted session ends + # up holding a dead refresh token and the next refresh logs the user out. + # When the field is absent the existing refresh token is still valid. + new_refresh_token = json.get("refresh_token") + if new_refresh_token: + self.refresh_token = new_refresh_token return True @property From b7482f35776bbb42017df2028e7f138adf203a58 Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Wed, 7 Oct 2026 23:18:38 +0200 Subject: [PATCH 02/12] Bugfix: Serialize concurrent access token refreshes When several threads (e.g. the paginated workers) hit an expired access token at the same time, each one POSTed the same refresh token. TIDAL rotates refresh tokens, so every POST after the first used a token that had just been invalidated, and the resulting broken lineage could end up persisted. Refreshes now go through a lock. A thread that gets the lock after another thread has already refreshed (the session's access token differs from the one its request was sent with) skips the refresh and just retries its request with the new token. --- tests/test_token_refresh.py | 50 +++++++++++++++++++++++++++++++++++++ tidalapi/request.py | 33 ++++++++++++++++++++---- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/tests/test_token_refresh.py b/tests/test_token_refresh.py index 0b14371b..0ee6f789 100644 --- a/tests/test_token_refresh.py +++ b/tests/test_token_refresh.py @@ -15,6 +15,8 @@ """Offline tests for the OAuth token refresh logic (no TIDAL account needed).""" +import threading +import time from unittest import mock import tidalapi @@ -69,3 +71,51 @@ def test_token_refresh_keeps_refresh_token_when_not_rotated(): assert session.access_token == "new-access" assert session.refresh_token == "old-refresh" + + +def test_concurrent_expired_requests_refresh_only_once(): + session = make_session() + session.session_id = "sid" + session.country_code = "US" + post_calls = [] + post_started = threading.Event() + + def post(url, params): + post_calls.append(params["refresh_token"]) + post_started.set() + # Hold the refresh open so the other threads pile up behind it. + time.sleep(0.2) + return fake_response( + json_data={ + "access_token": "new-access", + "expires_in": 3600, + "token_type": "Bearer", + "refresh_token": "rotated-refresh", + } + ) + + def request(method, url, params=None, data=None, headers=None): + if headers["authorization"] == "Bearer old-access": + return fake_response( + 401, {"userMessage": "The token has expired. (Expired on time)"} + ) + return fake_response(200, {"ok": True}) + + session.request_session = mock.Mock() + session.request_session.post.side_effect = post + session.request_session.request.side_effect = request + + results = [] + + def worker(): + results.append(session.request.basic_request("GET", "tracks/1").ok) + + threads = [threading.Thread(target=worker) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + + assert results == [True] * 5 + assert post_calls == ["old-refresh"] + assert session.refresh_token == "rotated-refresh" diff --git a/tidalapi/request.py b/tidalapi/request.py index c4f6d092..08d24b26 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -18,6 +18,7 @@ import json import logging +import threading from typing import ( TYPE_CHECKING, Any, @@ -61,6 +62,29 @@ def __init__(self, session: "Session"): self.session = session self.config = session.config self.latest_err_response = requests.Response() + # Serializes access-token refreshes, see `_refresh_access_token`. + self._refresh_lock = threading.Lock() + + def _refresh_access_token(self, stale_access_token: Optional[str]) -> bool: + """Refresh the access token, at most once for concurrent callers. + + Requests are often issued from several threads (e.g. the paginated workers). If + they all hit an expired token at the same time, each would POST the same + refresh token. TIDAL rotates refresh tokens, so every POST after the first + would use a token that has just been invalidated. + + :param stale_access_token: The access token the caller's request was sent + with. If the session holds a different one by the time the lock is + acquired, another thread has already refreshed and no new refresh is made. + :return: True if the session holds a valid, refreshed access token. + """ + with self._refresh_lock: + if self.session.access_token != stale_access_token: + return True + refresh_token = self.session.refresh_token + if not refresh_token: + return False + return self.session.token_refresh(refresh_token) def basic_request( self, @@ -92,10 +116,9 @@ def basic_request( if "User-Agent" not in headers: headers["User-Agent"] = self.user_agent - if self.session.token_type and self.session.access_token is not None: - headers["authorization"] = ( - self.session.token_type + " " + self.session.access_token - ) + sent_access_token = self.session.access_token + if self.session.token_type and sent_access_token is not None: + headers["authorization"] = self.session.token_type + " " + sent_access_token if base_url is None: base_url = self.session.config.api_v1_location @@ -116,7 +139,7 @@ def basic_request( "The token has expired." ): log.debug("The access token has expired, trying to refresh it.") - refreshed = self.session.token_refresh(refresh_token) + refreshed = self._refresh_access_token(sent_access_token) if refreshed: request = self.basic_request(method, url, params, data, headers) else: From 0b2e090194dfdbdc5769b3b97aae77e61f06127e Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Wed, 7 Oct 2026 23:19:55 +0200 Subject: [PATCH 03/12] Bugfix: Refresh access token before it expires The access token was only refreshed after a 401 whose userMessage starts with exactly "The token has expired.". TIDAL has changed that wording before; when it doesn't match, the 401 propagates to the caller (seen with stream/download requests). When the expiry time is known, requests now refresh the token up front if it has expired or expires within a minute. The message based refresh stays as a fallback. A failed proactive refresh is logged and does not fail the request, since the token may still be accepted. --- tests/test_token_refresh.py | 76 +++++++++++++++++++++++++++++++++++++ tidalapi/request.py | 32 +++++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/tests/test_token_refresh.py b/tests/test_token_refresh.py index 0ee6f789..950bf0d4 100644 --- a/tests/test_token_refresh.py +++ b/tests/test_token_refresh.py @@ -15,6 +15,7 @@ """Offline tests for the OAuth token refresh logic (no TIDAL account needed).""" +import datetime import threading import time from unittest import mock @@ -119,3 +120,78 @@ def worker(): assert results == [True] * 5 assert post_calls == ["old-refresh"] assert session.refresh_token == "rotated-refresh" + + +def _refreshing_session(expiry_time): + session = make_session() + session.expiry_time = expiry_time + session.request_session = mock.Mock() + session.request_session.post.return_value = fake_response( + json_data={ + "access_token": "new-access", + "expires_in": 3600, + "token_type": "Bearer", + } + ) + session.request_session.request.return_value = fake_response(200, {}) + return session + + +def test_request_refreshes_expired_token_before_sending(): + session = _refreshing_session( + datetime.datetime.utcnow() - datetime.timedelta(days=1) + ) + + assert session.request.basic_request("GET", "tracks/1").ok + + session.request_session.post.assert_called_once() + sent = session.request_session.request.call_args.kwargs["headers"] + assert sent["authorization"] == "Bearer new-access" + + +def test_request_refreshes_token_expiring_within_margin(): + session = _refreshing_session( + datetime.datetime.utcnow() + datetime.timedelta(seconds=10) + ) + + session.request.basic_request("GET", "tracks/1") + + session.request_session.post.assert_called_once() + + +def test_request_does_not_refresh_valid_token(): + session = _refreshing_session( + datetime.datetime.utcnow() + datetime.timedelta(days=1) + ) + + session.request.basic_request("GET", "tracks/1") + + session.request_session.post.assert_not_called() + + +def test_request_without_expiry_time_does_not_refresh_up_front(): + session = _refreshing_session(None) + + session.request.basic_request("GET", "tracks/1") + + session.request_session.post.assert_not_called() + + +def test_request_handles_timezone_aware_expiry_time(): + expired = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(1) + session = _refreshing_session(expired) + + session.request.basic_request("GET", "tracks/1") + + session.request_session.post.assert_called_once() + + +def test_failed_proactive_refresh_does_not_fail_the_request(): + session = _refreshing_session( + datetime.datetime.utcnow() + datetime.timedelta(seconds=10) + ) + session.request_session.post.return_value = fake_response( + 400, {"error": "invalid_grant", "error_description": "dead"} + ) + + assert session.request.basic_request("GET", "tracks/1").ok diff --git a/tidalapi/request.py b/tidalapi/request.py index 08d24b26..9c180232 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -16,6 +16,7 @@ # along with this program. If not, see . """A module containing functions relating to TIDAL api requests.""" +import datetime import json import logging import threading @@ -35,7 +36,7 @@ import requests -from tidalapi.exceptions import http_error_to_tidal_error +from tidalapi.exceptions import AuthenticationError, http_error_to_tidal_error from tidalapi.types import JsonObj log = logging.getLogger(__name__) @@ -48,6 +49,10 @@ from tidalapi.session import Session +#: Refresh the access token this long before it expires, so requests don't race the expiry. +TOKEN_EXPIRY_MARGIN = datetime.timedelta(seconds=60) + + class Requests(object): """A class for handling api requests to TIDAL.""" @@ -86,6 +91,22 @@ def _refresh_access_token(self, stale_access_token: Optional[str]) -> bool: return False return self.session.token_refresh(refresh_token) + def _access_token_expired(self) -> bool: + """Whether the session's access token is expired, or about to be. + + TIDAL signals an expired token with a `userMessage` that has changed over time, + so relying on that alone lets raw 401s reach the caller. The expiry time is + checked up front whenever it is known. + """ + expiry_time = self.session.expiry_time + if not expiry_time or not self.session.refresh_token: + return False + if expiry_time.tzinfo is None: + now = datetime.datetime.utcnow() + else: + now = datetime.datetime.now(datetime.timezone.utc) + return expiry_time - now <= TOKEN_EXPIRY_MARGIN + def basic_request( self, method: Methods, @@ -116,6 +137,15 @@ def basic_request( if "User-Agent" not in headers: headers["User-Agent"] = self.user_agent + if self._access_token_expired(): + log.debug("The access token is about to expire, trying to refresh it.") + try: + self._refresh_access_token(self.session.access_token) + except AuthenticationError as e: + # Don't fail the request: the token may still be accepted, and if + # it isn't, the expired-token handling below reports the failure. + log.warning("Proactive access token refresh failed: %s", e) + sent_access_token = self.session.access_token if self.session.token_type and sent_access_token is not None: headers["authorization"] = self.session.token_type + " " + sent_access_token From 36751f54db2a9074a155a847d6e7898c77c908a1 Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Wed, 7 Oct 2026 23:20:13 +0200 Subject: [PATCH 04/12] Persist expiry_time in session files save_session_to_file() left expiry_time commented out, so a session restored with load_session_from_file() never knew when its access token expires and could only react to a 401 after the fact. Store it as an ISO 8601 string and restore it on load. Files written by older versions have no expiry_time and keep loading as before. --- tests/test_token_refresh.py | 29 +++++++++++++++++++++++++++++ tidalapi/session.py | 12 ++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/tests/test_token_refresh.py b/tests/test_token_refresh.py index 950bf0d4..c5d0dab3 100644 --- a/tests/test_token_refresh.py +++ b/tests/test_token_refresh.py @@ -195,3 +195,32 @@ def test_failed_proactive_refresh_does_not_fail_the_request(): ) assert session.request.basic_request("GET", "tracks/1").ok + + +def test_session_file_round_trips_expiry_time(tmp_path): + session_file = tmp_path / "session.json" + expiry_time = datetime.datetime(2030, 1, 2, 3, 4, 5) + session = make_session() + session.expiry_time = expiry_time + session.check_login = mock.Mock(return_value=True) + session.save_session_to_file(session_file) + + loaded = tidalapi.Session(tidalapi.Config()) + loaded.load_oauth_session = mock.Mock(return_value=True) + assert loaded.load_session_from_file(session_file) + + assert loaded.load_oauth_session.call_args.kwargs["expiry_time"] == expiry_time + + +def test_session_file_without_expiry_time_still_loads(tmp_path): + session_file = tmp_path / "session.json" + session_file.write_text( + '{"token_type": {"data": "Bearer"}, "access_token": {"data": "a"},' + ' "refresh_token": {"data": "r"}, "is_pkce": {"data": false}}' + ) + + loaded = tidalapi.Session(tidalapi.Config()) + loaded.load_oauth_session = mock.Mock(return_value=True) + assert loaded.load_session_from_file(session_file) + + assert loaded.load_oauth_session.call_args.kwargs["expiry_time"] is None diff --git a/tidalapi/session.py b/tidalapi/session.py index d5253804..60888961 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -581,7 +581,9 @@ def save_session_to_file(self, session_file: Path): "access_token": {"data": self.access_token}, "refresh_token": {"data": self.refresh_token}, "is_pkce": {"data": self.is_pkce}, - # "expiry_time": {"data": self.expiry_time}, + "expiry_time": { + "data": self.expiry_time.isoformat() if self.expiry_time else None + }, } with session_file.open("w") as file: json.dump(data, file) @@ -596,12 +598,18 @@ def load_session_from_file(self, session_file: Path): return False assert self, "No session loaded" + expiry_time = None + if expiry_data := data.get("expiry_time", {}).get("data"): + try: + expiry_time = datetime.datetime.fromisoformat(expiry_data) + except ValueError: + log.info("Ignoring invalid expiry_time in %s", session_file) args = { "token_type": data.get("token_type", {}).get("data"), "access_token": data.get("access_token", {}).get("data"), "refresh_token": data.get("refresh_token", {}).get("data"), "is_pkce": data.get("is_pkce", {}).get("data"), - # "expiry_time": data.get("expiry_time", {}).get("data"), + "expiry_time": expiry_time, } return self.load_oauth_session(**args) From ded8ce642f47b06ad09356b6c1ea8261c2441dbb Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Wed, 7 Oct 2026 23:20:51 +0200 Subject: [PATCH 05/12] Bugfix: Keep device login working after client_enable_hires client_enable_hires() overwrites config.client_id/client_secret with the PKCE pair. login_oauth() reads those same fields, but TIDAL rejects the PKCE client on the device authorization endpoint ("Client is not a Limited Input Device client"). Loading a PKCE session and then starting a device login therefore failed. Remember the previous pair when swapping and use it for the device login flow. config.client_id keeps its current meaning for existing callers. --- tests/test_token_refresh.py | 42 +++++++++++++++++++++++++++++++++++++ tidalapi/session.py | 24 ++++++++++++++++++--- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/tests/test_token_refresh.py b/tests/test_token_refresh.py index c5d0dab3..93c37f3a 100644 --- a/tests/test_token_refresh.py +++ b/tests/test_token_refresh.py @@ -224,3 +224,45 @@ def test_session_file_without_expiry_time_still_loads(tmp_path): assert loaded.load_session_from_file(session_file) assert loaded.load_oauth_session.call_args.kwargs["expiry_time"] is None + + +def test_device_login_ignores_hires_client_swap(): + config = tidalapi.Config() + device_client_id = config.client_id + device_client_secret = config.client_secret + session = tidalapi.Session(config) + session.request_session = mock.Mock() + session.request_session.post.return_value = fake_response( + json_data={ + "deviceCode": "dc", + "userCode": "uc", + "verificationUri": "link.tidal.com", + "verificationUriComplete": "link.tidal.com/uc", + "expiresIn": 300, + "interval": 2, + } + ) + + session.client_enable_hires() + assert config.client_id == config.client_id_pkce + + link_login = session.get_link_login() + sent = session.request_session.post.call_args.args[1] + assert sent["client_id"] == device_client_id + + session.request_session.post.return_value = fake_response(json_data={"a": "b"}) + session._check_link_login(link_login, until_expiry=False) + sent = session.request_session.post.call_args.args[1] + assert sent["client_id"] == device_client_id + assert sent["client_secret"] == device_client_secret + + +def test_enable_hires_twice_keeps_device_client(): + config = tidalapi.Config() + device_client = (config.client_id, config.client_secret) + session = tidalapi.Session(config) + + session.client_enable_hires() + session.client_enable_hires() + + assert session._device_client_credentials() == device_client diff --git a/tidalapi/session.py b/tidalapi/session.py index 60888961..b90ca5fc 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -244,6 +244,8 @@ def __init__(self, config: Config = Config()): self.parse_page = self.page.parse self.is_pkce = False # True if current session is PKCE type, otherwise false + # The (client_id, client_secret) in use before `client_enable_hires` replaced them + self._device_client: Optional[Tuple[str, str]] = None self.type_conversions: List[TypeRelation] = [ TypeRelation( @@ -476,9 +478,23 @@ def login_pkce(self, fn_print: Callable[[str], None] = print) -> None: # self.client_enable_hires() def client_enable_hires(self): + """Swap `config.client_id` and `config.client_secret` for the PKCE pair, which is + the only one entitled to hi-res streams. + + The limited input device login (:meth:`login_oauth`) still uses the original + client, as TIDAL rejects the PKCE client on the device authorization endpoint. + """ + if self.config.client_id != self.config.client_id_pkce: + self._device_client = (self.config.client_id, self.config.client_secret) self.config.client_id = self.config.client_id_pkce self.config.client_secret = self.config.client_secret_pkce + def _device_client_credentials(self) -> Tuple[str, str]: + """The client_id and client_secret for the limited input device login.""" + if self._device_client: + return self._device_client + return self.config.client_id, self.config.client_secret + def pkce_login_url(self) -> str: """Returns the Login-URL to login via web browser. @@ -622,7 +638,8 @@ def get_link_login(self) -> LinkLogin: :rtype: :class:`LinkLogin` """ url = "https://auth.tidal.com/v1/oauth2/device_authorization" - params = {"client_id": self.config.client_id, "scope": "r_usr w_usr w_sub"} + client_id, _ = self._device_client_credentials() + params = {"client_id": client_id, "scope": "r_usr w_usr w_sub"} request = self.request_session.post(url, params) @@ -698,9 +715,10 @@ def _check_link_login( """ expiry: float = link_login.expires_in if until_expiry else 1 url = self.config.api_oauth2_token + client_id, client_secret = self._device_client_credentials() params = { - "client_id": self.config.client_id, - "client_secret": self.config.client_secret, + "client_id": client_id, + "client_secret": client_secret, "device_code": link_login.device_code, "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "scope": "r_usr w_usr w_sub", From 871941ff9db15d8de7b501f24b088d5faee25066 Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Wed, 7 Oct 2026 23:20:58 +0200 Subject: [PATCH 06/12] Bugfix: Don't share one Config between all default Sessions Session.__init__ used `config: Config = Config()`, which is evaluated once at import time. Every Session created without an explicit config therefore shared the same Config object, including its PKCE code_verifier and anything mutated later such as client_enable_hires(). Two sessions in one process (e.g. examples/transfer_favorites.py) would affect each other. Create a fresh Config per Session instead. --- tests/test_token_refresh.py | 11 +++++++++++ tidalapi/session.py | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_token_refresh.py b/tests/test_token_refresh.py index 93c37f3a..9972b76d 100644 --- a/tests/test_token_refresh.py +++ b/tests/test_token_refresh.py @@ -266,3 +266,14 @@ def test_enable_hires_twice_keeps_device_client(): session.client_enable_hires() assert session._device_client_credentials() == device_client + + +def test_sessions_do_not_share_a_default_config(): + first = tidalapi.Session() + second = tidalapi.Session() + device_client_id = second.config.client_id + + first.client_enable_hires() + + assert first.config is not second.config + assert second.config.client_id == device_client_id diff --git a/tidalapi/session.py b/tidalapi/session.py index b90ca5fc..ba6b7fbb 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -231,8 +231,8 @@ class Session: #: A :class:`.User` object containing the currently logged in user. user: Optional[Union["FetchedUser", "LoggedInUser", "PlaylistCreator"]] = None - def __init__(self, config: Config = Config()): - self.config = config + def __init__(self, config: Optional[Config] = None): + self.config = config if config is not None else Config() self.request_session = requests.Session() # Objects for keeping the session across all modules. From ed8a883abadfc3848f29c386717219b4e5c505ea Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Wed, 7 Oct 2026 23:34:49 +0200 Subject: [PATCH 07/12] Bugfix: Retry an expired access token request only once After refreshing the access token, basic_request() retried by calling itself. If TIDAL still answered "The token has expired." (for example because of clock skew), the retry refreshed and retried again, without limit, until the recursion limit was hit. Send the request through a helper and retry exactly once; a response that is still an error is returned to the caller. --- tests/test_token_refresh.py | 24 ++++++++++++++++++++++++ tidalapi/request.py | 28 +++++++++++++++++++++------- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/tests/test_token_refresh.py b/tests/test_token_refresh.py index 9972b76d..6e145fca 100644 --- a/tests/test_token_refresh.py +++ b/tests/test_token_refresh.py @@ -277,3 +277,27 @@ def test_sessions_do_not_share_a_default_config(): assert first.config is not second.config assert second.config.client_id == device_client_id + + +def test_expired_token_is_retried_only_once(): + session = make_session() + session.request_session = mock.Mock() + session.request_session.post.return_value = fake_response( + json_data={ + "access_token": "new-access", + "expires_in": 3600, + "token_type": "Bearer", + } + ) + # TIDAL keeps reporting an expired token even after a successful refresh. + session.request_session.request.return_value = fake_response( + 401, {"userMessage": "The token has expired. (Expired on time)"} + ) + + response = session.request.basic_request("GET", "tracks/1") + + assert response.status_code == 401 + assert session.request_session.post.call_count == 1 + assert session.request_session.request.call_count == 2 + retry_headers = session.request_session.request.call_args.kwargs["headers"] + assert retry_headers["authorization"] == "Bearer new-access" diff --git a/tidalapi/request.py b/tidalapi/request.py index 9c180232..31d34f22 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -107,6 +107,22 @@ def _access_token_expired(self) -> bool: now = datetime.datetime.now(datetime.timezone.utc) return expiry_time - now <= TOKEN_EXPIRY_MARGIN + def _send( + self, + method: Methods, + url: str, + request_params: Mapping[str, Union[str, int, None]], + data: Optional[JsonObj], + headers: MutableMapping[str, str], + ) -> requests.Response: + """Send a request with the session's current access token.""" + access_token = self.session.access_token + if self.session.token_type and access_token is not None: + headers["authorization"] = self.session.token_type + " " + access_token + return self.session.request_session.request( + method, url, params=request_params, data=data, headers=headers + ) + def basic_request( self, method: Methods, @@ -146,16 +162,12 @@ def basic_request( # it isn't, the expired-token handling below reports the failure. log.warning("Proactive access token refresh failed: %s", e) - sent_access_token = self.session.access_token - if self.session.token_type and sent_access_token is not None: - headers["authorization"] = self.session.token_type + " " + sent_access_token if base_url is None: base_url = self.session.config.api_v1_location url = urljoin(base_url, path) - request = self.session.request_session.request( - method, url, params=request_params, data=data, headers=headers - ) + sent_access_token = self.session.access_token + request = self._send(method, url, request_params, data, headers) refresh_token = self.session.refresh_token if not request.ok and refresh_token: @@ -171,7 +183,9 @@ def basic_request( log.debug("The access token has expired, trying to refresh it.") refreshed = self._refresh_access_token(sent_access_token) if refreshed: - request = self.basic_request(method, url, params, data, headers) + # Retry exactly once. If TIDAL still reports an expired token + # (e.g. clock skew), return that response instead of looping. + request = self._send(method, url, request_params, data, headers) else: log.debug("HTTP error on %d", request.status_code) log.debug("Response text\n%s", request.text) From 6c7dc3235aec585aa7f35eb7493c631276c1d55d Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Wed, 7 Oct 2026 23:34:49 +0200 Subject: [PATCH 08/12] Update changelog for auth and token refresh fixes --- HISTORY.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index ce853582..396d8e66 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,16 @@ History ======= +v0.8.12 (unreleased) +-------------------- +* Bugfix: Keep the rotated refresh token returned by TIDAL in `token_refresh`, previously users were logged out after a few days - tehkillerbee_ +* Bugfix: Serialize concurrent access token refreshes, so parallel requests no longer refresh with an already invalidated refresh token - tehkillerbee_ +* Bugfix: Refresh the access token shortly before it expires, instead of relying on the wording of the expired-token error - tehkillerbee_ +* Bugfix: Retry a request with an expired access token only once, instead of recursing without limit - tehkillerbee_ +* Bugfix: `login_oauth` works after `client_enable_hires` has been called - tehkillerbee_ +* Bugfix: Sessions created without a config no longer share one `Config` object - tehkillerbee_ +* `save_session_to_file` and `load_session_from_file` now persist `expiry_time` - tehkillerbee_ + v0.8.11 -------- * Bump mpegdash version to 0.4.1 (Fixes bug caused by non-compliant DASH manifests) From 7ab0ad29d2affeade1dbb19d67400014fc078947 Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Wed, 7 Oct 2026 23:39:12 +0200 Subject: [PATCH 09/12] Add AbuseDetected and optional backoff after rate limits TIDAL answers suspicious traffic with HTTP 429, or with HTTP 403 and a body mentioning abuse or a suspension. Requests sent while that is in effect only prolong it and can end in a permanent ban, but the library kept sending them and surfaced the 403 as a plain HTTPError. - AbuseDetected is raised for the 403 anti-abuse response, carrying retry_after and the reason. - Config(backoff=True) makes a Session stop sending requests after a 429 or an abuse response, for Retry-After seconds (60 s for a 429 and 30 minutes for abuse when absent). Requests made meanwhile raise TidalBackoffError with the remaining time, without reaching TIDAL. Session.request.backoff_remaining() exposes it. It is off by default, as it changes how long a rate limited session stays unusable. - Error bodies that are not JSON no longer break the error mapping, and a Retry-After given as an HTTP date no longer raises ValueError. --- tests/test_backoff.py | 179 +++++++++++++++++++++++++++++++++++++++++ tidalapi/exceptions.py | 87 +++++++++++++++++--- tidalapi/request.py | 64 ++++++++++++++- tidalapi/session.py | 6 ++ 4 files changed, 324 insertions(+), 12 deletions(-) create mode 100644 tests/test_backoff.py diff --git a/tests/test_backoff.py b/tests/test_backoff.py new file mode 100644 index 00000000..2c8f04d0 --- /dev/null +++ b/tests/test_backoff.py @@ -0,0 +1,179 @@ +# Copyright (C) 2023- The Tidalapi Developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""Offline tests for abuse detection and request backoff (no TIDAL account needed).""" + +import datetime +from unittest import mock + +import pytest +import requests + +import tidalapi +from tidalapi.exceptions import ( + AbuseDetected, + TidalBackoffError, + TooManyRequests, + http_error_to_tidal_error, +) + +ABUSE_BODY = '{"status": 403, "userMessage": "abuse_detected"}' + + +def fake_response(status_code=200, text="{}", headers=None): + response = requests.Response() + response.status_code = status_code + response._content = text.encode() + response.headers.update(headers or {}) + response.request = requests.Request("GET", "https://api.tidal.com/v1/x").prepare() + return response + + +def make_session(responses, backoff=True): + session = tidalapi.Session(tidalapi.Config(backoff=backoff)) + session.request_session = mock.Mock() + session.request_session.request.side_effect = list(responses) + return session + + +def http_error(response): + return requests.HTTPError(response=response) + + +def test_abuse_response_maps_to_abuse_detected(): + error = http_error_to_tidal_error( + http_error(fake_response(403, ABUSE_BODY, {"Retry-After": "120"})) + ) + + assert isinstance(error, AbuseDetected) + assert error.retry_after == 120 + assert "abuse_detected" in error.reason + + +def test_suspended_account_maps_to_abuse_detected(): + response = fake_response(403, '{"userMessage": "Account suspended"}') + + assert isinstance(http_error_to_tidal_error(http_error(response)), AbuseDetected) + + +def test_plain_forbidden_is_not_abuse(): + response = fake_response(403, '{"userMessage": "Not allowed"}') + + assert http_error_to_tidal_error(http_error(response)) is None + + +def test_non_json_error_body_does_not_break_error_mapping(): + response = fake_response(403, "abuse detected") + + assert isinstance(http_error_to_tidal_error(http_error(response)), AbuseDetected) + + +def test_retry_after_as_http_date_is_ignored(): + response = fake_response( + 429, headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"} + ) + + error = http_error_to_tidal_error(http_error(response)) + + assert isinstance(error, TooManyRequests) + assert error.retry_after == -1 + + +def test_request_raises_abuse_detected(): + session = make_session([fake_response(403, ABUSE_BODY)], backoff=False) + + with pytest.raises(AbuseDetected): + session.request.request("GET", "tracks/1") + + +def test_abuse_response_starts_backoff(): + session = make_session([fake_response(403, ABUSE_BODY)]) + + with pytest.raises(AbuseDetected): + session.request.request("GET", "tracks/1") + + assert session.request.backoff_remaining() > 29 * 60 + with pytest.raises(TidalBackoffError) as exc_info: + session.request.request("GET", "tracks/2") + assert "abuse detected" in exc_info.value.reason + assert exc_info.value.seconds_remaining > 29 * 60 + # The second request never reached TIDAL + assert session.request_session.request.call_count == 1 + + +def test_rate_limit_backoff_uses_retry_after(): + session = make_session([fake_response(429, headers={"Retry-After": "7"})]) + + with pytest.raises(TooManyRequests): + session.request.request("GET", "tracks/1") + + assert 5 < session.request.backoff_remaining() <= 7 + + +def test_rate_limit_backoff_defaults_to_a_minute(): + session = make_session([fake_response(429)]) + + with pytest.raises(TooManyRequests): + session.request.request("GET", "tracks/1") + + assert 55 < session.request.backoff_remaining() <= 60 + + +def test_backoff_is_off_by_default(): + session = make_session([fake_response(429), fake_response(200)], backoff=False) + + with pytest.raises(TooManyRequests): + session.request.request("GET", "tracks/1") + + assert session.request.backoff_remaining() == 0 + assert session.request.request("GET", "tracks/2").ok + + +def test_backoff_is_never_shortened(): + session = make_session([]) + requests_ = session.request + + requests_._start_backoff_if_needed( + fake_response(429, headers={"Retry-After": "600"}) + ) + requests_._start_backoff_if_needed(fake_response(429, headers={"Retry-After": "5"})) + + assert requests_.backoff_remaining() > 500 + + +def test_backoff_ends(): + session = make_session([fake_response(429), fake_response(200)]) + with pytest.raises(TooManyRequests): + session.request.request("GET", "tracks/1") + + session.request._backoff_until -= 61 + + assert session.request.backoff_remaining() == 0 + assert session.request.request("GET", "tracks/2").ok + + +def test_token_refresh_is_not_attempted_during_backoff(): + session = make_session([fake_response(429)]) + session.refresh_token = "refresh" + session.access_token = "access" + session.token_type = "Bearer" + with pytest.raises(TooManyRequests): + session.request.request("GET", "tracks/1") + session.expiry_time = datetime.datetime(2000, 1, 1) + + with pytest.raises(TidalBackoffError): + session.request.request("GET", "tracks/2") + + session.request_session.post.assert_not_called() diff --git a/tidalapi/exceptions.py b/tidalapi/exceptions.py index b7ddddb0..de063d27 100644 --- a/tidalapi/exceptions.py +++ b/tidalapi/exceptions.py @@ -3,7 +3,7 @@ import json import logging -from requests import HTTPError +from requests import HTTPError, Response log = logging.getLogger(__name__) @@ -28,6 +28,45 @@ def __init__(self, message: str = "Too many requests", retry_after: int = -1): self.retry_after = retry_after +class AbuseDetected(TidalAPIError): + """TIDAL's anti-abuse system rejected the request (HTTP 403 mentioning abuse or a + suspension). + + Making further requests while this is in effect can escalate to a permanent ban. + """ + + retry_after: int + reason: str + + def __init__( + self, + message: str = "Abuse detected by TIDAL", + retry_after: int = -1, + reason: str = "", + ): + super().__init__(message) + self.retry_after = retry_after + self.reason = reason + + +class TidalBackoffError(TidalAPIError): + """Raised instead of sending a request while a backoff period, started by a rate + limit or abuse response, is still in effect. + + See :attr:`Config.backoff`. + """ + + seconds_remaining: float + reason: str + + def __init__(self, seconds_remaining: float, reason: str): + super().__init__( + f"Backing off from TIDAL for another {seconds_remaining:.0f}s: {reason}" + ) + self.seconds_remaining = seconds_remaining + self.reason = reason + + class URLNotAvailable(TidalAPIError): pass @@ -64,23 +103,51 @@ class InvalidUPC(TidalAPIError): pass +def get_retry_after(response: Response) -> int: + """The Retry-After header of a response in seconds, or -1 if absent or not a number + of seconds.""" + try: + return int(response.headers.get("Retry-After", -1)) + except ValueError: + return -1 + + +def is_abuse_response(response: Response) -> bool: + """Whether the response is TIDAL's anti-abuse rejection: HTTP 403 whose body + mentions abuse or a suspension.""" + if response.status_code != 403: + return False + text = (response.text or "").lower() + return "abuse" in text or "suspend" in text + + def http_error_to_tidal_error(http_error: HTTPError) -> TidalAPIError | None: response = http_error.response if response.content: - json_data = response.json() - # Make sure request response contains the detailed error message - if "errors" in json_data: - log.debug("Request response: '%s'", json_data["errors"][0]["detail"]) - elif "userMessage" in json_data: - log.debug("Request response: '%s'", json_data["userMessage"]) + try: + json_data = response.json() + except ValueError: + # Not every error body is JSON, e.g. gateway or anti-abuse rejections + log.debug("Request response: '%s'", response.text[:200]) else: - log.debug("Request response: '%s'", json.dumps(json_data)) + # Make sure request response contains the detailed error message + if "errors" in json_data: + log.debug("Request response: '%s'", json_data["errors"][0]["detail"]) + elif "userMessage" in json_data: + log.debug("Request response: '%s'", json_data["userMessage"]) + else: + log.debug("Request response: '%s'", json.dumps(json_data)) if response.status_code == 404: return ObjectNotFound("Object not found") elif response.status_code == 429: - retry_after = int(response.headers.get("Retry-After", -1)) - return TooManyRequests("Too many requests", retry_after=retry_after) + return TooManyRequests( + "Too many requests", retry_after=get_retry_after(response) + ) + elif is_abuse_response(response): + return AbuseDetected( + retry_after=get_retry_after(response), reason=response.text[:200] + ) return None diff --git a/tidalapi/request.py b/tidalapi/request.py index 31d34f22..fe575a33 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -20,6 +20,7 @@ import json import logging import threading +import time from typing import ( TYPE_CHECKING, Any, @@ -36,7 +37,13 @@ import requests -from tidalapi.exceptions import AuthenticationError, http_error_to_tidal_error +from tidalapi.exceptions import ( + AuthenticationError, + TidalBackoffError, + get_retry_after, + http_error_to_tidal_error, + is_abuse_response, +) from tidalapi.types import JsonObj log = logging.getLogger(__name__) @@ -49,6 +56,11 @@ from tidalapi.session import Session +#: Backoff after a rate limit (HTTP 429) without a Retry-After header, in seconds. +RATE_LIMIT_BACKOFF = 60 +#: Backoff after an anti-abuse rejection without a Retry-After header, in seconds. +ABUSE_BACKOFF = 30 * 60 + #: Refresh the access token this long before it expires, so requests don't race the expiry. TOKEN_EXPIRY_MARGIN = datetime.timedelta(seconds=60) @@ -69,6 +81,10 @@ def __init__(self, session: "Session"): self.latest_err_response = requests.Response() # Serializes access-token refreshes, see `_refresh_access_token`. self._refresh_lock = threading.Lock() + # Backoff state, see `Config.backoff`. + self._backoff_lock = threading.Lock() + self._backoff_until = 0.0 + self._backoff_reason = "" def _refresh_access_token(self, stale_access_token: Optional[str]) -> bool: """Refresh the access token, at most once for concurrent callers. @@ -91,6 +107,46 @@ def _refresh_access_token(self, stale_access_token: Optional[str]) -> bool: return False return self.session.token_refresh(refresh_token) + def backoff_remaining(self) -> float: + """Seconds left of the current backoff, 0 if requests are not being held back. + + Backoff only ever starts if :attr:`Config.backoff` is enabled. + """ + with self._backoff_lock: + return max(0.0, self._backoff_until - time.monotonic()) + + def _raise_if_backing_off(self) -> None: + with self._backoff_lock: + remaining = self._backoff_until - time.monotonic() + reason = self._backoff_reason + if remaining > 0: + raise TidalBackoffError(remaining, reason) + + def _start_backoff_if_needed(self, response: requests.Response) -> None: + """Hold back further requests after a rate limit or anti-abuse response. + + Requests made while TIDAL is limiting or suspecting the account only prolong + it, and can lead to a permanent ban. + """ + if not self.config.backoff: + return + retry_after = get_retry_after(response) + if response.status_code == 429: + seconds = retry_after if retry_after > 0 else RATE_LIMIT_BACKOFF + reason = "rate limited (HTTP 429)" + elif is_abuse_response(response): + seconds = retry_after if retry_after > 0 else ABUSE_BACKOFF + reason = f"abuse detected (HTTP 403): {response.text[:200]}" + else: + return + log.warning("Backing off from TIDAL for %ds: %s", seconds, reason) + with self._backoff_lock: + # Never shorten a backoff that is already running + until = time.monotonic() + seconds + if until > self._backoff_until: + self._backoff_until = until + self._backoff_reason = reason + def _access_token_expired(self) -> bool: """Whether the session's access token is expired, or about to be. @@ -116,12 +172,15 @@ def _send( headers: MutableMapping[str, str], ) -> requests.Response: """Send a request with the session's current access token.""" + self._raise_if_backing_off() access_token = self.session.access_token if self.session.token_type and access_token is not None: headers["authorization"] = self.session.token_type + " " + access_token - return self.session.request_session.request( + response = self.session.request_session.request( method, url, params=request_params, data=data, headers=headers ) + self._start_backoff_if_needed(response) + return response def basic_request( self, @@ -132,6 +191,7 @@ def basic_request( headers: Optional[MutableMapping[str, str]] = None, base_url: Optional[str] = None, ) -> requests.Response: + self._raise_if_backing_off() request_params = { "sessionId": self.session.session_id, "countryCode": self.session.country_code, diff --git a/tidalapi/session.py b/tidalapi/session.py index ba6b7fbb..7dbe5ff1 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -117,6 +117,10 @@ class Config: image_url: str = "https://resources.tidal.com/images/%s/%ix%i.jpg" image_url_origin: str = "https://resources.tidal.com/images/%s/origin.jpg" item_limit: int + #: Stop sending requests for a while after TIDAL answers with a rate limit + #: (HTTP 429) or an anti-abuse rejection. While backing off, requests raise + #: :class:`.TidalBackoffError` instead of reaching TIDAL. + backoff: bool quality: str video_quality: str video_url: str = "https://resources.tidal.com/videos/%s/%ix%i.mp4" @@ -138,10 +142,12 @@ def __init__( video_quality: str = media.VideoQuality.default, item_limit: int = 1000, alac: bool = True, + backoff: bool = False, ): self.quality = quality self.video_quality = video_quality self.alac = alac + self.backoff = backoff if item_limit > 10000: log.warning( From f47f158bc8d0fa6232acce00872251e7bc641c1c Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Wed, 7 Oct 2026 23:39:12 +0200 Subject: [PATCH 10/12] Update changelog for abuse detection and backoff --- HISTORY.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index 396d8e66..a98deca1 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -12,6 +12,9 @@ v0.8.12 (unreleased) * Bugfix: `login_oauth` works after `client_enable_hires` has been called - tehkillerbee_ * Bugfix: Sessions created without a config no longer share one `Config` object - tehkillerbee_ * `save_session_to_file` and `load_session_from_file` now persist `expiry_time` - tehkillerbee_ +* Added `AbuseDetected`, raised when TIDAL answers with an anti-abuse HTTP 403 - tehkillerbee_ +* Added `Config(backoff=True)`: stop sending requests after a rate limit or abuse response and raise `TidalBackoffError` until it has passed - tehkillerbee_ +* Bugfix: Non-JSON error responses and `Retry-After` given as a date no longer break error handling - tehkillerbee_ v0.8.11 -------- From 1e9cf1c1f43ba853417b71f1fe41ce3b6eccde88 Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Wed, 7 Oct 2026 23:43:53 +0200 Subject: [PATCH 11/12] Enable request backoff by default Requests sent while TIDAL is rate limiting or suspecting an account only prolong it, so holding them back is the safer behavior. Config.backoff now defaults to True; pass backoff=False to keep sending requests regardless. Only inspect the response for 429 and anti-abuse 403 when deciding whether to back off. This runs for every request now, and parsing Retry-After on successful responses was unnecessary work that also broke for mocked responses. --- HISTORY.rst | 2 +- tests/test_backoff.py | 6 +++++- tidalapi/request.py | 4 +++- tidalapi/session.py | 4 ++-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index a98deca1..c2e26986 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -13,7 +13,7 @@ v0.8.12 (unreleased) * Bugfix: Sessions created without a config no longer share one `Config` object - tehkillerbee_ * `save_session_to_file` and `load_session_from_file` now persist `expiry_time` - tehkillerbee_ * Added `AbuseDetected`, raised when TIDAL answers with an anti-abuse HTTP 403 - tehkillerbee_ -* Added `Config(backoff=True)`: stop sending requests after a rate limit or abuse response and raise `TidalBackoffError` until it has passed - tehkillerbee_ +* Added `Config(backoff=...)`, enabled by default: stop sending requests after a rate limit or abuse response and raise `TidalBackoffError` until it has passed. Pass `backoff=False` to disable - tehkillerbee_ * Bugfix: Non-JSON error responses and `Retry-After` given as a date no longer break error handling - tehkillerbee_ v0.8.11 diff --git a/tests/test_backoff.py b/tests/test_backoff.py index 2c8f04d0..14df71ff 100644 --- a/tests/test_backoff.py +++ b/tests/test_backoff.py @@ -131,7 +131,11 @@ def test_rate_limit_backoff_defaults_to_a_minute(): assert 55 < session.request.backoff_remaining() <= 60 -def test_backoff_is_off_by_default(): +def test_backoff_is_enabled_by_default(): + assert tidalapi.Config().backoff is True + + +def test_backoff_can_be_disabled(): session = make_session([fake_response(429), fake_response(200)], backoff=False) with pytest.raises(TooManyRequests): diff --git a/tidalapi/request.py b/tidalapi/request.py index fe575a33..0ca85170 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -130,11 +130,13 @@ def _start_backoff_if_needed(self, response: requests.Response) -> None: """ if not self.config.backoff: return - retry_after = get_retry_after(response) + # Only inspect error responses, this runs for every request if response.status_code == 429: + retry_after = get_retry_after(response) seconds = retry_after if retry_after > 0 else RATE_LIMIT_BACKOFF reason = "rate limited (HTTP 429)" elif is_abuse_response(response): + retry_after = get_retry_after(response) seconds = retry_after if retry_after > 0 else ABUSE_BACKOFF reason = f"abuse detected (HTTP 403): {response.text[:200]}" else: diff --git a/tidalapi/session.py b/tidalapi/session.py index 7dbe5ff1..3d0b27bf 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -119,7 +119,7 @@ class Config: item_limit: int #: Stop sending requests for a while after TIDAL answers with a rate limit #: (HTTP 429) or an anti-abuse rejection. While backing off, requests raise - #: :class:`.TidalBackoffError` instead of reaching TIDAL. + #: :class:`.TidalBackoffError` instead of reaching TIDAL. Enabled by default. backoff: bool quality: str video_quality: str @@ -142,7 +142,7 @@ def __init__( video_quality: str = media.VideoQuality.default, item_limit: int = 1000, alac: bool = True, - backoff: bool = False, + backoff: bool = True, ): self.quality = quality self.video_quality = video_quality From 06b4813918d660de2ce0a261d114f6669726ec07 Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Wed, 7 Oct 2026 23:52:47 +0200 Subject: [PATCH 12/12] Version bump to 0.9.0 --- HISTORY.rst | 4 ++-- docs/conf.py | 2 +- pyproject.toml | 2 +- tidalapi/__init__.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index c2e26986..0c91f68d 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,8 +3,8 @@ History ======= -v0.8.12 (unreleased) --------------------- +v0.9.0 +------ * Bugfix: Keep the rotated refresh token returned by TIDAL in `token_refresh`, previously users were logged out after a few days - tehkillerbee_ * Bugfix: Serialize concurrent access token refreshes, so parallel requests no longer refresh with an already invalidated refresh token - tehkillerbee_ * Bugfix: Refresh the access token shortly before it expires, instead of relying on the wording of the expired-token error - tehkillerbee_ diff --git a/docs/conf.py b/docs/conf.py index 84697941..56c508f1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ author = "The tidalapi Developers" # The full version, including alpha/beta/rc tags -release = "0.8.11" +release = "0.9.0" # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index f0284388..b3f4b49d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tidalapi" -version = "0.8.11" +version = "0.9.0" description = "Unofficial API for TIDAL music streaming service." authors = ["Thomas Amland "] maintainers = ["tehkillerbee "] diff --git a/tidalapi/__init__.py b/tidalapi/__init__.py index 5f4f7be1..6603e014 100644 --- a/tidalapi/__init__.py +++ b/tidalapi/__init__.py @@ -17,4 +17,4 @@ User, ) -__version__ = "0.8.11" +__version__ = "0.9.0"