Skip to main content

Endpoint reference

The complete HTTP surface, derived from the route table in internal/api/api.go. Conventions (auth, errors, pagination, rate limits) are in the API conventions page and are not repeated per endpoint.

Auth legend - Public: no token. Session: bearer session token. Session (media): session token via header or ?token= query parameter. Admin: session token + admin role. A personal API key authenticates as its owner anywhere a Session token does - so an admin's key also satisfies Admin - with one carve-out: it is refused on the credential-minting routes (see Personal API keys).

All /api/v1 bodies and responses are JSON. Timestamps are RFC 3339. Remember that empty list fields may serialize as null.

Server & meta

GET /api/v1/server

Public. Server identity and capability discovery - call this before anything else and gate features on the flags.

{
"name": "AudioSilo",
"server_id": "kx8Qz1c7m2Vw0aB3dEfGh",
"version": "1.4.2",
"api": "v1",
"capabilities": {
"admin_ui": true,
"web_player": true,
"transcode": true,
"upload": false,
"websocket": false,
"api_keys": true,
"metadata": true
},
"auth": { "methods": ["auth_code", "password"] },
"demo": { "enabled": false }
}

server_id is a stable, per-install identity minted once and persisted in config.yaml (so it survives a database rebuild). It never changes for the life of the install; clients use it as the identity for a paired server and key their per-server state (downloads, progress, cache) on it. It is also returned by the pairing exchange (POST /auth/exchange) and login/demo responses, so a client has it the moment it pairs. version is stamped from the release tag ("dev" for local builds). transcode is true only when ffmpeg is configured; web_player only when the /web mount is populated; upload and websocket are reserved for future phases and currently always false. api_keys is true on servers that support user-minted personal API keys. metadata reflects the runtime state of community metadata lookup: it is true only when a valid metadata.base_url is configured and the lookup is switched on. An admin can flip it on or off at runtime via PATCH /api/v1/admin/settings (no restart), so this flag can change during a server's lifetime; clients gate the enriched-book section on it and should re-read it after reconnecting.

GET /healthz · GET /api/v1/healthz

Public. Liveness/readiness probe: checks database read-reachability under a 2-second deadline. Both paths serve the same handler (the root form suits container healthchecks).

{ "status": "ok" }
StatusMeaning
200database reachable for reads
503{"error":"database unavailable"}

Authentication & pairing

See API conventions - Authentication for the flow overview and Auth & security for the trust model.

POST /api/v1/auth/redeem

Public. Validates an auth code (admin-minted invite or user-owned recovery code - both redeem identically) and returns a pairing payload without consuming a use - the use is claimed when a device actually completes /auth/exchange, so opening an invite link costs nothing. Rate-limited: 10 failed attempts per IP per 15 minutes.

Request body:

FieldTypeRequiredNotes
codestringyeshuman-typable code, e.g. 9M4K-P2TQ-WX7V-3RHD; common look-alikes (O/0, I/L/1) are normalized

Response 200 - the pairing payload (PairingPayload in internal/api/qr.go):

{
"server_name": "AudioSilo",
"base_url": "https://books.example.com",
"pairing_token": "3vJx0eKQm9WZbT5nR8sHc2fLdA7yUqPgVi4oXk1NwsE",
"uri": "audiosilo://connect?server=https%3A%2F%2Fbooks.example.com&token=3vJx0eKQ…",
"web_url": "https://books.example.com/web/connect?token=3vJx0eKQ…",
"qr_png_data_uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg…",
"links": {
"web": "https://books.example.com/web",
"admin": "https://books.example.com/admin"
},
"code_expires_at": "2026-07-04T09:30:00Z",
"uses_remaining": 5
}

pairing_token is as redeemable as the code that minted it: redeemed from an invite it inherits the invite's remaining uses and expiry (one QR can pair several devices, each exchange claiming one use); redeemed from a recovery code it lasts 10 minutes (multi-scan within that window, since recovery codes are unlimited). Complete it with /auth/exchange. code_expires_at and uses_remaining describe the parent invite's budget (advisory - concurrent exchanges may consume uses after the redeem); both are omitted for recovery codes and unlimited invites. web_url is what the QR encodes (opens the native app via Universal/App Links on claimed domains, else the embedded web player); uri is the custom-scheme equivalent for an explicit "Open in app" action. links.ios/links.android (store links) are omitted until the store apps ship. base_url honors the configured public_url, falling back to the request host.

StatusMeaning
400code missing
401invalid or expired auth code (also: code owner disabled/deleted - a rejected attempt never burns a use)
429redeem lockout tripped

POST /api/v1/auth/exchange

