From ffe145202d3f82ff0eaae488767e6b1c77b60402 Mon Sep 17 00:00:00 2001 From: Petru101 <43178362+Petru101@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:24:19 +0000 Subject: [PATCH] Add Session.start_playback and report_playback to report plays to TIDAL The library measures each play and refuses implausible ones. Replies are handled as in TIDAL's SDKs: a play is done only when the reply lists it as sent or as a sender fault, anything else leaves it unsent. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 2 +- HISTORY.rst | 4 + docs/api.rst | 25 ++ tests/test_playlog.py | 694 +++++++++++++++++++++++++++++++++++++ tidalapi/__init__.py | 8 + tidalapi/media.py | 9 +- tidalapi/playlog.py | 657 +++++++++++++++++++++++++++++++++++ tidalapi/session.py | 70 +++- 8 files changed, 1465 insertions(+), 4 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 f1692a67..9f235fe1 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 1b079e22..4d17204f 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,10 @@ History ======= +Unreleased +---------- +* Added `Session.start_playback` and `Session.report_playback` to measure plays and report them to TIDAL, so they show up in Recently played on all devices and count for recommendations (Fixes #401) - Petru101_ + v0.9.0 ------ * (BREAKING!) Drop support for python3.10 and older, python 3.11 or newer is now required - tehkillerbee_ diff --git a/docs/api.rst b/docs/api.rst index 317b4398..c9641a02 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -167,3 +167,28 @@ API Reference .. autoclass:: Mix :members: :undoc-members: + +`tidalapi.playlog` +------------------ + +.. automodule:: tidalapi.playlog + +.. autoclass:: PlaybackTracker + :members: + +.. autoclass:: PlaybackSession + :members: + +.. autoclass:: PlaybackReport + :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..645b7c71 --- /dev/null +++ b/tests/test_playlog.py @@ -0,0 +1,694 @@ +# 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 dataclasses +import json +import platform +from unittest import mock + +import pytest +import requests + +import tidalapi +from tidalapi.exceptions import TidalBackoffError +from tidalapi.media import Stream +from tidalapi.playlog import ( + MAX_PLAY_AGE_MS, + ActionType, + PlaybackAction, + PlaybackSession, + SourceType, + get_token_claims, +) + +NOW = 1_800_000_000_000 + + +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=""): + 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 reply_xml(sent=(), failed=(), sender_fault=True): + """A reply as TIDAL sends it, with the namespace.""" + entries = [ + f"{event_id}" + "m" + for event_id in sent + ] + entries += [ + f"{event_id}" + f"{str(sender_fault).lower()}" + "" + for event_id in failed + ] + return ( + '' + f"{''.join(entries)}" + "" + ) + + +def event_ids(form): + return [v for k, v in form.items() if k.endswith(".Id")] + + +def accept_everything(method, url, data, **kwargs): + return fake_response(text=reply_xml(event_ids(data))) + + +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.side_effect = accept_everything + return session + + +def make_track(session, track_id=1, duration=200, fetched=True): + track = tidalapi.Track(session) + track.id = track_id + track.duration = duration + if fetched: + session.playlog.note_stream(track_id, "HI_RES_LOSSLESS", "DOLBY_ATMOS") + return track + + +def make_play(start=NOW - 300_000, seconds=200, **kwargs): + """A plausible play, `seconds` long, ending before NOW.""" + values = { + "track_id": 1, + "track_duration": 200, + "start_timestamp": start, + "end_timestamp": start + seconds * 1000, + "start_position": 0.0, + "end_position": float(seconds), + } + return PlaybackSession(**{**values, **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 + + +class FakeTime: + """The clocks the library measures with: seconds of wall clock and monotonic.""" + + def __init__(self): + self.now = NOW / 1000 + + def time(self): + return self.now + + def monotonic(self): + return self.now + + def advance(self, seconds): + self.now += seconds + + +@pytest.fixture +def clock(): + fake = FakeTime() + with mock.patch("tidalapi.playlog.time", fake): + yield fake + + +@pytest.fixture +def session(clock): + return make_session() + + +# Measuring a play + + +def test_tracker_measures_a_play(session, clock): + track = make_track(session) + tracker = session.start_playback(track) + clock.advance(120) + play = tracker.stop() + + assert play.track_id == 1 + assert play.start_timestamp == NOW + assert play.end_timestamp == NOW + 120_000 + assert play.start_position == 0.0 + assert play.end_position == 120.0 + assert play.actions == [] + assert play.quality == "HI_RES_LOSSLESS" + assert play.audio_mode == "DOLBY_ATMOS" + assert play.source_type == SourceType.item + assert play.source_id == 1 + + +def test_tracker_doesnt_count_pauses(session, clock): + tracker = session.start_playback(make_track(session)) + clock.advance(40) + tracker.pause() + clock.advance(1000) + assert tracker.position == 40.0 + tracker.resume() + clock.advance(10) + play = tracker.stop() + + assert play.end_position == 50.0 + assert tracker.listened_seconds == 50.0 + assert play.actions == [ + PlaybackAction(NOW + 40_000, 40.0, ActionType.playback_stop), + PlaybackAction(NOW + 1_040_000, 40.0, ActionType.playback_start), + ] + + +def test_tracker_records_seeks_as_the_players_do(session, clock): + tracker = session.start_playback(make_track(session)) + clock.advance(40) + tracker.seek(100.0) + clock.advance(10) + play = tracker.stop() + + assert play.end_position == 110.0 + assert play.actions == [ + PlaybackAction(NOW + 40_000, 40.0, ActionType.playback_stop), + PlaybackAction(NOW + 40_000, 100.0, ActionType.playback_start), + ] + assert play.get_problem(int(clock.now * 1000)) is None + + +def test_seeking_back_counts_the_time_listened(session, clock): + tracker = session.start_playback(make_track(session)) + clock.advance(20) + tracker.seek(0.0) + clock.advance(20) + play = tracker.stop() + + assert play is not None + assert play.end_position == 20.0 + assert play.get_problem(int(clock.now * 1000)) is None + + +def test_a_play_started_paused_begins_at_the_first_resume(session, clock): + tracker = session.start_playback(make_track(session), position=30.0, paused=True) + clock.advance(500) + tracker.seek(50.0) + tracker.resume() + clock.advance(40) + play = tracker.stop() + + assert play.start_timestamp == NOW + 500_000 + assert play.start_position == 50.0 + assert play.end_position == 90.0 + assert play.actions == [] + + +def test_stopping_twice_gives_the_same_play(session, clock): + tracker = session.start_playback(make_track(session)) + clock.advance(40) + play = tracker.stop() + clock.advance(40) + assert tracker.stop() is play + assert play.end_position == 40.0 + + +def test_short_plays_dont_count(session, clock): + tracker = session.start_playback(make_track(session)) + clock.advance(29) + assert tracker.stop() is None + + tracker = session.start_playback(make_track(session), paused=True) + clock.advance(100) + assert tracker.stop() is None + + +def test_a_track_shorter_than_30_seconds_counts_when_played_through(session, clock): + tracker = session.start_playback(make_track(session, duration=20)) + clock.advance(19.5) + assert tracker.stop() is not None + + +def test_the_position_stops_at_the_end_of_the_track(session, clock): + tracker = session.start_playback(make_track(session, duration=200)) + clock.advance(203) + assert tracker.stop().end_position == 200.0 + + +def test_only_tracks_with_a_fetched_stream_can_be_tracked(session): + track = make_track(session, fetched=False) + with pytest.raises(ValueError): + session.start_playback(track) + assert session.start_playback(track, local_copy=True) is not None + + +def test_get_stream_makes_a_track_playable(session, clock): + track = make_track(session, fetched=False) + stream = Stream() + stream.audio_quality = "LOSSLESS" + stream.audio_mode = "STEREO" + with ( + mock.patch.object(track.requests, "request"), + mock.patch.object(track.requests, "map_json", return_value=stream), + ): + track.get_stream() + + tracker = session.start_playback(track) + clock.advance(40) + assert tracker.stop().quality == "LOSSLESS" + + +@pytest.mark.parametrize( + "source_class, source_type", + [ + (tidalapi.Album, SourceType.album), + (tidalapi.Playlist, SourceType.playlist), + (tidalapi.Mix, SourceType.mix), + (tidalapi.MixV2, SourceType.mix), + (tidalapi.Artist, SourceType.artist), + ], +) +def test_source(session, clock, source_class, source_type): + source = source_class.__new__(source_class) + source.id = "source-id" + tracker = session.start_playback(make_track(session), source=source) + clock.advance(40) + play = tracker.stop() + assert play.source_type == source_type + assert play.source_id == "source-id" + + +# The 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"] == play.start_timestamp + assert payload["endTimestamp"] == play.end_timestamp + 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): + plays = [make_play(track_id=1), make_play(track_id=2, start=NOW - 700_000)] + + report = session.report_playback(plays, app_version="App 1.0") + assert report.accepted == plays + + 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["uuid"] == plays[0].event_id + 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): + 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__}" + + +# TIDAL's replies + + +def spaced_plays(count): + return [ + make_play(track_id=i, start=NOW - 1000 * 300 * (i + 1), seconds=200) + for i in range(count) + ] + + +def test_at_most_ten_events_per_request(session): + report = session.report_playback(spaced_plays(23)) + assert [len(entries(form)) for form in sent_forms(session)] == [10, 10, 3] + assert len(report.accepted) == 23 + + +@pytest.mark.parametrize("status", [400, 403, 404, 408, 429, 500, 503]) +def test_http_errors_keep_the_plays_for_later(session, status): + session.request_session.request.side_effect = [fake_response(status, "no")] + play = make_play() + + report = session.report_playback([play]) + + assert report.unsent == [play] + assert report.accepted == [] + assert report.rejected == [] + assert report.error is not None + + +def test_being_offline_keeps_the_plays_for_later(session): + error = requests.ConnectionError("offline") + session.request_session.request.side_effect = error + play = make_play() + + report = session.report_playback([play]) + + assert report.unsent == [play] + assert report.error is error + + +def test_a_rate_limit_holds_back_the_next_report(session): + session.request_session.request.side_effect = [fake_response(429, "slow down")] + play = make_play() + assert session.report_playback([play]).unsent == [play] + + report = session.report_playback([play]) + + assert report.unsent == [play] + assert isinstance(report.error, TidalBackoffError) + assert session.request_session.request.call_count == 1 + + +@pytest.mark.parametrize( + "reply", + [ + "", + "Log in to the Wi-Fi", + "Log in to the Wi-Fi", + reply_xml(["some-other-event"]), + reply_xml(), + ], +) +def test_a_success_that_doesnt_confirm_the_plays_keeps_them(session, reply): + session.request_session.request.side_effect = [fake_response(text=reply)] + play = make_play() + + report = session.report_playback([play]) + + assert report.unsent == [play] + assert report.accepted == [] + + +def test_each_play_in_a_reply_gets_its_own_result(session): + sent, sender_fault, server_fault = spaced_plays(3) + reply = reply_xml([sent.event_id], [sender_fault.event_id]).replace( + "", + f"{server_fault.event_id}" + "false" + "", + ) + session.request_session.request.side_effect = [fake_response(text=reply)] + + report = session.report_playback([sent, sender_fault, server_fault]) + + assert report.accepted == [sent] + assert report.rejected == [sender_fault] + assert report.unsent == [server_fault] + + +def test_a_failed_batch_doesnt_hide_the_accepted_ones(session): + plays = spaced_plays(25) + first = fake_response(text=reply_xml([play.event_id for play in plays[:10]])) + session.request_session.request.side_effect = [first, fake_response(503, "no")] + + report = session.report_playback(plays) + + assert report.accepted == plays[:10] + assert report.unsent == plays[10:] + assert session.request_session.request.call_count == 2 + + +def test_retrying_sends_the_same_ids(session): + session.request_session.request.side_effect = [fake_response(503, "no")] + play = make_play() + session.report_playback([play]) + session.request_session.request.side_effect = accept_everything + + assert session.report_playback([play]).accepted == [play] + + first, second = sent_forms(session) + assert event_ids(first) == event_ids(second) == [play.event_id] + + +def test_expired_token_is_refreshed_and_the_request_retried_once(session): + old_token = session.access_token + new_token = make_token({"uid": 123, "cid": 8017, "sid": "s-2"}) + play = make_play() + session.request_session.request.side_effect = [ + fake_response(401, '{"status": 401}'), + fake_response(text=reply_xml([play.event_id])), + ] + + 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([play]).accepted == [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" + + +# Implausible plays + + +@pytest.mark.parametrize( + "changes", + [ + pytest.param( + {"start_timestamp": NOW - 1000, "end_timestamp": NOW - 200_000}, + id="ends before it starts", + ), + pytest.param( + {"start_timestamp": NOW, "end_timestamp": NOW + 200_000}, + id="ends in the future", + ), + pytest.param( + { + "start_timestamp": NOW - MAX_PLAY_AGE_MS - 300_000, + "end_timestamp": NOW - MAX_PLAY_AGE_MS - 100_000, + }, + id="too old", + ), + pytest.param({"end_position": -1.0}, id="negative position"), + pytest.param({"track_duration": 100}, id="position past the end of the track"), + pytest.param( + {"end_timestamp": NOW - 290_000}, id="more listening than time passed" + ), + pytest.param( + { + "actions": [ + PlaybackAction(NOW - 290_000, 150.0, ActionType.playback_stop), + PlaybackAction(NOW - 289_000, 0.0, ActionType.playback_start), + ] + }, + id="listening before a pause faster than time", + ), + pytest.param( + { + "actions": [ + PlaybackAction(NOW - 200_000, 50.0, ActionType.playback_stop), + PlaybackAction(NOW - 250_000, 50.0, ActionType.playback_start), + ] + }, + id="actions out of order", + ), + pytest.param( + {"actions": [PlaybackAction(NOW, 50.0, ActionType.playback_stop)]}, + id="action after the end", + ), + ], +) +def test_implausible_plays_are_refused_without_sending(session, changes): + play = make_play(**changes) + + report = session.report_playback([play]) + + assert report.rejected == [play] + session.request_session.request.assert_not_called() + + +def test_rounded_durations_are_allowed_for(): + assert make_play(track_duration=199).get_problem(NOW) is None + + +def test_a_clock_adjusted_during_a_play_is_allowed_for(): + play = make_play(seconds=199, end_position=200.5) + assert play.get_problem(NOW) is None + + +def test_a_play_cant_be_reported_twice(session): + play = make_play() + assert session.report_playback([play]).accepted == [play] + + assert session.report_playback([play]).rejected == [play] + assert session.request_session.request.call_count == 1 + + +def test_a_play_cant_be_reported_twice_in_one_call(session): + play = make_play() + report = session.report_playback([play, play]) + assert report.accepted == [play] + assert report.rejected == [play] + + +def test_plays_cant_overlap_a_reported_one(session): + assert session.report_playback([make_play()]).accepted + overlapping = make_play(start=NOW - 250_000, end_position=100.0, seconds=100) + + assert session.report_playback([overlapping]).rejected == [overlapping] + + +def test_plays_cant_overlap_in_one_call(session): + first = make_play() + overlapping = make_play(start=NOW - 250_000, end_position=100.0, seconds=100) + + report = session.report_playback([first, overlapping]) + + assert report.accepted == [first] + assert report.rejected == [overlapping] + + +def test_consecutive_plays_dont_overlap(session): + first = make_play(start=NOW - 500_000) + second = make_play(start=first.end_timestamp) + assert session.report_playback([first, second]).accepted == [first, second] + + +def test_an_unsent_play_can_be_sent_again(session): + session.request_session.request.side_effect = [fake_response(503, "no")] + play = make_play() + assert session.report_playback([play]).unsent == [play] + session.request_session.request.side_effect = accept_everything + assert session.report_playback([play]).accepted == [play] + + +# Keeping plays for later + + +def test_a_kept_play_can_be_reported_later(session, clock): + tracker = session.start_playback(make_track(session)) + clock.advance(40) + tracker.seek(100.0) + clock.advance(10) + tracker.pause() + clock.advance(5) + play = tracker.stop() + + kept = json.loads(json.dumps(play.to_dict())) + restored = PlaybackSession.from_dict(kept) + + assert restored == play + assert restored.to_json() == play.to_json() + new_session = make_session() + assert new_session.report_playback([restored]).accepted == [restored] + + +def test_a_kept_play_goes_through_the_same_checks(session, clock): + kept = make_play().to_dict() + kept["end_position"] = 5000.0 + restored = PlaybackSession.from_dict(kept) + + assert session.report_playback([restored]).rejected == [restored] + + +def test_to_dict_covers_every_field(): + play = make_play(actions=[PlaybackAction(1, 2.0, ActionType.playback_stop)]) + names = {f.name for f in dataclasses.fields(PlaybackSession)} + assert set(play.to_dict()) == names diff --git a/tidalapi/__init__.py b/tidalapi/__init__.py index 6603e014..bedee3cf 100644 --- a/tidalapi/__init__.py +++ b/tidalapi/__init__.py @@ -7,6 +7,14 @@ 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, + PlaybackReport, + PlaybackSession, + PlaybackTracker, + SourceType, +) from .request import Requests # noqa: F401 from .session import Config, Session # noqa: F401 from .user import ( # noqa: F401 diff --git a/tidalapi/media.py b/tidalapi/media.py index 2ff35e9f..d56538c9 100644 --- a/tidalapi/media.py +++ b/tidalapi/media.py @@ -435,6 +435,9 @@ def get_url(self) -> str: raise e else: json_obj = request.json() + self.session.playlog.note_stream( + self.id, self.session.config.quality, AudioMode.stereo + ) return cast(str, json_obj["urls"][0]) def lyrics(self) -> Lyrics: @@ -527,7 +530,11 @@ def get_stream(self) -> Stream: json_obj = request.json() stream = self.requests.map_json(json_obj, parse=Stream().parse) assert not isinstance(stream, list) - return cast("Stream", stream) + stream = cast("Stream", stream) + self.session.playlog.note_stream( + self.id, stream.audio_quality, stream.audio_mode + ) + return stream @property def is_hi_res_lossless(self) -> bool: diff --git a/tidalapi/playlog.py b/tidalapi/playlog.py new file mode 100644 index 00000000..fc0ad67d --- /dev/null +++ b/tidalapi/playlog.py @@ -0,0 +1,657 @@ +# 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 library measures each play with a :class:`PlaybackTracker` and refuses implausible +plays, so reports describe real listening. This keeps honest applications from sending +bad data and makes faking plays less convenient, it can't stop someone who changes the +code. + +The event layout and the handling of replies follow TIDAL's open source SDKs on GitHub: +tidal-music/tidal-sdk-android (modules ``eventproducer`` and ``player/events``) and +tidal-music/tidal-sdk-web (module ``event-producer``). 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 collections.abc import Iterable +from dataclasses import asdict, dataclass, field +from enum import StrEnum +from typing import TYPE_CHECKING, Any +from xml.etree import ElementTree + +import requests + +from tidalapi import media +from tidalapi.album import Album +from tidalapi.artist import Artist +from tidalapi.exceptions import ( + AuthenticationError, + TidalAPIError, + http_error_to_tidal_error, +) +from tidalapi.mix import Mix, MixV2 +from tidalapi.playlist import Playlist +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 +#: TIDAL counts a play after this much listening, shorter plays aren't reported +MIN_LISTENED_SECONDS = 30 +#: Plays older than this aren't reported anymore, in milliseconds +MAX_PLAY_AGE_MS = 14 * 24 * 3600 * 1000 +#: Slack for clocks being adjusted during a play, in milliseconds +CLOCK_TOLERANCE_MS = 2000 +#: Slack for track durations being rounded to whole seconds, and for the position +#: advancing ahead of the clock, in seconds +POSITION_TOLERANCE_SECONDS = 2.0 + + +class SourceType(StrEnum): + """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" + + +class ActionType(StrEnum): + """A change during a play: playback stopped (paused) or started (resumed).""" + + playback_start = "PLAYBACK_START" + playback_stop = "PLAYBACK_STOP" + + +@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 finished play of a track, as measured by a :class:`PlaybackTracker`. + + Keep it with :meth:`to_dict` and :meth:`from_dict` to report it later, for example + after playing offline. It goes through the same checks when it's reported. + """ + + track_id: int | str + track_duration: int | None + start_timestamp: int + end_timestamp: int + start_position: float + end_position: float + source_type: SourceType = SourceType.item + source_id: int | str | None = 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())) + event_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 to_dict(self) -> dict[str, Any]: + """The play as JSON-compatible data, for :meth:`from_dict`.""" + data = asdict(self) + data["source_type"] = str(self.source_type) + data["quality"] = str(self.quality) + data["audio_mode"] = str(self.audio_mode) + for action in data["actions"]: + action["action_type"] = str(action["action_type"]) + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PlaybackSession: + """A play kept with :meth:`to_dict`.""" + data = dict(data) + data["source_type"] = SourceType(data["source_type"]) + data["actions"] = [ + PlaybackAction( + action["timestamp"], + action["position"], + ActionType(action["action_type"]), + ) + for action in data["actions"] + ] + return cls(**data) + + def get_problem(self, now: int) -> str | None: + """Why this play can't have happened, if so. + + :param now: The current time, in milliseconds since the epoch + :return: A description of the problem, or None if the play is plausible + """ + if self.end_timestamp < self.start_timestamp: + return "it ends before it starts" + if self.end_timestamp > now + CLOCK_TOLERANCE_MS: + return "it ends in the future" + if now - self.end_timestamp > MAX_PLAY_AGE_MS: + return "it is too old" + + positions = [self.start_position, self.end_position] + positions += [action.position for action in self.actions] + if min(positions) < 0: + return "a position is negative" + if ( + self.track_duration + and max(positions) > self.track_duration + POSITION_TOLERANCE_SECONDS + ): + return "a position is past the end of the track" + + return self._get_timeline_problem() + + def _get_timeline_problem(self) -> str | None: + last_timestamp = self.start_timestamp + playing_from = (self.start_timestamp, self.start_position) + for action in self.actions: + if action.timestamp < last_timestamp - CLOCK_TOLERANCE_MS: + return "its pauses and seeks are out of order" + if action.timestamp > self.end_timestamp + CLOCK_TOLERANCE_MS: + return "a pause or seek comes after its end" + last_timestamp = action.timestamp + + if action.action_type == ActionType.playback_start: + playing_from = (action.timestamp, action.position) + elif playing_from is not None: + if _advances_too_fast(playing_from, action.timestamp, action.position): + return "more was listened to than time passed" + playing_from = None + + if playing_from is not None and _advances_too_fast( + playing_from, self.end_timestamp, self.end_position + ): + return "more was listened to than time passed" + return None + + +def _advances_too_fast( + playing_from: tuple[int, float], timestamp: int, position: float +) -> bool: + start_timestamp, start_position = playing_from + elapsed = (timestamp - start_timestamp) / 1000 + return position - start_position > elapsed + POSITION_TOLERANCE_SECONDS + + +class PlaybackTracker: + """Measures one play of a track, see :meth:`.Session.start_playback`. + + Tell it when the player pauses (also while it's buffering), resumes or seeks, and + stop it when the track stops playing. + """ + + def __init__( + self, + track: media.Track, + source_type: SourceType, + source_id: int | str, + position: float, + paused: bool, + quality: str, + audio_mode: str, + ): + self.track = track + self.source_type = source_type + self.source_id = source_id + self.quality = quality + self.audio_mode = audio_mode + + self.duration = track.duration if track.duration and track.duration > 0 else 0 + self.actions: list[PlaybackAction] = [] + self.start_timestamp: int | None = None + self.start_position = 0.0 + self.listened_seconds = 0.0 + self.play: PlaybackSession | None = None + self.stopped = False + + self._position = self._limit_position(position) + self._resumed_at: float | None = None + if not paused: + self.resume() + + @property + def position(self) -> float: + """The position in the track, in seconds.""" + self._update() + return self._position + + def pause(self) -> None: + """The player paused, or stopped to buffer.""" + if self.stopped or self._resumed_at is None: + return + self._update() + self._resumed_at = None + self._add_action(ActionType.playback_stop, self._position) + + def resume(self) -> None: + """The player started playing. + + A play starts when it first does. + """ + if self.stopped or self._resumed_at is not None: + return + if self.start_timestamp is None: + self.start_timestamp = _now() + self.start_position = self._position + else: + self._add_action(ActionType.playback_start, self._position) + self._resumed_at = time.monotonic() + + def seek(self, position: float) -> None: + """The player moved to another position. + + :param position: The new position in the track, in seconds + """ + if self.stopped: + return + self._update() + position = self._limit_position(position) + if self._resumed_at is not None: + self._add_action(ActionType.playback_stop, self._position) + self._add_action(ActionType.playback_start, position) + self._position = position + + def stop(self) -> PlaybackSession | None: + """The track stopped playing. + + :return: The finished play, to pass to :meth:`.Session.report_playback`, or None + if too little was listened to for TIDAL to count it + """ + if self.stopped: + return self.play + self._update() + self._resumed_at = None + self.stopped = True + + if self.start_timestamp is None or not self._counts(): + return None + self.play = PlaybackSession( + track_id=self.track.id, + track_duration=self.duration or None, + start_timestamp=self.start_timestamp, + end_timestamp=_now(), + start_position=self.start_position, + end_position=self._position, + source_type=self.source_type, + source_id=self.source_id, + actions=self.actions, + quality=self.quality, + audio_mode=self.audio_mode, + ) + return self.play + + def _counts(self) -> bool: + needed = float(MIN_LISTENED_SECONDS) + if self.duration: + needed = min(needed, self.duration - 1) + return self.listened_seconds >= needed + + def _update(self) -> None: + if self._resumed_at is None: + return + now = time.monotonic() + elapsed = now - self._resumed_at + self._resumed_at = now + self.listened_seconds += elapsed + self._position = self._limit_position(self._position + elapsed) + + def _limit_position(self, position: float) -> float: + position = max(0.0, position) + return min(position, self.duration) if self.duration else position + + def _add_action(self, action_type: ActionType, position: float) -> None: + self.actions.append(PlaybackAction(_now(), position, action_type)) + + +@dataclass +class PlaybackReport: + """What happened to the plays given to :meth:`.Session.report_playback`. + + :param accepted: Plays TIDAL took. Don't report them again + :param rejected: Plays that were refused for good, by the library or by TIDAL. They + would be refused again + :param unsent: Plays that couldn't be sent or weren't confirmed. Keep them and try + again later + :param error: What stopped the sending, if an exception did + """ + + accepted: list[PlaybackSession] = field(default_factory=list) + rejected: list[PlaybackSession] = field(default_factory=list) + unsent: list[PlaybackSession] = field(default_factory=list) + error: Exception | None = None + + +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 {} + + +def _now() -> int: + return int(time.time() * 1000) + + +def _get_source(source: Any, track: media.Track) -> tuple[SourceType, int | str]: + if isinstance(source, Album): + return SourceType.album, source.id + if isinstance(source, Playlist): + return SourceType.playlist, source.id + if isinstance(source, (Mix, MixV2)): + return SourceType.mix, source.id + if isinstance(source, Artist): + return SourceType.artist, source.id + return SourceType.item, track.id + + +class PlayLog: + """Measures plays and reports them to TIDAL, see :meth:`.Session.start_playback` and + :meth:`.Session.report_playback`.""" + + def __init__(self, session: Session): + self.session = session + # The quality and audio mode of the streams fetched with this session, by track + self._streams: dict[int | str, tuple[str, str]] = {} + # When the plays this session reported happened, by id + self._reported: dict[str, tuple[int, int]] = {} + + def note_stream(self, track_id: int | str, quality: str, audio_mode: str) -> None: + """Remember that this session fetched a stream for a track.""" + self._streams[track_id] = (quality, audio_mode) + + def start( + self, + track: media.Track, + source: Any, + position: float, + paused: bool, + local_copy: bool, + ) -> PlaybackTracker: + """See :meth:`.Session.start_playback`.""" + stream = self._streams.get(track.id) + if stream is None and not local_copy: + raise ValueError( + f"This session didn't fetch a stream for track {track.id}, and it's " + "not playing from a local copy" + ) + quality, audio_mode = stream or ( + media.Quality.high_lossless, + media.AudioMode.stereo, + ) + source_type, source_id = _get_source(source, track) + return PlaybackTracker( + track, source_type, source_id, position, paused, quality, audio_mode + ) + + def report( + self, plays: Iterable[PlaybackSession], app_version: str + ) -> PlaybackReport: + """See :meth:`.Session.report_playback`.""" + report = PlaybackReport() + now = _now() + to_send: list[PlaybackSession] = [] + for play in plays: + problem = self._get_problem(play, to_send, now) + if problem: + log.warning("Not reporting play of %s: %s", play.track_id, problem) + report.rejected.append(play) + else: + to_send.append(play) + + for start in range(0, len(to_send), MAX_BATCH_SIZE): + batch = to_send[start : start + MAX_BATCH_SIZE] + # Like TIDAL's SDKs, stop at the first batch that doesn't go through + if report.unsent: + report.unsent += batch + continue + try: + accepted, rejected, unsent = self._post(batch, app_version) + except (requests.RequestException, TidalAPIError) as e: + log.info("Couldn't report plays to TIDAL: %s", e) + report.error = e + report.unsent += batch + continue + for play in accepted: + self._reported[play.playback_session_id] = ( + play.start_timestamp, + play.end_timestamp, + ) + report.accepted += accepted + report.rejected += rejected + report.unsent += unsent + return report + + def _get_problem( + self, play: PlaybackSession, earlier: list[PlaybackSession], now: int + ) -> str | None: + problem = play.get_problem(now) + if problem: + return problem + earlier_ids = {other.playback_session_id for other in earlier} + if ( + play.playback_session_id in self._reported + or play.playback_session_id in earlier_ids + ): + return "it was reported already" + + times = list(self._reported.values()) + times += [(other.start_timestamp, other.end_timestamp) for other in earlier] + for start, end in times: + if ( + play.start_timestamp < end - CLOCK_TOLERANCE_MS + and start < play.end_timestamp - CLOCK_TOLERANCE_MS + ): + return "it overlaps another play" + return None + + def _post( + self, plays: list[PlaybackSession], app_version: str + ) -> tuple[list[PlaybackSession], list[PlaybackSession], list[PlaybackSession]]: + """Send one batch. + + :return: The plays TIDAL accepted, rejected, and those to send again + :raises: :class:`requests.RequestException` or a :class:`.TidalAPIError` if the + batch couldn't be sent, which TIDAL's SDKs retry whatever the error + """ + 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(plays, app_version) + if response.status_code == 401 and requests_._refresh_access_token( + sent_access_token + ): + response = self._send(plays, app_version) + + try: + response.raise_for_status() + except requests.HTTPError as e: + if err := http_error_to_tidal_error(e): + raise err from e + raise + return self._read_reply(plays, response.text) + + def _read_reply( + self, plays: list[PlaybackSession], reply: str + ) -> tuple[list[PlaybackSession], list[PlaybackSession], list[PlaybackSession]]: + """Sort a batch by the reply. + + As in TIDAL's SDKs a play is only done when the reply lists it, as sent or as + malformed (``SenderFault``). + + Anything else answering with a success, like a Wi-Fi login page, confirms + nothing. + """ + accepted_ids: set[str | None] = set() + rejected_ids: set[str | None] = set() + try: + root = ElementTree.fromstring(reply) + except ElementTree.ParseError: + log.info("Couldn't read TIDAL's reply: %s", reply[:500]) + else: + for entry in root.iterfind(".//{*}SendMessageBatchResultEntry"): + accepted_ids.add(entry.findtext("{*}Id")) + for entry in root.iterfind(".//{*}BatchResultErrorEntry"): + if entry.findtext("{*}SenderFault") == "true": + log.warning("TIDAL rejected a reported play: %s", reply[:500]) + rejected_ids.add(entry.findtext("{*}Id")) + + accepted = [play for play in plays if play.event_id in accepted_ids] + rejected = [play for play in plays if play.event_id in rejected_ids] + done = accepted_ids | rejected_ids + return accepted, rejected, [p for p in plays if p.event_id not in done] + + def _send( + self, plays: list[PlaybackSession], app_version: str + ) -> requests.Response: + form = self.get_form(plays, app_version) + return self.session.request._send( + "POST", self.session.config.event_producer_location, {}, form, {} + ) + + def get_form( + self, plays: list[PlaybackSession], app_version: str + ) -> dict[str, str]: + """The request body for a batch of plays: an SQS ``SendMessageBatch`` form, with + one ``playback_session`` event per play. + + :param plays: 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(_now()) + + form = {} + for index, play in enumerate(plays, 1): + body = { + "group": "play_log", + "version": 2, + "ts": play.end_timestamp, + "uuid": play.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": play.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"] = play.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 71050fb4..bcc6b224 100644 --- a/tidalapi/session.py +++ b/tidalapi/session.py @@ -27,7 +27,7 @@ import random import time import uuid -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass from enum import Enum from pathlib import Path @@ -52,7 +52,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 @@ -112,6 +112,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" @@ -243,6 +245,7 @@ def __init__(self, config: Config | None = 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 @@ -896,6 +899,69 @@ def search( return result + def start_playback( + self, + track: media.Track, + source: Any = None, + position: float = 0.0, + paused: bool = False, + local_copy: bool = False, + ) -> playlog.PlaybackTracker: + """Start measuring a play of a track, to report it to TIDAL afterwards. + + Example:: + + stream = track.get_stream() + tracker = session.start_playback(track, source=album) + # When the player does: tracker.pause(), tracker.resume(), tracker.seek(90) + play = tracker.stop() + if play: + report = session.report_playback([play], app_version="My Player 1.0") + + :param track: The track that started playing. This session must have fetched a + stream for it + :param source: The :class:`.Album`, :class:`.Playlist`, :class:`.Mix` or + :class:`.Artist` it's played from, if any. Recently played lists the source + :param position: The position it starts from, in seconds + :param paused: The player isn't playing yet. The play starts at the first + :meth:`~tidalapi.playlog.PlaybackTracker.resume` + :param local_copy: It plays from a copy kept earlier (e.g. offline), so this + session may not have fetched a stream for it + :return: A :class:`~tidalapi.playlog.PlaybackTracker` + :raises: :class:`ValueError` if this session didn't fetch a stream for the track + """ + return self.playlog.start(track, source, position, paused, local_copy) + + def report_playback( + self, + plays: Iterable[playlog.PlaybackSession], + app_version: str | None = None, + ) -> playlog.PlaybackReport: + """Report finished 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. Measure each + play with :meth:`start_playback` and report it once it ends. + + Plays that can't have happened are refused: ones that end before they start, in + the future or too long ago, with a position past the end of the track, with more + listening than time passed, overlapping another play of this session, or that + this session reported already. + + :param plays: 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: A :class:`~tidalapi.playlog.PlaybackReport` saying which plays were + accepted, which were rejected for good, and which to try again later. Being + offline or failing to reach TIDAL doesn't raise, the plays are left unsent + """ + if app_version is None: + from tidalapi import __version__ + + app_version = f"tidalapi {__version__}" + return self.playlog.report(plays, 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: