← API reference

Import & export

Bulk in and out. Session-only.

8 of 8 · v0.120.0
GET /api/v1/export #
Session

Export your listens.

Session-only. Your whole history, newest first, streamed in pages so a large one cannot exhaust the server's memory. JSON by default — an array of Listen, the shape the importer reads back — or CSV with title,artist,album,format,chain,mbid,time.

Parameters

format query

csv, or anything else for JSON — the default.

string

Responses

200

An attachment, tapedeck-export.json or .csv.

application/json

array
each item
object

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

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

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

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

album string | null
timestamp integer · int64 required

Unix seconds, UTC, start of play.

duration integer | null · int64

Seconds.

source_id string required

Dedup key. Unique per user per play.

source_name string required

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

status string required

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

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

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

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

mbid_release_group string | null

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

mbid_release_track string | null

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

mbid_work string | null

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

mbid_album_artist string | null

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

caa_id integer | null · int64

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

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

pcm, dsd, mqa.

codec string | null
bitrate integer | null · int32

kbps.

sample_rate integer | null · int32

Hz.

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

Hz.

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

Set only on a genuine transcode.

delivery_bitrate integer | null · int32

kbps.

delivery_sample_rate integer | null · int32

Hz.

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

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

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

The resolved signal chain.

chain_variant_id integer | null · int64

Which setup of that chain was fitted.

session_id integer | null · int64
listening_context string | null

From the resolved chain.

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

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

source_ref integer | null · int64

The user_sources row this was polled from.

listened_ms integer | null · int64

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

is_shuffle boolean | null

Shuffle chose this, not the listener.

queue_source string | null

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

company string | null

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

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

joint_session_id string | null

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

delivered_sinks string | null

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

text/csv

string
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/backup #
Session

Download a database snapshot.

Session-only, admin role. A VACUUM INTO snapshot of the whole database — every user's listens, and the encrypted credentials.

The encryption key deliberately lives outside the database, so it does not ride along in this file. That is what the encryption protects against: a leaked backup, not host compromise. Do not describe it as more than that.

Responses

200

The SQLite file, as the attachment tapedeck-backup.db.

application/x-sqlite3

string · binary
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"
}
POST /api/v1/import/listenbrainz #
Session

Import from a ListenBrainz account.

Session-only. Paced deliberately: a 30s timeout, exponential backoff on network errors and 5xx, 429 honoured via X-RateLimit-Reset-In, and 400ms between pages. Do not remove the inter-page sleep to speed this up — hammering is what broke it at ~80k listens.

Request body required

application/json

object
username string required

The account to import from.

token string | null

ListenBrainz only: a user token, which lets a private history be read.

Responses

202

Job started; poll it.

application/json

object

The id to poll /api/v1/import/jobs/{job_id} with.

job_id string required
400

Malformed or rejected input.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

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

401

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

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

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

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
POST /api/v1/import/lastfm #
Session

Import from a Last.fm account.

Session-only.

Request body required

application/json

object
username string required

The account to import from.

token string | null

ListenBrainz only: a user token, which lets a private history be read.

Responses

202

Job started; poll it.

application/json

object

The id to poll /api/v1/import/jobs/{job_id} with.

job_id string required
400

Malformed or rejected input.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

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

401

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

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

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

{
  "code": 401,
  "error": "Authentication required. Log in to access this endpoint."
}
POST /api/v1/import/file #
Session

Import a file.

Session-only. Sniffs the content, not the filename — people rename downloads. Accepts a ListenBrainz export zip (JSONL, and its feedback.jsonl becomes loves), a Spotify Extended Streaming History zip, a CSV, or a Rockbox .scrobbler.log. The two zip formats are told apart by their entry names, since both are plain zips.

A ListenBrainz export carries a full mbid_mapping, so those listens skip MusicBrainz enrichment and the artwork chain — for a 20k history that is the difference between free and a week of rate-limited lookups. A Spotify export carries no MusicBrainz identifiers at all, so the opposite is true and every distinct track needs a lookup afterwards.

A Spotify export's ts is when the track stopped, not when it started, so it is corrected on the way in; each play is filed as a listen or a skip on the same half-the-track threshold the live sources use, rather than on Spotify's own skipped field, which records something else. Podcast episodes and audiobook chapters are dropped. Those listens keep spotify_track_id and land with source_name spotify.

Rockbox #TZ/UNKNOWN is refused, not guessed. DAPs often have no time sync, so their timestamps are local wall-clock seconds; assuming the server's zone would shift a whole device's history by the offset, unrecoverably. Supply timezone in that case.

Imports land as imported and are never forwarded.

Request body required

multipart/form-data

object

An export or log file, sniffed by content rather than by name.

file string · binary required
timezone string | null

IANA region. Required for a Rockbox log whose header says #TZ/UNKNOWN.

Responses

202

Job started; poll it.

application/json

object

The id to poll /api/v1/import/jobs/{job_id} with.

job_id string required
400

Malformed or rejected input.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

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

401

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

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

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

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

Jobs running right now.

Session-only. Live progress is an in-memory registry, so in-flight jobs are lost on restart (the import aborts with them). Finished jobs are persisted — see /api/v1/import/history.

Responses

200

OK.

application/json

object
jobs array required

Still running.

each item
object

One import's progress.

job_id string required
source string required

listenbrainz, lastfm or file.

state string required

running, done or error.

fetched integer required

Listens read from the source, before dedup.

imported integer required

New rows actually inserted.

enriched integer required

Listens already held that the incoming copy taught something to — an MBID, a duration, a cover-art id, a track number. Fields that are blank are filled from a duplicate; fields already populated are never touched. Counted apart from imported because a re-import of a history you already hold correctly inserts nothing, and imported: 0 alone reads as though the job did nothing at all.

total integer | null

A known upper bound, when the source reports one.

status string required

Human-readable progress; the error detail when state is error.

date integer · int64 required

Started, Unix seconds.

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/import/jobs/{job_id} #
Session

One job's progress.

Parameters

job_id path required
string

Responses

200

OK.

application/json

object

One import's progress.

job_id string required
source string required

listenbrainz, lastfm or file.

state string required

running, done or error.

fetched integer required

Listens read from the source, before dedup.

imported integer required

New rows actually inserted.

enriched integer required

Listens already held that the incoming copy taught something to — an MBID, a duration, a cover-art id, a track number. Fields that are blank are filled from a duplicate; fields already populated are never touched. Counted apart from imported because a re-import of a history you already hold correctly inserts nothing, and imported: 0 alone reads as though the job did nothing at all.

total integer | null

A known upper bound, when the source reports one.

status string required

Human-readable progress; the error detail when state is error.

date integer · int64 required

Started, Unix seconds.

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."
}
404

No such resource, or it belongs to another user.

application/json

object

Every error body in the API has this shape.

code integer · int32 required

Mirrors the HTTP status.

error string required

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

GET /api/v1/import/history #
Session

Finished import jobs.

Session-only. Persisted, so this survives restarts.

Responses

200

OK.

application/json

object
imports array required

The last fifty, newest first.

each item
object
source string required

listenbrainz, lastfm or file.

date integer · int64 required

Started, Unix seconds.

count integer · int64 required

New rows actually inserted.

status string required

done, error, or interrupted — a job the server restarted under.

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."
}