jukebox.

API

/api/v1.

This page is the reasoning, not the inventory. It explains the rules the API is built on and why they are what they are. The exhaustive list of routes lives in the API reference, which is generated from the router itself and cannot fall behind it — this page once described 42 of the server’s 102 routes, and a reader could reasonably have concluded there was no authentication, no player and no plugin store. Prose revised deliberately does not drift; a route table maintained by hand always does.

Status. This document is the target, not the current state. Routes marked ✅ exist; the rest are still to be implemented. The spec will be generated from the Zod schemas via @hono/zod-openapi — until that is done, this file can drift from the code, and that is the main risk on this page.

No privileged routes. Whatever the in-house frontend can do, a third-party frontend or a device can do too. That is the only guarantee of a genuinely open API.


1. The five rules that make the network bearable

They apply everywhere, without exception. They decide whether the interface is smooth across 100,000 tracks over mediocre Wi-Fi.

1.1 Cursor pagination, never OFFSET

GET /tracks?limit=200&cursor=eyJrIjpbIkFpciIsMTk5OF19
→ { items: [...], next: "eyJrIjpb…", revision: 48213 }

The total count lives on a separate route — GET /tracks/count — because a filtered COUNT(*) costs far more than a page and is not always useful.

OFFSET 90000 makes SQLite re-read 90,000 rows. A cursor encodes the last sort key and becomes a WHERE (sort_key) > (…) — constant cost no matter how deep you are.

1.2 Sync by revision — the real win

The library carries a monotonic counter. Every write increments it and stamps the row.

GET /tracks/delta?since=48120
→ { revision: 48213, changed: [...], deleted: ["t91", "t402"] }

A client coming back after five minutes gets what changed, not the library. That is the difference between 40 MB and 3 kB. The client keeps its local cache; the API never returns the entirety of anything.

1.3 ETag on every collection

GET /playlists          → 200  ETag: "rev-48213"
GET /playlists          → 304  (If-None-Match)

A collection’s ETag is its maximum revision. Free to compute, and it removes nearly all refresh traffic.

1.4 One page = one round trip

A track row arrives complete: metadata, device presence, artwork URL, playback state. Never N+1 — no per-track call just to find out whether it is on the iPod.

1.5 No polling

Progress goes over SSE. The client opens a stream, not a loop.


2. Conventions

Errors { error: { code, message, details? } }, stable and documented codes
Dates epoch milliseconds, integer
Sorting ?sort=artist,-year — - prefix for descending
Filters ?genre=Rock&year=1970..1979&rating=>=4
Fields ?fields=id,name,artist — trims long lists
Idempotency Idempotency-Key on every POST that creates a job
Compression brotli, gzip

3. Authentication

POST   /auth/login                 → { token, refresh, user }
POST   /auth/refresh
POST   /auth/logout
GET    /auth/me
POST   /auth/tokens                device tokens, scope + expiry
DELETE /auth/tokens/:id            revocation

Device tokens are narrowly scoped and long-lived: a satellite transferring for three hours must not expire halfway through (§8.3).

4. Library

GET    /health                  ✅ state + current revision
GET    /tracks                  ✅ paginated list, filterable, sortable
GET    /tracks/count            ✅ filtered count
GET    /tracks/delta?since=N    ✅ delta by revision
GET    /tracks/:id              ✅
GET    /artwork/:trackId        ✅ artwork extracted on demand, ETag mtime+size
PATCH  /tracks                  ✅ single **and** bulk editing — { ids, patch, writeToFiles? }
                                   `writeToFiles: false` updates the database without touching the disk
DELETE /tracks                     { ids: [], fromLibrary|fromPlaylist }
GET    /albums · /albums/:id/tracks
GET    /artists · /artists/:id/albums
GET    /genres · /composers
GET    /search?q=…&types=track,album,artist   FTS5, one query

PATCH /tracks is the modal’s bulk edit — one call, and a job if writing tags to disk takes a while.

The shape of a track

