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
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
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"
}
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.
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.
Responses
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.
plays integer · int64 required
hours number · double required
top_albums array required
each item object
plays integer · int64 required
hours number · double required
top_tracks array required
each item object
plays integer · int64 required
hours number · double required
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.
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.
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.
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
pct integer · int64 required
A whole percentage of the classified listens.
heatmap array required
Seven rows of 24, counts of plays by hour, in your own timezone. Row 0
is Monday.
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
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"
}
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
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.
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
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
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
lines array required
each item object
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
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
each item object
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
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.
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
plays integer · int64 required
duration integer | null · int64
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.
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
plain string | null
The words, plain. Absent clears them.
synced string | null
The timed form, [mm:ss.xx] words per line.
Responses
application/json
object
A hand correction, stored.
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
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.
album string | null
Advisory. Not used to match — track_key is artist and title — and kept
out of the store for the same reason.
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
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."
}
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.
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
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
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
lines array required
each item object
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
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
each item object
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
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.
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
plays integer · int64 required
duration integer | null · int64
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.
position integer · int64 required
position_known boolean required
Whether position is a measurement. Do not drive a highlight from a
guess.
duration integer | null · int64
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
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
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
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
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
lines array required
each item object
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
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
each item object
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
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.
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
plays integer · int64 required
duration integer | null · int64
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
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
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.
Responses
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
duration integer | null · int64
synced boolean required
Timed — a timed lyric follows the deck, a plain one cannot.
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.
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.
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.
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.
limit query
How many periods to return, newest first. 12 by default, at most 60.
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.
Responses
application/json
object
A feed of period cards, newest first.
timezone string required
The IANA zone the buckets were cut in.
periods array required
each item object
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.
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
plays integer · int64 required
top_track object | null
plays integer · int64 required
sub array required
One level finer than the period — days across a week, weeks across a
month, months across a year.
sub_labels array required
genres array required
The six biggest genres. Shares of tag mentions, not of listens.
each item object
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.
shares array required
One row per sub-bucket; each row is the named shares then other.
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
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
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
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
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
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.
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".
prev object | null
The period before, for deltas. Null for the oldest.
plays integer · int64 required
hours number · double required
avg_daily integer · int64 required
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
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"
}
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.
offset query
How many periods back from the newest, matching the Reports stepper.
size query
story (default), square or wide.
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.
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.
Responses
200 The image. Cache-Control is private: it is one person's listening, and a shared cache must not hold it.
image/png
image/svg+xml
400 An unsupported unit, size or format.
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.
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
error string required
Human-readable. Not a stable identifier — do not branch on it.