Skip to content

Add Session.start_playback and report_playback to report plays to TIDAL - #415

Open
Petru101 wants to merge 1 commit into
EbbLabs:mainfrom
Petru101:feature/playlog
Open

Petru101 wants to merge 1 commit into
EbbLabs:mainfrom
Petru101:feature/playlog

Conversation

@Petru101

@Petru101 Petru101 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #401.

Tracks played through tidalapi never show up in Recently played, and don't count in the listening history TIDAL uses for recommendations. Fetching and playing a stream isn't enough: the official players report every play as a playback_session event to TIDAL's event producer (https://ec.tidal.com/api/event-batch). The attempt in #401 sent the web player's events to tidal.com/api/event-batch, which TIDAL accepts but doesn't show anywhere.

This adds Session.start_playback(), Session.report_playback() and a tidalapi.playlog module. The library measures each play and builds the same events as TIDAL's own players. The layout comes from TIDAL's open source SDK, tidal-sdk-android: the eventproducer module (an SQS SendMessageBatch form with a Headers attribute) and the player/events models (PlaybackSession, AudioPlaybackSession, User, Client). The user and client fields come from the access token's claims, as the SDK does.

Apart from the client id of the OAuth client the session logged in with, the events describe the real client: the operating system and version from platform, and an application version the caller passes in (defaults to the tidalapi version). They don't add device details. Events in this format showed up in Recently played within 20 seconds in my tests.

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()  # None if under 30 seconds, TIDAL counts a play after that
if play:
    report = session.report_playback([play], app_version="My Player 1.0")
    # report.accepted, report.rejected, report.unsent
  • Each play has a source: the album, playlist, mix or artist it was played from, or ITEM with the track's own id for a track played on its own. Recently played lists the source.
  • Only tracks the session fetched a stream for can be tracked, unless they play from a local copy (local_copy=True), for applications with an offline cache.
  • report_playback refuses plays that can't have happened: ending before they start, in the future or over 14 days ago, with a position past the end of the track, with more listening than time passed, overlapping another play of the session, or already reported.
  • Up to 10 plays are sent per request, and replies are handled as in TIDAL's SDKs (web, Android): a play is done only when the reply lists it as sent or as a sender fault. After any other outcome (offline, an HTTP error, a reply that doesn't confirm it) it stays unsent, so the application can keep it with to_dict() and try again later with from_dict(). Retries reuse the same ids. It uses the session's token refresh and backoff.

The tests in tests/test_playlog.py run offline and are added to the test workflow. There are also docs in api.rst and an entry in HISTORY.rst.

High Tide, which uses tidalapi, already reports plays with these events in a local build, and a test build reported real listening through this tracker: TIDAL confirmed every play by id.

🤖 Generated with Claude Code

@Petru101

Copy link
Copy Markdown
Contributor Author

A note on why I think this matters beyond Recently played: TIDAL's own SDK puts the play log in its NECESSARY consent category (streaming metrics are PERFORMANCE), and every official player sends it for every play. Without it, plays from tidalapi-based players may not count the way TIDAL's own players' plays do, possibly including what artists get paid for them. I can't confirm how TIDAL uses it, but it seems worth getting right.

Two things you may want to weigh: the session's OAuth client is TIDAL's Android app, while the events describe the real client (OS, app version), so they don't fully match. And since report_playback takes any timestamps, it could be misused to fake plays. Happy to change it if you'd prefer a different shape, e.g. only reporting tracks the session actually fetched a stream for.

@tehkillerbee

Copy link
Copy Markdown
Collaborator

Thanks for the PR and you make a good point regarding the misuse risk. I'm holding this PR out of v0.9.0 for now, mainly because report_playback accepts any timestamps and track IDs, which makes faking plays too easy.