Public. Turns a pairing token into a durable, device-named session token. This is where an invite use is claimed: a token minted by redeeming an invite stays valid afterwards (governed by the invite's remaining uses and expiry, so one QR can pair several devices), while a token from /auth/pair or the demo flow is single-use and revoked on success. Shares the redeem rate limiter: 10 failed attempts per IP per 15 minutes.

FieldTypeRequiredNotes
pairing_tokenstringyesfrom /auth/redeem, /auth/pair, or a scanned QR
device_namestringnolabel shown in session listings, e.g. "Pixel 9"

Response 200:

{
"token": "Qm9WZbT5nR8sHc2fLdA7yUqPgVi4oXk1NwsE3vJx0eK",
"server_id": "kx8Qz1c7m2Vw0aB3dEfGh",
"user": {
"id": 4,
"username": "sam",
"role": "user",
"disabled": false,
"has_password": false,
"has_recovery": true,
"is_demo": false
}
}

server_id is the paired server's stable identity (see GET /server); the client adopts it as the connection's id.

StatusMeaning
400pairing_token missing
401invite already used on all its devices - ask for a new invite (the parent invite's use cap is spent)
401invite has expired - ask for a new invite (the parent invite expired after the redeem)
401invalid or expired pairing token (anything else: bogus/revoked token, single-use token already exchanged, code owner disabled)
429redeem lockout tripped

A refused exchange never burns an invite use.

POST /api/v1/auth/login

Public. Username/password login. Only works for accounts that have a password (admins always do; regular users may be pairing-only). Rate-limited: 10 failed attempts per IP per 15 minutes.

FieldTypeRequired
usernamestringyes
passwordstringyes
device_namestringno

Response 200: { "token": "…", "user": { … } } - same shape as /auth/exchange.

StatusMeaning
401invalid credentials (also returned for disabled or password-less accounts - deliberately indistinguishable)
429login lockout tripped

POST /api/v1/auth/pair

Session. Issues a fresh pairing payload for the calling user - "add another device" from an existing session. No request body. Response 200: a PairingPayload (same shape as /auth/redeem).

POST /api/v1/auth/logout

Session. Revokes the token used to make the call. No body. Response: 204 No Content.

GET /api/v1/me

Session. The authenticated account, reloaded so the derived fields are fresh:

{
"id": 4,
"username": "sam",
"role": "user",
"disabled": false,
"has_password": true,
"has_recovery": false,
"is_demo": false,
"last_seen_at": "2026-07-02T08:15:00Z"
}

last_seen_at is derived from the account's most recent token activity and is omitted when there is none. role is "admin" or "user".

Self-service account

The routes in this section, together with the API keys below, share one rate limit (10 attempts per IP per 15 minutes) and are refused for demo accounts (403), so a throwaway session can't mint a durable credential.

POST /api/v1/auth/password

Session. Set or change your own password.

FieldTypeRequiredNotes
passwordstringyesthe new password; empty is rejected (clearing a password is admin-only)
current_passwordstringconditionalrequired only when the account already has a password

Response: 204 No Content.

StatusMeaning
400password missing or too short
401current_password incorrect
403demo account
429account-mutation limit tripped

POST /api/v1/auth/recovery

:::caution Legacy - being retired The frontend player no longer offers recovery codes; it nudges users to set a password and relies on the reconnect flow instead. This endpoint (and the two recovery revoke routes below) is kept only so older app builds keep working, and is slated for removal once those clients age out. New clients should not call it. :::

Session. Mints (or replaces) the caller's durable recovery code - an auth code with unlimited uses and no expiry, owned by the user, redeemable through the normal /auth/redeem flow. Returned exactly once; only its hash is stored.

Response 201:

{ "recovery_code": "H7XD-4WQN-C9K2-TMPV" }

403 for demo accounts, 429 on the shared limit.

DELETE /api/v1/auth/recovery

Legacy (see above). Session. Removes the caller's recovery code (no-op if none). Response: 204 No Content.

Personal API keys

User-minted, non-expiring bearer credentials ("API keys") for headless integrations - dashboards, cron jobs, monitoring. A key authenticates on any Session or Session (media) route exactly like a session token and acts as its owner: an admin's key also passes Admin routes, a regular user's does not. A pairing token is never accepted as a bearer credential, and an API key is never valid for /auth/exchange. Revocation is the only lifecycle - a key never expires. All three routes share the self-service rate limit (10 attempts per IP per 15 minutes) and are refused for demo accounts (403).

Containment. A key cannot mint a fresh durable credential. A request that authenticates with an API key is refused (403) on the four credential-minting routes - POST /auth/tokens (another key), POST /auth/recovery, POST /auth/pair, and POST /auth/password - so revoking a leaked key cuts off everything it could reach: it can't spawn a key, recovery code, pairing token, or password that would outlive its own revocation (mirrors GitHub's "a token cannot create tokens"). It may still list and revoke keys and clear a recovery code, since those only reduce access.

POST /api/v1/auth/tokens

Session. Mints an API key and returns the secret exactly once.

FieldTypeRequiredNotes
labelstringyesdisplay name; trimmed, 1-100 characters

Response 200:

{
"token": "Qm9WZbT5nR8sHc2fLdA7yUqPgVi4oXk1NwsE3vJx0eK",
"api_key": {
"id": 3,
"label": "Home dashboard",
"created_at": "2026-07-08T14:02:11Z",
"last_seen": null
}
}

token is the bearer secret - only its SHA-256 hash is stored, so this is the one time it appears. api_key.last_seen is null until the key first authenticates a request.

StatusMeaning
400label missing/empty, or longer than 100 characters
403demo account, or the caller is itself authenticating with an API key (containment - a key can't mint another)
429account-mutation limit tripped

GET /api/v1/auth/tokens

Session. The caller's live (non-revoked) API keys, newest first - metadata only (never the secret or its hash):

{
"api_keys": [
{
"id": 3,
"label": "Home dashboard",
"created_at": "2026-07-08T14:02:11Z",
"last_seen": "2026-07-09T06:30:00Z"
}
]
}

403 for demo accounts; 429 on the shared limit.

DELETE /api/v1/auth/tokens/{id}

Session. Revokes one of the caller's own API keys by id; it stops working immediately. Response: 204 No Content.

StatusMeaning
403demo account
404no such API key for this caller (missing, already revoked, another user's, or not an API-key token id)
429account-mutation limit tripped

Demo

POST /api/v1/demo/session

Public (gated on demo mode). Provisions a throwaway demo account granted the configured demo library and logs the caller straight in. Per-IP limited (5 per 15 minutes) and capped globally (demo.max_users, default 200 live accounts); idle demo accounts are reaped in the background.

Request body (optional):

FieldTypeRequiredNotes
device_namestringnodefaults to "Demo"

Response 200 - a session plus a pairing payload so a phone can scan the QR and join as the same demo user:

{
"token": "Qm9WZbT5nR8sHc2fLdA7yUqPgVi4oXk1NwsE3vJx0eK",
"user": {
"id": 91,
"username": "demo_a3f19c02b7d4",
"role": "user",
"disabled": false,
"has_password": false,
"has_recovery": false,
"is_demo": true
},
"pairing": { "server_name": "AudioSilo", "pairing_token": "…", "…": "…" }
}
StatusMeaning
404demo mode is not enabled
429per-IP demo cap tripped
500configured demo.library doesn't exist
503demo is at capacity

:::note Demo root redirect When demo mode is enabled and the web player is mounted, GET / (the exact site root only) responds 302 Found/web/demo, landing visitors on the player's instant-demo screen. All other static routes (/connect, /admin, …) are untouched. :::

Libraries & browsing

GET /api/v1/libraries

Session. Libraries the caller can reach through any share (admins see all).

{
"libraries": [
{
"id": 1,
"name": "Audiobooks",
"root": "/srv/audiobooks",
"default_view": "hybrid",
"sort_order": 0
}
]
}

GET /api/v1/libraries/{id}/fs

Session. The filtered filesystem view - the real directory tree, scoped to the caller's share rules, requiring no prior indexing. Lists audio files and directories only (covers/NFOs are filtered out so every entry is actionable), with indexed-book metadata attached where available. Offset-paginated.

Query paramTypeDefaultNotes
pathstring"" (library root)directory to list, relative to the root
offsetint0
limitint200values ≤ 0 or > 500 fall back to 200
{
"path": "Brandon Sanderson/Mistborn",
"entries": [
{
"name": "The Final Empire",
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"is_dir": true,
"is_audio": false,
"size": 0,
"mod_time": 0,
"is_book": true,
"title": "The Final Empire",
"author": "Brandon Sanderson",
"series": "Mistborn",
"series_index": 1,
"duration": 88347.4
}
],
"total": 3,
"offset": 0
}

next_offset is present when more entries remain. The book annotation fields (is_book, title, author, series, series_index, duration) are omitted for plain directories/files; override ("book" or "collection") appears when an explicit folder-detection override is set (admin concern - see Scanner). Dotfiles are hidden; directories sort before files.

StatusMeaning
400invalid library id, or path escapes the root
403no share grants this library
404directory not found

GET /api/v1/libraries/{id}/books

Session. The computed view from the index, scoped to the caller's shares. Keyset-paginated (see conventions).

Query paramTypeDefaultNotes
authorstring-exact-match filter
seriesstring-exact-match filter
sortstringauthorauthor | title | recent (recent = newest added_at first)
limitint50≤ 0 or > 200 falls back to 50
cursorstring-opaque cursor from a previous page's next_cursor
{
"books": [
{
"id": 412,
"library_id": 1,
"rel_path": "Brandon Sanderson/Mistborn/The Final Empire",
"is_folder": true,
"title": "The Final Empire",
"author": "Brandon Sanderson",
"series": "Mistborn",
"series_index": 1,
"narrator": "Michael Kramer",
"duration": 88347.4,
"format": "m4b",
"codec": "aac",
"size": 512847361,
"added_at": "2026-05-14T09:12:44Z"
}
],
"next_cursor": "QnJhbmRvbiBTYW5kZXJzb24ANDEy"
}

Conditional book fields: asin/isbn appear only when known (tags or enrichment); codec is omitted when never probed; added_at when unknown. List responses omit files, chapters, and direct_playable (single-book responses include them). next_cursor is omitted on the last page. Invalid cursor → 400.

GET /api/v1/search

Session. Full-text search (FTS5 over title/author/series/narrator) across every library the caller can reach, scoped per-library to their share rules. Results are relevance-ranked and de-duplicated across libraries. De-dup keeps the best copy of a book: format tier first (M4B/AAC over MP3 over anything else), then single-file over multipart, then higher bitrate, then library order (internal/catalog/dedup.go).

Query paramTypeDefaultNotes
qstring-alphanumeric tokens are AND-ed with prefix matching; empty/symbol-only queries return no results
limitint50≤ 0 or > 200 falls back to 50

Response 200: { "books": [ … ] } - Book objects as in /books, plus the de-duplication annotations:

FieldTypeNotes
dedup_keystringgroups copies of the same logical book; a display hint, not an identity
multi_fileboolwhether this copy is multipart
other_locationsarraythe best copy in each other library, one entry per library (copies in the winner's own library are omitted): { library_id, library_name, path, format?, size?, multi_file? }

GET /api/v1/books/recent

Session. Most recently added books across all accessible libraries, merged and de-duplicated (same annotations as /search), newest added_at first - one call for a "recently added" shelf.

Query paramTypeDefaultNotes
limitint50≤ 0 or > 200 falls back to 50

Response 200: { "books": [ … ] }.

Books & content

These endpoints resolve (library, path) to a book via the index, indexing on demand if the background scan hasn't reached the path yet - so a freshly added book is playable immediately.

GET /api/v1/libraries/{id}/item

Session. Full book detail for a path.

Query paramTypeRequired
pathstringyes

Response 200 - a Book including files, chapters, and playability:

{
"id": 412,
"library_id": 1,
"rel_path": "Brandon Sanderson/Mistborn/The Final Empire",
"is_folder": true,
"title": "The Final Empire",
"author": "Brandon Sanderson",
"series": "Mistborn",
"series_index": 1,
"narrator": "Michael Kramer",
"duration": 88347.4,
"format": "m4b",
"codec": "aac",
"size": 512847361,
"added_at": "2026-05-14T09:12:44Z",
"files": [
{
"rel_path": "Brandon Sanderson/Mistborn/The Final Empire/The Final Empire.m4b",
"seq": 0,
"duration": 88347.4,
"format": "m4b",
"size": 512847361
}
],
"chapters": [
{
"index": 0,
"title": "Chapter 1",
"file_index": 0,
"file_path": "Brandon Sanderson/Mistborn/The Final Empire/The Final Empire.m4b",
"start": 0,
"end": 1843.2,
"book_offset": 0
}
],
"direct_playable": true
}

direct_playable reports whether the codec plays natively in browsers (unknown codec ⇒ true; the client falls back to ?transcode=1 if direct playback fails). Durations/positions are seconds (float).

StatusMeaning
400missing path / invalid library id
403path outside the caller's share scope
404no book at that path (not indexable)

GET /api/v1/libraries/{id}/chapters

Session. A book's normalized playable units. Every chapter carries file_path - the actual audio file to stream - plus its in-file start/end and book_offset on the whole-book timeline, so single-file m4b chapters and multi-file mp3 parts render identically.

Query paramTypeRequired
pathstringyes
{
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"duration": 88347.4,
"is_folder": true,
"files": [
{
"rel_path": "Brandon Sanderson/Mistborn/The Final Empire/The Final Empire.m4b",
"seq": 0,
"duration": 88347.4,
"format": "m4b",
"size": 512847361
}
],
"chapters": [
{
"index": 0,
"title": "Chapter 1",
"file_index": 0,
"file_path": "Brandon Sanderson/Mistborn/The Final Empire/The Final Empire.m4b",
"start": 0,
"end": 1843.2,
"book_offset": 0
}
],
"codec": "aac",
"direct_playable": true
}

Same status codes as /item.

GET /api/v1/libraries/{id}/meta

Session. Community metadata enrichment for a book - a description, production details, and the series it belongs to - composed server-side from the community metadata API (meta.audiosilo.app) and cached. The path is authorized against the caller's share scope exactly like /item. Gated by the metadata capability: when it is false the route returns 404, so a client that honours the flag never calls this.

The server resolves the book's asin/isbn (the fields on the indexed book, backfilled by the manager into book_enrichment), looks the recording up upstream, and folds the matched recording plus up to three series rails into one envelope. Every series-rail entry carries its own web_url, so a client links to the metadata site without ever building a URL itself.

Query paramTypeRequired
pathstringyes

Response 200 on a match:

{
"matched": true,
"work": {
"id": "the-martian",
"title": "The Martian",
"subtitle": "",
"authors": [{ "id": "andy-weir", "name": "Andy Weir" }],
"language": "en",
"first_published": "2011",
"description": "An astronaut is stranded on Mars…",
"characters": [
{
"id": "mark-watney",
"name": "Mark Watney",
"aliases": ["The Martian"],
"role": "protagonist",
"reveal": { "chapter": 1 },
"description": "The stranded astronaut, an engineer-botanist…"
}
],
"recaps": [
{
"through": { "chapter": 6 },
"scope": "book",
"text": "Watney has survived the storm and taken stock of Hab…"
}
],
"recap_summary": {
"in_short": "A botanist is left behind on Mars and has to keep himself alive…",
"ending": "The Ares 3 crew slingshots back and catches him mid-intercept…"
}
},
"recording": {
"id": "podium-2013",
"narrators": [{ "id": "r-c-bray", "name": "R. C. Bray" }],
"abridged": false,
"runtime_min": 634,
"release_date": "2013-03-22",
"publisher": "Podium Audio",
"cover_url": "https://…"
},
"series": [
{
"id": "wandering-earth",
"name": "The Wandering Earth",
"position": "1",
"works": [
{
"id": "the-martian",
"title": "The Martian",
"position": "1",
"authors": [{ "id": "andy-weir", "name": "Andy Weir" }],
"cover_url": "https://…",
"web_url": "https://meta.audiosilo.app/work?id=the-martian"
}
]
}
],
"web_url": "https://meta.audiosilo.app/work?id=the-martian"
}

work is always present on a match; recording and series are omitted when the upstream has none, and every omitempty string/number field (subtitle, first_published, description, abridged, runtime_min, release_date, publisher, cover_url) is dropped when empty. series[].position is this work's position in that series; series[].works is the full ordered rail, including the current work (the client filters it out before drawing a "more in this series" row). Positions are strings ("1", "2.5", "1-3.5").

work.characters, work.recaps and work.recap_summary are the community expressive layer (the CC BY-SA content, spoiler-tagged and position-keyed); all three are omitempty, so they are absent when the upstream has none. A position is { "chapter": <int >= 0> } on the work's own, edition-independent timeline (the logical work chapter, 1-based; 0 = front matter / prior-book knowledge), which the client maps onto its recording's chapters.

  • Each character carries an id (unique within the work, not global), a name, optional aliases and role (protagonist/antagonist/supporting/ minor), a reveal position (where the character is first disclosed - the spoiler gate), and an own-words description. Recurring characters are re-described per book (Kindle-X-Ray style), so a client reveals only up to where the listener is.
  • Each recap carries a through position (safe to show once the listener has finished that chapter), an optional scope (book or series - a chapter: 0 + series recap is the "previously, in earlier books" summary), and own-words text.
  • recap_summary is the whole-book refresher, not keyed to a position: { "in_short": …, "ending": … }, both fields omitempty. in_short is a spoiler-light "what this book is" summary a client can show up front; ending is by construction a full spoiler and must only be revealed deliberately (the player shows it once the book is finished, or behind an explicit tap on a previous book). The whole object is omitted when the work has neither, and an all-blank upstream object is never emitted.

These fields are additive: they are passed straight through from the upstream GET /works/{id} (see the three-repo seam), not composed or reshaped like recording/series. The one field the server drops is the character cross-reference (xref) - it is not exposed to clients. A client that ignores these fields is unaffected.

When the book has neither an asin nor an isbn, or the upstream reports no match, the response is 200 { "matched": false } - a normal, non-error result that the client treats as "nothing to show":

{ "matched": false }
StatusMeaning
200{ "matched": true, … } on a match, or { "matched": false } when there is nothing to show
400missing path / invalid library id
403path outside the caller's share scope
404no book at that path, or metadata lookup is disabled on this server (metadata capability false)
502metadata service unavailable - the upstream was unreachable or errored

GET /api/v1/meta/work

Session. One community work document by its metadata-database id, with no book, library, or path involved. This is what the player uses to catch a listener up on the earlier books of a series: the series rails returned by /libraries/{id}/meta name works the caller may not own, so there is no path to address them by.

Query paramTypeRequired
idstringyes - the work id from a series[].works[].id (or work.id)

The id rides in the query string, not a path segment, because metadata-site work slugs are not guaranteed to be path-segment safe.

Unlike /libraries/{id}/meta this route is plain authed, not scope-checked: a work id says nothing about what is on this server, so a scoped user may read any work. It is gated by the same metadata capability and served from the same bounded cache as the enrichment lookups (its own w: key space; 24 h positive / 1 h not-found / 2 min transport-error TTLs).

Response 200:

{
"work": {
"id": "the-martian",
"title": "The Martian",
"authors": [{ "id": "andy-weir", "name": "Andy Weir" }],
"language": "en",
"first_published": "2011",
"description": "An astronaut is stranded on Mars…",
"characters": [],
"recaps": [],
"recap_summary": { "in_short": "…", "ending": "…" }
}
}

work is exactly the same shape as work inside the /libraries/{id}/meta envelope (documented above, same omitempty rules) - there is deliberately no second work type. The enrichment fields that only make sense for a matched local book (matched, recording, series, web_url) are not returned here.

StatusMeaning
200{ "work": … }
400id is required - the id param is missing or blank; invalid id - it is longer than 200 bytes or contains control characters
404no such work - the upstream has no work with that id
404metadata lookup not enabled - the lookup is off on this server
502metadata service unavailable - the upstream was unreachable or errored

Streaming & media

Both routes take media auth (header or ?token=) and are exempt from the 30 s request timeout. See Media for serving internals.

GET /api/v1/libraries/{id}/stream

Session (media). Streams one audio file by path. The path must be a real file - a chapter's file_path or a files[].rel_path - never a book/folder path.

Query paramTypeDefaultNotes
pathstringrequiredlibrary-relative audio file path
download1-sets Content-Disposition: attachment so browsers save the file
transcode1-re-encode to MP3 via ffmpeg for codecs browsers can't decode
tfloat0with transcode=1: start the transcode this many seconds in
tokenstring-session token (media-auth fallback)

Direct serving (default) supports HTTP Range (206 Partial Content) and sets the audio Content-Type from the file. Transcoded output is MP3 and not byte-seekable - no Range, no Content-Length; a client seeks by re-requesting with a new t. The ffmpeg process is bound to the request, so disconnecting kills it.

StatusMeaning
200 / 206file bytes (Range honored for direct serving)
400missing path / path escapes the root
401missing/invalid token
403path outside the caller's scope
404file does not exist
503transcode=1 but ffmpeg is not configured (check the transcode capability)

GET /api/v1/libraries/{id}/cover

Session (media). A book's cover for a path: an indexed sibling cover file if present, otherwise embedded art extracted from the book's primary audio file (served with Cache-Control: private, max-age=86400).

Query paramTypeRequired
pathstringyes
tokenstringno (media-auth fallback)

Response 200: image bytes with the appropriate Content-Type; 404 (no cover) when there is neither a cover file nor embedded art.

Listening state

Per-user durable state, addressed by (library, path) - the book path. Positions are seconds on the whole-book timeline. Every path-scoped route below requires ?path= and authorizes it against the caller's share scope (400 missing path, 403 out of scope apply throughout). Cross-book list routes (/me/…) filter to paths the caller can still access, so state under a revoked share isn't returned.

GET /api/v1/me/progress

Session. All progress rows for the caller (offline-sync seed).

{
"progress": [
{
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"position": 12043.6,
"duration": 88347.4,
"finished": false,
"playback_speed": 1.25,
"version": 7,
"device_id": "pixel-9-sam",
"updated_at": "2026-07-01T19:42:07Z"
}
]
}

GET /api/v1/libraries/{id}/progress

Session. Progress for one book. Response 200: { "progress": { … } } - or { "progress": null } when none exists.

PUT /api/v1/libraries/{id}/progress

Session. Upserts progress with last-write-wins reconciliation: the newer updated_at wins; version breaks exact-timestamp ties. A stale write is not an error - the response returns the effective stored progress, so clients converge.

Body fieldTypeNotes
positionfloatseconds, whole-book timeline
durationfloatbook duration as the client knows it
finishedbool
playback_speedfloatvalues ≤ 0 are normalized to 1.0
versionintsend the last version you saw; 0 lets the server assign (stored + 1)
device_idstringfree-form writer identifier
updated_atstringRFC 3339; empty = server time. This drives the merge - send the real client-side write time when replaying offline queues
library_id, path-accepted but ignored; taken from the URL and ?path=

Response 200: { "progress": { … } } (the winning row).

GET /api/v1/libraries/{id}/bookmarks · POST /api/v1/libraries/{id}/bookmarks

Session. List / add bookmarks for a book (?path= on both).

GET response: { "bookmarks": [ … ] } (objects as below).

POST body: { "position": 4211.5, "note": "great line" } (note optional). Response 201 - the created bookmark unwrapped:

{
"id": 12,
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"position": 4211.5,
"note": "great line",
"created_at": "2026-06-30T21:04:11Z"
}

DELETE /api/v1/bookmarks/{id}

Session. Deletes one of the caller's own bookmarks by id (another user's id is a silent no-op). Response: 204 No Content (idempotent - no 404).

GET /api/v1/libraries/{id}/notes · POST /api/v1/libraries/{id}/notes

Session. List / add free-form notes for a book (?path= on both).

POST body: { "position": 0, "body": "re-read ch. 12 for the foreshadowing" } (position optional). Response 201 - the created note unwrapped:

{
"id": 5,
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"position": 0,
"body": "re-read ch. 12 for the foreshadowing",
"created_at": "2026-06-28T10:00:00Z",
"updated_at": "2026-06-28T10:00:00Z"
}

GET response: { "notes": [ … ] } (same object shape).

DELETE /api/v1/notes/{id}

Session. Deletes one of the caller's own notes. 204 No Content.

GET /api/v1/me/history

Session. The caller's recent listening spans across all books, newest first.

Query paramTypeDefaultNotes
limitint100≤ 0 or > 500 falls back to 100
{
"history": [
{
"id": 88,
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"from_pos": 11250.0,
"to_pos": 12043.6,
"started_at": "2026-07-01T19:20:00Z",
"ended_at": "2026-07-01T19:42:07Z"
}
]
}

GET /api/v1/libraries/{id}/history

Session. History for one book (?path= required; limit as above). Response: { "history": [ … ] }.

POST /api/v1/libraries/{id}/history

Session. Records a listening span (?path= required).

Body fieldTypeRequiredNotes
from_posfloatnospan start position (seconds); not validated - defaults to 0 if omitted
to_posfloatnospan end position; not validated - defaults to 0 if omitted
started_atstringnoRFC 3339; defaults to server time
ended_atstringnoRFC 3339; defaults to server time

Response: 201 Created, empty body.

GET /api/v1/me/favourites

Session. The caller's favourites across all accessible libraries, newest first, enriched from the index where a book exists at the path:

{
"favourites": [
{
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"is_book": true,
"title": "The Final Empire",
"author": "Brandon Sanderson",
"series": "Mistborn",
"series_index": 1,
"duration": 88347.4,
"created_at": "2026-06-25T18:30:00Z"
}
]
}

A favourite may also be a plain navigation folder - then is_book is false and the book fields are empty (render it by its path leaf).

POST /api/v1/libraries/{id}/favourites · DELETE /api/v1/libraries/{id}/favourites

Session. Heart / un-heart a path (?path= required on both; one favourite per user+library+path). Both are idempotent. POST → 201 Created (empty body); DELETE → 204 No Content.

Admin: users & auth codes

All Admin. Plaintext codes/passwords are never retrievable after creation - responses that include a code are the one time you see it.

GET /api/v1/admin/users

All accounts, wrapped as { "users": [ … ] }:

{
"users": [
{
"id": 4,
"username": "sam",
"role": "user",
"disabled": false,
"has_password": false,
"has_recovery": true,
"is_demo": false,
"last_seen_at": "2026-07-02T08:15:00Z"
}
]
}

POST /api/v1/admin/users

Create an account.

Body fieldTypeRequiredNotes
usernamestringyes
passwordstringadmins onlyoptional for non-admins (pairing-only accounts); required for role: "admin"
rolestringyes"admin" or "user"

Response 201: the created user object. 409 username already taken on a duplicate username; 400 with a specific message on a validation failure (missing admin password, password too short, …).

GET /api/v1/admin/users/{id}

One account plus everything the console needs to manage it:

{
"user": { "id": 4, "username": "sam", "role": "user", "disabled": false,
"has_password": false, "has_recovery": true, "is_demo": false },
"accessible_libraries": [ { "id": 1, "name": "Audiobooks", "root": "/srv/audiobooks",
"default_view": "hybrid", "sort_order": 0 } ],
"shares": [ { "id": 2, "name": "Fantasy shelf", "description": "", "read_only": true,
"paths": [ { "library_id": 1, "path": "Brandon Sanderson" } ] } ],
"auth_codes": [
{
"id": 9,
"label": "Invite for sam",
"max_uses": 5,
"uses": 1,
"expires_at": "2026-07-03T10:00:00Z",
"redeemed_at": "2026-07-02T11:20:31Z",
"created_at": "2026-07-02T10:00:00Z"
}
]
}

auth_codes is invite metadata only (never the code itself, and never recovery codes - recovery presence surfaces as user.has_recovery). expires_at empty/omitted = no expiry; max_uses: 0 = unlimited; redeemed_at omitted = never redeemed. 404 if the user doesn't exist.

PATCH /api/v1/admin/users/{id}

Edit an account in place - any subset of:

Body fieldTypeNotes
rolestring"admin" | "user"
passwordstring"" clears the password (non-admins only)
disabledboolreversible lockout; disabling revokes nothing but blocks all token use

Response 200: the updated user object.

StatusMeaning
400admin must keep a password / password too short
404user not found
409would demote/disable the last enabled admin

DELETE /api/v1/admin/users/{id}

Permanently deletes an account and all its durable state (sessions, auth codes, progress, bookmarks, notes, history, share grants) via cascade; files on disk are untouched. Response: 204 No Content.

StatusMeaning
400self-delete refused (disable your own account instead)
404user not found
409last enabled admin

POST /api/v1/admin/users/{id}/authcode

Mints an invite code for a user. Minting atomically supersedes the user's other still-redeemable invites (one active invite per user; spent/expired ones remain as history). Body optional:

Body fieldTypeDefaultNotes
labelstring""display label
max_usesint5explicit 0 = unlimited (negative values are clamped to 0)
ttl_daysint1explicit 0 = never expires

Response 201 - shown once:

{
"auth_code": "9M4K-P2TQ-WX7V-3RHD",
"invite_url": "https://books.example.com/connect#code=9M4K-P2TQ-WX7V-3RHD"
}

The code rides in the invite_url fragment, so it never reaches server logs; the connect page auto-redeems it client-side.

DELETE /api/v1/admin/users/{id}/recovery

Legacy - being retired alongside POST /auth/recovery once old clients age out. Revokes a user's recovery code (the admin's only lever for a leaked one, since recovery codes are not listable). No-op if none. 204 No Content.

POST /api/v1/admin/authcodes/{id}/rotate

Regenerates an existing invite's secret in place (the admin "Resend"): the old code dies, no new row is created, and max_uses is preserved with the expiry renewed for the invite's original window. No body. Response 200: { "auth_code": "…", "invite_url": "…" } (same shape as creation). 404 if the invite doesn't exist.

DELETE /api/v1/admin/authcodes/{id}

Revokes (deletes) an issued invite immediately. 204 No Content.

Admin: libraries & shares

All Admin.

GET /api/v1/admin/libraries

All libraries in display order, wrapped as { "libraries": [ … ] } - the same library object shape as GET /api/v1/libraries.

POST /api/v1/admin/libraries

Creates a library and kicks off an initial background scan (browsing via /fs works immediately; the index fills in behind).

Body fieldTypeRequiredNotes
namestringyesunique
rootstringyesserver-local filesystem path (mount network shares first)
default_viewstringnodefaults to "hybrid"

Response 201: the created library. 409 name already taken.

PUT /api/v1/admin/libraries/order

Sets display order from an ordered id list (position 0 first); ids not listed keep their order. This order is also the final de-duplication tiebreaker between otherwise-equal copies of the same book (see GET /api/v1/search).

Body: { "ids": [2, 1, 3] }. Response 200: { "libraries": [ … ] } in the new order.

PATCH /api/v1/admin/libraries/{id}

Edits name, root, and/or default_view - empty/omitted fields keep their current values (sort_order is managed via /order). Changing anything triggers a background rescan. Response 200: the updated library. 404 / 409 as for create.

DELETE /api/v1/admin/libraries/{id}

Removes the library and everything indexed under it (books, files, chapters, FTS rows). Audio files on disk are untouched. 204 No Content.

PUT /api/v1/admin/libraries/{id}/folder-override

Forces how the auto-detector classifies a folder, then rescans. ?path= required (must resolve inside the root).

Body: { "mode": "collection" } - "book" = the folder is one multi-file book; "collection" = one book per file inside it.

Response 200: { "status": "override set", "path": "…", "mode": "collection" }. 400 for any other mode; 404 library not found.

DELETE /api/v1/admin/libraries/{id}/folder-override

Clears the override (back to auto-detection) and rescans. ?path= required. Response 200: { "status": "override cleared", "path": "…" }.

PUT /api/v1/admin/libraries/{id}/enrichment

Attaches durable, path-keyed external identifiers to a book (used by the desktop manager after matching a book against Audible/ISBN sources). Survives rescans; modifies no file on disk. ?path= required.

Body fieldTypeRequired
asinstringat least one of the two
isbnstringat least one of the two

Response 200: { "status": "enrichment set", "path": "…" }.

POST /api/v1/admin/libraries/{id}/scan

Starts a background rescan. Returns immediately: 202 Accepted, { "status": "scan started" }. 404 library not found.

GET /api/v1/admin/libraries/{id}/scan

Progress of the (possibly running) scan:

{ "running": true, "total": 812, "done": 394, "indexed": 388 }

GET /api/v1/admin/shares

All shares (with their path rules):

{
"shares": [
{
"id": 2,
"name": "Fantasy shelf",
"description": "Sam's corner",
"read_only": true,
"paths": [ { "library_id": 1, "path": "Brandon Sanderson" } ]
}
]
}

A rule's path: "" means the whole library.

POST /api/v1/admin/shares

Creates a share, optionally with initial path rules (inserted atomically - a bad rule rolls the whole thing back).

Body fieldTypeRequiredNotes
namestringyesunique
descriptionstringno
read_onlyboolno
pathsarrayno[ { "library_id": 1, "path": "Brandon Sanderson" } ]

Response 201: the full share (with paths). 409 name already taken.

GET /api/v1/admin/shares/{id}

One share with its paths. 404 if missing.

PATCH /api/v1/admin/shares/{id}

Updates share metadata. An empty name keeps the current one, but description and read_only are replaced with whatever the body says (send the full desired values). Path rules are not editable here - use the /paths sub-routes. Response 200: the updated share. 404 / 409.

DELETE /api/v1/admin/shares/{id}

Deletes the share; its path rules and user grants cascade. 204 No Content.

POST /api/v1/admin/shares/{id}/paths · DELETE /api/v1/admin/shares/{id}/paths

Adds / removes one path rule. Body for both:

Body fieldTypeRequiredNotes
library_idintyes
pathstringno"" = whole library

Response: 204 No Content. 400 when library_id is missing/zero.

POST /api/v1/admin/share-access · DELETE /api/v1/admin/share-access

Grants / revokes a share to/from a user. Body: { "user_id": 4, "share_id": 2 }. Response: 204 No Content.

POST /api/v1/admin/library-access

Convenience sugar: grants a user an entire library by creating/granting a whole-library share under the hood. Body: { "user_id": 4, "library_id": 1 }. Response: 204 No Content.

Admin: stats

GET /api/v1/admin/stats

Admin. Powers the console dashboard: catalog totals, per-library counts, and a cross-user "currently listening" feed (up to 200 rows, newest first; title/ author may be empty if the scan hasn't reached a path yet).

{
"total_books": 1284,
"total_libraries": 2,
"total_users": 5,
"libraries": [
{ "id": 1, "name": "Audiobooks", "book_count": 1201 },
{ "id": 2, "name": "Kids", "book_count": 83 }
],
"listening": [
{
"user_id": 4,
"username": "sam",
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"title": "The Final Empire",
"author": "Brandon Sanderson",
"position": 12043.6,
"duration": 88347.4,
"finished": false,
"updated_at": "2026-07-01T19:42:07Z"
}
]
}

Admin: settings

Runtime-toggleable server settings, surfaced in the console's Overview section. The envelope is a feature-keyed object so future settings can join it without reshaping the wire contract; today it carries only metadata.

GET /api/v1/admin/settings

Admin. Returns the current runtime settings.

{
"metadata": {
"enabled": true,
"base_url": "https://meta.audiosilo.app",
"available": true
}
}

For metadata: enabled is the runtime on/off flag; base_url is the configured metadata service URL (empty when none is set); available reports whether the lookup can be enabled at all - true only when base_url is a valid absolute http(s) URL. When available is false the feature is permanently off until the server config gains a valid base_url, and any attempt to enable it is rejected. The live metadata capability is enabled && available.

PATCH /api/v1/admin/settings

Admin. Flips runtime settings and persists them to config.yaml (so the change survives a restart). Send only the fields you want to change - an absent field is left unchanged. Returns the same envelope as GET with the new state.

{ "metadata": { "enabled": false } }

Setting metadata.enabled to true when the lookup is unavailable (no valid metadata.base_url) is a 400 - configure metadata.base_url first. Toggling the flag takes effect immediately across the server: it gates GET /libraries/{id}/meta and the metadata capability, so every connected player starts or stops showing the enriched-book section without a restart.

StatusMeaning
200updated; body is the current settings envelope
400invalid body, or enabling metadata when no valid metadata.base_url is configured
500the settings could not be persisted (the in-memory change is rolled back)

Well-known

Native deep-link association files. Both are Public, config-driven (app_links in the YAML - see Configuration), and 404 when the relevant identifiers are unset - clients then fall back to the web player and the custom-scheme "Open in app" button.

GET /.well-known/apple-app-site-association

iOS Universal Links. Served when app_links.apple_app_ids is configured; the claimed paths are the pairing handoff and connect pages:

{
"applinks": {
"apps": [],
"details": [
{
"appIDs": ["ABCDE12345.app.audiosilo.player"],
"components": [ { "/": "/web/connect*" }, { "/": "/connect*" } ]
}
]
}
}

GET /.well-known/assetlinks.json

Android App Links. Served when app_links.android_package and app_links.android_sha256 are configured:

[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "app.audiosilo.player",
"sha256_cert_fingerprints": ["14:6D:E9:83:C5:73:AB:31:0F:..."]
}
}
]

First-run setup wizard

Only active when the launcher enabled it (--setup / pkg/launcher); a normal headless deployment never exposes this surface. The wizard self-closes the moment an admin exists. The one-time setup token rides in the page URL fragment (/setup#token=…) so it never reaches server logs; the POST verifies it in constant time. See Web UI.

GET /setup

Public (gated). Serves the wizard HTML. 404 when the wizard was never enabled; 303 See Other/admin when enabled but an admin already exists.

POST /setup

Public (token-guarded). Creates the first admin and the first library, then starts a background scan.

Body fieldTypeRequiredNotes
tokenstringyesthe one-time setup token
usernamestringnodefaults to "admin"
passwordstringyesadmins must have a password
library_namestringyes
library_rootstringyesmust be an existing directory on the server

Response 201: { "user": { … }, "library": { … } }.

StatusMeaning
400validation (missing library fields, folder doesn't exist, password rules)
403invalid setup token
409setup not available (already completed or never enabled)

:::note Static UI routes internal/api/api.go also mounts the baked-in static UI via web.Register: GET / (connect page), /connect, /admin, /assets/…, /favicon.ico, /sw.js, /manifest.webmanifest, and the web player at /web/… (when configured). These are plain pages over the API - they hold no privilege of their own and are documented in Web UI, not here. :::