Star ratings held from your media servers, and the rule applied to them.
Session-only. Reading is a fact about your library and pulling spends a
source's credential, so a token gets 401 here and scopes never enter
into it — the same line /api/v1/sources sits on.
Every rating carries the raw number the server gave and what that
number was out of, because a bare 7 means nothing: Plex counts to
ten (half stars, so a 7 is three and a half), Subsonic to five, and
Jellyfin/Emby's legacy Rating to ten. shown is the same rating on
the display scale you chose, and is presentation only — every threshold
comparison is made on the underlying fraction, so a rating that reads
as four stars on a coarse scale does not thereby clear a four-star bar.
threshold comes back in the units of scale, which is what you typed.
Responses
application/json
object
The ratings held, on the scale you read them on, and the rule that turns
them into loves.
scale_label string required
scale_choices array required
Every threshold the scale offers.
threshold number · float required
The love threshold, on this scale.
include_starred boolean required
Whether a starred item counts as loved whatever its rating.
sources array required
each item object
What is held, per source, so a screen can say where the numbers came from
without listing them.
source_id integer · int64 required
source_kind string required
rated integer · int64 required
starred integer · int64 required
scale_max number · double required
fetched_at integer | null · int64
sample array required
Up to 60 ratings, to show what the rule would do.
each item object
source_kind string required
rating number | null · double
The raw number, exactly as the server gave it — "7 out of 10, from
Plex".
scale_max number · double required
shown number · float required
The same rating on your display scale.
loved boolean required
Whether the current rule loves 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
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/ratings/pull # Session
Read the ratings your media servers hold.
Session-only — it spends the credential of a configured source.
Walks every enabled library source and replaces what that source said
last time, so a rating raised, lowered or cleared on the server is
raised, lowered or cleared here: the server is the authority on its own
ratings and this is a copy of them. One source failing does not stop the
others, and an error is reported separately from a count of zero — a
server that could not be reached is not a library with no ratings in it.
Roon is reported as having nothing rather than returning an empty
list. Its extension API publishes no rating, favourite or thumb field
at all, so a source that silently returned nothing would look broken.
Ratings are stored; nothing becomes a love until
/api/v1/ratings/apply.
Responses
application/json
object
Ratings read from every enabled media server.
found integer required
Ratings stored, across every source.
sources array required
One entry per enabled source. A source that failed says why and keeps
what it held before.
each item object
What one source's pull did.
error and a zero count are different answers and both are reported: a
server that could not be reached is not a library with no ratings in it, and
a screen that showed them the same way would send somebody looking for
ratings that are sitting right there.
What one source handed over.
source_id 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
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/ratings/apply # Session
Love what clears the threshold, and withdraw what no longer does.
Session-only.
dry_run is required and has no default: the two things this can do
are "tell me what would happen" and "write to my Loved page", and a body
that merely misspelled the key must not be read as an answer either way.
A preview and a real run go through the same code, so a preview cannot
describe something the apply then does differently — and a preview
writes nothing at all, including no entities.
Two rules hold it safe. A rating on something you have never listened
to is kept and not loved — loves point at entities and entities are
things you actually played — and the count comes back as unmatched so
it is visible rather than silently absent. And only loves this
instance derived are ever withdrawn: one pressed by hand, or pulled
from Last.fm or ListenBrainz, is outside the reach of a threshold
however far it moves.
Nothing here is forwarded to Last.fm or ListenBrainz. These are loves
being imported, not loves being pressed, and a threshold you are
expected to try at several settings would otherwise be a broadcast at
every setting.
Request body required
application/json
object
threshold number | null · float
The cut-off, in the units of scale. Absent means "whatever is stored".
scale string | null
Which scale threshold is expressed in. Absent means the stored one.
include_starred boolean | null
dry_run boolean required
Preview only. Required — see the note.
save boolean
Store the rule as well as applying it.
Responses
200 What happened, or what would.
application/json
object
DELETE /api/v1/ratings — forget the stored ratings.
Does not un-love anything, and that is the point rather than an
omission: a love already made is a statement that was made, and this is a
request to forget the ratings. Un-loving is what moving the threshold to the
top and applying does, which is a separate and visible act.
What a rule did, or would do.
threshold number · float required
The rule applied, on scale.
include_starred boolean required
report object required
What applying a threshold did, or would do.
matched and unmatched are stated separately for the reason every paired
count in this codebase is: a rating on something never played is real data
and is kept, but it does not become a love, and a screen reporting only the
loves would silently understate what the library holds.
qualifying integer required
Ratings that clear the bar.
matched integer required
…of which this many are things this user has actually listened to.
unmatched integer required
…and this many are not, so they are not loved. See the note on the rule.
loved integer required
Loves newly made by this run.
withdrawn integer required
Loves withdrawn because their rating no longer clears the bar.
already integer required
Already loved, by hand or by an earlier run. Untouched.
dry_run boolean required
Whether anything was actually written.
400 Malformed or rejected input.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
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
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/loves/pull # Session
Pull loves from Last.fm / ListenBrainz.
Session-only — it uses stored service credentials. Not one code path:
ListenBrainz returns only a recording MBID so an un-enriched track
cannot be matched, while Last.fm returns names and can fall back to a
name match. A love whose track is not in your history is skipped,
never invented — loves point at entities, and entities are things you
actually played. Libre.fm has no loved-tracks call and is not asked.
Responses
application/json
object
Loves fetched from every connected service.
loved integer required
Newly stored, across every service.
services array required
One entry per connected service; one failing does not stop the other.
each item object
What one service's pull did. Every field is a count of loves, not of
requests, so the numbers mean the same thing across services.
found integer required
Loves the service reported.
loved integer required
Newly stored — already-loved tracks are not counted twice.
skipped integer required
Reported by the service but not in this user's history.
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
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."
}
Love or un-love.
Scope: write (since v0.39).
Loves attach to entities, never to a listen — loving one playing
of a track is a category error and is rejected.
A love of a recording is mirrored outward (Last.fm track.love,
ListenBrainz recording-feedback); albums and artists stay local
because neither service has the concept. ListenBrainz needs a recording
MBID, so an un-enriched track is skipped rather than guessed at. The
forward is spawned, not awaited — the love is already stored and the UI
should not wait on Last.fm.
Request body required
application/json
object
What to love, named the ways TargetSpec allows — a track, an album or an
artist; not a listen, a period or a line.
entity_id integer | null · int64
scrobble_id integer | null · int64
period_unit string | null
week, month or year, alongside period_key. Both or neither.
period_key string | null
As Reports names it — 2026, 2026-08, 2026-W32.
line integer | null
A pin: the line's number, alongside artist and name (the
track's title). Blank lines are numbered.
from_scrobble integer | null · int64
With kind: track, prefer the album this listen was filed under.
kind string | null
track/recording, album/release or artist.
loved boolean | null
Omitted means "love it"; pass false to un-love.
Responses
application/json
object
A love, set or withdrawn.
entity object | null
Something a note or a love can point at: a recording, a release, an artist.
id integer · int64 required
kind string required
recording, release or artist.
artist_name string required
Empty for kind = 'artist', where the name is the artist.
created_at integer · int64 required
400 Malformed or rejected input.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
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
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
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"
}
Every loved entity key, as a flat set.
Scope: read (since v0.39). One request instead of one per history
row.
Since v0.69 a love is published in up to three forms, and a row is
loved if any key it can derive is in the set. Two players rarely spell
one song identically — Plex files a listen under the record's artist
while a scrobble client reports what the player displayed — so an exact
match alone left the heart empty on the same track from another source.
kind|artist|name — trimmed and lowercased, an artist's artist half
empty. The exact key, as before.
kind|artist|name folded — accents, ligatures and all punctuation
removed, and the primary taken from an explicit feat. credit. Note a
trailing title qualifier is not removed: "Archangel" and "Archangel
(Remastered)" stay apart.
mbid:<kind>:<mbid> — present when the entity has a MusicBrainz id.
The strongest of the three; a listen row carries a recording id.
If your derivation drifts from the server's, hearts silently stop
filling — nothing errors. The folded form is pinned character for
character by the_folded_key_is_exactly_what_the_client_must_reproduce.
Responses
application/json
object
Every loved entity as its identity key.
keys array required
kind|artist|name, lowercased — recording|aphex twin|xtal. Derive a
row's key the same way and match locally.
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
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
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"
}