diff --git a/HISTORY.rst b/HISTORY.rst index ce85358..0c91f68 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,19 @@ History ======= +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_ +* 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_ +* Added `AbuseDetected`, raised when TIDAL answers with an anti-abuse HTTP 403 - 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 -------- * Bump mpegdash version to 0.4.1 (Fixes bug caused by non-compliant DASH manifests) diff --git a/docs/conf.py b/docs/conf.py index 8469794..56c508f 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 f028438..b3f4b49 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/tests/test_backoff.py b/tests/test_backoff.py new file mode 100644 index 0000000..14df71f --- /dev/null +++ b/tests/test_backoff.py @@ -0,0 +1,183 @@ +# 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_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): + 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/tests/test_token_refresh.py b/tests/test_token_refresh.py new file mode 100644 index 0000000..6e145fc --- /dev/null +++ b/tests/test_token_refresh.py @@ -0,0 +1,303 @@ +# 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).""" + +import datetime +import threading +import time +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" + + +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" + + +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 + + +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 + + +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 + + +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 + + +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/__init__.py b/tidalapi/__init__.py index 5f4f7be..6603e01 100644 --- a/tidalapi/__init__.py +++ b/tidalapi/__init__.py @@ -17,4 +17,4 @@ User, ) -__version__ = "0.8.11" +__version__ = "0.9.0" diff --git a/tidalapi/exceptions.py b/tidalapi/exceptions.py index b7ddddb..de063d2 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 c4f6d09..0ca8517 100644 --- a/tidalapi/request.py +++ b/tidalapi/request.py @@ -16,8 +16,11 @@ # along with this program. If not, see . """A module containing functions relating to TIDAL api requests.""" +import datetime import json import logging +import threading +import time from typing import ( TYPE_CHECKING, Any, @@ -34,7 +37,13 @@ import requests -from tidalapi.exceptions import 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__) @@ -47,6 +56,15 @@ 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) + + class Requests(object): """A class for handling api requests to TIDAL.""" @@ -61,6 +79,110 @@ 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() + # 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. + + 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 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 + # 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: + 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. + + 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 _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.""" + 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 + 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, @@ -71,6 +193,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, @@ -92,17 +215,21 @@ 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 - ) + 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) + 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: @@ -116,9 +243,11 @@ 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) + # 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) diff --git a/tidalapi/session.py b/tidalapi/session.py index 6fb4d8b..3d0b27b 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. Enabled by default. + 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 = True, ): self.quality = quality self.video_quality = video_quality self.alac = alac + self.backoff = backoff if item_limit > 10000: log.warning( @@ -231,8 +237,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. @@ -244,6 +250,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 +484,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. @@ -581,7 +603,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 +620,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) @@ -614,7 +644,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) @@ -690,9 +721,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", @@ -750,6 +782,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