A TidaLuna plugin that repairs Last.fm scrobbling in the TIDAL desktop client.
If you play an album or a queue straight through, TIDAL's built-in Last.fm integration scrobbles almost nothing. This plugin takes scrobbling over and measures play time correctly.
Add this URL under Luna Settings → Plugin Store, then install ScrobbleFix from the store card that appears:
https://github.com/ZakME/ScrobbleFix/releases/download/latest/store.json
Open Luna Settings → ScrobbleFix and press Connect. If TIDAL is already linked to Last.fm this adopts that session immediately; otherwise it opens Last.fm in your browser to authorise. More detail below.
TIDAL's own scrobbler is a redux saga that runs on playbackControls/ENDED and applies Last.fm's rule "scrobble once half a track, or four minutes, has been played". It reads the play time from state.accumulatedPlaybackTime.storedTimes[productId].
That store never sees most of your listening. Its reducer only banks elapsed wall-clock time in three situations, and none of them is "audio is playing":
playbackControls/SET_PLAYBACK_STATEto any state other thanPLAYING(pause, stall, stop)playbackControls/SEEKplaybackControls/ENDED
Each of those adds now - lastPlayingTimestamp. The problem is what resets that clock. On playbackControls/MEDIA_PRODUCT_TRANSITION the reducer sets lastPlayingTimestamp = now whenever playback is already PLAYING — and for a gapless or crossfaded queue transition, TIDAL's player starts the next track (dispatching MEDIA_PRODUCT_TRANSITION for track B) before the outgoing element's ended handler dispatches ENDED for track A.
So for a track played from start to finish without touching the controls:
MEDIA_PRODUCT_TRANSITION(track B) →lastPlayingTimestamp = now, clock resetENDED(track A) →storedTimes[A] += now - lastPlayingTimestamp≈ 0 ms- The scrobble saga divides duration by ~0 seconds played, fails the 50% / 4 minute check, and returns
Two supporting bugs make it worse. The saga reads the track length from state.playbackControls.playbackContext.actualDuration, which by then already describes the next track, so even the threshold arithmetic and the duration sent to Last.fm are about the wrong song. And the SEEK branch never resets lastPlayingTimestamp, so seeking double-counts time.
The visible symptom matches exactly: continuous playback never scrobbles, while pausing part-way through a track sometimes does — because pausing is one of the few things that actually banks time.
- Derives play time from reported playback position (
playbackControls/TIME_UPDATE) rather than wall-clock timestamps, so pauses, stalls, seeks and queue transitions are all handled without depending on the order redux actions arrive in. - Scrobbles once the real threshold is met: half the track, or four minutes, whichever comes first, and never for tracks under 30 seconds.
- Skips videos and TIDAL's "Advertisement" placeholders, matching what TIDAL's own scrobbler does.
- Sends
timestampas the moment the track started, anddurationfor the track that actually played. - Sends
track.updateNowPlayingwhen a track genuinely starts producing audio. - Queues scrobbles that fail to send (offline, Last.fm outage) and retries them automatically. Last.fm accepts backdated scrobbles, so nothing is lost.
- Reuses the Last.fm session TIDAL already holds, so if TIDAL is linked to Last.fm you do not need to authorise anything again.
- Keeps TIDAL's built-in scrobbler switched off, because when its accounting does fire it submits the same track a second time with a different timestamp.
No Last.fm API key of your own is needed; the plugin looks up the credentials the TIDAL client already ships with, at runtime, rather than hardcoding them.
While suppression is on, TIDAL's Settings → Last.fm page will show as not connected. That is expected: the plugin clears TIDAL's connection flag to stop its saga. Your stored session key is left untouched, so turning suppression off in the plugin settings hands scrobbling straight back to TIDAL.
- Install TidaLuna.
- In TIDAL, open Luna Settings → Plugin Store and add this store URL:
https://github.com/ZakME/ScrobbleFix/releases/download/latest/store.json
- Install ScrobbleFix from the store card that appears.
- Open Luna Settings → ScrobbleFix and press Connect. If TIDAL is already linked to Last.fm this adopts that session immediately; otherwise it opens Last.fm in your browser to authorise.
Then play a track past its halfway point. The Diagnostics panel shows what is being measured in real time, so you can watch the heard-time climb and the scrobble fire.
Requires Node 24 and pnpm (corepack enable).
pnpm install
pnpm run watchwatch builds to ./dist and serves it on port 3000. TidaLuna looks for a dev store on that exact port, so a [DEV] store card appears under Luna Settings → Plugin Store; install ScrobbleFix from there. Enable Live reload on the plugin and it picks up each rebuild within a second.
| Setting | Default | Purpose |
|---|---|---|
| Scrobble tracks | on | Submit listens once the threshold is reached. |
| Update now playing | on | Show the current track on your profile. |
| Suppress TIDAL's built-in scrobbler | on | Prevents duplicate scrobbles. |
| Look up MusicBrainz IDs | off | Better matching for obscure releases, one extra request per track. |
| Verbose logging | off | Records every position sample in the event log. |
- Your Last.fm session key never leaves your machine, except in requests to Last.fm itself. It is not sent anywhere else and does not appear in the event log or in the text produced by Copy diagnostics, which is written to be safe to paste into a public bug report. That text contains no account name and no session key.
- The session key is stored in TidaLuna's plugin storage in plain text, in the same IndexedDB that TIDAL uses for its own copy. Any other TidaLuna plugin you install can read it, so treat installing plugins as a trust decision. Encrypting it locally would not help, since the plugin would need to keep the decryption key next to it.
- Signing out from the plugin settings deletes the stored key. It does not touch the copy TIDAL keeps. To revoke access entirely, remove the application from your Last.fm applications page.
- The authorisation link is only placed on your clipboard if a browser could not be opened. That link embeds a one-time request token which, before you approve it, is enough for someone else to obtain a session key on your account — because the exchange is signed with TIDAL's API secret, which ships publicly in the client. Do not share the link.
- No API key of your own is needed. The plugin reads the Last.fm API credentials that the TIDAL client already ships with, at runtime rather than hardcoded, so nothing sensitive lives in this repository and a credential rotation on TIDAL's side does not require a plugin update. The trade-off is that scrobbles are attributed to TIDAL's registered application rather than to a ScrobbleFix one.
docs/tidal-scrobbling.md documents the bug and the correct
implementation independently of TidaLuna: how to measure play time without depending on event
ordering, Last.fm's thresholds and signing rules, the error codes worth retrying, and the traps
(casting session ids, TIME_UPDATE not being a position ticker). Useful if you are writing your own
TIDAL client rather than a plugin.
tools/ holds the scripts used to reverse-engineer the bug from TIDAL's shipped bundle: fetch-tidal-assets.mjs downloads every chunk of the web player, and grep-assets.mjs, find-defs.mjs and resolve-import.mjs search it and map minified identifiers back to their source modules. Every claim in The bug in TIDAL was checked against the shipped code with these.
tools/md5.test.mjs checks the bundled MD5 (needed for Last.fm request signing, and absent from SubtleCrypto) against Node's crypto. tools/check-bundle.mjs parses the built plugin and reports the host modules it expects, since the bundle itself can only run inside TIDAL.
pnpm test
pnpm run typecheck
node --experimental-vm-modules tools/check-bundle.mjstypecheck reports only first-party diagnostics. The luna dependency is consumed as TypeScript source and its own dependencies (@mui, musicbrainz-api, ...) are not installed here, so it produces errors that cannot be fixed from this repo; tools/typecheck.mjs filters those out and fails on anything under plugins/ or tools/.