Features

Everything Tapedeck records, and why it records it that way.

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.

Universal ingest, and a proxy in front of both

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.

Two deliberate differences
Reads need a token

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.

Two deliberate differences
No MSIDs

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.

Forwarding
Strictly per user

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.

Where listens come from

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.

Plex

Tested

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.

Backfill Yes — walks Plex history, paged
Audio quality From session metadata
Device → chain Yes
MusicBrainz IDs From the library where present
Star ratings Out of 10, half stars — not yet run against a real server

Audio quality, scored 0–100

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.

Source file
Delivered over
85
/ 100 delivered
Source — FLAC 24/96+85
Delivery — Wired / USB ±0
Source quality preserved no

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.

Four ways of looking at the same history

Statistics
Ranged totals and a personality

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.

Top Charts
Timeframe-ranged, honestly bounded

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

Reports
Chapters, not one card resized

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.

Genre Map
Placed on Every Noise at Once

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.

Rediscovery
Collections from your own history

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.

Playlist Lab
Ten dimensions, three ways through

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

A week, a month or a year can be shared as an image, drawn on the server

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.

Per-recording sound

The ceiling on that map, and the two ways past it

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.

Analysis plugin

First-party

Sits beside your library, reachable through Plex, Jellyfin or OpenSubsonic, and decodes the files where the bandwidth already is.

MethodPure DSP — no machine learning
Runs onThe hardware Tapedeck already runs on
StatusIn development
How it will work →

AudioMuse-AI

Third-party

Points at your media server and works out how each recording sounds — embeddings, tempo, key, energy, mood and six continuous axes.

MethodLearned audio embeddings
Runs on4 cores and 8 GB — a machine of its own
StatusConnected, not yet read
What Tapedeck does with it →

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.

  1. 1 How much could ever match. A listen joins on a media-server track id, so an imported one has nothing to join on — however much gets analysed.
  2. 2 How much has been analysed, weighted by plays. A track played fifty times is worth fifty of one played once.
  3. 3 What share of your listening that sample was, so the figure above is never read as a fact about the whole history.

Writing, loving, and the language a title cannot give

Liner Notes

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.

Loved

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.

Lyrics

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.

Bringing a history in, and taking it away

FormatWhat comes acrossWhat it costs
ListenBrainz export .zipListens and loved tracks, with MusicBrainz IDs and cover-art references already in itNo lookups at all
Spotify extended history .zipRe-anchored to when tracks started, durations recovered for ~93% of rows, skips judged by your own thresholdReal enrichment work
Last.fm / ListenBrainz by usernamePublic listens, pulled and deduplicatedBackground job
Rockbox .scrobbler.logTrack numbers, lengths, any MusicBrainz IDs the player recorded; skips kept but never countedAsks the device's timezone
.json / .jsonl / .csvWhatever the file carries, validated on the same path a live client takesBackground job
One pasted listenA ListenBrainz-shaped body, on the Import screenInstant
Export

Your entire history as JSON or CSV, and a consistent VACUUM INTO backup of the whole database — safe to take while running.

Notes as Markdown

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.

Imports never re-forward

Stored as imports, so a backfill of old history is never pushed onward onto a permanent public record.

Trusted as a system of record

Durable ingest

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.

At-least-once forwarding

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.

One dead service can't stall the rest

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.

Graceful shutdown

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.

Backups you can take while running

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.

Login throttling

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.

Token scopes, none implying another

ScopeWhat it allows
submitSubmit listens, and read the threshold they are judged by. Nothing else — what every scrobble client holds
readHistory, 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
writeLoves, notes, editing or deleting a listen, and the shelf: barcode lookups, adding and editing a record, uploading its scans, running times, playing a side
allread + 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.

Permanently session-only

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.

One spec, served by the binary

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.

Read the deep dives