diff --git a/HISTORY.rst b/HISTORY.rst index e40db50..6bf181a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -16,12 +16,13 @@ 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_ * 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 `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 -------- @@ -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..f847f44 100644 --- a/tests/test_token_refresh.py +++ b/tests/test_token_refresh.py @@ -215,6 +215,65 @@ 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(): + # 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.now(datetime.timezone.utc) + + +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.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") + + 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..7e4486e 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,24 @@ 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. 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. """ + 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 + + 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