← API reference

Statistics

Aggregations, charts, personality.

13 of 13 · v0.120.0
GET /api/v1/stats/dashboard #
Token reador Session

Dashboard counters.

Scope: read (since v0.39). "Today" and "this week" are bucketed in the user's own timezone and honour their week-start preference.

Deliberately does not carry distinct artist, album or track counts. It used to compute all three, each a COUNT(DISTINCT) building a temp B-tree over the whole history, on the most-loaded page in the application — and nothing rendered any of them. GET /api/v1/stats answers the same question against a range control, which is where it belongs.

Responses

200

OK.

application/json

object

The home screen's numbers. "Today" and "this week" are in your own timezone, and the week starts on the day your preferences say.

today integer · int64 required
this_week integer · int64 required
total integer · int64 required
lossless_pct integer · int64 required

A whole percentage of fidelity_known, not of total.

fidelity_known integer · int64 required

Listens whose format is known — the population lossless_pct is a share of. When that is a small slice, show the coverage rather than the percentage: a 94% computed from 558 of 21,390 listens is a fact about the 558.

top_artist string required

The most-played artist of the last 30 days, or — with none.

top_artist_count integer · int64 required
total_hours number · double required
today_hours number · double required
week_hours number · double required
avg_quality number · double required

Over the listens that carry a score; 0 with none.

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 /api/v1/stats #
Token reador Session

Ranged statistics.

Scope: read (since v0.39). Totals, top lists (50 rows each), fidelity mix, decades, a 7×24 listening heatmap, breadth, genres and languages.

The genre breakdown is a share of tag mentions, not listens — one track carries several tags. Never present it as a percentage of plays.

Parameters

range query

The window, rolling back from now: today, d7 (or week), month (or last30), year, or all, the default. Anything else is all time.

string
metric query

plays (default) or hours. How the three top lists are ranked, not merely what number is shown. They are cut to 50 rows server-side, so re-sorting in the client would only reorder the top 50 by plays and would miss a long record played a handful of times — which is exactly the row switching to hours exists to surface. Listens whose source reported no duration count as zero and sort last.

string

Responses

200

OK.

application/json

object

Ranged statistics: totals, top lists (50 rows each), fidelity mix, decades, a 7×24 listening heatmap, breadth, genres and languages.

totals object required
plays integer · int64 required
hours number · double required

Summed over the listens with a known length — see duration_known.

duration_known integer · int64 required

Listens hours is summed over. A listen whose source reported no length contributes nothing, so hours is a floor rather than a total.

artists integer · int64 required
albums integer · int64 required
tracks integer · int64 required
avg_quality number · double required

0–100, averaged over quality_known listens only; 0 when there are none.

quality_known integer · int64 required

Listens avg_quality is averaged over. Only a live source ever writes a quality score, so an imported history has none and the average is over the same narrow population the fidelity shares are. Present it with its coverage or not at all; at zero there is no score to show, not a score of zero.

lossless_pct integer · int64 required

Share of fidelity_known, not of plays. A whole percentage.

dsd_pct integer · int64 required

Share of fidelity_known, not of plays. A whole percentage.

fidelity_known integer · int64 required

Listens in this window whose format is known — the denominator of the two shares above. The fidelity array is a share of plays instead, because it carries an Unknown bucket of its own.

skips integer · int64 required

Skipped plays in this window. Every other count in this object excludes them; this one is about them. Not every source reports skips, so zero means "nothing told us", not "nothing skipped".

per_day number · double required

Plays per calendar day of the window, including days with no listening. Dividing by active days instead reports a busy afternoon's rate as the year's.

first_listen integer | null · int64

Earliest non-skipped listen, unix seconds. A fact about the whole history and therefore not ranged — asking it of a one-week window would only restate the window.

loves integer · int64 required

Loved entities: tracks, records and artists together. All time, not ranged.

top_artists array required
each item
object

A top artist. Counted over everyone credited, the way the charts count.

name string required
plays integer · int64 required
hours number · double required

One decimal place.

image_url string | null
top_albums array required
each item
object
name string required
artist string required
plays integer · int64 required
hours number · double required

One decimal place.

top_tracks array required
each item
object
title string required
artist string required
album string | null
plays integer · int64 required
hours number · double required

One decimal place.

fidelity array required

DSD, Lossless, Lossy and Unknown, largest first. A share of plays, because it carries the Unknown bucket of its own.

each item
object

