← API reference

Listens

The scrobble history — read, edit, delete, search.

14 of 14 · v0.120.0
GET /api/v1/scrobbles #
Session or Token read

The listen history.

Scope: read (since v0.39). Newest first. Filterable.

Parameters

limit query

Page size. 50 by default, at most 500.

integer · int64
offset query

Rows to skip.

integer · int64
artist query

Exact artist, as stored.

string
album query

Exact album, as stored.

string
track query

Exact track title, as stored.

string
after query

Unix seconds, inclusive.

integer · int64
before query

Unix seconds, inclusive.

integer · int64
social query

true for listening marked as heard in company, false for the rest.

Absent means no filter, which is the only sensible default: a listen nobody marked is unmarked, not proven solitary, and quietly excluding unmarked listening from a history page would hide most of it.

boolean

Responses

200

OK.

application/json

object

A page of the history, newest first.

scrobbles array required
each item
object

A stored listen. The quality columns are flat, not nested under a quality object — the frontend reads them raw so its own qualityLabel() produces the same tag everywhere, and a server-side copy of that logic would drift.

The audio fields are null for most of any real history: they are only ever written by ingest and the session-shaped sources, so an imported listen carries none of them. Any share computed over them is a share of that population, not of the listening.

id integer · int64 required
user_id integer · int64 required
title string required
artist string required

The primary artist. Everyone credited lives in scrobble_artists and is what the artist pages and charts aggregate over, so a listen crediting a soloist appears under both names there while this field still names the one the record is filed under.

album string | null
timestamp integer · int64 required

Unix seconds, UTC, start of play.

duration integer | null · int64

Seconds.

source_id string required

Dedup key. Unique per user per play.

source_name string required

Plex, Jellyfin, Navidrome, ingest:<client>, Import…

status string required

pending is queued for forwarding, synced has been forwarded, and imported is stored only and will never be forwarded. Both an import and a skip land as imported — forwarding either would put a wrong scrobble on a permanent public record.

There is no sent and no failed. A delivery that fails stays pending and is retried, and which sinks it has already reached is in delivered_sinks — that is what stops a partial failure double-scrobbling.

mbid_recording string | null
mbid_release string | null
mbid_artist string | null

A JSON-encoded array, not an array — the column stores what was written to it. Parse it before use.

mbid_release_group string | null

The release group, which is what relates a reissue, a remaster and a regional edition to one another. Filled from a submitting client's additional_info, a ListenBrainz export's mbid_mapping, or a Jellyfin/Emby session — never by a lookup.

mbid_release_track string | null

The track on a particular release, as distinct from the recording: the same recording is a different release-track on an album, a single and a compilation. Only a tagging client ever knows it.

mbid_work string | null

The composition rather than a performance of it — the identifier that relates every recording of one piece. Only a tagging client ever knows it.

mbid_album_artist string | null

A JSON-encoded array, like mbid_artist. Whoever the record is credited to, which on a compilation or a soundtrack is not the performer of the track.

caa_id integer | null · int64

Cover Art Archive id. -1 is a tombstone meaning "asked, no artwork exists" — treat any non-positive value as no artwork.

caa_release_mbid string | null
isrc string | null
format_type string | null

pcm, dsd, mqa.

codec string | null
bitrate integer | null · int32

kbps.

sample_rate integer | null · int32

Hz.

bit_depth integer | null · int32
channels integer | null · int32
container string | null
source_quality string | null
is_lossless boolean | null
dsd_rate integer | null · int64

Hz.

dsd_multiplier integer | null · int32
dsd_to_pcm_converted boolean | null
delivery_codec string | null

Set only on a genuine transcode.

delivery_bitrate integer | null · int32

kbps.

delivery_sample_rate integer | null · int32

Hz.

delivery_bit_depth integer | null · int32
is_transcoded boolean | null
transcode_reason string | null
quality_score number | null · double

0–100, computed once at ingest and never recomputed. Null for anything imported, so an average over it has the same narrow population as the audio fields.

device_id integer | null · int64
chain_id integer | null · int64

The resolved signal chain.

chain_variant_id integer | null · int64

Which setup of that chain was fitted.

session_id integer | null · int64
listening_context string | null

From the resolved chain.

submission_client string | null
track_number integer | null · int32
artwork_url string | null

Empty string is a tombstone, same idea as caa_id: -1.

source_ref integer | null · int64

The user_sources row this was polled from.

listened_ms integer | null · int64

Milliseconds actually heard, paused time excluded. Only a pushing client can report this, so it is null for everything polled and everything imported. What skipped cannot express — "cut short three seconds in" and "cut short with thirty seconds left" are opposite behaviours.

is_shuffle boolean | null

Shuffle chose this, not the listener.

queue_source string | null

What it played from, in the client's own words.

company string | null

Who this was heard with, as names. Filled automatically for a listen that happened inside a Shared Spool, and by hand via POST /api/v1/scrobbles/bulk-company otherwise.

null means unmarked, not alone. Nobody recorded who was there — which is true of every listen predating the field. Counting marked listens is exact; reporting a share of listening that was solitary is not, and would be a fact about how much has been marked.

joint_session_id string | null

The Shared Spool this listen belonged to, as the spool's uuid. A joint listen is N rows across N decks sharing this — never one row with several owners. Only a sitting ever sets it.

delivered_sinks string | null

JSON array of the sinks this has already reached, so a retry only hits the ones that have not. A string, not an array.

count integer required

Rows in this page, not in the history.

limit integer · int64 required
offset integer · int64 required
401

No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
403

Authenticated, but not permitted. Either the token lacks the required scope, or the endpoint needs the admin role. Deliberately not a 401 — re-authenticating will not help.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 403,
  "error": "This token does not have the 'write' scope"
}
DELETE /api/v1/scrobbles #
Session

Delete the caller's entire history.

Session-only and irreversible. Deliberately not reachable with a write token — this is the one listen operation where a lost phone would be unrecoverable. Clears listen-attached notes first, since foreign keys are enforced.

Parameters

confirm query

Must be true. Anything else is a 400 and nothing is touched.

boolean

Responses

204

Cleared.

400

confirm=true was not sent. Nothing was touched.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

401

No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
PATCH /api/v1/scrobbles/{id} #
Token writeor Session

Edit a listen.

Scope: write (since v0.39). Only the caller's own rows.

Parameters

id path required
integer · int64

Request body required

application/json

object

Every field optional; only the fields sent change.

title string | null
artist string | null
album string | null
mbid_recording string | null
mbid_release string | null

Fixing this also clears the resolved artwork, so the cover is re-fetched for the release you actually meant.

signal_chain_id integer | null · int64

Not chain_id. The chain to attribute this listen to.

chain_variant_id integer | null · int64

Which setup of that chain was fitted. Only accepted alongside signal_chain_id, and must belong to that chain.

listening_context string | null
format_type string | null
codec string | null

Responses

200

Updated. The fresh row, so a client can re-render without a refetch.

application/json

An edited listen: the fresh row, or — when it could not be read back — just its id.

one of
option 1 object

A stored listen. The quality columns are flat, not nested under a quality object — the frontend reads them raw so its own qualityLabel() produces the same tag everywhere, and a server-side copy of that logic would drift.

The audio fields are null for most of any real history: they are only ever written by ingest and the session-shaped sources, so an imported listen carries none of them. Any share computed over them is a share of that population, not of the listening.

id integer · int64 required
user_id integer · int64 required
title string required
artist string required

The primary artist. Everyone credited lives in scrobble_artists and is what the artist pages and charts aggregate over, so a listen crediting a soloist appears under both names there while this field still names the one the record is filed under.

album string | null
timestamp integer · int64 required

Unix seconds, UTC, start of play.

duration integer | null · int64

Seconds.

source_id string required

Dedup key. Unique per user per play.

source_name string required

Plex, Jellyfin, Navidrome, ingest:<client>, Import…

status string required

pending is queued for forwarding, synced has been forwarded, and imported is stored only and will never be forwarded. Both an import and a skip land as imported — forwarding either would put a wrong scrobble on a permanent public record.

There is no sent and no failed. A delivery that fails stays pending and is retried, and which sinks it has already reached is in delivered_sinks — that is what stops a partial failure double-scrobbling.

mbid_recording string | null
mbid_release string | null
mbid_artist string | null

A JSON-encoded array, not an array — the column stores what was written to it. Parse it before use.

mbid_release_group string | null

The release group, which is what relates a reissue, a remaster and a regional edition to one another. Filled from a submitting client's additional_info, a ListenBrainz export's mbid_mapping, or a Jellyfin/Emby session — never by a lookup.

mbid_release_track string | null

The track on a particular release, as distinct from the recording: the same recording is a different release-track on an album, a single and a compilation. Only a tagging client ever knows it.

mbid_work string | null

The composition rather than a performance of it — the identifier that relates every recording of one piece. Only a tagging client ever knows it.

mbid_album_artist string | null

A JSON-encoded array, like mbid_artist. Whoever the record is credited to, which on a compilation or a soundtrack is not the performer of the track.

caa_id integer | null · int64

Cover Art Archive id. -1 is a tombstone meaning "asked, no artwork exists" — treat any non-positive value as no artwork.

caa_release_mbid string | null
isrc string | null
format_type string | null

pcm, dsd, mqa.

codec string | null
bitrate integer | null · int32

kbps.

sample_rate integer | null · int32

Hz.

bit_depth integer | null · int32
channels integer | null · int32
container string | null
source_quality string | null
is_lossless boolean | null
dsd_rate integer | null · int64

Hz.

dsd_multiplier integer | null · int32
dsd_to_pcm_converted boolean | null
delivery_codec string | null

Set only on a genuine transcode.

delivery_bitrate integer | null · int32

kbps.

delivery_sample_rate integer | null · int32

Hz.

delivery_bit_depth integer | null · int32
is_transcoded boolean | null
transcode_reason string | null
quality_score number | null · double

0–100, computed once at ingest and never recomputed. Null for anything imported, so an average over it has the same narrow population as the audio fields.

device_id integer | null · int64
chain_id integer | null · int64

The resolved signal chain.

chain_variant_id integer | null · int64

Which setup of that chain was fitted.

session_id integer | null · int64
listening_context string | null

From the resolved chain.

submission_client string | null
track_number integer | null · int32
artwork_url string | null

Empty string is a tombstone, same idea as caa_id: -1.

source_ref integer | null · int64

The user_sources row this was polled from.

listened_ms integer | null · int64

Milliseconds actually heard, paused time excluded. Only a pushing client can report this, so it is null for everything polled and everything imported. What skipped cannot express — "cut short three seconds in" and "cut short with thirty seconds left" are opposite behaviours.

is_shuffle boolean | null

Shuffle chose this, not the listener.

queue_source string | null

What it played from, in the client's own words.

company string | null

Who this was heard with, as names. Filled automatically for a listen that happened inside a Shared Spool, and by hand via POST /api/v1/scrobbles/bulk-company otherwise.

null means unmarked, not alone. Nobody recorded who was there — which is true of every listen predating the field. Counting marked listens is exact; reporting a share of listening that was solitary is not, and would be a fact about how much has been marked.

joint_session_id string | null

The Shared Spool this listen belonged to, as the spool's uuid. A joint listen is N rows across N decks sharing this — never one row with several owners. Only a sitting ever sets it.

delivered_sinks string | null

JSON array of the sinks this has already reached, so a retry only hits the ones that have not. A string, not an array.

option 2 object

The fallback answer to an edit whose fresh row could not be read back.

id integer · int64 required
status string required

Always updated.

400

A variant without a chain, or one belonging to another chain.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

401

No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
403

Authenticated, but not permitted. Either the token lacks the required scope, or the endpoint needs the admin role. Deliberately not a 401 — re-authenticating will not help.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 403,
  "error": "This token does not have the 'write' scope"
}
404

No such resource, or it belongs to another user.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

DELETE /api/v1/scrobbles/{id} #
Token writeor Session

Delete one listen.

Scope: write (since v0.39). Clears attached notes first.

Parameters

id path required
integer · int64

Responses

204

Deleted.

401

No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
403