Some suggestions for improvements:

  • The library measures each play, e.g. tracker = session.start_playback(track) with tracker.pause() and tracker.stop(), instead of taking timestamps from the caller. Offline queuing can still work if the measured play is kept for later.
  • Implausible plays are rejected, e.g.: end before start, timestamps in the future or too old, a position past the end of the track, or listening time longer than elapsed time.
  • Plays in a session can't overlap, and a play ID can't be reported twice.
  • Only tracks the session fetched a stream for can be reported.
  • Also, it might not be the best idea that we use the Android client ID alongside events that describe a different client. But not sure what's the best way forward...

Potential bugs:

  • _post treats every failure other than 401, 403 and 5xx as a permanent rejection. Does that match how TIDAL's SDK handles responses from this endpoint, or are some of those failures temporary and worth retrying? E.g. can we get a 429 (Rate limit)?
  • If a later batch fails, the exception doesn't say which batches were accepted, so retrying can report plays twice.
  • The playlog.py docstring has a broken line around the SDK URL.

By the way, main has changed a bit since you opened this PR. Soon it will require Python 3.11+ and runs ruff's UP rules in make lint, so a rebase will hit a conflict in the session.py imports and will require some typing updates in playlog.py.

ruff check --fix handles most of those, but Optional[str] in report_playback needs changing by hand to str | None.

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 <noreply@anthropic.com>
@Petru101

Petru101 commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! I've reworked it along those lines and rebased it on main (Python 3.11+, make lint passes).

Measuring. session.start_playback(track, source=album) returns a tracker with pause(), resume(), seek() and stop(). stop() gives the finished play, or None if it was under 30 seconds, and session.report_playback([play]) sends it. Timestamps and positions now come from the library, not the caller. To report later, a play can be saved with to_dict() and restored with from_dict().

Checks. report_playback refuses plays that end before they start, end in the future, are over 14 days old, have a position past the end of the track, have more listening than time passed, overlap another play of the session, or were already reported. There's a little slack because track lengths are rounded to whole seconds and clocks get adjusted. These run again on restored plays. They keep honest apps from sending bad data and make faking less convenient, but someone can always change the code or post to the address directly, so I don't want to oversell them.

Your bug questions.

  • Failures: I don't know if this address ever sends a 429, but it doesn't change the answer. TIDAL's SDKs (web, Android) keep an event after any HTTP error, and only forget it when the reply lists it as sent or marks it as a sender fault. It now works the same way.
  • That also fixed one you didn't list: any 200 without an error marker counted as accepted, so a Wi-Fi login page lost the play. A play now has to be confirmed by id.
  • Batches: report_playback no longer raises partway. It returns which plays were accepted, rejected and unsent, so an application only retries the unsent ones, and retries reuse the same ids.
  • The docstring: docformatter was moving the URL onto its own line, so I took the URLs out of it.

Two things I'd like your call on.

  1. Only tracks with a fetched stream: start_playback raises if the session didn't fetch a stream for the track, but there's a local_copy=True option. Without it, real plays would be lost, because apps with a local cache (High Tide is one) can play offline with no stream fetched. It's also an easy way around the check, since the caller just declares it. A saved play isn't checked against this again either, because a new session won't have fetched its stream. If you'd rather be strict, removing the option is a small change, but offline plays then can't be reported.
  2. The Android client ID: I don't think this PR can solve it. All of tidalapi logs in as that client, and the events just follow whichever client the session uses, so an application with its own client ID gets matching events automatically. I wouldn't make the events claim to be Android, as that only hides the mismatch. The real fix would be a client ID TIDAL approves for this, and I don't know if that's possible. Do you know if anyone has asked them?

The tests here are offline, but I also ran this code against TIDAL: a test build of High Tide reported real listening through the tracker. TIDAL confirmed every play by id, including ones with a pause and seeks, and the tracker's position stayed within about 0.1 seconds of the player's.

@Petru101 Petru101 changed the title Add Session.report_playback to report plays to TIDAL Add Session.start_playback and report_playback to report plays to TIDAL Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Playback logging endpoint?

2 participants