One slice of a breakdown.

name string required
plays integer · int64 required
pct integer · int64 required

A whole percentage of the breakdown's own total — which total, each breakdown says.

decades array required

Release decades, 1970s and so on, oldest first. Only listens whose release year has been resolved count, so pct is a share of those.

each item
object

One slice of a breakdown.

name string required
plays integer · int64 required
pct integer · int64 required

A whole percentage of the breakdown's own total — which total, each breakdown says.

genres array required

The twelve most-mentioned genres. A share of tag mentions, not listens — one track carries several tags. Never present it as a percentage of plays.

each item
object

One slice of a breakdown.

name string required
plays integer · int64 required
pct integer · int64 required

A whole percentage of the breakdown's own total — which total, each breakdown says.

languages array required

The twelve largest languages, over the listens that have been classified.

each item
object
name string required
pct integer · int64 required

A whole percentage of the classified listens.

regions array required

Always empty; reserved.

each item
object
heatmap array required

Seven rows of 24, counts of plays by hour, in your own timezone. Row 0 is Monday.

each item
array
each item
integer · int64
breadth object | null

Absent until the genre map is fetched and genres backfilled — "you play 0 genres" would be a lie about the listening rather than a statement about the data.

genres_played integer required
genres_placed integer | null

Of those, the ones the map places.

effective_genres number | null · double

exp(H) over play counts — as varied as playing N genres equally.

evenness number | null · double

effective_genres over genres_played, 0–1. 1.0 would mean every genre played exactly equally.

reach number | null · double

Your play-weighted spread over the map's own spread, 0–1. Null when nothing played is on the map.

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 /api/v1/personality #
Token reador Session

Musical personality — a verdict, traits and loyalty.

Scope: read (since v0.120).

Parameters

range query

The window, rolling back from now: today, d7 (or week), month (or last30), year, or all, the default. Anything else is all time.

string
metric query

plays (default) or hours. How the three top lists are ranked, not merely what number is shown. They are cut to 50 rows server-side, so re-sorting in the client would only reorder the top 50 by plays and would miss a long record played a handful of times — which is exactly the row switching to hours exists to surface. Listens whose source reported no duration count as zero and sort last.

string

Responses

200

OK.

application/json

object
verdict string required
traits array required
each item
object
label string required
low string required
high string required
score number · double required
evidence string required
loyalty array required
each item
object
artist string required
plays integer · int64 required
loyalty_score number · double required
total_plays integer 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/lyrics #
Token reador Session

The words to one track, as numbered lines.

Scope: read.

Numbered including blank lines. A stanza break is a line, because a liner note refers to a line by its index — [[line:12]] or [[line:A:3:12]] — and skipping empties would renumber every line after one, silently re-pointing every quote already written.

Never triggers an LRCLIB fetch. That service is free and asks callers to be considerate; the backfill is what paces it, and a screen that fetched on open would let a page refresh drive the rate.

It does reach the user's own media server when nothing is stored yet. Their Plex or Jellyfin has no rate limit and no shared budget to spend, and they tag their own albums — so those words are usually right there while the LRCLIB trickle is still hours from this track. Only when the cache holds nothing: a stored lyric is never re-fetched on a page load.

Three answers, all real: lines, instrumental: true (a track with no words, which is not a gap), and an empty list.

Lyrics are the provider's text. They are cached outside the listening database, so GET /api/v1/backup does not carry them, and they are not served by the public now-playing endpoint or by Patch.

Parameters

artist query required
string
title query required
string
album query

Which record to draw the tracklist from.

Optional and resolved from the history when it is absent, because the caller is usually a note or a history row that knows the track and not the record it was filed under. Passed explicitly it wins, since a track on three compilations has three honest answers and only the caller knows which one is being read.

string

Responses

200

OK — including for a track nothing is held for.

application/json

object

Everything the lyric reader draws for one track: the words, and the furniture round them.

one of
option 1 object

The words, as numbered lines.

language string | null

As detected from the text.

timed boolean required

Whether any line carries a time, so the reader can follow the deck.

lines array required

Numbered including blanks. A stanza break is a line, because a note refers to a line by its index and skipping empties would renumber every line after one.

each item
object

One numbered line.

n integer required

1-based, blanks included.

at number | null · double

Seconds into the track, when the lyric is timed.

text string required

Empty for a stanza break, which is a real line.

pin object | null

The liner note pinned to this line, if any.