Authenticated, but not permitted. Either the token lacks the required scope, or the endpoint needs the admin role. Deliberately not a 401 — re-authenticating will not help.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 403,
  "error": "This token does not have the 'write' scope"
}
404

No such resource, or it belongs to another user.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

POST /api/v1/scrobbles/bulk-time #
Session

Move listens in time.

An offset over a selection, not an absolute time, and that is the design rather than a limitation. A timestamp is almost never wrong on its own: a clock was out by an hour, an evening was reconstructed from times laid out by arithmetic, or a capture arrived carrying somebody else's clock. Every one of those moves a run of listens by a constant, and a constant cannot scramble an ordering the way a set of hand-typed times can. Correcting a single listen is this operation over one id — subtract to get the offset.

Where the listen has not been accepted yet, PATCH /api/v1/patch/captures/{id} is the cheaper place to do it: nothing is in a history there, so none of the consequences below apply.

What it takes care of:

  • timestamp_original is written once, so a second correction does not overwrite the observation the first one replaced.
  • Nothing is moved on top of another listen of the same track. Dedup runs at ingest and never again, so a duplicate created here is one nothing will ever reconcile. Such a listen is skipped and is not counted in moved.
  • Sessions are recomputed — their aggregates, not their grouping.

What it reports, because it cannot fix them:

  • already_forwarded — how many of the moved listens had already gone to Last.fm or ListenBrainz. Neither has an edit API, so their copy keeps the time it was sent with, permanently and silently. The edit is still right; a screen that did not say this would let somebody believe they had corrected a public record they had not.
  • grouping_stale — whether a moved listen now sits further from its session's neighbours than the gap those sessions were grouped by. Sessions are stored rather than computed, which is what lets a manual merge survive, and sessions.uuid is what a handed-over sitting points at — so re-grouping silently on a time edit would move an identity somebody else holds a reference to. Rebuild stays something the user asks for.

Request body required

application/json

object

The body of POST /api/v1/scrobbles/bulk-time.

ids array required

The listens to move. Required and non-empty; anything not yours is skipped.

each item
integer · int64
offset_seconds integer | null · int64 required

Seconds to move these listens by. Negative moves them earlier. Required, not zero, and at most a year either way.

An offset rather than an absolute time, and that is the design. A timestamp is almost never wrong on its own: a clock was out by an hour, an evening was reconstructed from times laid out by arithmetic, or a capture arrived carrying somebody else's clock. Every one of those moves a run of listens by a constant, and a constant cannot scramble an ordering the way a set of hand-typed times can. Correcting a single listen is this operation over one id — the caller subtracts.

Responses

200

What moved, and what could not be reached.

application/json

object

What moving a listen in time actually did, and what it could not do.

Three of these four fields exist because a timestamp edit has consequences this application cannot reach, and saying so is the whole difference between a correction and a quiet inconsistency.

moved integer · int64 required

Listens whose timestamp moved.

already_forwarded integer · int64 required

Of those, how many had already been forwarded to somebody's Last.fm or ListenBrainz.

Neither service has an edit API, so their copy keeps the time it was sent with, permanently and silently. The edit is still the right thing to do — the local record is the one that has to be true — but a screen that did not say this would be letting somebody believe they had corrected a public record they had not.

sessions_touched integer · int64 required

Sessions whose aggregates were recomputed to match.

grouping_stale boolean required

Whether a moved listen now sits far enough from its session's neighbours that the grouping is stale.

The aggregates are fixed here; the grouping is not, and deliberately. Sessions are stored rather than computed (which is what lets a manual merge survive), sessions.uuid is what a handed-over sitting points at, and re-grouping silently on a metadata edit would move an identity somebody else is holding a reference to. So this reports, and Rebuild remains a thing the user asks for.

400

No ids, no offset, a zero offset, or one of more than a year.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

401

No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
POST /api/v1/scrobbles/bulk-company #
Session

Mark many listens as heard in company.

Session-only. Records who you were listening with, on the listens themselves.

Marking is retroactive by design: nobody stops mid-record to note who is in the room. A Shared Spool fills the same column in automatically from its joined members; this is for the commoner case of somebody in the room who has no account on this instance, which is why it is free text rather than a list of users.

