Analysis plugin
First-partySits beside your library, reachable through Plex, Jellyfin or OpenSubsonic, and decodes the files where the bandwidth already is.
Each of these exists because a listening history that quietly guesses is worse than one that admits it doesn't know. Where a number is drawn from a narrower population than "everything you played", it says so.
Tapedeck implements the ListenBrainz Core API endpoints a scrobble client actually exercises, so Pano Scrobbler, Web Scrobbler, multi-scrobbler and mpdscribble work out of the box. Point one at your URL, paste a token, done.
Beyond the spec it accepts extended fields for audio quality, device identification, chain
tagging and session metadata — and reads them back in additional_info, so a
listen round-trips intact.
It reads forgivingly. A track number may be 7 or "7/12"; anything unusable costs the track number and never the listen, because
a whole batch failing over one cosmetic field is the worst possible trade.
On listenbrainz.org a user's listens are public. A self-hosted Tapedeck usually isn't, so these endpoints authenticate and return only your own data.
Listens are keyed by source rather than assigned recording MSIDs. Playlists, lb-radio and similar-users return a clean JSON 404 rather than a stub.
Each account connects its own Last.fm and Libre.fm by OAuth and ListenBrainz by token. There are no server-wide credentials, so nothing leaks between users on a shared instance. Connect nothing and your listens simply stay local.
Sources belong to a user, not to the server — each person connects their own token and Tapedeck records only that account's playback. As many as you like, of any kind, each with its own name, poll interval and filters. Polling is staggered so a household doesn't hit the same server on one tick.
Polled directly — no webhooks, no Plex Pass — with live Now Playing on the dashboard and a scrobble threshold that is yours: half the track or four minutes by default, changed in Settings and pushed to running sources without a restart.
Plex also keeps a play history, and Tapedeck walks it. That catches plays that finished between polls, and it pages back rather than reading one screenful — a source unreachable for an hour catches up instead of losing the gap.
Every scrobble can carry codec, sample rate, bit depth, DSD rate and delivery format. Tapedeck tracks source against delivered, so you know when your FLAC was degraded on the way out. Pick a format and a link to see what it scores.
Nothing between the file and the DAC.
Hours on each piece of gear are derived from listens rather than accumulated as they arrive, so assigning a chain to a device moves the wear for plays you already have. Skips count for nothing — crediting a pair of headphones with a full running time for three seconds of audio is not a measurement.
Top artists, records and tracks side by side, a listening clock in your own timezone, language and decade breakdowns, and five explainable axes that name you. All local — no external call, and never a percentile against other people.
"Various Artists" never appears as an artist anywhere: it is a filing convention meaning look at the track. The plays still count in every total, so 50 plays and 1 artist can both be true.
A month gets the full treatment; a week the compact one, because seven days make a thin hour-of-day distribution; a year is a bento built around a twelve-month line with the prior year behind it. Folded from one pass over the history.
Your listening on Glenn McDonald's map of the genre space, with a taste trajectory over time and the regions bordering what you already play. A placeholder until per-recording features are computed from your real files — by the analysis plugin or by AudioMuse-AI.
Forgotten favourites, loved but barely played, one-listen records, seasonal, deep cuts, the 3 AM club, dusty vinyl, and never heard on a particular pair of headphones. Nothing here recommends music you don't own.
Similar songs, a dimensional crawl between seeds, or a rollercoaster along whichever axis you pick. A Judge by slider weights genre-space against your own listening, because otherwise "similar" drifts toward "played at the same hour".
Story, square or wide — and each period gets its own composition rather than one card
relabelled. A week draws by-day bars and no genre split, because seven days make a thin one;
a year draws by-month bars and genre bands instead. The fonts are embedded in the binary and
nothing is fetched, so the same period produces the same picture from any client, and a phone
app asks GET /api/v1/reports/image rather than reimplementing a layout that would
drift from this one.
Every Noise places artists. Lifting that needs a coordinate per recording, and Tapedeck cannot work one out — it reads what your server reports about a play, never the audio itself. Two things can, and they are different trades rather than competitors.
Sits beside your library, reachable through Plex, Jellyfin or OpenSubsonic, and decodes the files where the bandwidth already is.
Points at your media server and works out how each recording sounds — embeddings, tempo, key, energy, mood and six continuous axes.
Tapedeck can talk to AudioMuse-AI today — and what that gives you is a measurement, not a feature. Nothing reads the vectors yet. What it reports is how much of your history could ever be joined to them, in three parts kept separate, because one number would flatter itself.
Markdown kept next to the music, about a track, a release, an artist, or one particular playing. Edited in place with every earlier version archived. Rendered and sanitised server-side, so shortcode resolution happens in the same pass and notes are safe to display anywhere.
Searchable across both what you wrote and what you wrote it about, filterable by kind, sortable by edit, by date written or alphabetically.
Every heart in one place — tracks, records and artists, since a love attaches to an entity rather than only a song. A recording love mirrors out to Last.fm and ListenBrainz; albums and artists stay local because neither service has the concept.
Loves you already have on those services can be pulled back in from Settings → Connections. The star ratings sitting in Plex, Navidrome, Jellyfin or Emby become loves from Settings → Ratings, above a threshold you set in five or ten stars, halves or whole.
Each rating is kept exactly as the server gave it, beside what it was out of, and the threshold is applied to the fraction. So four out of five is the same verdict as eight out of ten, and a Plex 7 does not clear either. Raising the threshold takes back only the loves it made. A love you pressed yourself is outside its reach, and nothing it does is forwarded to Last.fm or ListenBrainz.
Read from your own Plex or Jellyfin first, from LRCLIB after that, and words you type in yourself outrank both. They are fetched at all for one reason: script detection settles any non-Latin title and gives up on a romanised one. Three outcomes, all real answers — a language by script weight, instrumental when the track has no words, or nothing at all for a Latin lyric, because guessing French is the mistake the fidelity cards were fixed for.
Cached outside the backup, never redistributed. A note quotes a line by index, never by its words.
When words are present there is a lyrics theatre — current line large, the rest falling away, following the clock when they're timed. It exists for the listening you can't touch: a side is playing and your hands are on a record.
| Format | What comes across | What it costs |
|---|---|---|
ListenBrainz export .zip | Listens and loved tracks, with MusicBrainz IDs and cover-art references already in it | No lookups at all |
Spotify extended history .zip | Re-anchored to when tracks started, durations recovered for ~93% of rows, skips judged by your own threshold | Real enrichment work |
| Last.fm / ListenBrainz by username | Public listens, pulled and deduplicated | Background job |
Rockbox .scrobbler.log | Track numbers, lengths, any MusicBrainz IDs the player recorded; skips kept but never counted | Asks the device's timezone |
.json / .jsonl / .csv | Whatever the file carries, validated on the same path a live client takes | Background job |
| One pasted listen | A ListenBrainz-shaped body, on the Import screen | Instant |
Your entire history as JSON or CSV, and a consistent VACUUM INTO backup of the
whole database — safe to take while running.
One file per note with YAML frontmatter, or a single document. Drop it in an Obsidian or
Logseq vault and [[track:…]] arrives as working wikilinks.
Stored as imports, so a backfill of old history is never pushed onward onto a permanent public record.
Listens are written to the database before the API returns — on SQLite, the default, in WAL mode with a busy timeout, so concurrent writers wait rather than error. If one can't be persisted, submit-listens returns a retryable 503 and dedup makes the retry safe.
Each pending listen tracks which sinks have accepted it, so a retry only re-sends to the ones that haven't. A flaky Last.fm won't get duplicates because ListenBrainz was down.
Retries are budgeted separately for rate-limited and unreachable, and after three consecutive failures a service is skipped until a single probe finds it back. The flush is bounded against the poll tick.
On SIGTERM the HTTP server drains in-flight requests, then the poll engine finishes its tick and exits. Even a hard SIGKILL can't corrupt the database; pending scrobbles resume on restart.
A consistent VACUUM INTO snapshot of the whole database from the Settings screen or one endpoint, plus /health for uptime monitoring and admin-only Prometheus metrics.
Repeated failed logins from an IP are rate-limited — eight tries in five minutes with Retry-After on block — to blunt online guessing. Argon2 already makes each attempt expensive.
| Scope | What it allows |
|---|---|
submit | Submit listens, and read the threshold they are judged by. Nothing else — what every scrobble client holds |
read | History, stats, charts, search, reports, sessions, Rediscovery, album and artist pages, loves, notes, lyrics, now-playing, chains, gear, the shelf, saved playlists, the Crate, the genre map |
write | Loves, notes, editing or deleting a listen, and the shelf: barcode lookups, adding and editing a record, uploading its scans, running times, playing a side |
all | read + write |
submit deliberately does not imply read: a token on a phone that
gets lost must not be able to read the history it is appending to. A valid token missing a
scope gets 403, not 401 — a 401 would send a client into a refresh loop it
cannot win.
Whatever scopes a token carries: everything under /admin/, plus sources,
service connections, Discogs settings, export and backup, the metadata sanitiser, the
enrichment jobs, and clearing every listen — the one operation a lost phone could make
unrecoverable. Removing a record from the shelf, or one of its scans, stays session-only
for the same reason: uploaded scans are not in the backup, so a deleted one is gone.
The whole API is in openapi.yaml, served unauthenticated at /api/openapi.yaml by every running instance — so it describes that instance at that version. It is generated from the handlers' own Rust types with
utoipa, so a schema is what the server actually serialises rather than a description of it.
A drift test walks every route in both directions, and others check each operation's
documented auth against what its handler accepts, so the build fails rather than sending
you to an endpoint that 404s.