note_id integer · int64 required
body_md string required
excerpt string required

The first 200 characters, as text.

visibility string required
updated_at integer · int64 required
quoted integer required

How many notes quote this line. Zero is sent, never omitted.

plain string | null

The stored text, for correcting it by hand.

synced string | null

The stored timed text, [mm:ss.xx] words per line.

edited boolean required

Corrected by hand, and therefore never overwritten by a lookup.

source string | null

Where the words came from — your library, LRCLIB, or you. Shown wherever the words are, because they are not Tapedeck's.

option 2 object

A track with no words — an answer, not a gap.

instrumental boolean required

Always true.

lines array required

Always empty.

each item
object

One numbered line.

n integer required

1-based, blanks included.

at number | null · double

Seconds into the track, when the lyric is timed.

text string required

Empty for a stanza break, which is a real line.

pin object | null

The liner note pinned to this line, if any.

note_id integer · int64 required
body_md string required
excerpt string required

The first 200 characters, as text.

visibility string required
updated_at integer · int64 required
quoted integer required

How many notes quote this line. Zero is sent, never omitted.

option 3 object

Nothing held for this track.

lines array required

Always empty.

each item
object

One numbered line.

n integer required

1-based, blanks included.

at number | null · double

Seconds into the track, when the lyric is timed.

text string required

Empty for a stanza break, which is a real line.

pin object | null

The liner note pinned to this line, if any.

note_id integer · int64 required
body_md string required
excerpt string required

The first 200 characters, as text.

visibility string required
updated_at integer · int64 required
quoted integer required

How many notes quote this line. Zero is sent, never omitted.

artist string required
title string required
album string | null

The record the tracklist is drawn from — as asked for, or else the one the track is filed under in your history.

plays integer · int64 required

Your plays of this track.

tracklist array required

The record's tracks, empty with no record.

each item
object
title string required
plays integer · int64 required
duration integer | null · int64

Seconds.

track_number integer | null · int64
lyrics string | null

words or instrumental; null for a track nobody has asked about, which is a different thing from one that has no words.

PUT /api/v1/lyrics #
Session

Correct the words by hand.

Scope: session only.

A value you supplied is never overwritten by a lookup — the rule the shelf's running times already follow, and what settles the collision between this and "newest fetch wins": between two fetches the newer one wins, and between a fetch and something a person typed, the person does. The row is marked as corrected and the backfill will not write over it.

Instance-wide rather than per user, like everything else in this cache: these are one track's words, not one person's opinion of them.

The response reports the line count before and after, and renumbered when they differ. Every reference already written is an index, so a correction that adds or removes a line moves every quote after it onto different words — silently, because an index cannot be wrong, only pointed somewhere else. It is reported rather than refused: the correction is usually the point, and the alternative is a lyric that stays wrong for ever.

Request body required

application/json

object
artist string required
title string required
plain string | null

The words, plain. Absent clears them.

synced string | null

The timed form, [mm:ss.xx] words per line.

Responses

200

Saved.

application/json

object

A hand correction, stored.

status string required

Always saved.

language string | null
lines_before integer required
lines_after integer required
renumbered boolean required

The line count changed, so every [[line:…]] quote past the change now points at different words. Say so.

400

No artist, no title, or no words.

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/lyrics/library #
Token writeor Session

Words a player found in its own files.

Scope: write.

Not a correction. PUT /api/v1/lyrics calls set_words, which sets edited and stamps the source as You — a claim only a person typing gets to make, and the flag that outranks every later fetch. A client reporting what a file contains is not correcting anything, so this goes through the library path instead: it refuses to write over an edited row, re-reads the language from the new words, and upserts, so a repeat send is harmless.

Not POST /api/v1/lyrics/fetch either, which spends a shared politeness budget at LRCLIB. This is offering words, not asking for them.

Sends nothing to any third party, and the words never leave the instance — not in a backup, not in an export, not to a patched deck.

Request body required

application/json

object

Words a player found in its own files, offered by the player.

Deliberately not PUT /api/v1/lyrics, which is the correction path: that one calls set_words, which sets edited and stamps the source as You. Typing a line in is a claim only a person gets to make, and a client reporting what a file contains is not correcting anything — so it must not be able to mint the flag that outranks every later fetch.

Nor additional_info on a submission: a lyric is a few KB on every play of a track already held, and ingest stays about the listening.

artist string required
title string required
album string | null

Advisory. Not used to match — track_key is artist and title — and kept out of the store for the same reason.

plain string | null
synced string | null
source string | null

Whose words these are, and required.

The reader prints "Words via X", and that sentence is the entire reason this route exists apart from the LRCLIB one: from your own files and from a lyrics service are different statements about whose text somebody is reading. A client that will not say who it is should not get to write provenance, and inventing one here would be the placeholder User-Agent mistake in a new place.

Typed Option and required in the handler, not by serde. A missing required field is rejected by axum's Json extractor with a 422 before any of this runs — so the client is handed a deserialiser's sentence about a field name instead of the reason, which is the trap additional_info already documents. The requirement is unchanged; only the answer is legible.

Responses

200

Stored, or left alone because the row holds a correction.

application/json

object

What became of words a player offered.

status string required

saved, or kept when the words held were corrected by hand — a correction outranks a file's own tags, so those were left alone.

why string | null

Why they were kept; null when saved.

language string | null

The language of the words now held.

lines integer required

Lines in the words now held.

400

A blank artist, title or source, or no words.

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/lyrics/live #
Token reador Session

The words for whatever is on the deck.

Scope: read.

The reader's whole state in one request, so the lyric theatre stops being addressed by URL. GET /api/v1/lyrics names a track, which is right for a [[line:...]] chip and wrong for reading along: the words stayed on whichever song the address named, so the next track left them behind and the only way out was editing the query string.

The body is a LyricDoc with the playhead beside it, so a cold open lands on the right line without waiting for a now-playing poll. Nothing playing is { "playing": false } and a normal answer, not an error.

Ask it once per track, not once per poll. The document carries the tracklist and every pin on the track; a client should watch GET /api/v1/now-playing (which it is already polling) and re-ask this only when the track changes.

Responses

200

The deck's track and its words, or nothing playing.

application/json

The words for whatever is on the deck, or that nothing is.

one of
option 1 object

Nothing is playing — a state, not a failure.

playing boolean required

Always false.

option 2 object

The lyric document for what is playing, plus the playhead.

one of
option 1 object

The words, as numbered lines.

language string | null

As detected from the text.

timed boolean required

Whether any line carries a time, so the reader can follow the deck.

lines array required

Numbered including blanks. A stanza break is a line, because a note refers to a line by its index and skipping empties would renumber every line after one.

each item
object

One numbered line.

n integer required

1-based, blanks included.

at number | null · double

Seconds into the track, when the lyric is timed.

text string required

Empty for a stanza break, which is a real line.

pin object | null

The liner note pinned to this line, if any.

note_id integer · int64 required
body_md string required
excerpt string required

The first 200 characters, as text.

visibility string required
updated_at integer · int64 required
quoted integer required

How many notes quote this line. Zero is sent, never omitted.

plain string | null

The stored text, for correcting it by hand.

synced string | null

The stored timed text, [mm:ss.xx] words per line.

edited boolean required

Corrected by hand, and therefore never overwritten by a lookup.

source string | null

Where the words came from — your library, LRCLIB, or you. Shown wherever the words are, because they are not Tapedeck's.

option 2 object

A track with no words — an answer, not a gap.

instrumental boolean required

Always true.

lines array required

Always empty.

each item
object

One numbered line.

n integer required

1-based, blanks included.

at number | null · double

Seconds into the track, when the lyric is timed.

text string required

Empty for a stanza break, which is a real line.

pin object | null

The liner note pinned to this line, if any.

note_id integer · int64 required
body_md string required
excerpt string required

The first 200 characters, as text.

visibility string required
updated_at integer · int64 required
quoted integer required

How many notes quote this line. Zero is sent, never omitted.

option 3 object

Nothing held for this track.

lines array required

Always empty.

each item
object

One numbered line.

n integer required

1-based, blanks included.

at number | null · double

Seconds into the track, when the lyric is timed.

text string required

Empty for a stanza break, which is a real line.

pin object | null

The liner note pinned to this line, if any.

note_id integer · int64 required
body_md string required
excerpt string required

The first 200 characters, as text.

visibility string required
updated_at integer · int64 required
quoted integer required

How many notes quote this line. Zero is sent, never omitted.

artist string required
title string required
album string | null

The record the tracklist is drawn from — as asked for, or else the one the track is filed under in your history.

plays integer · int64 required

Your plays of this track.

tracklist array required

The record's tracks, empty with no record.

each item
object
title string required
plays integer · int64 required
duration integer | null · int64

Seconds.

track_number integer | null · int64
lyrics string | null

words or instrumental; null for a track nobody has asked about, which is a different thing from one that has no words.

playing boolean required

Always true.

position integer · int64 required

Seconds into the track.

position_known boolean required

Whether position is a measurement. Do not drive a highlight from a guess.

paused boolean required
duration integer | null · int64

Seconds.

POST /api/v1/lyrics/fetch #
Session

Look for one track's words now.

Session only. A token is refused with 401 and its scopes never enter into it: this spends a shared politeness budget at a free service and writes an instance-wide cache, which is not something a scrobble client holding read has any business doing.

The backfill runs at a few tracks a minute, most-played first, which is the right pace for a queue nobody is watching and no use for the track somebody is looking at. This is the button behind that, and it resolves the track exactly as the backfill would: the user's own library first, then LRCLIB, with cues winning.

A deliberate press is not a page load, which is why this may ask LRCLIB where GET /api/v1/lyrics may not. It bypasses the 30-day miss tombstone — LRCLIB fills its own gaps, so "nobody had this a fortnight ago" is exactly the answer worth re-asking — but it will not displace words already held: already_held: true says so, and /api/v1/lyrics/matches is the control for disagreeing with them.

A rate limit or an unreachable LRCLIB is 200 with unavailable, not a 5xx. Being asked to wait is not this server failing, and reporting it as "nothing found" would be a claim about the track rather than about the request.

Request body required

application/json

object
artist string required
title string required
album string | null

Narrows the LRCLIB lookup when the caller knows it.

duration integer | null · int64

Likewise — LRCLIB matches on length, and a wrong-length record is the usual reason a well-known song comes back with somebody else's words.

Responses

200

The whole document as it now stands, so the caller can replace what it is showing — or unavailable with a why.

application/json

A lookup's outcome: the whole lyric document, or why nobody could be asked.

one of
option 1 object

LRCLIB could not be asked — rate limited or unreachable. Not "there are no words": nothing was learned about the track.

unavailable boolean required

Always true.

why string required
option 2 object

The document, as the reader should now show it.

one of
option 1 object

The words, as numbered lines.

language string | null

As detected from the text.

timed boolean required

Whether any line carries a time, so the reader can follow the deck.

lines array required

Numbered including blanks. A stanza break is a line, because a note refers to a line by its index and skipping empties would renumber every line after one.

each item
object

One numbered line.

n integer required

1-based, blanks included.

at number | null · double

Seconds into the track, when the lyric is timed.

text string required

Empty for a stanza break, which is a real line.

pin object | null

The liner note pinned to this line, if any.

note_id integer · int64 required
body_md string required
excerpt string required

The first 200 characters, as text.

visibility string required
updated_at integer · int64 required
quoted integer required

How many notes quote this line. Zero is sent, never omitted.

plain string | null

The stored text, for correcting it by hand.

synced string | null

The stored timed text, [mm:ss.xx] words per line.

edited boolean required

Corrected by hand, and therefore never overwritten by a lookup.

source string | null

Where the words came from — your library, LRCLIB, or you. Shown wherever the words are, because they are not Tapedeck's.

option 2 object

A track with no words — an answer, not a gap.

instrumental boolean required

Always true.

lines array required

Always empty.

each item
object

One numbered line.

n integer required

1-based, blanks included.

at number | null · double

Seconds into the track, when the lyric is timed.

text string required

Empty for a stanza break, which is a real line.

pin object | null

The liner note pinned to this line, if any.

note_id integer · int64 required
body_md string required
excerpt string required

The first 200 characters, as text.

visibility string required
updated_at integer · int64 required
quoted integer required

How many notes quote this line. Zero is sent, never omitted.

option 3 object

Nothing held for this track.

lines array required

Always empty.

each item
object

One numbered line.

n integer required

1-based, blanks included.

at number | null · double

Seconds into the track, when the lyric is timed.

text string required

Empty for a stanza break, which is a real line.

pin object | null

The liner note pinned to this line, if any.

note_id integer · int64 required
body_md string required
excerpt string required

The first 200 characters, as text.

visibility string required
updated_at integer · int64 required
quoted integer required

How many notes quote this line. Zero is sent, never omitted.

artist string required
title string required
album string | null

The record the tracklist is drawn from — as asked for, or else the one the track is filed under in your history.