It is stored on the listen and not on the session, which is not a preference — POST /api/v1/sessions/rebuild deletes every session row and re-inserts it, so anything held there is erased by a regroup with nothing to say so.

company is required. Send an explicit null to unmark; an absent field is a 400, for the reason signal_chain_id on /api/v1/scrobbles/bulk-chain is — this overwrites the field on every listen in ids, so a misspelled key must not deserialise into "clear it". An empty or whitespace-only string is treated as null rather than becoming a listen marked as heard with nobody.

This never writes joint_session_id. That column says this exact play happened on several decks at once, which only a Shared Spool can know.

Request body required

application/json

object

The body of POST /api/v1/scrobbles/bulk-company.

ids array required

The listens to change. Anything not yours is skipped.

each item
integer · int64
company string | null required

Who these listens were heard with, as names, or an explicit null to unmark them. Required — absent is a 400. Trimmed; an empty string means the same as null. At most 200 characters.

Responses

200

How many listens were changed.

application/json

object

How many listens a bulk edit changed.

updated integer · int64 required
400

company was absent, or longer than 200 characters.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

401

No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
POST /api/v1/scrobbles/bulk-chain #
Session

Attribute many listens to one signal chain.

Session-only. Retro-attribution after adding gear — and since v0.46.0 the chain's hours follow the listens, so doing this moves the usage numbers for listening you already have rather than only for future plays.

The field is signal_chain_id, and it is required. This entry documented it as chain_id for several releases, alongside three parameters (from, to, source_name) the handler has never accepted — and the mismatch was silently destructive rather than an error: the body deserialised cleanly with the field missing, which meant "clear the chain", so a client following the spec wiped the attribution on every listen in ids and got 200 {"updated": N} back. An absent field is a 400 now.

Send an explicit null to clear. That case is real — it is how the History bulk edit removes a chain — which is why absent and null cannot be collapsed.

Request body required

application/json

object
ids array required

The listens to change. Anything not yours is skipped.

each item
integer · int64
signal_chain_id integer | null · int64 required

Which chain to put these listens on. Required: present-and-null clears it; absent is a 400. Not chain_id — a body with that spelling is refused rather than read as "clear". defaulting to None — so a request that merely misspelled the key

chain_variant_id integer | null · int64

Which setup of that chain was fitted. Absent means none — and it is written either way, never left alone: a listen moved from chain A to chain B must not keep a variant belonging to A.

Responses

200

Applied.

application/json

object

How many listens a bulk edit changed.

updated integer · int64 required
400

Malformed or rejected input.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

401

No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
GET /api/v1/skips #
Token reador Session

Tracks you bailed out of.

Scope: read (since v0.120). Skips are stored but excluded from every listening figure, so this is the only place they are readable.

listened_ms is how far in the listener got, and is null when nothing measured a playhead — a polled server reports one, a submitting client often does not. listened_fraction is derived only when both the playhead and the running time are known, rather than estimated.

played_before / played_after are the nearest real listens either side, which is the only context this application can honestly record for a skip. Both are null for a skip at the edge of a sitting. Nothing here says why a track was skipped and nothing should infer it.

Parameters

limit query

Page size. 25 by default, at most 100.

integer · int64
offset query
integer · int64

Responses

200

OK.

application/json

object

Your skips, newest first, and the tracks skipped most.

total integer · int64 required

Every skip on record, for paging.

skips array required
each item
object

One skip, with what was playing either side of it.

id integer · int64 required
title string required
artist string required
album string | null
timestamp integer · int64 required
duration integer | null · int64

Seconds.

listened_ms integer | null · int64

How far in, when something measured a playhead; null otherwise, never wall-clock arithmetic.

listened_fraction number | null · double

listened_ms over duration, 0–1. Only when both are known.

source string required
chain string | null

The signal chain's name.

played_before object | null

The listen before it.

title string required
artist string required
played_after object | null

The listen after it.

title string required
artist string required
most_skipped array required

The ten tracks skipped most often, over the whole history.

each item
object
title string required
artist string required
skips integer · int64 required
401