{
  "id": "t9134", "rev": 48120,
  "name": "…", "artist": "…", "album": "…", "albumArtist": "…",
  "genre": "…", "year": 1977, "trackNumber": 5, "discNumber": 1,
  "duration": 384, "bitRate": 320, "size": 15360000, "format": "flac",
  "rating": 4, "loved": false, "playCount": 23, "lastPlayed": 1755300000000,
  "sourceId": "src-nas", "path": "Music/Fleetwood Mac/Rumours/05 …flac",
  "artwork": "/api/v1/artwork/t9134",      // extracted on demand, ETag mtime+size
  "devices": ["dev-classic"],              // §5 — presence, embedded
  "kind": "music"                          // music | audiobook | podcast
}

5. Device presence — column and filter

Both, because they answer two different needs.

The column answers “where is this track?” — it is passive, you consult it. Each track’s devices field carries the ids of the devices holding it. The frontend turns that into a compact column (one dot per device), hidden when no device is connected.

The filter answers “what is left for me to sync?” — that is the working tool, and it has to be computed server side, never by filtering a page already received.

GET /tracks?onDevice=dev-classic          present on this device
GET /tracks?notOnDevice=dev-classic       missing — the real question
GET /tracks?onDevice=dev-classic,dev-nano&match=all

The filter also applies to playlists, audiobooks and podcasts: kind= combines with notOnDevice=.

All of it rests on a join table device_track (device_id, track_id, device_local_id, state, synced_at), indexed in both directions.

6. Playlists

GET    /playlists ✅ · POST /playlists ✅ · PATCH /playlists/:id ✅ · DELETE /playlists/:id ✅
GET    /playlists/:id ✅ · GET /playlists/:id/tracks ✅
POST   /playlists/:id/tracks    ✅ { ids: [], position? }   deduplicated
DELETE /playlists/:id/tracks    ✅ { ids: [] }
PUT    /playlists/:id/order     ✅ { ids: [], toIndex }     preserves the batch's relative order
POST   /playlists/:id/duplicate
GET    /playlists/:id/cover        generated 2×2 quilt, immutable cache

Smart playlists carry rules and are evaluated in SQL: “the 25 most played” over 100,000 tracks does not load 100,000 rows to keep 25. They reject track POST/DELETE with a 409 — their content changes through their rules.

{ "all": [{ "field": "rating", "op": "gte", "value": 4 }],
  "any": [{ "field": "genre", "op": "is", "value": "Rock" }],
  "sort": "rating", "limit": 25 }

A rule’s field is validated against a closed list before it reaches the SQL — this is the only point in the project where an injection would be possible.

7. Sources

GET    /sources ✅ · POST /sources ✅ · PATCH /sources/:id · DELETE /sources/:id
POST   /sources/:id/test           connection test, no side effects
POST   /sources/:id/scan        ✅ → job
GET    /sources/:id/browse?path=   raw tree, for the organization tool
POST   /sources/:id/move           { ops: [{from, to}] }  → job, logged

Write capabilities are per source and disabled by default: a read-only source exposes neither move nor DELETE.

8. Jobs — the central mechanism

GET    /jobs?state=running&kind=scan   ✅
GET    /jobs/:id                       ✅ state + aggregates, never the item list
PATCH  /jobs/:id                       ✅ { action: "pause" | "resume" }
DELETE /jobs/:id                       ✅ cancellation
POST   /jobs                           idempotent on Idempotency-Key
GET    /jobs/:id/items                 paginated detail
GET    /jobs/:id/events                SSE
job    queued → running → done | failed | cancelled
                   ↕ paused

kind ∈ scan · transcode · fingerprint · podcast · sync · acquire · analyze · relay · move · backup. One implementation for all of them.

9. Devices and satellites

GET    /satellites · POST /satellites            mDNS discovery or a URL entered by hand
GET    /devices                                  all devices, all satellites
GET    /devices/:id                              capacity, serial, firmware,
                                                 acceptedFormats, battery
PATCH  /devices/:id                              name, autoSync, syncMode, playlists

9.1 Seeing the device independently of the library

This is what the original iTunes did not do.

GET    /devices/:id/tracks?limit=200&cursor=…&orphansOnly=true   ✅ real contents
GET    /devices/:id/playlists
GET    /devices/:id/stats                       ✅ space, orphans

Every device track carries a libraryTrackId — or null. The nulls are the feature: that is the music present on the iPod and absent from the library.

POST   /devices/:id/import         { deviceTrackIds: [], targetSourceId,
                                     targetPath } → job

Recovering the music from an old iPod whose library is long gone. Matching uses Chromaprint fingerprints, so a re-encode or rewritten tags do not stop a duplicate from being recognized.

POST   /devices/:id/sync           { add: [], remove: [], dryRun? } → job
POST   /devices/:id/backup         → job
DELETE /devices/:id/tracks         { ids: [] }
POST   /devices/:id/eject

dryRun returns the plan — what would be added, removed, transcoded, and the resulting free space — without writing anything.

9.2 The satellite pulls

The server creates the job and hands out signed URLs; the satellite fetches the files at its own pace, resumes after an outage via Range, and only writes the device’s database once everything has transferred (the committing phase, atomic).

9.3 Token lifetime

A job token lives for the duration of the job plus a margin, and it is revoked on cancellation. One-hour tokens kill a three-hour transfer at 33% — the kind of detail you only see in production.

10. Playback and outputs

GET    /outputs                              discovered renderers
POST   /outputs/:id/play                     { trackId | url, position? }
POST   /outputs/:id/{pause,resume,stop,next,previous}
PUT    /outputs/:id/volume · /seek
GET    /outputs/:id/state                    SSE

GET    /queue · PUT /queue · POST /queue/tracks · DELETE /queue/:index

The play queue is shared between controllers: what you queue from the browser shows up on the tablet.

GET    /stream/:trackId?token=…&profile=sonos       Range, on-the-fly transcoding
GET    /artwork/:hash                                immutable cache, 1 year
POST   /live/sessions                                { url } → resolution + relay
GET    /live/:sessionId                              remuxed stream

11. Podcasts, radios, audiobooks

GET/POST/PATCH/DELETE  /podcasts               source = folder or RSS URL
POST   /podcasts/:id/refresh                   → job
GET    /podcasts/:id/episodes
POST   /podcasts/:id/episodes/:eid/download    → job
GET/POST/PATCH/DELETE  /radios
POST   /radios/:id/cover                       upload, or auto discovery
GET    /audiobooks · /audiobooks/:id/chapters

Each podcast carries its own schedule and its keepLast: N.

12. Plugins and store

GET    /plugins                      installed
POST   /plugins/:id/{enable,disable}
GET/PUT /plugins/:id/config          rendered by the host from `ui.json`
GET    /store/plugins?family=source  catalogue
POST   /store/plugins/:id/install    { version } → job
GET    /plugins/contributions        declared UI zones, for the frontend

13. Settings and backup

GET/PUT /settings
GET    /settings/backup?includeSecrets=false   secrets excluded by default
POST   /settings/restore
GET/POST/PATCH/DELETE  /schedules              named workflows
POST   /schedules/:id/run                      run right now

14. Events

GET /events    SSE

Emitted today: hello, job.progress. Planned: library.changed, device.connected, playback.state. Filtering by ?topics= is planned but not yet applied server side — the SDK already sends it.

One stream. The client never polls in a loop.


15. Server-side optimizations

SQLite WAL mode, synchronous=NORMAL, covering indexes on the sort keys
Search FTS5, external table, rebuilt by the scan job
Device presence join indexed in both directions, never computed per track
Artwork content hash in the URL → Cache-Control: immutable, one year
Streaming sendfile when no transcoding is needed
Transcoding cached by (track, profile), LRU eviction
32-bit discipline streaming scan, aggregates in SQL, no route returns everything

Architecture detail: architecture.md · stack: stack.md · overview: the map