plays integer · int64 required

Your plays of this track.

tracklist array required

The record's tracks, empty with no record.

each item
object
title string required
plays integer · int64 required
duration integer | null · int64

Seconds.

track_number integer | null · int64
lyrics string | null

words or instrumental; null for a track nobody has asked about, which is a different thing from one that has no words.

already_held boolean required

The words were already held, so nothing was fetched.

400

An artist or a title that is blank.

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.

422

The body is missing artist or title outright.

GET /api/v1/lyrics/matches #
Token reador Session

The other records LRCLIB holds for a track.

Session or a read token.

Not a version history. LRCLIB has none — /api/get returns one record and nothing behind it. These are different records for the same song, from /api/search: other uploads, other pressings, a live take filed under the same name. Picking a better one is how a wrong match is fixed without typing the words out.

Records with no words are omitted. The whole gesture is "these are the wrong words, give me the other ones", which an instrumental cannot answer.

Adopt one with PUT /api/v1/lyrics, sending the chosen plain and synced_text. That is the ordinary correction path, so the choice is marked edited and survives every later fetch — and it reports renumbered, which matters here more than anywhere: a [[line:...]] reference is an index, so a different record re-points every quote already written.

This reaches the network, so it belongs on a press rather than a page load. A rate limit or an unreachable LRCLIB comes back 200 with unavailable: true and a reason rather than a 5xx: the upstream asking us to wait is not this server failing, and the distinction is what stops a client reporting "no other matches" when it never managed to ask.

Parameters

artist query required
string
title query required
string
album query

Which record to draw the tracklist from.

Optional and resolved from the history when it is absent, because the caller is usually a note or a history row that knows the track and not the record it was filed under. Passed explicitly it wins, since a track on three compilations has three honest answers and only the caller knows which one is being read.

string

Responses

200

OK.

application/json

object

The records LRCLIB holds for a track.

matches array required
each item
object

One LRCLIB record for the track.

id integer | null · int64

LRCLIB's id.

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

Seconds.

synced boolean required

Timed — a timed lyric follows the deck, a plain one cannot.

lines integer required

Lines, blanks included.

plain string | null
synced_text string | null
unavailable boolean | null

Present, and true, when LRCLIB could not be asked. matches is then empty and says nothing about the track.

why string | null

Why, when unavailable.

400

Missing artist or title.

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/lyrics/keys #
Token reador Session

Every track this instance holds words for.

Scope: read.

One request for the whole set, exactly the shape GET /api/v1/loves/keys has and for the same reason: a history page wants to know which of fifty rows has something to read, and fifty questions is not the way to ask.

A key is <artist>|<title>, both trimmed and lowercased — the same fold loveKey uses for its artist half. The browser's derivation and this one must stay in step, or "Read the words" quietly stops appearing on rows that have lyrics and nothing errors.

Only tracks with words. An instrumental is a real answer and a finished lookup, but there is nothing to open a reader on.

Not scoped to the caller, and it cannot be: the cache is one copy of one track's words for the whole instance, keyed by the track rather than by who played it.

Responses

200

OK. An instance with no lyrics cache answers an empty list rather than failing.

application/json

object

Every track this instance holds words for.

keys array required

<artist key>|<title> for each: the artist folded the way artist pages fold names (case, accents, spacing), the title trimmed and lowercased. Derive a row's key the same way.

each item
string
GET /api/v1/reports #
Session or Token read

Period reports — a feed of week / month / year cards, newest first.

Scope: read (since v0.41).

Reads the history once and folds it in Rust. A card carries a dozen aggregates and every one is bucketed in the caller's own timezone, which SQL here cannot do — a year of monthly cards would otherwise be hundreds of round trips.

Weeks are ISO (Monday-start), deliberately not the caller's week-start preference: that setting shapes the dashboard counter and the heatmap rows, whereas a report is a named object and its boundary has to stay put, or last month's report changes shape when the preference does.

avg_daily divides by calendar days — a finished period by its full length, one still running by the days it has actually had. Dividing by days-with-listens would report the rate of your busiest fortnight as the month's average.

Two things deliberately absent. There is no percentile against other users ("you out-listened 88% of listeners"): that needs other people's listening, and on a private instance it would leak how much the rest of the household plays. And crush is omitted for the oldest period, where every artist is new by construction and it would just be the top artist relabelled.

Parameters

unit query

week, month (default) or year.

string
limit query

How many periods to return, newest first. 12 by default, at most 60.

integer
offset query

Periods to skip, counting back from the newest. The stepper on the Reports page walks this one period at a time. Costs nothing extra — the whole history is folded on every call regardless.

integer

Responses

200

OK.

application/json

object

A feed of period cards, newest first.

unit string required

week, month or year.

timezone string required

The IANA zone the buckets were cut in.

periods array required
each item
object

One week, month or year.

key string required

2026-W31, 2026-08, 2026.

label string required
start integer · int64 required

Unix seconds — the first listen in the period.

end integer · int64 required

Unix seconds — the last listen in the period.

plays integer · int64 required
hours number · double required
avg_daily integer · int64 required

Plays per calendar day — a finished period by its full length, one still running by the days it has actually had.

artists integer required
albums integer required
tracks integer required
new_artists integer required

Artists with no earlier listen anywhere in the history.

top_artist object | null

Never "Various Artists", which is a filing convention, not an artist.

name string required

The latest spelling actually submitted.

plays integer · int64 required
hours number · double required
landscape_url string | null

A wide image, when you chose one for this artist. Absent otherwise — nothing Tapedeck can reach supplies one.

top_album object | null
name string required
artist string required
plays integer · int64 required
top_track object | null
title string required
artist string required
plays integer · int64 required
clock array required

24 counts, local hours.

each item
integer · int64
sub array required

One level finer than the period — days across a week, weeks across a month, months across a year.

each item
integer · int64
sub_labels array required
each item
string
genres array required

The six biggest genres. Shares of tag mentions, not of listens.

each item
object
name string required
pct integer · int64 required

A whole percentage of tag mentions.

genre_bands object required

The period's three biggest genres by name, fixed across every sub-bucket, plus other as the remainder. Drawn as stacked bands by the year chapter. A sub-bucket with no tags at all is all zeroes rather than 100% other — an empty month is an absence, not a genre.

Shares of tag mentions, like genres.

names array required

At most three.

each item
string
shares array required

One row per sub-bucket; each row is the named shares then other.

each item
array
each item
number · double
rank integer required

Where this period sits among your own periods by plays, 1 being the busiest. This is what fills the slot the design gave to a cross-user percentile — there is no such thing here: it needs other people's listening, and on a private instance it would leak how much the rest of the household plays.

rank_of integer required

How many periods rank is out of.

colour string | null

Play-weighted blend of the period's genre colours, in linear light. Null until the genre map is fetched.

on_repeat array required

The heaviest single days of single tracks — three plays or more.

each item
object
title string required
artist string required
plays integer · int64 required
crush object | null

Most-played artist never played before, with at least five plays. Null for the oldest period, where every artist is new by construction and it would just be the top artist relabelled.

name string required

The first spelling it arrived under.

plays integer · int64 required
since integer · int64 required

Unix seconds of the first listen.

shelf object required

Sides played off the shelf.

sides integer · int64 required
rows array required

The five most-played sides.

each item
object
title string required
artist string required
side string required
spins integer · int64 required
top_chain string | null

The chain most listens went through.

gear object required

What the period was listened through. unattributed and no_duration are stated rather than folded away: on most histories the first is nearly everything, and the hours are a floor without the second.

chains array required
each item
object
name string required
icon string | null
plays integer · int64 required
hours number · double required
items array required

The eight pieces of gear with the most hours, each counted once per chain even when a chain names it twice.

each item
object
name string required
type string required

The component type, lowercased — dac, amp, transducer…

plays integer · int64 required
hours number · double required
unattributed integer · int64 required

Listens with no chain at all.

no_duration integer · int64 required

Chained listens whose source reported no length.

company object required

Listens marked as heard with somebody. company is null on everything nobody has tagged, so of_plays travels as the denominator — never present a bare share, and never read the complement as "alone".

marked_plays integer · int64 required
marked_hours number · double required
of_plays integer · int64 required
people array required

The eight people heard with most.

each item
object
name string required
plays integer · int64 required
fingerprint object required

Five ratios between 0 and 1, and the same five for you, usually — every one answerable from any history, and none of them a comparison against another person. The baseline is the mean of your own periods, not one shape over the whole history: variety shrinks as plays grow and discovery is pinned at 1.0 over a whole history, so a whole-history baseline would report every period as more varied and less exploratory than usual.

axes array required

Variety, Repeat, Discovery, Spread, Runs.

each item
string
you array required
each item
number · double
usual array required
each item
number · double
usual_periods integer required

How many periods the baseline rests on. One is not a habit — a radar comparing a month against itself draws two identical shapes and means nothing by it, so decline to draw the baseline below two.

milestones array required

Sentences worth a line of their own — "your 10,000th listen".

each item
string
prev object | null

The period before, for deltas. Null for the oldest.

label string required
plays integer · int64 required
hours number · double required
avg_daily integer · int64 required
artists integer required
albums integer required
tracks integer required
sub array required
each item
integer · int64
offset integer required

Where this page sits, counting back from the newest.

total_periods integer required

How many periods exist at this bucket size — what tells a stepper where the ends are.

capped boolean | null

The scan hit the analytics row cap, so the oldest periods are partial and must not be presented as final. Absent with no listening at all.

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 /api/v1/reports/image #
Session or Token read

A report chapter as a shareable PNG, rendered on the server.

Scope: read (since v0.96).

Rendered on the server on purpose. A share image is a deliverable rather than a view: a native client asks for one instead of reimplementing a layout, a type scale and a palette that would then be free to drift from the web's. The fonts are embedded in the binary and no artwork is fetched, so the same period produces the same bytes on any host.

It computes nothing. Every figure comes from GET /api/v1/reports with the same unit and offset, so a share cannot disagree with the chapter it was shared from — including the rule that the only comparison on the card is against the caller's own periods.

The card is a composition, not a screenshot of the chapter, and each unit has its own — never one card relabelled, the same reason the app has three chapter components:

  • week — a headline, the by-day bars, the top artist and the track of the week, the counts, the crush. Deliberately no genre split: seven days make a thin one, which is the chapter's own judgement.
  • month — the top artist and record, the counts, the top genres, what was on repeat, the crush. No by-week bars: five columns say less than the ranked genre list beneath them.
  • year — the by-month bars and the genre bands (the three biggest genres tracked by name across the year, everything else folded into other), the artist and record of the year, what was discovered, the crush.

A section that would not fit is left out whole rather than clipped — a PNG has no scrollbar, so a section drawn over the footer would look deliberate. A thin period therefore draws fewer sections rather than empty headings, and a full year gives up its discovery line before it gives up the crush.

Parameters

unit query

week, month (default) or year. Each has its own composition — there is no card that is another card at a different size.

string
offset query

How many periods back from the newest, matching the Reports stepper.

integer
size query

story (default), square or wide.

string
theme query

dark (default) or light. Asked at export time, not inherited — a share lands on somebody else's feed rather than inside the app, so the reader's own theme preference is not the question being answered.

string
format query

png (default) or svg. The SVG is the same document the PNG is rasterised from: smaller, sharp at any size, and something a native client can draw itself. It is not a second renderer — if the two ever disagreed it would be because one of them was not this one.

string

Responses

200

The image. Cache-Control is private: it is one person's listening, and a shared cache must not hold it.

image/png

string · binary

image/svg+xml

string
400

An unsupported unit, size or format.

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.

404

No report for that period — an offset past the end of the history, or an instance with nothing in it yet. A real answer, not a failure.

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/charts #
Token reador Session

Top charts.

Scope: read (since v0.120). Note this offers a different range set from /api/v1/stats — it has today. The two answering the same question over different windows is known, and is why the Insights redesign wants one shared range control.

Parameters

kind query

artists (default), albums or tracks.

string
range query

The window, as on GET /api/v1/stats: today, d7, month, year or all, the default.

string
limit query

100 by default, at most 500.

integer · int64
metric query

plays (default) or hours — the ranking, not just the number shown. Statistics hands off here carrying its own metric, and the two lists disagreeing would defeat the point of the handoff.

string

Responses

200

OK.

application/json

object

Ranked chart over one window. The Statistics page shows the head of these three lists; this serves the whole thing for the dedicated charts screen. A ranked chart.

kind string required

artists, albums or tracks.

range string required

The window, as asked.

rows array required
each item
object

One row of a chart. sub is the secondary line — an album's or track's artist — and is null for an artist chart, where the name says it all.

name string required
sub string | null
album string | null

Only set on a tracks chart — the record the track is on, so the row can open the album like every other track listing in the app.

plays integer · int64 required
seconds integer · int64 required
artwork_url string | null

A cover, from one of the listens counted.

caa_id integer | null · int64
caa_release_mbid string | null
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."
}