From e40cdda0e9e5ce8cac498e898343b65075717a7c Mon Sep 17 00:00:00 2001 From: Petru101 <43178362+Petru101@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:45:22 +0100 Subject: [PATCH] Add Session.report_playback to report plays to TIDAL Fetching and playing a stream doesn't count as a play, so tracks played through tidalapi never show up in Recently played or in the listening history TIDAL uses for recommendations. The official players report each play as a playback_session event to TIDAL's event producer; tidalapi.playlog builds the same events, following TIDAL's open source SDK (tidal-sdk-android), and Session.report_playback sends them. The events describe the real client (operating system and application version) instead of an official TIDAL app. Fixes #401 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 2 +- HISTORY.rst | 2 + docs/api.rst | 19 +++ tests/test_playlog.py | 236 +++++++++++++++++++++++++++++ tidalapi/__init__.py | 6 + tidalapi/playlog.py | 301 +++++++++++++++++++++++++++++++++++++ tidalapi/session.py | 49 +++++- 7 files changed, 613 insertions(+), 2 deletions(-) create mode 100644 tests/test_playlog.py create mode 100644 tidalapi/playlog.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b1534159..b6ae93d2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,4 +26,4 @@ jobs: - run: poetry env use python - run: poetry install # Offline tests only, the remaining tests require a TIDAL account - - run: poetry run pytest tests/test_backoff.py tests/test_parsing.py tests/test_token_refresh.py + - run: poetry run pytest tests/test_backoff.py tests/test_parsing.py tests/test_token_refresh.py tests/test_playlog.py diff --git a/HISTORY.rst b/HISTORY.rst index 70399b71..8fe01463 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -5,6 +5,7 @@ History v0.9.0 ------ +* Added `Session.report_playback` to report plays to TIDAL, so they show up in Recently played on all devices and count for recommendations (Fixes #401) - Petru101_ * (BREAKING!) Drop support for python3.9 and older, python 3.10 or newer is now required - tehkillerbee_ * Added support for python 3.14 - tehkillerbee_ * Removed unused runtime dependency `pyaes` - tehkillerbee_ @@ -323,4 +324,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/docs/api.rst b/docs/api.rst index 317b4398..01d404cb 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -167,3 +167,22 @@ API Reference .. autoclass:: Mix :members: :undoc-members: + +`tidalapi.playlog` +------------------ + +.. automodule:: tidalapi.playlog + +.. autoclass:: PlaybackSession + :members: + +.. autoclass:: PlaybackAction + :members: + +.. autoclass:: SourceType + :members: + :undoc-members: + +.. autoclass:: ActionType + :members: + :undoc-members: diff --git a/tests/test_playlog.py b/tests/test_playlog.py new file mode 100644 index 00000000..cb05a30e --- /dev/null +++ b/tests/test_playlog.py @@ -0,0 +1,236 @@ +# 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 reporting plays (no TIDAL account needed, nothing is sent).""" + +import base64 +import json +import platform +from unittest import mock + +import pytest +import requests + +import tidalapi +from tidalapi.exceptions import TidalAPIError +from tidalapi.playlog import ( + ActionType, + PlaybackAction, + PlaybackSession, + SourceType, + get_token_claims, +) + +ACCEPTED = ( + '' + "x" + "" +) +REJECTED = ( + "" + "true" + "" +) + + +def make_token(claims): + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode() + return f"header.{payload.rstrip('=')}.signature" + + +def fake_response(status_code=200, text=ACCEPTED): + response = requests.Response() + response.status_code = status_code + response._content = text.encode() + response.request = requests.Request( + "POST", "https://ec.tidal.com/api/event-batch" + ).prepare() + return response + + +def make_session(): + session = tidalapi.Session(tidalapi.Config()) + session.token_type = "Bearer" + session.access_token = make_token({"uid": 123, "cid": 8017, "sid": "s-1"}) + session.refresh_token = "refresh" + session.is_pkce = True + session.request_session = mock.Mock() + session.request_session.request.return_value = fake_response() + return session + + +def make_play(track_id=1, **kwargs): + return PlaybackSession( + track_id=track_id, + start_timestamp=1_000, + end_timestamp=201_000, + end_position=200.0, + **kwargs, + ) + + +def sent_forms(session): + return [c.kwargs["data"] for c in session.request_session.request.call_args_list] + + +def entries(form): + """The (body, headers, name) of each event in a SendMessageBatch form.""" + events = [] + index = 1 + while f"SendMessageBatchRequestEntry.{index}.Id" in form: + entry = f"SendMessageBatchRequestEntry.{index}." + events.append( + ( + json.loads(form[entry + "MessageBody"]), + json.loads(form[entry + "MessageAttribute.2.Value.StringValue"]), + form[entry + "MessageAttribute.1.Value.StringValue"], + ) + ) + index += 1 + return events + + +def test_payload(): + play = make_play( + source_type=SourceType.album, + source_id=55, + actions=[ + PlaybackAction(50_000, 49.5, ActionType.playback_stop), + PlaybackAction(60_000, 49.5, ActionType.playback_start), + ], + quality=tidalapi.Quality.hi_res_lossless, + ) + payload = play.to_json() + + assert payload["requestedProductId"] == "1" + assert payload["actualProductId"] == "1" + assert payload["productType"] == "TRACK" + assert payload["sourceType"] == "ALBUM" + assert payload["sourceId"] == "55" + assert payload["actualQuality"] == "HI_RES_LOSSLESS" + assert payload["actualAudioMode"] == "STEREO" + assert payload["startTimestamp"] == 1_000 + assert payload["endTimestamp"] == 201_000 + assert payload["startAssetPosition"] == 0.0 + assert payload["endAssetPosition"] == 200.0 + assert payload["actions"] == [ + {"timestamp": 50_000, "assetPosition": 49.5, "actionType": "PLAYBACK_STOP"}, + {"timestamp": 60_000, "assetPosition": 49.5, "actionType": "PLAYBACK_START"}, + ] + assert payload["playbackSessionId"] + + +def test_track_played_on_its_own_uses_its_own_id_as_source(): + payload = make_play(track_id=7).to_json() + assert payload["sourceType"] == "ITEM" + assert payload["sourceId"] == "7" + + +def test_token_claims(): + assert get_token_claims(make_token({"uid": 1, "cid": 2})) == {"uid": 1, "cid": 2} + assert get_token_claims("not a token") == {} + assert get_token_claims("a.%%%.b") == {} + + +def test_report_sends_one_playback_session_event_per_play(): + session = make_session() + + assert session.report_playback([make_play(1), make_play(2)], app_version="App 1.0") + + call = session.request_session.request.call_args + assert call.args == ("POST", "https://ec.tidal.com/api/event-batch") + assert call.kwargs["headers"]["authorization"] == "Bearer " + session.access_token + + events = entries(call.kwargs["data"]) + assert [body["payload"]["requestedProductId"] for body, _, _ in events] == [ + "1", + "2", + ] + body, headers, name = events[0] + assert name == "playback_session" + assert body["group"] == "play_log" + assert body["version"] == 2 + assert body["user"] == {"id": 123, "clientId": 8017, "sessionId": "s-1"} + assert body["client"] == { + "token": "8017", + "deviceType": "desktop", + "version": "App 1.0", + "platform": platform.system().lower(), + } + assert headers["app-version"] == "App 1.0" + assert headers["os-name"] == platform.system() + assert headers["client-id"] == session.config.client_id_pkce + assert headers["authorization"] == session.access_token + assert headers["consent-category"] == "NECESSARY" + + +def test_events_describe_the_real_client(): + session = make_session() + session.report_playback([make_play()]) + + body, headers, _ = entries(sent_forms(session)[0])[0] + assert "device-model" not in headers + assert "device-vendor" not in headers + assert body["client"]["platform"] != "android" + assert headers["app-version"] == f"tidalapi {tidalapi.__version__}" + + +def test_report_sends_at_most_ten_events_per_request(): + session = make_session() + + assert session.report_playback([make_play(i) for i in range(23)]) + + assert [len(entries(form)) for form in sent_forms(session)] == [10, 10, 3] + + +def test_rejected_plays_return_false(): + session = make_session() + session.request_session.request.return_value = fake_response(text=REJECTED) + assert not session.report_playback([make_play()]) + + session.request_session.request.return_value = fake_response(400, "bad request") + assert not session.report_playback([make_play()]) + + +def test_server_errors_raise_so_plays_can_be_sent_later(): + session = make_session() + session.request_session.request.return_value = fake_response(503, "unavailable") + + with pytest.raises((requests.HTTPError, TidalAPIError)): + session.report_playback([make_play()]) + + +def test_expired_token_is_refreshed_and_the_request_retried_once(): + session = make_session() + old_token = session.access_token + new_token = make_token({"uid": 123, "cid": 8017, "sid": "s-2"}) + session.request_session.request.side_effect = [ + fake_response(401, '{"status": 401}'), + fake_response(), + ] + + def token_refresh(refresh_token): + session.access_token = new_token + return True + + with mock.patch.object(session, "token_refresh", side_effect=token_refresh): + assert session.report_playback([make_play()]) + + calls = session.request_session.request.call_args_list + assert len(calls) == 2 + assert calls[0].kwargs["headers"]["authorization"] == "Bearer " + old_token + assert calls[1].kwargs["headers"]["authorization"] == "Bearer " + new_token + body, headers, _ = entries(calls[1].kwargs["data"])[0] + assert headers["authorization"] == new_token + assert body["user"]["sessionId"] == "s-2" diff --git a/tidalapi/__init__.py b/tidalapi/__init__.py index 6603e014..6d45691d 100644 --- a/tidalapi/__init__.py +++ b/tidalapi/__init__.py @@ -7,6 +7,12 @@ from .mix import Mix, MixV2 # noqa: F401 from .page import Page # noqa: F401 from .playlist import Playlist, UserPlaylist # noqa: F401 +from .playlog import ( # noqa: F401 + ActionType, + PlaybackAction, + PlaybackSession, + SourceType, +) from .request import Requests # noqa: F401 from .session import Config, Session # noqa: F401 from .user import ( # noqa: F401 diff --git a/tidalapi/playlog.py b/tidalapi/playlog.py new file mode 100644 index 00000000..58667cfc --- /dev/null +++ b/tidalapi/playlog.py @@ -0,0 +1,301 @@ +# 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 . +"""Reporting plays to TIDAL (the play log). + +Fetching and playing a stream doesn't count as a play: the official TIDAL players report +every play as a ``playback_session`` event to TIDAL's event producer. Reported plays +show up in Recently played on all devices and in the listening history TIDAL uses for +recommendations. + +The event layout follows TIDAL's open source SDK ( + +https://github.com/tidal-music/tidal-sdk-android, +modules ``eventproducer`` and +``player/events``). The events describe the real client (the operating system and +the application version), they don't pretend to come from an official TIDAL app. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import platform +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Iterable, List, Optional, Union + +import requests + +from tidalapi import media +from tidalapi.exceptions import AuthenticationError, http_error_to_tidal_error +from tidalapi.types import JsonObj + +if TYPE_CHECKING: + from tidalapi.session import Session + +log = logging.getLogger(__name__) + +#: The most events TIDAL's event producer accepts in one request +MAX_BATCH_SIZE = 10 + + +class SourceType(str, Enum): + """What a track was played from. + + A track played on its own (from search, a list of tracks, the play queue) is an + ``ITEM`` with the track's own id as source id. + """ + + album = "ALBUM" + playlist = "PLAYLIST" + mix = "MIX" + artist = "ARTIST" + item = "ITEM" + + def __str__(self) -> str: + return self.value + + +class ActionType(str, Enum): + """A change during a play: playback stopped (paused) or started (resumed).""" + + playback_start = "PLAYBACK_START" + playback_stop = "PLAYBACK_STOP" + + def __str__(self) -> str: + return self.value + + +@dataclass +class PlaybackAction: + """A pause, resume or seek during a play. + + A pause is a ``PLAYBACK_STOP``, resuming is a ``PLAYBACK_START``. A seek is a + ``PLAYBACK_STOP`` at the old position followed by a ``PLAYBACK_START`` at the new + one. + + :param timestamp: When it happened, in milliseconds since the epoch + :param position: The position in the track, in seconds + :param action_type: :class:`ActionType` + """ + + timestamp: int + position: float + action_type: ActionType + + def to_json(self) -> JsonObj: + return { + "timestamp": self.timestamp, + "assetPosition": self.position, + "actionType": str(self.action_type), + } + + +@dataclass +class PlaybackSession: + """One play of a track, from the moment it started to the moment it stopped. + + TIDAL counts a play after 30 seconds of listening, so shorter plays don't need to be + reported. + + :param track_id: The id of the played track + :param start_timestamp: When the play started, in milliseconds since the epoch + :param end_timestamp: When the play ended, in milliseconds since the epoch + :param end_position: The position in the track when the play ended, in seconds + :param start_position: The position in the track when the play started, in seconds + :param source_type: What the track was played from, see :class:`SourceType` + :param source_id: The id of the album, playlist, mix or artist the track was played + from. Defaults to the track id, for a track played on its own + :param actions: Pauses, resumes and seeks during the play, see + :class:`PlaybackAction` + :param quality: The audio quality that was played, see :class:`.media.Quality` + :param audio_mode: The audio mode that was played, see :class:`.media.AudioMode` + :param playback_session_id: A unique id for this play. Generated if not given + """ + + track_id: Union[int, str] + start_timestamp: int + end_timestamp: int + end_position: float + start_position: float = 0.0 + source_type: SourceType = SourceType.item + source_id: Optional[Union[int, str]] = None + actions: List[PlaybackAction] = field(default_factory=list) + quality: str = media.Quality.high_lossless + audio_mode: str = media.AudioMode.stereo + playback_session_id: str = field(default_factory=lambda: str(uuid.uuid4())) + + def to_json(self) -> JsonObj: + """The event payload, as TIDAL's players send it.""" + source_id = self.source_id if self.source_id is not None else self.track_id + return { + "playbackSessionId": self.playback_session_id, + "startTimestamp": self.start_timestamp, + "startAssetPosition": self.start_position, + "isPostPaywall": True, + "productType": "TRACK", + "requestedProductId": str(self.track_id), + "actualProductId": str(self.track_id), + "actualAssetPresentation": "FULL", + "actualAudioMode": str(self.audio_mode), + "actualQuality": str(self.quality), + "sourceType": str(self.source_type), + "sourceId": str(source_id), + "actions": [action.to_json() for action in self.actions], + "endTimestamp": self.end_timestamp, + "endAssetPosition": self.end_position, + } + + +def get_token_claims(access_token: str) -> JsonObj: + """The claims of an access token, without verifying it. + + :param access_token: A TIDAL access token (a JWT) + :return: The claims, or an empty dict if the token can't be decoded + """ + try: + payload = access_token.split(".")[1] + payload += "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload)) + except (IndexError, ValueError): + return {} + + +class PlayLog: + """Reports plays to TIDAL, see :meth:`.Session.report_playback`.""" + + def __init__(self, session: Session): + self.session = session + + def report( + self, playback_sessions: Iterable[PlaybackSession], app_version: str + ) -> bool: + """Report plays to TIDAL. + + :param playback_sessions: The plays to report + :param app_version: The version of the application that played them + :return: True if TIDAL accepted the plays, False if it rejected them. Rejected + plays would be rejected again, so they shouldn't be sent again. + :raises: :class:`requests.RequestException` or a :class:`.TidalAPIError` if the + plays couldn't be sent (e.g. offline, server errors). They can be sent again + later. + """ + playback_sessions = list(playback_sessions) + accepted = True + for start in range(0, len(playback_sessions), MAX_BATCH_SIZE): + batch = playback_sessions[start : start + MAX_BATCH_SIZE] + accepted = self._post(batch, app_version) and accepted + return accepted + + def _post(self, playback_sessions: List[PlaybackSession], app_version: str) -> bool: + requests_ = self.session.request + if requests_._access_token_expired(): + try: + requests_._refresh_access_token(self.session.access_token) + except AuthenticationError as e: + log.warning("Access token refresh failed: %s", e) + + sent_access_token = self.session.access_token + response = self._send(playback_sessions, app_version) + if response.status_code == 401 and requests_._refresh_access_token( + sent_access_token + ): + response = self._send(playback_sessions, app_version) + + if response.status_code in (401, 403) or response.status_code >= 500: + try: + response.raise_for_status() + except requests.HTTPError as e: + if err := http_error_to_tidal_error(e): + raise err from e + raise + + if not response.ok or "BatchResultErrorEntry" in response.text: + log.warning("TIDAL rejected the reported plays: %s", response.text[:500]) + return False + return True + + def _send( + self, playback_sessions: List[PlaybackSession], app_version: str + ) -> requests.Response: + form = self.get_form(playback_sessions, app_version) + return self.session.request._send( + "POST", self.session.config.event_producer_location, {}, form, {} + ) + + def get_form( + self, playback_sessions: List[PlaybackSession], app_version: str + ) -> dict: + """The request body for a batch of plays: an SQS ``SendMessageBatch`` form, with + one ``playback_session`` event per play. + + :param playback_sessions: At most :data:`MAX_BATCH_SIZE` plays + :param app_version: The version of the application that played them + :return: The form fields + """ + access_token = self.session.access_token or "" + claims = get_token_claims(access_token) + os_name = platform.system() + client_id = ( + self.session.config.client_id_pkce + if self.session.is_pkce + else self.session.config.client_id + ) + + sent_timestamp = str(int(time.time() * 1000)) + + form = {} + for index, playback_session in enumerate(playback_sessions, 1): + event_id = str(uuid.uuid4()) + body = { + "group": "play_log", + "version": 2, + "ts": playback_session.end_timestamp, + "uuid": event_id, + "user": { + "id": claims.get("uid"), + "clientId": claims.get("cid"), + "sessionId": claims.get("sid"), + }, + "client": { + "token": str(claims.get("cid", "")), + "deviceType": "desktop", + "version": app_version, + "platform": os_name.lower(), + }, + "payload": playback_session.to_json(), + } + headers = { + "app-version": app_version, + "os-name": os_name, + "os-version": platform.release(), + "consent-category": "NECESSARY", + "requested-sent-timestamp": sent_timestamp, + "authorization": access_token, + "client-id": client_id, + } + entry = f"SendMessageBatchRequestEntry.{index}." + form[entry + "Id"] = event_id + form[entry + "MessageBody"] = json.dumps(body) + form[entry + "MessageAttribute.1.Name"] = "Name" + form[entry + "MessageAttribute.1.Value.StringValue"] = "playback_session" + form[entry + "MessageAttribute.1.Value.DataType"] = "String" + form[entry + "MessageAttribute.2.Name"] = "Headers" + form[entry + "MessageAttribute.2.Value.StringValue"] = json.dumps(headers) + form[entry + "MessageAttribute.2.Value.DataType"] = "String" + return form diff --git a/tidalapi/session.py b/tidalapi/session.py index 2b364c0a..749b70f4 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -34,6 +34,7 @@ TYPE_CHECKING, Any, Callable, + Iterable, List, Literal, Optional, @@ -56,7 +57,7 @@ ) from tidalapi.types import JsonObj -from . import album, artist, genre, media, mix, page, playlist, request, user +from . import album, artist, genre, media, mix, page, playlist, playlog, request, user if TYPE_CHECKING: from tidalapi.user import FetchedUser, LoggedInUser, PlaylistCreator @@ -116,6 +117,8 @@ class Config: api_v1_location: str = "https://api.tidal.com/v1/" api_v2_location: str = "https://api.tidal.com/v2/" openapi_v2_location: str = "https://openapi.tidal.com/v2/" + #: Where plays are reported, see :meth:`Session.report_playback` + event_producer_location: str = "https://ec.tidal.com/api/event-batch" client_id: str client_secret: str image_url: str = "https://resources.tidal.com/images/%s/%ix%i.jpg" @@ -247,6 +250,7 @@ def __init__(self, config: Optional[Config] = None): # Objects for keeping the session across all modules. self.request = request.Requests(session=self) + self.playlog = playlog.PlayLog(session=self) self.genre = genre.Genre(session=self) self.parse_user = user.User(self, None).parse @@ -886,6 +890,49 @@ def search( return result + def report_playback( + self, + playback_sessions: Iterable[playlog.PlaybackSession], + app_version: Optional[str] = None, + ) -> bool: + """Report plays to TIDAL, so they show up in Recently played on all devices and + count for recommendations. + + Fetching and playing a stream doesn't count as a play on its own. Report each + play of a track once it ends (e.g. when the next track starts), if it lasted at + least 30 seconds, as TIDAL counts plays from then on. + + Example:: + + session.report_playback( + [ + PlaybackSession( + track_id=track.id, + start_timestamp=started_at_ms, + end_timestamp=ended_at_ms, + end_position=position_seconds, + source_type=SourceType.album, + source_id=track.album.id, + ) + ], + app_version="My Player 1.0", + ) + + :param playback_sessions: The plays to report, see + :class:`~tidalapi.playlog.PlaybackSession` + :param app_version: The name and version of the application that played them. + Defaults to the tidalapi version + :return: True if TIDAL accepted the plays, False if it rejected them (they + would be rejected again, so they shouldn't be sent again) + :raises: :class:`requests.RequestException` or a :class:`.TidalAPIError` if + the plays couldn't be sent (e.g. offline). They can be sent again later. + """ + if app_version is None: + from tidalapi import __version__ + + app_version = f"tidalapi {__version__}" + return self.playlog.report(playback_sessions, app_version) + def check_login(self) -> bool: """Returns true if current session is valid, false otherwise.""" if self.user is None or not self.user.id or not self.session_id: