From b608421d85820d885a9f06adf113a62083f901e0 Mon Sep 17 00:00:00 2001 From: Petru101 <43178362+Petru101@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:12:17 +0100 Subject: [PATCH 1/3] Accept an ISO 8601 string as expiry_time in load_oauth_session Applications such as High Tide store the token's expiry time as text and pass it to load_oauth_session. tidalapi didn't use the value before, but now that requests refresh the access token shortly before it expires, a string made every request fail with AttributeError: 'str' object has no attribute 'tzinfo'. It is now parsed with datetime.fromisoformat, as load_session_from_file does, and ignored if it can't be parsed. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 2 ++ tests/test_token_refresh.py | 39 +++++++++++++++++++++++++++++++++++++ tidalapi/session.py | 13 +++++++++++-- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index e40db50..f77d01a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -22,6 +22,7 @@ v0.9.0 * Bugfix: Non-JSON error responses and `Retry-After` given as a date no longer break error handling - tehkillerbee_ * Bugfix: Parsing tracks and albums no longer fails when TIDAL returns no or empty artist information - tehkillerbee_ * Parsed `Track` flags (`pay_to_stream`, `premium_streaming_only`, `editable`, `upload`, `spotlighted`) are now `False` instead of `None` when TIDAL omits them, and `url` is an empty string - tehkillerbee_ +* Bugfix: `load_oauth_session` accepts the expiry time as an ISO 8601 string, as applications that store it as text pass it. Since the token is now refreshed before it expires, such a string made every request fail - Petru101_ v0.8.11 -------- @@ -324,4 +325,5 @@ v0.6.2 .. _nilathedragon: https://github.com/nilathedragon .. _semohr: https://github.com/semohr .. _rafrombrc: https://github.com/rafrombrc +.. _Petru101: https://github.com/Petru101 diff --git a/tests/test_token_refresh.py b/tests/test_token_refresh.py index 27f66e4..d2b5d37 100644 --- a/tests/test_token_refresh.py +++ b/tests/test_token_refresh.py @@ -215,6 +215,45 @@ def test_failed_proactive_refresh_does_not_fail_the_request(): assert session.request.basic_request("GET", "tracks/1").ok +def _load_oauth_session(expiry_time): + session = _refreshing_session(None) + session.request_session.request.return_value = fake_response( + 200, {"sessionId": "sid", "countryCode": "US", "userId": 1} + ) + with mock.patch.object(tidalapi.user.User, "factory"): + assert session.load_oauth_session( + "Bearer", "old-access", "old-refresh", expiry_time, is_pkce=True + ) + return session + + +def test_load_oauth_session_accepts_expiry_time_string(): + expired = datetime.datetime.utcnow() - datetime.timedelta(days=1) + + session = _load_oauth_session(str(expired)) + + # The expired token is refreshed up front, which sets a new expiry time + session.request_session.post.assert_called_once() + assert session.access_token == "new-access" + assert session.expiry_time > datetime.datetime.utcnow() + + +def test_load_oauth_session_accepts_iso_format_expiry_time(): + expiry_time = datetime.datetime(2030, 1, 2, 3, 4, 5) + + session = _load_oauth_session(expiry_time.isoformat()) + + assert session.expiry_time == expiry_time + session.request_session.post.assert_not_called() + + +def test_load_oauth_session_ignores_invalid_expiry_time_string(): + session = _load_oauth_session("None") + + assert session.expiry_time is None + session.request_session.post.assert_not_called() + + 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) diff --git a/tidalapi/session.py b/tidalapi/session.py index 2843a3b..0f79d38 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -384,7 +384,7 @@ def load_oauth_session( token_type: str, access_token: str, refresh_token: str | None = None, - expiry_time: datetime.datetime | None = None, + expiry_time: datetime.datetime | str | None = None, is_pkce: bool | None = False, ) -> bool: """Login to TIDAL using details from a previous OAuth login, automatically @@ -394,10 +394,19 @@ def load_oauth_session( :param access_token: The access token received from an oauth login or refresh :param refresh_token: (Optional) A refresh token that lets you get a new access token after it has expired - :param expiry_time: (Optional) The datetime the access token will expire + :param expiry_time: (Optional) The datetime the access token will expire. A + string in ISO 8601 format (e.g. ``str(datetime)``) is accepted too :param is_pkce: (Optional) Is session pkce? :return: True if we believe the login was successful, otherwise false. """ + if isinstance(expiry_time, str): + # Applications often store the expiry time as text + try: + expiry_time = datetime.datetime.fromisoformat(expiry_time) + except ValueError: + log.info("Ignoring invalid expiry_time %r", expiry_time) + expiry_time = None + self.token_type = token_type self.access_token = access_token self.refresh_token = refresh_token From e8df902cac4bf0d78ed4dc431abf8c9cf074be49 Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Thu, 5 Nov 2026 21:51:11 +0100 Subject: [PATCH 2/3] Treat naive expiry_time as UTC in load_oauth_session --- HISTORY.rst | 2 +- tests/test_token_refresh.py | 24 ++++++++++++++++++++++-- tidalapi/session.py | 7 ++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index f77d01a..e868186 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -16,7 +16,7 @@ v0.9.0 * 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_ -* `Session.expiry_time` is now a timezone-aware UTC datetime, replacing the deprecated `datetime.utcnow()`. Naive datetimes passed to `load_oauth_session` are still supported - tehkillerbee_ +* `Session.expiry_time` is now a timezone-aware UTC datetime, replacing the deprecated `datetime.utcnow()`. Naive datetimes passed to `load_oauth_session` are treated as UTC - 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_ diff --git a/tests/test_token_refresh.py b/tests/test_token_refresh.py index d2b5d37..f847f44 100644 --- a/tests/test_token_refresh.py +++ b/tests/test_token_refresh.py @@ -228,14 +228,16 @@ def _load_oauth_session(expiry_time): def test_load_oauth_session_accepts_expiry_time_string(): - expired = datetime.datetime.utcnow() - datetime.timedelta(days=1) + # A naive string, as stored by applications using a previous tidalapi version + now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + expired = now - datetime.timedelta(days=1) session = _load_oauth_session(str(expired)) # The expired token is refreshed up front, which sets a new expiry time session.request_session.post.assert_called_once() assert session.access_token == "new-access" - assert session.expiry_time > datetime.datetime.utcnow() + assert session.expiry_time > datetime.datetime.now(datetime.timezone.utc) def test_load_oauth_session_accepts_iso_format_expiry_time(): @@ -243,10 +245,28 @@ def test_load_oauth_session_accepts_iso_format_expiry_time(): session = _load_oauth_session(expiry_time.isoformat()) + assert session.expiry_time == expiry_time.replace(tzinfo=datetime.timezone.utc) + session.request_session.post.assert_not_called() + + +def test_load_oauth_session_accepts_timezone_aware_expiry_time_string(): + expiry_time = datetime.datetime(2030, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) + + session = _load_oauth_session(str(expiry_time)) + assert session.expiry_time == expiry_time session.request_session.post.assert_not_called() +def test_load_oauth_session_treats_naive_datetime_as_utc(): + expiry_time = datetime.datetime(2030, 1, 2, 3, 4, 5) + + session = _load_oauth_session(expiry_time) + + assert session.expiry_time == expiry_time.replace(tzinfo=datetime.timezone.utc) + session.request_session.post.assert_not_called() + + def test_load_oauth_session_ignores_invalid_expiry_time_string(): session = _load_oauth_session("None") diff --git a/tidalapi/session.py b/tidalapi/session.py index 0f79d38..7e4486e 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -395,7 +395,8 @@ def load_oauth_session( :param refresh_token: (Optional) A refresh token that lets you get a new access token after it has expired :param expiry_time: (Optional) The datetime the access token will expire. A - string in ISO 8601 format (e.g. ``str(datetime)``) is accepted too + string in ISO 8601 format (e.g. ``str(datetime)``) is accepted too. Values + without a timezone are treated as UTC :param is_pkce: (Optional) Is session pkce? :return: True if we believe the login was successful, otherwise false. """ @@ -407,6 +408,10 @@ def load_oauth_session( log.info("Ignoring invalid expiry_time %r", expiry_time) expiry_time = None + if expiry_time is not None and expiry_time.tzinfo is None: + # Treat naive datetimes (e.g. from legacy utcnow()) as UTC + expiry_time = expiry_time.replace(tzinfo=datetime.timezone.utc) + self.token_type = token_type self.access_token = access_token self.refresh_token = refresh_token From f763fece06edb5ac9bb1f7bb930e10061ff157e2 Mon Sep 17 00:00:00 2001 From: Johannes Linde Date: Thu, 5 Nov 2026 21:58:29 +0100 Subject: [PATCH 3/3] Clarify changelog entry for expiry_time strings --- HISTORY.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index e868186..6bf181a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -22,7 +22,7 @@ v0.9.0 * Bugfix: Non-JSON error responses and `Retry-After` given as a date no longer break error handling - tehkillerbee_ * Bugfix: Parsing tracks and albums no longer fails when TIDAL returns no or empty artist information - tehkillerbee_ * Parsed `Track` flags (`pay_to_stream`, `premium_streaming_only`, `editable`, `upload`, `spotlighted`) are now `False` instead of `None` when TIDAL omits them, and `url` is an empty string - tehkillerbee_ -* Bugfix: `load_oauth_session` accepts the expiry time as an ISO 8601 string, as applications that store it as text pass it. Since the token is now refreshed before it expires, such a string made every request fail - Petru101_ +* Bugfix: `load_oauth_session` accepts `expiry_time` as an ISO 8601 string. Applications that store it as text (e.g. High Tide) would otherwise fail on every request, now that the expiry time is checked before each request - Petru101_ v0.8.11 --------