No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
GET /api/v1/now-playing #
Token reador Session

What is on the deck.

Scope: read. In-memory only. Entries expire on read — past duration

  • 10s, or after 10 minutes when the source reported no duration.

Responses

200

OK — playing: false when the deck is empty.

application/json

object

Never persisted. Held in memory only, so a restart correctly shows an empty deck until a source reports again.

playing boolean required
position integer | null · int64

Seconds into the track, extrapolated to now. Absent when nothing is playing. Read track.position_known before presenting this as a readout — for a source with no playhead it is the server's own wall-clock arithmetic, not a measurement.

track object | null

Absent when nothing is playing.

title string required
artist string required
album string | null
duration integer | null · int64

Track length in seconds, when the source reports one.

progress integer · int64 required

Seconds in as of updated_at. Extrapolate from there rather than expecting a tick per second — but see position_known first, and stop extrapolating when paused.

position_known boolean required

False when progress is the server's own wall-clock arithmetic rather than a reported playhead. A ListenBrainz playing_now has no position field in the format and Subsonic answers minutesAgo, so for those there is nothing to report and the number is a guess. Draw it as one. Plex, Jellyfin, Emby, Roon and the shelf report a real playhead, and so does any client sending tapedeck_playback.

paused boolean required

Only a pushing client can know this — a poll that stops arriving is indistinguishable from a client that crashed — so it is false for every polled source. A paused entry holds its position rather than counting forward, and expires after ten minutes of silence.

updated_at integer · int64 required

Unix seconds when this was last reported.

source string required

Plex, Roon, ingest, physical…

listening_context string | null
chain_name string | null

The signal chain, only where it was a deliberate statement — an ingest submission's resolved chain, or the turntable a pressing on the shelf remembers. A polled source falls back to its device's default and leaves this null rather than naming a chain nobody chose.

format_type string | null
codec string | null
bit_depth integer | null · int32
sample_rate integer | null · int32
dsd_multiplier integer | null · int32
dsd_to_pcm_converted boolean | null
delivery_codec string | null
is_lossless boolean | null
artwork_url string | null

A cover for what is on the deck, once something has asked for one — it is resolved lazily, at most once per track, by the public widget.

401

No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
403

Authenticated, but not permitted. Either the token lacks the required scope, or the endpoint needs the admin role. Deliberately not a 401 — re-authenticating will not help.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 403,
  "error": "This token does not have the 'write' scope"
}
GET /public/np/{username} #
Public

What this person is listening to, if they publish it.

Unauthenticated — the one route that publishes someone's listening without a credential. It exists because a static site renders long before anyone visits it, so a live now-playing widget must be fetched from the reader's browser, where no credential of the owner's can safely go.

Sends Access-Control-Allow-Origin: * and Cache-Control: public, max-age=5. A GET with no custom headers is a CORS simple request, so there is no preflight and no OPTIONS.

A username that does not exist and one that has not published return the same 404. Anything that told them apart would make this a membership oracle for a private, invite-only instance.

Everything past title/artist/album appears only if its owner ticked it — see the publish switch above. An absent field means "not published" or "not known"; the two are deliberately not distinguished.

Parameters

username path required
string

Responses

200

The person publishes. playing is false when the deck is empty.

application/json

object

The public payload. Only playing, user and name are always present; title/artist/album appear whenever something is playing, and everything below that appears only if its owner opted into it.

An absent field means "not published" or "the source did not know" — the two are not distinguished, and a client must not infer either.

playing boolean required
user string required

The username as asked for.

name string | null

Display name, when set.

title string | null
artist string | null
album string | null

Present whenever something is playing; null when the track has no album.

position integer | null · int64

Opt-in (position). Seconds in. Read position_known before drawing this as a measurement.

duration integer | null · int64

Opt-in (position). Seconds; null when the source reported no length.

position_known boolean | null

Opt-in (position). False when the number is the server's own wall-clock arithmetic rather than a reported playhead.

paused boolean | null

Opt-in (position). Only a pushing source can know this.

quality object | null

Opt-in (fidelity). Every member may be null — most of a history has no format at all.

format string | null

pcm, dsd or mqa.

codec string | null
bit_depth integer | null · int32
sample_rate integer | null · int32
dsd_multiplier integer | null · int32
lossless boolean | null
chain string | null

Opt-in (gear). The signal chain, and only where the source actually chose one — an ingest submission or a side off the shelf. A polled listen falls through to the device default, the coarsest rung of the ladder, and omits this rather than dressing a fallback up as a decision.

context string | null

Opt-in (gear). The listening context, or the medium for a shelf play.

artwork string | null

Opt-in (artwork). A cover URL, resolved at most once per track.

404

No such user, or they do not publish. Identical answers.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

GET /api/v1/profile/public-now-playing #
Session

The caller's publish switch for now-playing.

Session-only. A token gets 401 — the endpoint does not accept one at all, so scopes never enter into it. A scrobble client has no business deciding that its owner's listening is public.

Responses

200

OK.

application/json

object

Your own view of your public now-playing setting.

enabled boolean required
fields array required

The extra disclosures currently granted, in canonical order.

each item
string
known_fields array required

Every field this build knows how to publish. Render the toggles from this rather than a hardcoded list, so the two cannot drift.

each item
string
path string required

The public address, e.g. /public/np/ashwin. Built server-side so a settings screen cannot assemble a different one.

401

No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
PUT /api/v1/profile/public-now-playing #
Session

Turn public now-playing on or off.

Session-only. enabled is required — a body that merely misspelled the key must not be read as an answer either way, because getting this one wrong is published rather than merely wrong.

fields names the extra disclosures beyond title/artist/album. Unknown names are dropped rather than rejected, so a newer client cannot make an older server fail; it can only fail to grant something. Omitting fields while enabling publishes the base payload alone.

Request body required

application/json

object

What a caller may ask for. enabled is required: this endpoint decides whether a person's listening leaves the instance, and a body that merely misspelled the key must not be read as an answer either way. Same reasoning as bulk-chain and default_chain_id, in the one place where guessing wrong is published rather than merely wrong.

enabled boolean | null required

Required — a body that does not say true or false explicitly is a 400, never read as either.

fields array | null

The extra disclosures to grant: any of artwork, position, fidelity, gear. Unknown names are dropped rather than rejected, so a newer client cannot make an older server fail — it can only fail to grant something, which is the safe direction. Absent grants none.

each item
string

Responses

200

OK.

application/json

object

Your own view of your public now-playing setting.

enabled boolean required
fields array required

The extra disclosures currently granted, in canonical order.

each item
string
known_fields array required

Every field this build knows how to publish. Render the toggles from this rather than a hardcoded list, so the two cannot drift.

each item
string
path string required

The public address, e.g. /public/np/ashwin. Built server-side so a settings screen cannot assemble a different one.

400

enabled was absent.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

401

No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
GET /api/v1/scrobbles/{id}/annotations #
Session or Token read

Notes and loves attached to one listen.

Scope: read (since v0.39).

Parameters

id path required

The listen.

integer · int64

Responses

200

OK.

application/json

object

What has been written or loved around one listen.

listen_note_id integer | null · int64

The note on this playing itself.

track object | null

Null when the listen has no title.

name string required
entity_id integer | null · int64
note_id integer | null · int64
loved boolean required
album object | null

Null when the listen has no album.

name string required
entity_id integer | null · int64
note_id integer | null · int64
loved boolean required
artist object | null

A track, album or artist around a listen. entity_id is null until someone actually writes about it or loves it — inspecting a listen mints nothing.

name string required
entity_id integer | null · int64
note_id integer | null · int64
loved boolean required
401

No valid session cookie or token. Also returned when a token is presented to a session-only endpoint — the endpoint does not accept tokens at all, so the scope is irrelevant.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
403

Authenticated, but not permitted. Either the token lacks the required scope, or the endpoint needs the admin role. Deliberately not a 401 — re-authenticating will not help.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.

{
  "code": 403,
  "error": "This token does not have the 'write' scope"
}
404

No such resource, or it belongs to another user.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

Human-readable. Not a stable identifier — do not branch on it.