GET /api/v1/physical/lookup # Token writeor Session
Resolve a barcode to a pressing.
Scope: write (since v0.120), although it is a GET: it exists only to
add a record to the shelf, and it spends rate-limited Discogs and
MusicBrainz lookups doing it, so a token that can only read the shelf has
no use for it. GET /api/v1/art/library makes the same call.
Discogs first when configured — it catalogues pressings, so a barcode
usually resolves to the exact edition, often with real side letters that
MusicBrainz lacks. Falls back to MusicBrainz.
Barcodes may be sent with or without the printed spacing; the unspaced
form a scanner produces matches slightly more often.
Parameters
barcode query
Scanned off the sleeve or the spine.
catalog_number query
The label's catalogue number, which is on the spine when the barcode
isn't — and is the only identifier on most pre-1980 pressings.
artist query
With release, when there is neither.
kind query
vinyl, cassette, cd or sacd; narrows the format filter.
Responses
application/json
object
Candidate pressings for what you are holding.
candidates array required
Discogs first when a barcode and a credential are in hand, then
MusicBrainz.
each item object
year integer | null · int64
mbid_release string | null
catalog_number string | null
artwork_url string | null
tracks array required
each item object
One track on a physical side.
side string required
"A", "B", "C"… Cassettes have two, a double LP four.
position integer · int64 required
Position within the side, from 1.
duration_secs integer | null · int64
disc integer | null · int64
Which physical disc of the set this track is on, from 1.
Null means nobody recorded which disc, not disc 1.
discogs_enabled boolean required
notes array required
A provider that failed, and why. The others still answered.
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/physical/export # Session
The whole shelf as a zip.
Session-only. shelf.json (pressings, purchase dates, tracklists and an
image manifest), plays.csv (every side played, oldest first, gear
named), gear.json (equipment and chains with purchase dates) and
scans/ — the images the user uploaded, one directory per pressing.
Images that live at the Cover Art Archive or Discogs are listed with
their URLs rather than copied in: they are not the user's to
redistribute and they are still there to fetch. Their own scans exist
nowhere else, which is why those are the ones in the file.
This exists separately from GET /api/v1/backup for a concrete reason:
a backup is a VACUUM INTO snapshot of the database, and uploaded scans
are files beside it rather than rows inside it. A backup and this export
together are the whole shelf.
Responses
401 No valid session cookie or token. Also returned when a token is
presented to a session-only endpoint — the endpoint does not accept
tokens at all, so the scope is irrelevant.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
{
"code": 401,
"error": "Authentication required. Log in to access this endpoint."
}
POST /api/v1/physical/import # Session
Put a shelf back from an export.
Session-only, multipart/form-data, 256 MB maximum. The counterpart of
GET /api/v1/physical/export, and deliberately not a database
restore: it brings back the objects — pressings, tracklists, gear,
the sides played, and your own scans.
What it cannot bring back is the listening. A shelf export holds no
scrobbles, so the listens a side generated are not in the file, and
the chain hours derived from them do not return with the objects. The
response says so rather than leaving it to be discovered.
Everything is matched before it is inserted, so importing the same
archive twice changes nothing the second time. A pressing is matched on
its barcode or release MBID when it has one, and otherwise on artist,
title and medium together; a side played is matched on its side and
timestamp, so re-importing cannot double the wear on a stylus.
Signal chains are matched by name, never created — the same rule
[[gear:…]] follows. A chain is something you build step by step, and
conjuring an empty one from a name in a file would silently attribute
listens to a chain with no components. Gear is created, since a piece
of equipment is fully described by the file.
Nothing is written until shelf.json parses, so an unrelated or
truncated zip fails before it has half-populated a shelf.
Request body required
multipart/form-data
object
A multipart/form-data upload of one file.
The server takes the first file part whatever its field name; file is the
name to use. Its declared Content-Type decides how the file is stored — a
filename from the client never reaches a path on disk.
file string · binary required
Responses
application/json
object
releases integer required
already_on_the_shelf integer required
Matched an existing pressing and left alone.
skipped integer required
Entries that could not be read.
missing_scans integer required
Listed in the manifest but absent from the archive.
note string required
What a shelf export cannot carry.
400 Malformed or rejected input.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
401 No valid session cookie or token. Also returned when a token is
presented to a session-only endpoint — the endpoint does not accept
tokens at all, so the scope is irrelevant.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
{
"code": 401,
"error": "Authentication required. Log in to access this endpoint."
}
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/physical/settings/discogs/test # Session
Verify the stored Discogs credential.
Session-only, and necessary rather than a nicety: Discogs does not
reject a bad credential. With a bogus key and secret, /releases/1 and
/database/search both return a normal 200 with real data, and the
rate-limit header reads the authenticated 60 — the limit is raised for
the presence of an Authorization header without checking its contents.
/users/{username} is the one endpoint that actually verifies, and is
what this calls. Without it, a typo is indistinguishable from a working
credential.
Responses
application/json
object
A rejection is a 200 with ok: false: the request succeeded and the answer
is no.
message string required
Discogs' own words, which name the credential shape it rejected.
400 Discogs is not configured on this server.
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."
}
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/physical/spin # Token reador Session
The side currently on the deck.
Scope: read (since v0.120). Returns { "spin": null } when nothing is on.
The shelf is the one source where the browser is the player, so the
countdown runs in the page — but what is on and when it started live
here. That is what lets a second device see the record, and what makes a
side survive a browser crash.
A spin whose release has since been deleted is cleared and reported as
nothing on, rather than returned as a dangling id.
Responses
application/json
object
spin object | null
Null when nothing is on the deck.
release_id integer · int64 required
artwork_url string | null
mbid_release string | null
started_at integer · int64 required
paused_at integer | null · int64
paused_total integer · int64 required
outcomes array required
Per-track {position, started_at, skipped}, as the deck wrote them.
chain_id integer | null · int64
equipment_id integer | null · int64
start_secs integer · int64 required
How far into the side this spin began — non-zero when the needle was
dropped mid-side, or a tape resumed from where it was parked.
updated_at integer · int64 required
401 No valid session cookie or token. Also returned when a token is
presented to a session-only endpoint — the endpoint does not accept
tokens at all, so the scope is irrelevant.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
{
"code": 401,
"error": "Authentication required. Log in to access this endpoint."
}
PUT /api/v1/physical/spin # Token writeor Session
Put a side on the deck, or update the one that is on.
Scope: write (since v0.120). One deck per user — the table's primary key is the user,
because nobody listens to two records at once, so starting a second side
replaces the first rather than erroring.
The whole state is sent every time rather than a patch per field: it is
half a dozen small numbers, it changes only on real events (start,
pause, skip, next track), and a partial update would let two devices
interleave into a state neither of them meant.
Request body required
application/json
object
The whole state of the deck, every time.
release_id integer · int64 required
Must be yours — a 404 otherwise.
started_at integer · int64 required
paused_at integer | null · int64
paused_total integer · int64
outcomes array
Per-track {position, started_at, skipped}, stored whole.
chain_id integer | null · int64
equipment_id integer | null · int64
start_secs integer · int64
How far into the side this spin began — the needle dropped mid-side, or
a tape resumed from where it was parked.
Responses
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."
}
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/physical/images/{id}/file # Token reador Session
An uploaded scan's bytes.
Scope: read (since v0.120), and scoped to the caller by (id, user_id) — these are
photographs of someone's own possessions and ids must not be walkable,
the same rule the artwork proxy keeps.
Cached hard, because a scan never changes once uploaded, but private:
it is per-user, and a shared cache holding it would serve it to the next
person through the proxy. A provider-hosted image 404s here — it has a
URL of its own and this server is not in front of it.
Responses
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."
}
404 No such resource, or it belongs to another user.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
Add a release to the shelf.
Scope: write (since v0.120). Seed from a barcode lookup, or supply the tracklist yourself.
Request body required
application/json
object
kind string required
vinyl, cassette, cd or sacd. A disc has no sides: its
tracklist is one numbered unit per disc.
year integer | null · int64
mbid_release string | null
catalog_number string | null
artwork_url string | null
tracks array
each item object
One track on a physical side.
side string required
"A", "B", "C"… Cassettes have two, a double LP four.
position integer · int64 required
Position within the side, from 1.
duration_secs integer | null · int64
disc integer | null · int64
Which physical disc of the set this track is on, from 1.
Null means nobody recorded which disc, not disc 1.
format string | null
The medium the release declares — "CD", "12\" Vinyl", "Hybrid SACD" — as the lookup returned it. When present it overrides kind,
because kind is what was picked before anyone looked at the record and
this is what the record says it is.
purchased_at integer | null · int64
When you bought it, unix seconds. Optional — most of a shelf is
catalogued long after the fact and guessing would be worse than blank.
acquired_as string | null
How it came to be yours — new, used, gift or inherited.
Optional, and absent means nobody said: a shelf catalogued before this
existed must not be retroactively claimed as bought new.
acquired_from string | null
Where from — the shop, the record fair, the person who gave it to you.
Free text, because that is the half that makes a thrift find a story
rather than a flag.
fetch_images boolean
Pull the sleeve, labels and booklet from the Cover Art Archive as part
of adding it. On by default. A failure does not fail the add, and
it does nothing without mbid_release.
Responses
application/json
object
id integer · int64 required
images integer required
How many scans came back from the archive.
corrected_from string | null
Set when the release's own format overrode the kind asked for — a
box of CDs looked up by barcode is filed as CDs whatever was picked.
400 Malformed or rejected input.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
401 No valid session cookie or token. Also returned when a token is
presented to a session-only endpoint — the endpoint does not accept
tokens at all, so the scope is irrelevant.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
{
"code": 401,
"error": "Authentication required. Log in to access this endpoint."
}
PATCH /api/v1/physical/{id} # Token writeor Session
Edit the shelf-keeping fields.
Scope: write (since v0.120). When it was bought, what it is normally played through,
and the note beside it.
Every field distinguishes absent from null: omitting one leaves the
column alone, and an explicit null clears it. That is not decoration —
a bare optional reads a misspelled key as an explicit null, so a typo
would clear the chain it was trying to set and answer 200. Same shape and
the same reason as default_chain_id on PATCH /admin/tokens/{id}.
chain_id and equipment_id are checked against the caller's own rows
before they are stored.
kind is the exception to the absent/null rule — a format has no
"cleared" state, so a plain optional is correct. Changing it rewrites
the tracklist's sides, because what a side is depends on the medium: a
record has a break assign_sides has to place by playing time, and a
disc has none at all. The response reports sides_rewritten so a client
knows the tracklist it is holding is stale.
Request body required
application/json
object
The shelf-keeping fields. An absent field is left alone and an explicit
null clears it — a misspelled key is never read as a clear.
purchased_at integer | null · int64
When you bought it, Unix seconds.
acquired_as string | null
new, used, gift or inherited.
acquired_from string | null
The shop, the fair, the person.
chain_id integer | null · int64
What it is normally played on. Must be yours — a 400 otherwise.
equipment_id integer | null · int64
Must be yours — a 400 otherwise.
chain_variant_id integer | null · int64
The variant of chain_id this pressing is normally played on — the
cartridge you keep fitted for it.
kind string | null
Move it to another shelf: vinyl, cassette, cd or sacd. Not
clearable. Changing it re-derives the tracklist's sides, because what a
side is depends on the medium.
mbid_release string | null
The MusicBrainz release this pressing is, typed in by hand — also the
cheapest way to ask for running times.
Responses
application/json
object
sides_rewritten boolean required
The medium changed and the tracklist's sides were re-derived — a
record's A/B break, or a disc's numbers.
400 Malformed or rejected input.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
401 No valid session cookie or token. Also returned when a token is
presented to a session-only endpoint — the endpoint does not accept
tokens at all, so the scope is irrelevant.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
{
"code": 401,
"error": "Authentication required. Log in to access this endpoint."
}
404 No such resource, or it belongs to another user.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
DELETE /api/v1/physical/{id} # Session
Remove a shelf item.
Session-only, deliberately, although the rest of the shelf takes a
write token: its plays and its images go with it, and any scan files the
user uploaded are removed from disk — nothing else knows those files
exist, so leaving them would orphan them permanently. Scans are not in
GET /api/v1/backup, so this is the one shelf operation a lost phone could
make unrecoverable, which is the line DELETE /api/v1/scrobbles sits on.
Responses
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."
}
404 No such resource, or it belongs to another user.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
PUT /api/v1/physical/{id}/tracks # Token writeor Session
Replace the tracklist and side split.
Scope: write (since v0.120). Sides are not reliably available from any provider —
MusicBrainz records a vinyl track number as "A1" only when an editor
entered one. When there is no explicit side letter the split is guessed
by playing time, not track count, because a mastering engineer
balances sides and a nine-minute closer would otherwise land on the
wrong one. It is still a guess, which is why this endpoint exists.
None of that applies to a disc. A CD or SACD plays start to finish,
so its tracks are numbered one unit per disc with no guessing at all —
splitting a CD by playing time would invent a break that is not there.
The medium's own format string decides which rule applies, not what was
searched for.
It is also how a running time is typed in, which for a pressing no
provider carries is the only way one is ever known. That is not
cosmetic: scrobbles.duration is what every hours readout sums and
physical_plays.duration_secs is what the stylus meter reads, so this
corrects both alongside the tracklist rather than leaving three
answers free to disagree. Whole-side plays only — a partial records how
many tracks ran and never which, so its wear cannot be reconstructed.
Unlike POST /durations, a value here overwrites a stored one. That
is the whole difference between the two: there the source is another
pressing and a hand-typed figure must survive it, here the source is the
person, and refusing their correction would make the field pointless.
Request body required
application/json
object
tracks array required
The whole tracklist. Not empty, no side and position twice, and no
running time of zero or less.
each item object
One track on a physical side.
side string required
"A", "B", "C"… Cassettes have two, a double LP four.
position integer · int64 required
Position within the side, from 1.
duration_secs integer | null · int64
disc integer | null · int64
Which physical disc of the set this track is on, from 1.
Null means nobody recorded which disc, not disc 1.
Responses
application/json
object
changed integer required
Running times that differ from what was stored.
listens_updated integer · int64 required
Listens already recorded from these sides that were corrected.
plays_recomputed integer · int64 required
Whole-side plays whose recorded wear was brought back in line.
400 Empty tracklist, a duplicated side and position, or a running time of zero or less.
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."
}
404 No such resource, or it belongs to another user.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
GET /api/v1/physical/{id}/durations # Session
What another pressing says this record's tracks run.
Preview only — nothing is written. Session-only.
Exists because a record catalogued from Discogs often has no running
times at all, which used to leave the deck showing three minutes a track
as though it were a length. The lookup is a search (MusicBrainz id if
the record has one, else barcode, else artist and title), so whatever
comes back is a different pressing and may be ordered differently or
carry bonus tracks — tracks are therefore matched by title, never by
position.
complete: true means every track already has a duration and there is
nothing to do. An empty durations with a null source means nothing
with running times was found, which is a normal answer.
Responses
application/json
object
What another pressing says the tracks run. Nothing is written.
complete boolean required
Every track already has a running time; there is nothing to ask.
durations array required
each item object
One track's proposed running time, as previewed and as applied.
position integer · int64 required
current integer | null · int64
What the shelf holds now.
proposed integer | null · int64
What the looked-up pressing says. Null where nothing matched — never a
guess.
source object | null
The pressing the running times came from.
year integer | null · int64
mbid_release string | null
unavailable boolean | null
The lookup could not be made — MusicBrainz was busy, throttling, or
did not answer inside this endpoint's own 20-second deadline. Still a
200, deliberately: an upstream being slow is a normal outcome of
asking, and a 5xx here is indistinguishable from a reverse proxy's own.
An empty durations without this means the lookup succeeded and found
nothing.
400 That record has no tracklist.
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."
}
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/physical/{id}/durations # Session
Write the running times that were previewed.
Session-only. Takes the proposals back rather than searching again,
so what lands is what was on the screen — a preview that applies
something else is worse than no preview.
Only fills a duration the record does not already have: one that was
typed in, or that an earlier lookup got right, is never overwritten by a
later fetch.
The listens already recorded from those sides are given the same figures,
because every hours readout derives from scrobbles.duration. Their
timestamps are not touched — physical_plays records when a side
started and nothing about when it ended, so where the tracks fell is
unrecoverable, and re-spacing them would assume the side ran
uninterrupted.
Request body required
application/json
object
The proposals exactly as the preview showed them.
durations array required
each item object
One track's proposed running time, as previewed and as applied.
position integer · int64 required
current integer | null · int64
What the shelf holds now.
proposed integer | null · int64
What the looked-up pressing says. Null where nothing matched — never a
guess.
Responses
application/json
object
filled integer required
Tracks that gained a duration. One the shelf already had is never
overwritten.
listens_updated integer · int64 required
Listens already recorded that were corrected.
plays_recomputed integer · int64 required
Side plays whose recorded wear was brought back in line. Whole-side
plays only — a partial play records how many tracks ran but never
which, so its wear cannot be reconstructed and is left alone.
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."
}
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/physical/{id}/play # Token writeor Session
Record a side as played.
Scope: write (since v0.120). Writes one listen per track, not one per side — every
counting query in Tapedeck is over listens, so a side-shaped row would
be invisible to all of them.
Timestamps are the one inferred thing here: you observed when the side
started, so tracks are spaced by their running times. A track of
unknown length gets three minutes — wrong but bounded, where stacking
them on one instant would break session grouping and the hour-of-day
heatmap outright.
Per-track outcomes from the browser deck are taken as authoritative: a
side laid out from a start time can only describe an uninterrupted play.
A skipped track is stored, excluded from every count, and never
forwarded — and does not accrue gear hours.
Request body required
application/json
object
tracks array
Per-track outcomes from the deck. Authoritative where present — the
browser ran the clock, so it is the only thing that saw the pauses and
the skips. Without it the running order is laid out from started_at.
each item object
What actually happened to one track on the side, as the deck saw it.
position integer · int64 required
started_at integer · int64 required
When this track actually started, unix seconds.
skipped boolean
Skipped rather than heard. Stored, but excluded from every count and
never forwarded.
started_at integer | null · int64
When the side started, unix seconds. Defaults to now minus its running
time, which is what you want when you press the button as it finishes.
equipment_id integer | null · int64
The turntable, cartridge or deck. Its playback hours are summed from
these rows.
chain_id integer | null · int64
to_position integer | null · int64
The last track that played, when the side was not run to its end. With
from_position this selects a range; the two equal is a single track.
Tracks after it are not written at all — not as skips either. The play
still counts as wear.
from_position integer | null · int64
The first track that actually played, when the side did not start at
its beginning. Load-bearing: without it, starting at track 4
records tracks 1–3 as played. They are not written at all — and not as
skips, since a track you never reached is not one you skipped. Wear
is credited only for the part that played.
parked_secs integer | null · int64
Where a tape was left when it came off, in seconds into the side.
Cassettes only, and clamped to the side's length. Absent means the
side ran through and the tape is wound back.
chain_variant_id integer | null · int64
Which variant of the chain was fitted — the cartridge in the headshell.
The shelf is the one write path that legitimately knows this, because
you told it when you dropped the needle.
Responses
application/json
object
listens_added integer required
duration_secs integer · int64 required
Running time credited as wear.
started_at integer · int64 required
next_side string | null
The side to reach for next — only when this one ran to its end.
400 Malformed or rejected input.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
401 No valid session cookie or token. Also returned when a token is
presented to a session-only endpoint — the endpoint does not accept
tokens at all, so the scope is irrelevant.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
{
"code": 401,
"error": "Authentication required. Log in to access this endpoint."
}
404 No such resource, or it belongs to another user.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
POST /api/v1/physical/{id}/now-playing # Token writeor Session
A side is on the deck.
Scope: write (since v0.120). The shelf is the one source where the browser is the
player: the clock runs in front of the turntable, in localStorage, and
nothing server-side can derive a playhead from a stack of rows. So the
deck pushes here, where Plex, Jellyfin and Navidrome are polled.
Call it on every track change, on pause and resume, and on a heartbeat
of about ten seconds. The heartbeat is not optional: the registry
expires an entry by running past the end of the track it knows about, so
a paused record would drop off the dashboard one track length after it
was paused.
Now-playing is forwarded to the user's Last.fm and ListenBrainz only
when the track actually changed, and never while paused — their
now-playing is a one-shot that expires on its own, so re-sending it on
every heartbeat is noise aimed at someone else's servers. The listens
themselves are written by /play when the side comes off; this endpoint
stores nothing.
Request body required
application/json
object
position integer · int64 required
The track's position on the side.
paused boolean
A paused record is not playing, but it is still on the deck. The entry
stays and stops advancing, which is what the still platter shows.
Responses
application/json
object
changed boolean required
A different track from the last report.
400 Malformed or rejected input.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
401 No valid session cookie or token. Also returned when a token is
presented to a session-only endpoint — the endpoint does not accept
tokens at all, so the scope is irrelevant.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
{
"code": 401,
"error": "Authentication required. Log in to access this endpoint."
}
404 No such resource, or it belongs to another user.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
GET /api/v1/physical/{id}/sides # Token reador Session
How often each side has been played, and for how long.
Scope: read (since v0.120). A record has a side you reach for and a side you rarely
turn over, and the release's own totals cannot say which is which.
Everything here is derived from the sides played on read — never a
counter — so it cannot drift from the plays it counts, and deleting a
play takes its wear back with it. secs is the running time of the
tracks that actually played, which is the same figure the stylus is
charged for: a side started at track four counts four tracks' worth.
It is nominal rather than wall clock.
Only sides with plays are returned. A side that has never been
played is not an entry with zeros — the caller holds the tracklist and
fills those in. That also means a side may appear that is not on the
tracklist at all: re-filing a release rewrites its sides (a vinyl A/B
becomes a CD's 1) while its plays keep the label they were recorded
with, and dropping those would leave the sides summing to less than the
release total with nothing to say why.
Responses
application/json
object
sides array required
Only sides that have been played, including any recorded under a label
the tracklist no longer uses.
each item object
One side of a pressing, and what it has been through.
Every field is derived from the plays on read.
side string required
As it was recorded: "A", "B", or a disc number.
plays integer · int64 required
secs integer · int64 required
Running time of what actually played, summed over every play.
last_played integer | null · int64
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."
}
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/physical/{id}/images # Token reador Session
Every scan held for a pressing.
Scope: read (since v0.120). Sleeves, labels, obi strips and booklet pages — the parts
of a record you actually hold, which is why they are rows rather than
one more artwork_url column. The Cover Art Archive carries 29 for a
typical CD, 19 of them booklet pages.
Each entry carries a single src and thumb whichever provenance it
came from: a provider's image is its own URL, an uploaded scan is a
Tapedeck URL that checks who is asking. The server's own file layout is
never in the response.
Responses
application/json
object
images array required
each item object
One scan or photograph of a pressing.
id integer · int64 required
release_id integer · int64 required
kind string required
front, back, booklet, medium, spine, … Free text rather than
an enum: the archive's vocabulary grows, and a scan of something it has
no word for is still a scan.
src string required
What to render, whichever provider it came from.
position integer · int64 required
bytes integer | null · int64
added_at integer · int64 required
401 No valid session cookie or token. Also returned when a token is
presented to a session-only endpoint — the endpoint does not accept
tokens at all, so the scope is irrelevant.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
{
"code": 401,
"error": "Authentication required. Log in to access this endpoint."
}
POST /api/v1/physical/{id}/images # Token writeor Session
Upload scans of your own copy.
Scope: write (since v0.120). multipart/form-data: any number of file fields, plus
optional kind and caption text fields that apply to all of them —
which is what makes uploading a booklet in one go work, since the files
land in the order they are sent.
The declared content type decides the stored extension; a filename from
the client never reaches the path. Max 24 MB per image.
Uploads are files on the server rather than blobs in the database, so
GET /api/v1/backup does not carry them — GET /api/v1/physical/export
is what does.
Request body required
multipart/form-data
object
A scan of your own copy. Several file fields may be sent at once.
file string · binary required
JPEG, PNG, WebP, GIF, AVIF or TIFF, at most 24 MB.
Responses
400 Malformed or rejected input.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
401 No valid session cookie or token. Also returned when a token is
presented to a session-only endpoint — the endpoint does not accept
tokens at all, so the scope is irrelevant.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
{
"code": 401,
"error": "Authentication required. Log in to access this endpoint."
}
404 No such resource, or it belongs to another user.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.
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.
415 Not an image type this server stores.
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/physical/{id}/images/fetch # Token writeor Session
Pull the sleeve from the Cover Art Archive.
Scope: write (since v0.120). Runs when the user asks rather than on a clock: the
archive is not going to change under a record that has been on a shelf
for thirty years, and the one case that does — an editor uploading the
booklet you were missing — is exactly the case where the user knows and
Tapedeck cannot.
Idempotent on the provider's own image id, so re-running this adds
what is new and leaves the rest alone rather than stacking a second copy
of all nineteen booklet pages on top of the first.
Discogs runs second and only when a credential is configured; it says
only "primary" or "secondary" about an image, so everything but the
cover lands in other for the user to file. A pressing with no
MusicBrainz id is not an error — it comes back with a note saying
there is nothing to look it up by.
Responses
application/json
object
notes array required
A provider with nothing to offer or that failed, and why.
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."
}
404 No such resource, or it belongs to another user.
application/json
object
Every error body in the API has this shape.
code integer · int32 required
error string required
Human-readable. Not a stable identifier — do not branch on it.