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
(a book's cover and stream also take a cast token
as the ?token=).
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. There is one carve-out: it is refused on the credential-minting routes (see
Personal API keys).
Bridge token: the paired Bridge's own token, accepted only on the
/bridge/* routes and refused everywhere else.
Feed token: a listener's calendar feed token, accepted only on
GET /me/upcoming/releases.ics (header or
?token=), which takes no other credential.
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,
"export": true,
"meta_bundle": true,
"meta_covers": true,
"browse_people": true,
"series_memberships": true,
"series_books": true,
"cover_sizes": true,
"next_book": true,
"queue": true,
"collections": true,
"ratings": true,
"progress_edit": true,
"user_stats": true,
"my_devices": true,
"annotations": true,
"addresses": true,
"household": true,
"cast": true,
"household_members": true,
"bridge": false,
"contributions": false,
"podcasts": false,
"audible": false,
"podcast_fetch": true,
"podcast_discovery": false,
"podcast_opml": true,
"ebooks": true,
"readalong": true,
"upcoming": true,
"upcoming_calendar": true,
"upcoming_covers": true,
"listening_sync": true,
"renditions": true
},
"auth": { "methods": ["auth_code", "password"] },
"renditions": ["watch"],
"demo": { "enabled": false }
}
nameis the server's display name,"AudioSilo"when unset. It is thenamekey inconfig.yaml, which an admin sets in the console's Settings > General, so it can change while the server runs.server_idis a stable, per-install identity, minted once and persisted inconfig.yamlso it survives a database rebuild. It never changes for the life of the install, and clients use it as the identity for a paired server. The pairing exchange (POST /auth/exchange) and the login and demo responses return it too, so a client has it the moment it pairs. The player keys its per-connection state (token, downloads, progress, cache) on<server_id>.<user_id>, one user on one server, so two people signed in to one server stay apart (see Connection identity).versionis stamped from the release tag:"dev"for local builds,"dev-<short sha>"for an:edgeimage.
The first flags in capabilities:
transcodeis true only when ffmpeg is configured.web_playeris true only when the/webmount is populated.uploadandwebsocketare reserved for future features and currently always false.api_keysis true on servers that support user-minted personal API keys.metadatareflects the runtime state of community metadata lookup. It is true only when a validmetadata.base_urlis configured and the lookup is switched on. An admin can flip it at runtime withPATCH /api/v1/admin/settings, with no restart, so the flag can change during a server's lifetime. Clients gate the enriched-book section on it and should re-read it after reconnecting.exportis true on servers that support the admin library export.
The flags below are newer. An older server omits each one, so a client treats a missing flag as false and never sends the request it gates. The browse and series flags:
| Flag | Value | Gates |
|---|---|---|
meta_bundle | the same as metadata | the /meta query params include=previous and spoilers=hide (an older server ignores both and sends the full envelope) |
browse_people | always true | the browse lists /authors, /narrators and /series, and the narrator filter on /books |
cover_sizes | always true | cover thumbnails, /cover?size= |
next_book | always true | /next, what to play after a book |
series_memberships | always true | memberships=1 on /books and /series: a book counts in every series it is in, not only its main one (an older server ignores the param and places books by their main series) |
series_books | always true | /series/books: the first page of several series' books in one request (without it, ask /books once per series) |
The listener's own state and stats, all always true on a server that has them
and absent on an older one:
| Flag | Value | Gates |
|---|---|---|
queue | always true | Up next: /me/queue, every method |
collections | always true | Collections: /me/collections/** and /me/share-targets |
ratings | always true | /libraries/{id}/rating and /me/ratings |
progress_edit | always true | PATCH /libraries/{id}/progress, and started_at / finished_at on the progress responses |
user_stats | always true | Your listening: /me/stats, /me/listening and /me/goal |
my_devices | always true | Your devices: /me/devices |
Three more, each absent on an older server:
annotations(alwaystrue) gates bookmark labels, the owner's edits of bookmarks and notes, and the lists across books. See Capability flags for what it covers.addresses(alwaystrue) says the server reports its home and away addresses. They are on every pairing payload and its links, on the exchange, login and demo answers, and atGET /addresses.meta_covers(the same asmetadata) says the server serves the community covers a book's/metaenvelope hands out, as thumbnails atGET /libraries/{id}/meta/cover.
Listening in the house and casting, both always true on a server that has them
and absent on an older one:
| Flag | Value | Gates |
|---|---|---|
household | always true | Listening in the house: GET/PUT /me/household and GET /household/listening |
cast | always true | Casting: POST /cast/token, and a cast token as the ?token= of a book's cover and stream |
Household members, always true on a server that has it and absent on an older one:
| Flag | Value | Gates |
|---|---|---|
household_members | always true | Household members: /me/household/members/**; managed_by, member_kind and (for a member) display_name on the user; member_sign_ins and ?also_members=1 on Your devices |
The flag never depends on the caller or on the admin's switches. Whether this
caller may add a member is the members list's allowed.
Podcasts have three core flags, each absent on an older server (treat absent as
unsupported). podcast_fetch is always true. It gates
on-demand fetch, status and Retry and the
optional fetch:Status on episodes and Book.episode. It stays true whether or
not the module and the Bridge are available. The other two:
| Flag | Value | Gates |
|---|---|---|
podcast_discovery | podcasts.discovery.enabled at start and the Podcasts module's toggle on; sent false otherwise. It does not depend on the Bridge or on a directory key | directory search (check providers for Podcast Index) |
podcast_opml | always true | the OPML import and export protocol, even offline or module-off; the import still needs the module (404 module_off), the export does not |
Both describe the server's configuration, not the caller's permissions. The routes still refuse a kid, a demo account or an unrecognized client.
The reader and read-along, always true on a server that has them and absent on
an older one:
| Flag | Gates |
|---|---|
ebooks | the reader: a book's ebook field, GET /libraries/{id}/ebook and GET/PUT /libraries/{id}/reading-progress. A client offers Read only when the book has an ebook and it is true (the player also while it is unknown offline, for a book whose download holds the EPUB; never when false) |
readalong | read-along: a book's readalong field on /item and GET /libraries/{id}/readalong. It says the route exists, not that any book has a map: a client also needs the book's readalong.state to be ready (and its ebook). Making a map needs a Bridge whose features list readalong |
Upcoming releases has three flags that follow two settings: upcoming,
upcoming_calendar and upcoming_covers. All three are true while community
metadata is on and the upcoming releases job is switched on, and false
(sent, not absent) otherwise. A server older than 2.6.0 omits them. See
Upcoming releases and
Capability flags.
Listening sync and the watch, each absent on an older server:
listening_sync(alwaystrueon a server that has it) gates listening sync.renditionsgates watch renditions:rendition=on/streamand the renditions routes, with the kinds in the top-levelrenditionslist. When it is true is in Capability flags.
bridge follows the Bridge rather than a setting, so it can change from one
request to the next. It is true only while a Bridge is paired and
its last heartbeat is under two minutes old. Only then, and only for a signed-in
caller, does a top-level bridge: {version, features} object sit beside
capabilities. An older server has neither. See
the bridge capability.
Each managed module adds a flag
named after it. The flag is true only while an admin has the module on and,
for a module that needs the Bridge, an online Bridge lists the feature it needs in
bridge.features. It is sent as false otherwise and is absent on an older
server. Like bridge, it can change from one request to the next.
| Flag | Value | Gates |
|---|---|---|
contributions | the Contributions module on and a Bridge offering contribute | the console's contributing actions: the generate dialog's contribute switch, Share with the community and Suggest corrections (which also needs meta in bridge.features); the player doesn't read it. The module has no routes or background work of its own: its actions are Bridge jobs it owns (404 module_off while it is off) |
podcasts is true only with its toggle on and an online Bridge offering
podcast. It gates following and checking, new fetches, policy writes and
scheduling. Core reads and ready media stay available while the
Bridge is offline or the module is off. podcast_fetch advertises protocol
support separately.
audible is true only with its toggle on and an online Bridge offering audible.
It gates the console's Audible routes and the scheduled polls.
The Bridge's own Audible routes work either way. Only the console reads it. The
player has no Audible surface and doesn't mirror it.
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" }
| Status | Meaning |
|---|---|
200 | database 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:
| Field | Type | Required | Notes |
|---|---|---|---|
code | string | yes | human-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",
"server_id": "kx8Qz1c7m2Vw0aB3dEfGh",
"base_url": "https://books.example.com",
"pairing_token": "3vJx0eKQm9WZbT5nR8sHc2fLdA7yUqPgVi4oXk1NwsE",
"uri": "audiosilo://connect?server=https%3A%2F%2Fbooks.example.com&token=3vJx0eKQ…&sid=kx8Qz1c7m2Vw0aB3dEfGh",
"web_url": "https://books.example.com/web/connect?token=3vJx0eKQ…&sid=kx8Qz1c7m2Vw0aB3dEfGh",
"qr_png_data_uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg…",
"links": {
"web": "https://books.example.com/web",
"admin": "https://books.example.com/admin",
"ios": "https://apps.apple.com/us/app/audiosilo/id6783431375",
"android": "https://play.google.com/store/apps/details?id=app.audiosilo",
"apk": "https://github.com/KodeStar/audiosilo-frontend/releases/latest"
},
"code_expires_at": "2026-07-04T09:30:00Z",
"uses_remaining": 5,
"admin": true,
"addresses": {
"home": "http://192.168.1.20:8080",
"away": "https://books.example.com"
}
}
pairing_token is as redeemable as the code that minted it. Complete it with
/auth/exchange.
- Redeemed from an invite, it inherits the invite's remaining uses and expiry. One QR can pair several devices, and each exchange claims one use.
- Redeemed from a recovery code, it lasts 10 minutes. Recovery codes are unlimited, so several devices can scan it within that window.
The other fields:
code_expires_atanduses_remainingdescribe the parent invite's budget. They are advisory, because concurrent exchanges may consume uses after the redeem. Both are omitted for recovery codes and unlimited invites.web_urlis what the QR encodes. The app's own scanner reads the token from it. A phone's camera app opens it in the browser, at the embedded web player, because the store apps claim no domain: a self-hosted server's domain is its own. Only a build named inapp_linksopens from it (Well-known).uriis the custom-scheme equivalent, for an explicit "Open in app" action.linkssays where to get the player:webandadminon this server,ios(the App Store),android(Google Play) andapk(the player's latest GitHub release page, never a file, for an Android phone without Google Play).apkis additive. The connect page shows the same store links before any redeem.administruewhen the code pairs an admin's account (an admin's invite, or an admin's recovery code), so the connect page offers the console only to an admin. It is absent for a listener's code, and on every other pairing payload (/auth/pair, the demo).base_urlhonors the configuredpublic_url, falling back to the request host.server_nameis the server's display name, asGET /server'sname.server_idis its stable id, asGET /server'sserver_id. Both links end with that id assid=, after the address params described in Home and away addresses, so a client can check who answers at an address before sending the token there.sid=is absent only on a server without an id, and the launcher always mints one.addressesis the server's home and away addresses, omitted when it has neither. Both links then carry them too.
Home and away addresses
On a server with the addresses capability, every pairing payload (/auth/redeem,
/auth/pair, the demo session's pairing), the /auth/exchange,
/auth/login and /demo/session
answers, and GET /addresses carry the same object:
| Field | Type | Meaning |
|---|---|---|
home | string, optional | The server's address on the household network: the configured lan_url, else the address this request arrived on (scheme + Host) when that is a home-network host and the request did not come through a proxy (the rule). On a self-signed server with home HTTP the derived one is always http://, on the request's port |
away | string, optional | The address that works from anywhere: the configured public_url |
A home equal to the away is dropped. On the pairing payloads and the sign-in answers
the object is omitted when neither is known. Because home can be derived from the
request, an answer read through the away address usually has no home. A client keeps a
home it already knows rather than forgetting it.
The two links carry the same addresses as extra query parameters after the existing ones, only those that are set, so an older client ignores them:
audiosilo://connect?server=https%3A%2F%2Fbooks.example.com&token=…&away=https%3A%2F%2Fbooks.example.com&home=http%3A%2F%2F192.168.1.20%3A8080&sid=kx8Qz1c7m2Vw0aB3dEfGh
https://books.example.com/web/connect?token=…&away=https%3A%2F%2Fbooks.example.com&home=http%3A%2F%2F192.168.1.20%3A8080&sid=kx8Qz1c7m2Vw0aB3dEfGh
Read them by name: the encoder sorts them (away before home), and sid comes last.
On a self-signed server with home HTTP (the
default), the port also answers plain HTTP to home-network clients. A derived home
is then http:// even when the request came over https:
- A device pairing at
https://192.168.1.20:8080getshomehttp://192.168.1.20:8080. - An https request without a port gets
http://<host>:443, the port that answers.
base_url, web_url and the uri's server= stay https, even for a pairing asked
for over plain HTTP. They use the https base on the same port, or :80 for a Host
without one. A link then reads
…server=https%3A%2F%2F192.168.1.20%3A8080&token=…&home=http%3A%2F%2F192.168.1.20%3A8080&sid=….
A native app that can't trust the certificate at server exchanges the token at that
home instead. It does so only when the home has the same host and port and a
tokenless GET <home>/api/v1/server answers with the link's sid. The token then
travels in cleartext, so it must reach this server and no other box at the same
private address. The player's rule is in
Connect and home/away addresses.
A client must not send a token to a home address until a tokenless
GET <home>/api/v1/server there has answered with the same server_id, because a
private address can belong to a different machine on another network. The player's rule is in
Connect and home/away addresses.
| Status | Meaning |
|---|---|
400 | code missing |
401 | code: "invite_used": a real invite with no uses left (from 2.5.0) |
401 | code: "invite_expired": a real invite past its expiry (from 2.5.0) |
401 | invalid or expired auth code, no code (anything else: an unknown code, the code owner disabled or deleted - a rejected attempt never burns a use) |
429 | redeem lockout tripped, with Retry-After (seconds until the lockout ends) |
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.
| Field | Type | Required | Notes |
|---|---|---|---|
pairing_token | string | yes | from /auth/redeem, /auth/pair, or a scanned QR |
device_name | string | no | label 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 player's
connection id is <server_id>.<user_id> with the answer's user
(Connection identity). On a server with addresses, the answer also carries
addresses ({ "home"?, "away"? }, omitted when neither is known; see
Home and away addresses).
| Status | Meaning |
|---|---|
400 | pairing_token missing |
401 | invite already used on all its devices - ask for a new invite (the parent invite's use cap is spent) |
401 | invite has expired - ask for a new invite (the parent invite expired after the redeem) |
401 | invalid or expired pairing token (anything else: bogus/revoked token, single-use token already exchanged, code owner disabled) |
429 | redeem lockout tripped, with Retry-After |
A refused exchange never burns an invite use. Its 401s carry no code (unlike
/auth/redeem's): tell them apart only by the English error, or not at all.
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.
| Field | Type | Required |
|---|---|---|
username | string | yes |
password | string | yes |
device_name | string | no |
device_id | string | no |
device_id is a random id the client keeps for itself across sign-ins (16 to
64 of A-Z a-z 0-9 _ -; anything else is ignored, as if absent). The server
stores only its SHA-256, on the session. A sign-in whose device_id an earlier
session of the same person already carried, signed out or not, is not
announced as a new_device event; one without it
always is. The admin console sends one, and players may adopt it. The server
forgets device_ids so that the browser's next sign-in is announced again:
- An admin signing a session out (
DELETE /admin/devices/{id}) forgets that session'sdevice_idon every session of that person. - A new password, Sign out everywhere or a disabled account forgets all of that person's.
A sign-in racing a password change gets no session, only 401 invalid credentials
(see Password changes end sessions).
Response 200: { "token": "…", "user": { … }, "server_id": "…" }, plus addresses
on a server with that capability. It is the same shape as /auth/exchange.
From 2.5.0 a username with surrounding spaces (a pasted or autocompleted
"alex ") signs in as the account without them, when no account has the name
exactly as typed (that one wins). Case still counts (auth.Service.Authenticate).
| Status | Meaning |
|---|---|
401 | invalid credentials (also returned for disabled or password-less accounts - deliberately indistinguishable) |
429 | login lockout tripped, with Retry-After (seconds until the lockout ends) |
POST /api/v1/auth/pair
Session. Issues a fresh pairing payload for the calling user, to add another device from an existing session. The body is optional:
{ "ttl_seconds": 120 }
Response 200: a PairingPayload, the same shape as /auth/redeem without the
invite budget fields and admin. Its token is single-use and lasts 10 minutes (pairingTTL),
so the player counts down from when the payload arrived.
ttl_seconds (from 2.4.0) asks for a shorter life, clamped to 30 seconds through
10 minutes (pairTTL). A caller can shorten a token's life, never lengthen it. No
body or no field gives the 10 minutes, and a malformed body is 400 invalid request. The admin console's Listen in player uses it to sign the web player
in as the admin in a new tab. It asks for 120 seconds (PLAYER_TAB_TTL_SECONDS),
because that token rides the tab's URL and history.
An API key gets 403. A kid's account (a household member
with member_kind: "kid") gets 403 code: "kid_account". Its owner sets up its
devices with POST /me/household/members/{id}/pair.
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",
"managed_by": null,
"member_kind": null
}
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".
On a server with household_members, managed_by and member_kind are always
present. Both are null for an account nobody manages. For a
household member they are the owner
({ "id": 4, "username": "sam" }) and "kid" or "grownup".
A member's user also carries display_name, the name the owner typed for them
(users.display_name), so the member's own device can greet them by it. It is
omitted while that is empty (a member the admin assigned, until the owner renames
them) and for everyone else, a former member included (api.meUser).
The same user object rides on every session envelope: exchange, login, demo and a
member's session. The member's member_access is not on it. That is the owner's
business (the members list) and the admin's. The
player's managed_by never carries the owner's own typed name; only the admin's
does. An older player ignores the new fields, and the server limits a kid whatever
the player shows.
GET /api/v1/addresses
Session (any role, demo accounts included). Capability addresses. The server's
home and away addresses as seen from this request, so a
device paired before an address was set learns it:
{ "home": "http://192.168.1.20:8080", "away": "https://books.example.com" }
Either field is omitted when unknown, and the answer is {} when neither is.
| Status | Meaning |
|---|---|
200 | the addresses ({} when neither is known) |
401 | no valid session or API key |
Self-service account
The routes in this section and the API keys routes share one
rate limit and one demo refusal. DELETE /auth/recovery only reduces access, so
it is neither limited nor refused. The key listing GET /auth/tokens is not
counted against the rate limit, but a demo account is still refused it.
- Rate limit: 10 attempts per IP per 15 minutes. A refusal is
429withRetry-After, the seconds until the lockout ends. - Demo accounts are refused (403
code: "demo_account",not available for demo accounts), so a throwaway session can't mint a durable credential.
POST /auth/password, POST /auth/recovery, POST /auth/tokens and
POST /auth/pair are also refused for a kid's account (403 code: "kid_account",
not available for a kid's account). A kid signs in only on devices its owner sets
up, and the admin can't mint or rotate a kid's invite either (409 kid_account,
below). Where the refusal happens:
- One route wrapper (
api.notKid) refuses before the handler runs. - The recovery code, API key and pairing writes refuse a kid again inside their own
transaction (
auth.refuseKid). A request that passed the wrapper just before the owner's switch to kid committed still gets403 kid_accountand leaves nothing behind. - A password racing that switch is refused inside
auth.UpdateUserinstead (ErrKidNoPassword), and the route answers it as403 kid_accounttoo.
POST /api/v1/auth/password
Session. Set or change your own password.
| Field | Type | Required | Notes |
|---|---|---|---|
password | string | yes | the new password; empty is rejected (clearing a password is admin-only) |
current_password | string | conditional | required only when the account already has a password |
Response: 204 No Content.
Changing a password ends the other sessions. Replacing a password the account had revokes:
- every
sessiontoken of the caller but the one making the request; - the caller's open pairing codes: unlinked
pairingtokens fromPOST /auth/pair, or an owner'sPOST /me/household/members/{id}/pairfor a member; - the caller's recovery code (
has_recoveryturnsfalse); - the caller's household members' sessions made on the caller's other devices, with every member's open pairing code. A member's own paired device stays.
API keys, the calendar feed token and Bridge tokens stay, and
a first password ends nothing (see
Password changes end sessions).
An ended token is refused (401) on its very next request, media ?token= included.
A shipped app takes its existing 401 path: signed out, pair again. The response
carries no count. A client that wants to say what happened reads
GET /me/devices again.
An admin's change also ends the first-run sign-in hint and turns
GET /admin/setup's printed_password false for the
first-run admin.
| Status | Meaning |
|---|---|
400 | password missing or too short |
401 | current_password incorrect: code: "current_password_incorrect" (2.7.0), so a client can tell it from a session that ended and keep the session. An older server sends no code |
403 | demo account (demo_account); a kid's account (kid_account, also when the owner's switch to kid commits between the wrapper and the write) |
429 | account-mutation limit tripped |
POST /api/v1/auth/recovery
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 (demo_account) and kids (kid_account), 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 such as dashboards, cron jobs and 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, because a key never
expires. Creating and revoking share the self-service rate
limit (10 attempts per IP per 15 minutes; a refusal is 429 with Retry-After);
listing does not spend it (from 2.4.0). All three 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 credential-minting routes:
POST /auth/tokens(another key),POST /auth/recovery,POST /auth/pairandPOST /auth/password;POST /cast/token, refused although a cast token would die with the key;POSTandDELETE /me/upcoming/calendar, a calendar link, which would outlive the key;- every household member route. Adding one with
sign_in_here,…/{id}/sessionand…/{id}/pairmake a member's session or pairing code.
So revoking a leaked key cuts off everything it could reach. It can't spawn a key, recovery code, pairing token, password or member session that would outlive its own revocation (this 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.
| Field | Type | Required | Notes |
|---|---|---|---|
label | string | yes | display 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.
| Status | Meaning |
|---|---|
400 | label missing/empty, or longer than 100 characters |
403 | demo account (demo_account), a kid's account (kid_account), or the caller is itself authenticating with an API key (containment - a key can't mint another) |
429 | account-mutation limit tripped |
GET /api/v1/auth/tokens
Session. The caller's live (non-revoked) API keys, newest first. They are 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.
| Status | Meaning |
|---|---|
403 | demo account |
404 | no such API key for this caller (missing, already revoked, another user's, or not an API-key token id) |
429 | account-mutation limit tripped |
Your devices
Session. Capability my_devices. The caller's own signed-in devices, the
player-side counterpart of the admin's
GET /admin/devices: a device is a live session token
(a paired phone, a browser) or a personal API key, never a pairing token. These
routes only ever touch the caller's own tokens, and, with ?also_members=1, the
sessions and API keys of the caller's own household members made on the device being
signed out.
GET /api/v1/me/devices
Session. The caller's live session and API-key tokens, most recently seen
first (auth.ListDevices for the caller).
{
"devices": [
{
"id": 57,
"kind": "session",
"name": "Pixel 9",
"client": { "app": "AudioSilo", "version": "1.4.2", "platform": "android" },
"created_at": "2026-07-02T11:20:31Z",
"last_seen": "2026-10-04T19:41:56Z",
"last_ip": "192.168.1.24",
"current": true,
"member_sign_ins": [ { "id": 12, "name": "Evie" } ]
}
]
}
The fields are those of the admin's device list
without user_id and username: kind is session or api, name the device
name sent at sign-in (an API key's label), client the app the token last
reported (null until a request names one), last_seen the newest
authenticated request (null before any), last_ip that request's address, and
current marks the token making this request.
member_sign_ins (capability household_members; always present there, [] when
none, and always [] for a member caller, who has no members) names the caller's own household members holding a live
session or API key that was made on that device: a member signed in through
sign_in_here or …/members/{id}/session
records the caller's token as its tokens.created_on_token_id. name is the member's
display name, else their username. Someone else's members never appear, and a
member's own device (paired from its code) is not "made on" any of the caller's.
DELETE /api/v1/me/devices/{id}
Session. Signs one of the caller's devices out by revoking its token. The caller's other devices stay signed in.
{ "current": false, "members_signed_out": 2 }
?also_members=1 (or true; any other value is off) also revokes, in the same
transaction, every session and API key of the caller's own household members whose
created_on_token_id is that device: the "Also sign out Evie and Ellis on that
device" of a lost phone. members_signed_out counts the members signed out (not
their tokens): always present, 0 without the parameter. A member's sign-ins made
anywhere else stay. The parameter is only meaningful on a server with
household_members; an older server ignores it and answers { "current": … }.
Revoking the current device is allowed (unlike the admin route's
409 current_device). The response says "current": true, and the token is
dead for every later request. A player shouldn't offer it for that row, though.
Signing out the normal way (POST /auth/logout after the app's own teardown)
lets the app save the final position and flush queued progress first, which a
revoke would refuse.
- An API-key caller may revoke too, since revoking only reduces access.
- A token of the caller's that has expired but isn't revoked yet can still be
revoked (
200), though the list already hides it. - Revoking also clears that browser's sign-in key, so a later password sign-in from it is announced as a new device.
- A self-revoke is not written to the admin audit log, but each member that
?also_members=1signs out is. The entry ismember.device_revokewith the caller as actor, the member as target and the caller'sdevice_id, as on the member route.
| Status | Meaning |
|---|---|
200 | revoked; current says whether it was the token making this request, members_signed_out how many members went with it |
400 | invalid device id - not a number |
404 | device not found - not one of the caller's session or API-key tokens: another user's, unknown, a pairing token or already revoked (never 403, so the answer doesn't confirm that another user's token exists) |
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):
| Field | Type | Required | Notes |
|---|---|---|---|
device_name | string | no | defaults 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",
"server_id": "kx8Qz1c7m2Vw0aB3dEfGh",
"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": "…", "…": "…" }
}
On a server with addresses, the answer and its pairing also carry addresses (see
Home and away addresses).
| Status | Meaning |
|---|---|
404 | demo mode is not enabled |
429 | per-IP demo cap tripped, with Retry-After |
500 | configured demo.library doesn't exist |
503 | demo is at capacity |
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, or, for a household member, through their owner (admins see all).
{
"libraries": [
{
"id": 1,
"name": "Audiobooks",
"kind": "books",
"root": "/srv/audiobooks",
"default_view": "hybrid",
"sort_order": 0
}
]
}
kind is always sent: books or podcasts. Podcast libraries are omitted
unless the authenticated request passes the
podcast client gate, including
for admins. Library membership does not require a visible show, so a granted
podcast library containing only unfollowed private shows still appears here.
root (the library's folder on the server's disk) is sent only to an admin who
is not a demo visitor. From 2.5.0 a listener and a demo session get each library
without it (listenerLibrary in handlers_library.go); a client treats it as
optional. The admin routes (GET /admin/libraries) keep it.
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. For podcast libraries this is an index view instead: show folders at the root, episode titles inside, serial shows oldest first and episodic shows newest first. Episodes appear even without files; feed/artwork files never appear. The podcast branch caps a page at 2,000 entries and applies private scope.
| Query param | Type | Default | Notes |
|---|---|---|---|
path | string | "" (library root) | directory to list, relative to the root; over 4096 bytes is 400 path is too long |
offset | int | 0 | |
limit | int | 200 | values ≤ 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
},
{
"name": "The Well of Ascension",
"path": "Brandon Sanderson/Mistborn/The Well of Ascension",
"is_dir": true,
"is_audio": false,
"size": 0,
"mod_time": 0,
"pending_book": true
}
],
"total": 2,
"offset": 0
}
next_offset is present when more entries remain. The book annotation fields
(is_book, title, author, series, series_index, series_index_end for a
range as on a book, duration) are
omitted for plain directories/files; override ("book" or "collection")
appears when an explicit folder-detection override is set (an admin concern; see
Scanner). split_discs: true marks a folder whose audio is
only in disc folders directly in it (CD1, CD2, ...), each indexed as its
own book, which a book override would join into one (see
Joined books); it is sent to admin
callers only and omitted otherwise, so the player's listing is unchanged.
Order. Directories come first, then files, each group in natural order
(naturalLess, the order a folder book's files play in, so 2 lists before 10;
see Book detection). A
joined book's discs play in discOrder, which also ignores spaces, hyphens and
underscores, so only a disc set named unevenly (CD1 beside CD 2) lists in
another order than it plays. An older server sorts each group by its lowercased
name, character by character (10 before 2).
What is listed. Dotfiles are hidden. An .mka beside a file of the same name
in another audio format (Book.m4b + Book.mka) is left out, as the scanner
leaves it out of the book (library.shadowedMKA, the rule folderAudio uses;
see Recognized audio); a
lone .mka is listed.
pending_book: true marks a folder the background scan has not
indexed yet that the scanner would read as a book (library.MarkPendingBooks,
called from annotateWithBooks), so a player can open it as a book: any content
route then indexes it on demand
(On-demand indexing). The scanner's
own rules decide, from directory reads alone (no audio is opened):
- a folder holding audio of its own (the hidden and
ignore rules applied) is one, unless it has a
collectionoverride; - a folder with a
bookoverride that is a disc set is the joined book, and its disc folders are none; without that override each disc folder holding audio is one and the folder above is none.
It is sent to every caller (player and console), but only on a folder the
caller's scope allows: a folder listed only as the way down to a grant is
never flagged. It is never sent alongside is_book, never in a podcast library's
listing, and absent (not false) otherwise. No capability flag: an older player
ignores the field, and a newer player on an older server finds it absent.
The pass is bounded (see the filesystem view), so a folder can be listed unflagged: one with an indexed book at or below it, one the pass doesn't reach in its 2 seconds, and every folder when the library's folder overrides or the index lookups for the page can't be read.
| Status | Meaning |
|---|---|
400 | invalid library id, or path escapes the root |
403 | no share grants this library |
404 | directory 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 param | Type | Default | Notes |
|---|---|---|---|
author | string | - | the books whose author credit is this value, or names it as one of its people (Brandon Sanderson finds Brandon Sanderson & Janci Patterson; see the people lists). Exact, case included |
series | string | - | the books whose main series is this series, in any spelling (case, accents, punctuation and spacing aside; see /series). With memberships=1, also the books that list it among their other series (see series_list below) |
memberships | 1 | - | series_memberships capability: makes series match every series a book is in. Without it a book is found only by its main series, as shipped players expect |
narrator | string | - | as author, on the narrator credit (browse_people capability; an older server ignores it and returns the unfiltered list; one before the co-credit split matches whole credits only) |
genre | string | - | the books with this genre (see genres below): one value, matched case-insensitively with spacing collapsed and trimmed (?genre=science%20fiction finds Science Fiction). No capability: an older server ignores it and returns the unfiltered list |
tag | string | - | as genre, on tags. With genre, both must match |
sort | string | author | author | title | recent (recent = newest added_at first) |
limit | int | 50 | ≤ 0 or > 200 falls back to 50 |
cursor | string | - | 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",
"published": "2006-07-17",
"cover_color": { "bg": "#1d2a3a", "accent": "#e8a33c", "on_accent": "#000000" },
"cover_version": "3f9a1c07be"
}
],
"next_cursor": "QnJhbmRvbiBTYW5kZXJzb24ANDEy"
}
Conditional book fields: asin/isbn appear only when known (attached by a
Bridge's ASIN backfill or set by an
admin edit); codec is omitted when never
probed; added_at when unknown. Every book response (lists, search, recent,
/item, /next) also carries, each omitted when empty:
| Field | Type | Notes |
|---|---|---|
published | string | the publication date, YYYY, YYYY-MM or YYYY-MM-DD: the effective value from an admin edit or an accepted community match |
cover_color | object | the cover's palette, lowercase #rrggbb: bg is its dominant colour; accent a vibrant colour of the cover adjusted to a WCAG contrast of at least 4.5:1 against bg, and on_accent (#ffffff or #000000) the text colour on it. accent and on_accent are omitted together when the cover has no usable vibrant colour |
cover_version | string | an opaque token (10 characters) for the cover art. Append it to a cover URL as v= so a client cache refetches a replaced cover |
series_index_end | number | the end of a main series position that is a range (an omnibus of books 1-3: series_index 1, series_index_end 3). series_index stays the start, so a client that doesn't know the field reads the omnibus as book 1. Omitted for a single position |
series_list | array | every series the book is in, [{"name", "position", "position_end"?}] (position 0 = unnumbered; position_end only for a range, as series_index_end): its main series first (at series_index), then the others an admin edit or a community match added. Sent only for a book in more than one series; otherwise series/series_index say it all |
subtitle | string | the book's subtitle: from an Audiobookshelf metadata.json, the work's subtitle on an accepted community match, or an admin edit. Never from the audio tags |
publisher | string | the publisher, from the same sources as subtitle (the recording's publisher on a match) |
language | string | the language as the source gives it: a name (English) or a code (en, en-GB), at most 64 characters. Same sources as subtitle (the work's language on a match) |
abridged | boolean | whether the recording is abridged. Absent means unknown, never "unabridged": an Audiobookshelf file and a community match only ever say true (both write false for "not recorded"), so false comes only from an admin edit |
genres | string[] | the book's genres: the files' genre tag (split on ; and NUL, never ,; placeholders such as Audiobook, Speech, Spoken Word and Other dropped), an Audiobookshelf metadata.json, or an admin edit. Each label once, compared case-insensitively, the first spelling kept; at most 50, each at most 64 characters. Sent on list and search pages too, so a client can filter a loaded library on the device |
tags | string[] | freeform tags, as genres, from an Audiobookshelf metadata.json or an admin edit only (no audio tag carries them) |
generated | boolean | true when the book's .audiosilo/ folder holds characters, recaps or a description the Bridge generated on this server (read from the book's attachment signature, so a file that failed its checks still counts); omitted otherwise. A client asks /meta for such a book even when it has no asin or isbn |
ebook | object | {"path", "size"}: the book's ebook (capability ebooks), the one readable .epub the scan found beside its audio (DRM is not judged) (see A book's ebook): its library-relative path (for display: the file is fetched by the book's path at /ebook) and its size in bytes when last scanned. Omitted when the book has none |
A book at a range, here as the omnibus of Mistborn 1-3 that is also book 1 of a box-set series:
{
"series": "Mistborn",
"series_index": 1,
"series_index_end": 3,
"series_list": [
{ "name": "Mistborn", "position": 1, "position_end": 3 },
{ "name": "Cosmere Box Sets", "position": 1 }
]
}
cover_version is on every indexed book: a short hash of the book's cover art
identity (books.cover_art). That identity starts from index data alone (a custom
cover's upload time, else the book's mtime, size and sidecar path), so it moves
when a custom cover is uploaded or removed, when a re-index rewrites the book, and
when a move or a disc join carries a custom cover. It also moves once at the
book's first thumbnail of file art, to the version of the image itself (the
sidecar's or audio file's size and modification time), so from then on it follows
a sidecar overwritten in place, which leaves the index unchanged. A client may
therefore fetch a cover once more after its first thumbnail; it is a cache buster,
not a content hash.
cover_color is read from a thumbnail of the art (any
/cover?size= request or the console's
POST /admin/covers) and stored tagged with the
cover_version it was read for. It is sent only while that tag is the book's
current cover_version. So it appears after the first thumbnail of the current
art. When the art identity moves it disappears, without anything being cleared,
until the next thumbnail. The long description is not on list responses. Only
/item reads and sends it.
Metadata fields (title, subtitle, author, narrator, series,
series_index, published, publisher, language, abridged, genres,
tags, asin, isbn) and chapter titles carry the effective values: what the
scan found, with any admin metadata edits layered on top. The shape is unchanged,
and a player sees the edited value. The same holds for every book-shaped response,
search, the /fs annotations and the export.
A book with a series and no position takes the one its title gives, when the
title is the series' name and then a number ("He Who Fights with Monsters 4: A
LitRPG Adventure" is series_index 4). A number run on from the name, a range
("1-12") or a number followed by more words gives none. A range comes only from an
edit, a community match or a series-part tag such as 1-3 (see
series_index_end).
List responses omit files, chapters, and direct_playable (single-book
responses include them). next_cursor is omitted on the last page. An invalid
cursor is 400.
GET /api/v1/libraries/{id}/authors · GET /api/v1/libraries/{id}/narrators
Session. The distinct authors (or narrators) of one library, with their book
counts and total duration. These are the player's browse lists (browse_people
capability). The same aggregate as the admin console's
/admin/authors, but limited
to the caller's share scope and without merge_suggestions.
{
"authors": [
{ "name": "Brandon Sanderson", "books": 14, "duration": 1204112.6 }
],
"unknown": 3
}
The narrators route uses the key narrators instead of authors.
- A co-credit counts once for each person it names: a
Michael Kramer & Kate Readingcredit adds a book to bothMichael KramerandKate Reading, and theauthor/narratorfilter on/booksfinds it by either name. Names sort case-insensitively. unknowncounts books with the field blank (they are not listed).- Only books the caller's shares grant are counted, so a share-scoped user never sees a count for a book outside their grant.
| Status | Meaning |
|---|---|
400 | invalid library id |
403 | no access to this library - no share grants it |
404 | library not found |
GET /api/v1/libraries/{id}/series
Session. Every series of one library with the books the caller can reach in it
(browse_people capability): the admin console's
/admin/series aggregate, within the caller's share
scope.
{
"series": [
{ "name": "Mistborn", "author": "Brandon Sanderson", "books": 3,
"duration": 284110.2, "positions": [1, 2, 3], "extra_books": 0 }
]
}
author is the most common author among the series' books; positions lists the
distinct non-zero series positions held, ascending (so a client can mark the
gaps). A book at a range holds its start, every whole number inside it and its
end (an omnibus of 1-3 adds 1, 2, 3; one of 2.5-4 adds 2.5, 3, 4), so an
older player shows no gaps inside an omnibus it has. Sorted case-insensitively
by name; books with no series are not counted.
Same status codes as /authors.
One series, any spelling. Series names that differ only in case, accents,
punctuation or spacing are one series ("He Who Fights With Monsters", "He Who
Fights with Monsters" and "He Who Fights WIth Monsters"): one entry, its books,
positions and length merged. Its name is the spelling its matched books (with
an ASIN or ISBN, or from 2.6.0 a recorded community match) use most, else the one most of its books use, ties to the first
alphabetically. Every series filter takes any spelling of the series it names
(/books?series=, /series/books, the
admin /admin/books?series=) and so does
/next's series step, so the name here loads all of
them. Nothing fuzzier merges: "The Expanse" and "Expanse" stay two series. The
JSON is unchanged; an older player simply sees fewer, fuller series.
By default a book counts only in its main series, which is what shipped players
expect. With ?memberships=1 (series_memberships capability) it counts in every
series of its series_list, at its position there, and extra_books is how many
of books are in the series that way (without the param it is always 0). Load
such a series' books with /books?series=...&memberships=1.
GET /api/v1/libraries/{id}/series/books
Session. The first page of books of several series in one request
(series_books capability): the player's Library > Series cards ask for the
series on screen together instead of one /books
request each.
| Query param | Type | Default | Notes |
|---|---|---|---|
name | string, repeated | - | a series name, once per series: ?name=Mistborn&name=Discworld. It finds the series in any spelling (see /series) and comes back verbatim. At least one, at most 50 distinct. Empty names are ignored and a repeated name is answered once |
limit | int | 50 | the page size of each series, as on /books (≤ 0 or > 200 falls back to 50) |
{
"series": [
{
"name": "Mistborn",
"books": [
{ "id": 412, "library_id": 1, "rel_path": "Brandon Sanderson/Mistborn/The Final Empire",
"title": "The Final Empire", "series": "Mistborn", "series_index": 1 }
],
"next_cursor": "QnJhbmRvbiBTYW5kZXJzb24ANDEy"
},
{ "name": "Discworld", "books": [] }
]
}
One entry per distinct name, in the order the names were given. Each entry is
exactly the first page that /books?series=<name>&memberships=1&limit=<limit>
returns (the default author sort, a book in several series found by each of
them, the same book fields), so its next_cursor continues on that /books
query; it is omitted once the series has no more books. books is [], never
null, for a series with no books the caller can reach. The caller's share path
rules apply as on /books.
| Status | Meaning |
|---|---|
400 | invalid library id; at least one series name is required (no non-empty name); at most 50 series per request |
403 | no access to this library - no share grants it |
404 | library not found |
GET /api/v1/search
Session. Full-text search (FTS5 over title/author/series/narrator/genres/tags) across
every library the caller can reach, scoped per-library to their share rules.
Results are relevance-ranked and de-duplicated across books libraries. Podcast
libraries are excluded even for podcast-capable callers. 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 param | Type | Default | Notes |
|---|---|---|---|
q | string | - | alphanumeric tokens are AND-ed with prefix matching; empty/symbol-only queries return no results |
limit | int | 50 | ≤ 0 or > 200 falls back to 50 |
Response 200: { "books": [ … ] }, Book objects as in /books, plus the
de-duplication annotations:
| Field | Type | Notes |
|---|---|---|
dedup_key | string | groups copies of the same logical book; a display hint, not an identity |
multi_file | bool | whether this copy is multipart |
other_locations | array | the 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 accessible books libraries,
merged and de-duplicated (same annotations as /search), newest added_at
first: one call for a "recently added" shelf.
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | ≤ 0 or > 200 falls back to 50 |
Response 200: { "books": [ … ] }.
Podcasts
The podcast API covers:
- feed following;
- indexed show and episode reads;
- scheduled and admin-requested downloads, and retention;
- CHAP chapters;
- on-demand fetch, status and Retry;
- directory search and OPML import and export, API only.
The player's podcasts use these routes and add none.
Only recognized clients see podcast content. They identify themselves through
X-AudioSilo-Client:
- the console's
AudioSilo Admin; - the player's
AudioSilowith versiondev; - released players from
AudioSilo/2.2.0(auth.PodcastPlayerMinVersion).
A missing or other client, or a player older than 2.2.0, is denied podcast content even for an admin. See Podcast visibility.
Podcast read routes
Session. Core routes, not module-gated; kids may read within their scope.
Library routes require a podcast library (409 not_podcast_library on books).
Normal library access errors apply. An authorized library with every show
filtered out returns empty lists, not 403. A hidden individual show is
404 show_not_found on the dedicated episode endpoint.
| Route | Response |
|---|---|
GET /api/v1/libraries/{id}/podcasts | 200 {shows: [Show]}; visible shows in title order, cap 2,000 |
GET /api/v1/libraries/{id}/podcasts/episodes | 200 {episodes: [Episode], next_cursor: string | null} |
GET /api/v1/me/podcasts | 200 {shows: [Show], requests: [Request]}; followed visible shows plus the caller's open subscribe requests and those ended within seven days, in accessible podcast libraries |
Episodes require show=<show path>. order is newest (default) or oldest;
limit defaults to 50 and must be 1 through 200 (400 invalid_request otherwise).
Episodes are ordered by published (undated as the earliest), then
season (none counts as 0), then episode number (unnumbered before numbered,
oldest first), then path, the order the Bridge's feed.json uses, so an
undated serial such as a LibriVox reading lists as its feed numbers it.
Pagination uses an opaque cursor over that keyset; it is bound to show and
order, and a cursor from a server before 2.4.0 (a published/path keyset) is
refused. Invalid order/cursor gives 400 invalid cursor.
Per-episode grants are applied before pagination.
A Show:
{
"library_id": 3, "path": "Example Show", "title": "Example Show",
"author": "Example", "description": "Plain text", "link": "https://example.com/",
"language": "en", "type": "episodic", "explicit": false, "private": false,
"feed_url": "https://example.com/feed.xml", "feed_host": "example.com",
"podcast_guid": "", "episode_count": 1,
"latest_published": "2026-10-19T21:00:00Z", "cover_version": "9f2c1a",
"following": true, "retention": {"mode":"keep","keep":3,"days":7},
"status": {"last_checked": null, "last_success": null, "next_check": null, "error": null}
}
Private shows have feed_url: null and link: "". Only their followers see
them, including player-facing admins; following by URL proves access through
the current or previous normalized URL hash. Unknown times and
latest_published are null. A status error is {code, message}.
Artwork is GET /api/v1/libraries/{id}/cover?path=<show>[&size=160|320|640] (96 too from 2.5.1);
episode covers use the same show artwork.
retention is additive policy data on both show-list routes; older clients
must tolerate it. The server supplies the default when there is no override.
An Episode:
{
"path": "Example Show/2026-10-19-3fa94c0b1d.mp3", "title": "An episode",
"published": "2026-10-19T21:00:00Z", "duration": 1720, "duration_source": "feed",
"season": null, "episode": null, "episode_type": "full", "explicit": false,
"description": "Plain text", "downloaded": false, "size": 0, "removed": false
}
The episode fields:
durationis seconds.duration_sourceisfile(present file and positivefetch.duration_ms),feedornone(zero duration).published,seasonandepisodemay benull.episode_typeisfull,trailerorbonus.removedmeans the episode disappeared from the feed, whether or not its audio is present.downloadedneeds committed regular audio of the expected size, with no deletion timestamp or publication barrier. An absent size is zero.
GET /libraries/{id}/item?path=<episode> adds episode to the normal Book JSON,
with show_path, published, season, episode, episode_type, explicit,
downloaded, duration_source and removed. That object is omitted for books.
Both the episode list and Book.episode add optional fields:
generation(integer),sha256(string) andtimeline_changed(boolean), when a valid Fetch exists, including a deleted generation.timeline_changedis explicitly false for an unchanged first generation and latches true after different bytes are fetched. It does not rescale or reset saved progress or bookmarks.fetch:Status, independently of those generation fields, from one batched status query per page. Older servers omit it. This public summary differs from the on-disk Fetch pin; see status and Retry.
An episode without a file has empty chapters and files. A stream request for it
triggers shared work and waits up to two seconds before a status-bearing 503. The
server never indexes podcast audio on demand itself. Present episodes use the
probed duration and codec. Valid MP3 CHAP is served through the existing chapters
and files envelope with optional chapters_source:"id3_chap". Without usable CHAP
a present file gets one whole-file chapter.
Direct podcast streams/downloads use
ETag: "podcast-g<generation>-<sha256>", Cache-Control: private, no-cache and
byte ranges. Matching If-Range permits 206; an old validator gets the full
current generation as 200. Changed hash/generation changes the ETag even at
equal size/mtime. Each request keeps its validated opened descriptor through
serving/transcode; transformed output does not advertise the source strong
ETag or byte seeking. Ready files remain core when the module/Bridge is off.
Podcast fetch status and Retry
The core podcast_fetch flag advertises this protocol,
independently of capabilities.podcasts (the module on with its online Bridge
requirement met). Neither flag bypasses the client gate, private-show scope or
authentication.
Session with episode read scope; kids allowed. GET is core observation only.
POST also requires not-demo auth (403 demo_account), but does not use the
adult follow/check wrapper. The canonical non-hidden path must name an exact
indexed episode in a podcast library. POST JSON is strict, at most 4 KiB;
only path is accepted, never URL, priority, kind or identity.
| Route | Query/body | Success |
|---|---|---|
GET /api/v1/libraries/{id}/podcasts/fetch | ?path=<show>/<episode.mp3> | 200 {fetch:Status}, never queues work |
POST /api/v1/libraries/{id}/podcasts/fetch | {"path":"<show>/<episode.mp3>"} | 202 {fetch:Status} for shared/new work, 200 if ready |
Both routes set Cache-Control: no-store. New POST work needs the module on,
an online Bridge offering podcast.fetch and a writable mapping. It bypasses
terminal failure cooldown, shares/promotes any open intent to 100 and never
replaces a valid present pin. Ready returns 200 before those storage checks;
existing work can still be returned/promoted when new work would be refused.
POST also starts one-ahead queue preparation. Unlike the
admin fetch route, its response is a fetch summary,
not a job request.
Every Status key is present:
| Field | Type | Meaning |
|---|---|---|
state | string | absent, queued, fetching, ready, failed or unavailable |
request_id | integer or null | matching fetch job id, if available |
bytes_received | int64, nonnegative | safe byte count, possibly a proven resumed prefix; may reset |
bytes_total | int64 or null | nonnegative known total; not a feed estimate |
retry_after_seconds | integer | 2 for queued/fetching/unavailable, 0 for absent/ready/failed |
error | null or {code,message} | sanitized server-owned reason; no raw job text/URL |
Absent is id/total null, received 0, retry 0, error null. Queued has an id,
received 0, total null and error null. Claimed/running/parked work is fetching,
with sanitized progress counters (received is clamped to a known total).
Ready has id null, both counts equal the committed Fetch bytes, retry 0 and
error null; it wins over stale job state. Failed retains a safe reason and
the latest failed id when available (null after pruning). Unavailable overlays
a refusal with retry 2, retaining an existing id/counters if any. Reasons
include module_off, bridge_unavailable, library_not_writable,
demo_account and, on a refused stream creation, fetch_limit.
Module-off status is unavailable even for claimed/running work that may finish.
List summaries use indexed presence; these GET/POST routes verify a committed
descriptor opens before answering ready. Status never includes requester,
params or progress messages.
The shared wire-2b.json examples are:
{
"fetch": {
"state": "fetching", "request_id": 81,
"bytes_received": 100, "bytes_total": 1000,
"retry_after_seconds": 2, "error": null
}
}
{"state":"ready","request_id":null,"bytes_received":16493,"bytes_total":16493,"retry_after_seconds":0,"error":null}
{"state":"failed","request_id":82,"bytes_received":0,"bytes_total":null,"retry_after_seconds":0,"error":{"code":"too_large","message":"The episode exceeds the size limit."}}
Missing-audio /stream (including Range, download=1 and transcode) shares the
same intent at priority 100. After access/exact-path checks, the absent-file
fetch branch spends at most two seconds on enqueue/promotion/DB reads/wait,
with a final presence check. A ready descriptor serves normal media; otherwise
the response is 503, Retry-After: 2, Cache-Control: no-store and JSON:
{
"error": "Episode is being fetched.", "code": "podcast_fetching",
"fetch": {
"state": "fetching", "request_id": 81,
"bytes_received": 100, "bytes_total": 1000,
"retry_after_seconds": 2, "error": null
}
}
Unavailable uses code:"podcast_fetch_unavailable" and
error:"The episode cannot be fetched right now."; terminal failure uses
code:"podcast_fetch_failed" and error:"The episode could not be fetched.",
with the same headers and their Status. The final deadline summary can lag a
transition (queued while already fetching). Disconnect/timeout never cancels
household work. There is no publisher pass-through or partial playback.
Every trigger shares one open intent per library/path and current identity.
Play and Retry use priority 100; the other triggers' priorities are in
fetch and retain jobs.
Promotion never demotes or resets requester/attempts/progress.
A non-admin may create five open fetch intents, counting prefetch and parked
jobs across libraries. Admins are exempt; joining/promoting existing work
does not spend or transfer quota. At the cap, a stream returns unavailable
with nested fetch.error.code:"fetch_limit" and no new id. POST Retry instead
returns 409 fetch_limit with the ordinary error envelope.
| Status/code | GET/POST refusal |
|---|---|
400 invalid_request | invalid/missing episode path, malformed or unknown POST fields |
403 / 404 | normal scope/client/private-path denial; queues nothing |
404 episode_not_found | episode missing from the index |
409 not_podcast_library | books library |
404 module_off | POST would create work with the module off |
409 bridge_unavailable / library_not_writable | POST would create work without the needed Bridge/mapping |
409 fetch_limit | POST would exceed the non-admin creator cap |
409 identity_changed | identity changed, or replacement work is still held; can also interrupt a stream trigger |
Unexpected faults remain sanitized 500s. GET reports availability/failure in Status, not as a scheduling error. Ordinary stream retries honor terminal cooldown; GET never retries a failed job. Clients repeat one request (GET status, or the media request) no sooner than every two seconds, and only while it is queued or fetching. Stop at ready, failed or unavailable. A failure needs an explicit POST Retry or a later scheduled attempt. An unavailable retry interval is not permission for a POST loop. The player's mirrors, hooks, cache refresh and generation and CHAP rules are in the Bridge podcasts page's Player section.
Podcast follow requests
A Request:
{
"id": 81, "library_id": 3, "state": "failed", "feed": "https://example.com/...",
"private": true, "show_path": null,
"error": {"code": "request_expired", "message": "The follow request expired. Follow the feed again."},
"created_at": "2026-10-20T08:00:00Z", "updated_at": "2026-10-20T08:01:00Z"
}
feed is a redacted display address. library_id and show_path may be
null; error is null unless failed. Follow requests use the Bridge job states.
Podcast actions
Session, adult, not demo; Podcasts module. The common module user wrapper
checks auth, notKid (403 kid_account), notDemo (403 demo_account), then
the toggle (404 module_off). The capability flag needs an online Bridge too;
the route gate checks the toggle and handlers check Bridge availability when
queuing work. POST bodies are strict JSON, at most 8 KiB. Feed URLs go in the
body, never in query strings.
| Route | Body/query | Success |
|---|---|---|
POST /api/v1/podcasts/follow | {library_id, path} or {library_id, feed_url, private?} | 200 {state:"following", show:Show} for an existing show; 202 {state:"subscribing", request:Request} when queued |
DELETE /api/v1/podcasts/follow | ?library_id=3&path=<show> | 204, also when already unfollowed |
POST /api/v1/podcasts/check | {library_id, path} | 202 {request:{id,state}}, returning an open poll when one exists |
Follow by path requires a visible show. Follow by URL needs a grant on the
whole library and matches current/previous URL hashes before queuing; knowing
a private URL supplies proof to follow that show. An existing follow is
idempotent. A missing private credential queues a restoring subscribe when
the URL is supplied. private:true forces privacy; false cannot override
automatic detection. Each user can have five open subscribe requests; a second
request for their same library/hash and the same privacy returns the open one.
An OPML import row answers the same way as a
follow by URL. Manual check requires
the caller to follow a visible show and, without an open poll, refuses a check
within 60 seconds of the last one.
| Status/code | Meaning |
|---|---|
400 invalid_request | missing/invalid library, both or neither path and URL, private with path, an invalid unfollow/check show path, or unknown body fields |
400 invalid_feed_url | malformed, non-HTTP(S), hostless or over-2,048-byte feed URL, or one whose normalized form changes when normalized again |
403 no_library_access | no accessible library, or URL follow without a whole-library grant |
404 show_not_found | missing, hidden or invalid show on follow by path, or check without following |
409 not_podcast_library | a books library |
409 bridge_unavailable | no online Bridge offering the action's podcast kind |
409 library_not_writable | Bridge mapping is not writable |
409 privacy_conflict | the caller already has an open subscribe request for this feed with the other privacy (a public request never joins a private one, and a private URL is never attached to a public one); wait for it to end, then follow again |
429 too_many_requests | five open subscribe requests (Retry-After: 30, a guess at when one has settled) |
429 checked_recently | last check under 60 seconds ago (Retry-After: the seconds until the minute is up) |
Async failures appear on Request/Show status, rather than as the follow
response's HTTP status. Codes include invalid_feed_url, blocked_address,
dns, timeout, tls, network, http_<status>, gone (410), too_large,
not_a_feed, not_a_podcast, not_podcast_library, library_not_writable,
feed_secret_missing, request_expired, write_failed, and server-only
bridge_lost. A private request lost before claim on a server restart is
request_expired; follow again. A private request whose requester's account was
deleted fails request_expired before any claim too, its URL dropped. Lost Bridge credentials are
feed_secret_missing; follow the URL again to restore them. No podcast job
sends a finished/failed notification. See the Bridge implementation.
Podcast discovery
Session, adult, not demo; Podcasts module. Directory search finds public
shows by name. Following one is the ordinary
POST /podcasts/follow with {library_id, feed_url}.
There is no provider-id follow and no new follow field. The player's Add shows
page calls these routes (the player from 2.4.0; see
Player: discovery and OPML).
The console does not.
| Route | Query | Success |
|---|---|---|
GET /api/v1/podcasts/discovery/providers | none | 200 {providers:[{id, available}]} |
GET /api/v1/podcasts/discovery/search | q, optional provider, limit, country | 200 PodcastDiscoverySearch |
GET /api/v1/podcasts/discovery/artwork | id (and a media token) | 200 image/jpeg |
The checks run in this order, and a refused request has no other effect:
authentication (401), 403 kid_account, 403 demo_account,
404 module_off, then the client gate
(403 no_library_access, no admin exception), then 404 discovery_off while
podcasts.discovery.enabled was off at start
(configuration).
Search needs no library grant: it reads only public directory data and
reveals nothing about the server's libraries. An unknown or repeated query
parameter is 400 invalid_request. JSON answers carry
Cache-Control: no-store. HEAD on these routes (and on the export below)
answers 405 method_not_allowed with Allow: GET before anything else runs,
so it never starts a fetch. The artwork route is Session (media): it also
takes a session or API key as ?token= (for an <img>), uses the media rate
limit, and refuses cast, pairing and Bridge tokens (401).
Providers. Always two entries, in this order:
{"providers":[{"id":"itunes","available":true},{"id":"podcast_index","available":false}]}
itunes (Apple's iTunes Search API) is always available. podcast_index is
available when the server holds a valid Podcast Index key pair from its
environment; nothing probes the service, so available says only that the
pair is configured. A search uses exactly the provider it names: there is no
automatic fallback and no merged ranking.
Search parameters.
| Param | Rule | Error |
|---|---|---|
q | required; valid UTF-8 with no control characters, then trimmed and runs of whitespace collapsed to one space, letter case kept; 2 to 200 characters and at most 800 bytes after that | 400 invalid_query |
provider | itunes (default) or podcast_index | 400 invalid_provider |
limit | a decimal integer 1 to 50, default 20 | 400 invalid_limit |
country | iTunes only: an ISO 3166-1 alpha-2 code, any case, default US (never taken from metadata.region); refused with podcast_index | 400 invalid_country |
There is no pagination. A podcast_index search while that provider is not
configured is 409 provider_unavailable.
{
"provider": "itunes",
"query": "fixture show",
"country": "US",
"results": [
{
"title": "Fixture One",
"author": "A & B",
"feed_url": "https://feeds.example.org/one.xml",
"artwork_url": "/api/v1/podcasts/discovery/artwork?id=4745a0c5c9b5a0a81f9d9396ab9abcc27c981462c6fdcbb7ab8f117de612d0e6",
"provider_ids": {"itunes": "12345", "podcast_index": null},
"episode_count": 12,
"provider_url": "https://podcasts.apple.com/us/podcast/fixture-one/id12345"
},
{
"title": "Fixture Two",
"author": "",
"feed_url": "https://feeds.example.org/two.xml",
"artwork_url": null,
"provider_ids": {"itunes": "9007199254740993", "podcast_index": null},
"episode_count": 0,
"provider_url": null
}
]
}
Every key is always present; results is [] when nothing matched.
query echoes the cleaned search text and country is null for Podcast
Index. In each result:
titleandauthorare plain text (controls stripped, at most 500 characters;authoris""when the directory gave none). Never HTML. A row without a title is dropped.feed_urlis the normalized public feed URL, the value to follow. Rows whose feed looks private (a query, user info, a token-like path, a private-feed host), points at a literal non-public address,localhostor.local, is not HTTP(S), is over 2,048 bytes or does not normalize stably are dropped. The server does not fetch or validate the feed at search time; the Bridge's subscribe job does that when someone follows it. Results are deduplicated byfeed_url, the first row winning, in the directory's order.artwork_urlisnullor a relative link to this server's artwork proxy; the directory's own image URL is never returned.provider_idsholds each directory's numeric id as a decimal string (ids can exceed 2^53, so never parse them as a float),nullwhen unknown.episode_countis an integer ornullwhen the directory gave none or an invalid one;0is a real zero.provider_urlis the show's page on the directory (podcasts.apple.comoritunes.apple.comover HTTPS for iTunes,https://podcastindex.org/podcast/<id>for Podcast Index), ornull.
No result says anything about the server's own state (whether a show is already in a library or followed).
What the directory receives. One GET per search the cache cannot answer,
with no retry and no redirect followed, to a fixed HTTPS origin:
https://itunes.apple.com/search with term, limit, country and fixed
media=podcast&entity=podcast&version=2, or
https://api.podcastindex.org/api/1.0/search/byterm with q and max.
Podcast Index requests also carry X-Auth-Key, X-Auth-Date (Unix seconds)
and Authorization (the lowercase hex SHA-1 of key, secret and that time,
joined with nothing); the secret itself is never sent. Both send
User-Agent: AudioSilo/<version>. The directory sees the search text and the
server's IP address; no user, library or device information is sent.
Budgets. All in memory, reset by a restart.
| Budget | Policy |
|---|---|
| Per user (across their tokens and both providers) | 10 searches/minute, burst 3, cache hits included, admins too |
| iTunes directory fetches | 20/minute, burst 1, server-wide (a conservative local policy) |
| Podcast Index directory fetches | 30/minute, burst 2, server-wide |
| Concurrent directory fetches | 2 server-wide; identical searches in flight share one fetch |
| Result cache | provider + cleaned q (case kept) + country + limit; 15 minutes, at most 256 entries and 8 MiB |
| Failure cache | a failed search is answered again for 15 seconds without asking the directory |
Upstream 429 | the provider cools down for its Retry-After (seconds or a date), clamped to 1 to 300 seconds, 60 by default |
A directory fetch has five seconds end to end, bodies are capped at 2 MiB
decoded and must be exactly one JSON document. Turning the Podcasts module off
cancels fetches in flight (their waiters get 404 module_off) and empties
every cache, so turning it back on starts empty.
| Status/code | Retry-After | When |
|---|---|---|
409 provider_unavailable | - | provider=podcast_index without a valid key pair |
429 discovery_rate_limited | seconds until the next token | the caller's own search (or artwork) budget is spent |
429 provider_rate_limited | seconds | the server's directory or image budget is spent, or the directory is cooling down after its own 429 |
503 discovery_busy | 1 | both fetch slots are busy, or the per-user budget table is full |
504 discovery_timeout | - | the directory did not answer within the budget |
502 provider_rejected | - | Podcast Index refused the key pair (401/403) |
502 discovery_unavailable | - | any other directory failure: another status, network or TLS failure, a refused address or redirect, malformed or oversize JSON |
Error messages are safe text; they never carry a directory's body, headers, URL or the credentials. Logs name the provider and a short reason only.
Artwork. id is 64 lowercase hex characters naming an image URL a recent
search result registered (its SHA-256). The caller can never name a URL. An id
of the wrong shape, unknown, expired or evicted is 404 artwork_not_found and
fetches nothing.
The server fetches the image itself and re-encodes it as a JPEG of at most 320 px. The fetch has these limits:
- public addresses only, checked at connect time;
- at most three redirects, and never from HTTPS down to HTTP;
- no credentials, cookies or caller headers;
- five seconds, and at most 2 MiB.
SVG, HTML and anything that does not decode as an image fail. Success is
image/jpeg with X-Content-Type-Options: nosniff and
Cache-Control: private, max-age=300. A failed fetch is 502 artwork_unavailable,
answered again for a minute without refetching. Registrations last 15 minutes, with
at most 4,096, so eviction can 404 an id before its search expires. Images are
cached up to 32 MiB.
Artwork has its own budgets, sized by a results page of 20 and never shared with search's (from 2.5.1):
- 120 artwork requests a minute per user (burst 40, hits included);
- 120 image fetches a minute server-wide (burst 40);
- four fetching at once, and up to 40 more misses waiting for a slot, each for up to the five-second fetch budget.
A miss past those 44, or one that got no slot in time, is 503 discovery_busy
(Retry-After: 1). A miss that never fetched gives its image token back. A spent
bucket gives the same 429 codes as search. Show a placeholder when the artwork
404s or fails.
OPML import and export
OPML is how podcast apps move subscription lists. The import follows each feed of an uploaded list as the caller; the export writes the caller's followed public shows. The player imports from its Add shows page and exports from the Shows library mode (see Player: discovery and OPML). The console has no screen for either.
POST /api/v1/podcasts/opml/import
Session, adult, not demo; Podcasts module. ?library_id=<id> names one
podcast library the caller holds whole (no other parameter; a user can
only import for themselves). The body is the raw OPML document: no JSON,
multipart, remote URL or compression.
curl -X POST "$SERVER/api/v1/podcasts/opml/import?library_id=7" \
-H "Authorization: Bearer $TOKEN" -H "X-AudioSilo-Client: AudioSilo/dev" \
-H "Content-Type: text/x-opml" --data-binary @subscriptions.opml
(--data-binary @- reads it from standard input.) The server runs these checks
in order:
- authentication;
403 kid_account;403 demo_account;404 module_off;- the client gate (
403 no_library_access); - unknown or repeated parameters (
400 invalid_request); - a missing or non-positive
library_id(400 invalid_request); 404 library_not_found;- no whole-library grant (
403 no_library_access), checked before the kind so an ungranted library's kind is not revealed; 409 not_podcast_library;- the content type:
application/xml,text/xml,text/x-opmlorapplication/x-opml+xml, with no parameter butcharset=utf-8and noContent-Encodingbutidentity, else415 unsupported_opml_type; - the caller's import budget, 2 a minute with burst 1:
429 opml_rate_limitedwithRetry-After, or503 opml_busywithRetry-After: 1while the server's per-user budget table is full; - whether one of two server-wide import slots is free (
503 opml_busy,Retry-After: 1, answered before the body is read); - the body, read whole, at most 1 MiB;
- then the slot.
A busy answer refunds the budget it took, so the Retry-After retry runs rather
than meeting 429.
The whole document is validated before any row acts, so a refused document changes nothing:
| Status/code | When |
|---|---|
413 opml_limit | over 1,048,576 bytes (whatever Content-Length says), over 1,000 outlines (categories included), outlines nested over 16 deep, XML depth over 32, over 5,000 elements, over 32 attributes on one element, or one decoded attribute value over 8,192 bytes |
400 invalid_opml | not well-formed; a DOCTYPE, DTD or other directive (even unused); a processing instruction other than the initial XML declaration; an encoding other than UTF-8; an unknown entity; a duplicated attribute (including xmlUrl in another letter case); not exactly one unnamespaced <opml> root with version 1.0 or 2.0, with at most one <head> and exactly one <body>; content after the root |
A leading UTF-8 byte order mark is accepted. Entities, includes and linked
OPML are never fetched. Titles, groups and htmlUrl are ignored: only the
<body> outline tree is read, flattened in document order. An outline with an
xmlUrl attribute (any letter case) is a subscription, whatever its type;
one whose xmlUrl is blank, or a type="rss" outline without one, is a row
with missing_feed_url (htmlUrl or url is never used instead); any other
outline is a category and produces no row (but counts in outline_index).
Within an upload the first outline with a given normalized URL wins.
200 answers the decisions, not completion:
type OpmlRowStatus = 'followed' | 'already_following' | 'queued' |
'already_queued' | 'duplicate' | 'invalid' | 'limited' | 'unavailable' | 'error';
interface OpmlImportRow {
outline_index: number; // 1-based among ALL outlines, categories included
status: OpmlRowStatus;
feed: string | null; // a redacted label (scheme and host, "/..." for the rest)
private: boolean;
path: string | null;
request_id: number | null;
duplicate_of: number | null;
error: { code: string; message: string } | null;
}
interface OpmlImportResult { library_id: number; rows: OpmlImportRow[] }
Every key is always present and rows is [] for a list with no
subscriptions. For example, a list whose first outline is a category:
{
"library_id": 7,
"rows": [
{"outline_index": 2, "status": "already_following", "feed": "https://feeds.example.org/...", "private": false, "path": "Fixture One", "request_id": null, "duplicate_of": null, "error": null},
{"outline_index": 3, "status": "duplicate", "feed": "https://feeds.example.org/...", "private": false, "path": null, "request_id": null, "duplicate_of": 2, "error": null},
{"outline_index": 5, "status": "queued", "feed": "https://feeds.example.org/...", "private": true, "path": null, "request_id": 81, "duplicate_of": null, "error": null},
{"outline_index": 6, "status": "invalid", "feed": null, "private": true, "path": null, "request_id": null, "duplicate_of": null, "error": {"code": "invalid_feed_url", "message": "Invalid feed URL."}}
]
}
status | Meaning | Set fields |
|---|---|---|
followed | a show already in the library, now followed | path |
already_following | the caller already follows it (a previous URL of the show counts too) | path |
queued | a new podcast.subscribe request (or a secret repair) was queued | request_id; path for a repair |
already_queued | the caller's open subscribe request for this feed was reused (a parked one too) | request_id; path when it repairs a known show |
duplicate | the same normalized URL appeared earlier in this upload (even if that row failed) | duplicate_of (the earlier outline_index) |
invalid | error.code missing_feed_url or invalid_feed_url | error |
limited | error.code too_many_requests: the wave is full (below) | error |
unavailable | error.code bridge_unavailable, library_not_writable or module_off | error |
error | error.code privacy_conflict, no_library_access, subscribe_failed or recently_failed | error; request_id for recently_failed |
feed is the redacted label even for public URLs, and null for an
invalid or missing URL; private follows the usual automatic detection, a
matched private show's flag, and is true for a row whose URL could not be
classified. An import cannot force a public-looking URL private: use the
single follow's private:true for a member feed without a recognizable secret.
Errors never echo the document, a title or a raw URL.
Each row acts as the caller through the same follow-by-URL service as
POST /podcasts/follow:
- The whole-library grant and the module are rechecked per row. A module switched
off midway turns the remaining rows into
unavailable/module_off, and a revoked grant intoerror/no_library_access. - A known show (current or previous URL) is followed at once, even with the Bridge offline.
- Only a new feed or a secret repair needs an online Bridge running
podcast.subscribeand a writable mapping.
Each row commits on its own, so a later failure never undoes an earlier row.
Recently failed feeds. When the caller's last podcast.subscribe request
for a feed in this library failed within the last hour
(podcast.RecentFailureWindow), the row answers error with
error.code: "recently_failed" and request_id set to that failed request,
and nothing is queued again, so a few dead feeds cannot take every wave's
slots. Only a failure of the feed itself counts (its address, its answer, the
network): a request that ended request_expired (a server restart),
bridge_lost, library_not_writable, not_podcast_library, write_failed,
feed_secret_missing or identity_changed does not (notFeedFailures in
internal/bridge), nor one the caller has asked again since. A single POST /podcasts/follow by
address always retries; that is the player's Try again. Older players show the
row as a plain error.
Waves. One upload creates at most five new subscribe requests, and the
caller can never have more than five open subscribe requests at once (single
follows and imports together, admins included). Following known shows and
reusing open requests spend neither. Rows past either limit answer limited
with too_many_requests; nothing is kept for later. Uploading the same
document again is the retry: rows already done answer already_following or
already_queued, and once earlier requests finish (poll
GET /me/podcasts's requests), the next five
limited rows are admitted. A private URL lives only in the server's memory
until the Bridge claims its request; a restart before that ends the request as
request_expired, and re-uploading recovers it.
The import runs under the server's 30-second request timeout. If that or a dropped connection ends it, rows already committed stay and the rest are not attempted; a re-upload continues safely.
GET /api/v1/podcasts/opml/export
Session, not demo. A core read, not module-gated: it works with the module
off, the Bridge offline or unpaired, discovery off or no directory key. Kids
may export within their scope. The client gate applies (403 no_library_access),
there are no parameters (400 invalid_request), and the per-user budget is
6/minute, burst 2 (429 opml_rate_limited with Retry-After; 503 opml_busy
with Retry-After: 1 while the per-user budget table is full).
curl -OJ -D - "$SERVER/api/v1/podcasts/opml/export" \
-H "Authorization: Bearer $TOKEN" -H "X-AudioSilo-Client: AudioSilo/dev"
(-D - prints the response headers, so the X-OPML-* counts below show.)
It writes the shows the caller follows now, across every podcast library their
current scopes show them (no admin or other-user bypass; a follow of a show
since replaced at its path, pruned or hidden counts for nothing). No follows is
a valid, empty document. The body is deterministic OPML 2.0:
<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
<head><title>AudioSilo subscriptions</title></head>
<body>
<outline type="rss" text="Fixture One" title="Fixture One" xmlUrl="https://feeds.example.org/one.xml"></outline>
</body>
</opml>
UTF-8, LF line endings, two-space indent, flat outlines with type, text,
title and xmlUrl in that order (titles control-stripped, at most 500
characters, Podcast when empty), sorted by URL bytes. A public URL followed
in several libraries is written once, titled from the lowest library id then
show path. No date, user or server name is written, and nothing but the
subscription: no progress, policies, pending requests or files.
Private shows are never exported, whoever asks, admins included, and there is no option to include them: the server keeps no recoverable copy of a private URL. A public show whose URL is missing or no longer normalizes is left out as unavailable, and one whose URL now looks private as private.
| Header | Value |
|---|---|
Content-Type | text/x-opml; charset=utf-8 |
Content-Disposition | attachment; filename="audiosilo-subscriptions.opml" |
Cache-Control | no-store |
X-Content-Type-Options | nosniff |
X-OPML-Exported | the number of outlines written (unique public URLs) |
X-OPML-Omitted-Private | followed, visible shows left out as private |
X-OPML-Omitted-Unavailable | followed, visible shows left out with no usable public URL |
The omission counts count follows before duplicate URLs are merged. More than
10,000 matching follows, or an export over 4 MiB, is 413 opml_export_limit
(never a truncated list); the two refusals share the code and their
error.message says which limit was hit ("Too many subscriptions to export
(more than 10,000)." or "The subscription list is too large to export (over
4 MiB)."). Fetch it with the Authorization header; there is no
?token= download link.
Podcast storage (admin)
Admin, not demo. These routes use the separate admin catalog, not the player
client/private-show gate. Retention GET is core. PUT/DELETE and a new fetch
need the Podcasts module on, an online Bridge advertising the exact job kind
(podcast.retain or podcast.fetch) and a mapped, writable podcast library.
Non-admin callers get 403. Request JSON is strict and capped at 4 KiB.
| Route | Query/body | Success |
|---|---|---|
GET /api/v1/admin/libraries/{id}/podcasts/retention | ?path=<show> | 200 {retention:Policy} |
PUT /api/v1/admin/libraries/{id}/podcasts/retention | ?path=<show>, body Policy | 200 {retention:Policy} after durable save |
DELETE /api/v1/admin/libraries/{id}/podcasts/retention | ?path=<show> | 204, resets to default |
POST /api/v1/admin/libraries/{id}/podcasts/fetch | {"path":"<show>/<episode.mp3>"} | 202 {request:{id:81,state:"queued"}}, or 200 {downloaded:true} for a valid present pin |
Policy is {"mode":"keep","keep":3,"days":7} by default. All three fields
are required on PUT and returned, including unused values: mode
keep|on_demand|archive, keep integer 1..1000, days integer 0..365. Overrides
are shared per library/show path/show id and survive index rebuilds; replacing
a show at the same path starts with the default. PUT/DELETE wake scheduling
and do not wait for disk changes. There is no retention UI yet.
Keep downloads the newest N currently listed episodes (the episode order descending, undated last); Archive downloads all currently listed episodes oldest first and never deletes. On demand schedules no feed-wide downloads; play, one-ahead queue preparation and admin fetch work. No followers means no policy-scheduled downloads/deletions; authorized play/Retry and queue preparation still work. Removed feed entries are not auto-fetched, but explicit fetch can try the saved enclosure.
Keep deletes only outside its window; On demand has no window requirement; both wait for every follower to finish plus the grace days, and favourites, queues, active streams and open fetches protect an episode. Keep N is not a disk limit. The full rules are in retention.
Fetch uses priority 20 and the admin's requester id, returns the existing open job's actual state, and bypasses terminal failure cooldown. It never replaces a valid present pin or creates duplicate open work. The present-pin 200 check runs before module/Bridge/mapping checks, so it works offline or module-off. Player-facing fetch/status routes use a different envelope and read scope; see status and Retry.
| Status/code | Meaning |
|---|---|
400 invalid_request | missing/invalid show or episode path, unknown fields, incomplete/out-of-bounds policy |
404 library_not_found / show_not_found / episode_not_found | missing library/show/episode |
403 demo_account | a demo account (other non-admin callers get 403 without a code) |
404 module_off | a storage-changing action while the module is off |
409 not_podcast_library | a books library |
409 bridge_unavailable / library_not_writable | no online Bridge offering the needed kind, or no writable mapping |
409 identity_changed | the indexed identity changed during the operation, or a fetch for a replaced episode at the same path is still running |
Unexpected failures use sanitized 500 responses. Async fetch failures are a
failed job with a safe {code,message,retry_after_seconds?} result; see
jobs for the codes,
params/results, backoff, progress and recovery.
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 param | Type | Required |
|---|---|---|
path | string | yes |
Response 200: a Book including files, chapters, playability and, when there
is one, its description:
{
"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",
"published": "2006-07-17",
"description": "For a thousand years the ash fell and no flowers bloomed…",
"subtitle": "Mistborn, Book 1",
"publisher": "Macmillan Audio",
"language": "English",
"abridged": false,
"genres": ["Fantasy", "Epic"],
"tags": ["Owned", "Book club"],
"cover_color": { "bg": "#1d2a3a", "accent": "#e8a33c", "on_accent": "#000000" },
"cover_version": "3f9a1c07be",
"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
}
description is the book's effective description (an admin edit or an accepted
community match), omitted when empty. It can be long, so only this single-book
response carries it; list, search, recent and /next books leave it out. It is
not the community-written community_description on
/meta. The other fields every book carries
(published, subtitle, publisher, language, abridged, genres, tags,
cover_color, cover_version, and series_index_end, series_list,
generated and ebook when they apply) are described under
/books; each is omitted when empty or unknown.
chapters_source says where the book's chapters come from:
"community": a community recording's chapter list fitted onto its audio rather than the files' own (see Community chapters)."generated": the chapters a Bridge found in the narration of a book with none of its own (see Generated chapters)."id3_chap": a podcast's validated MP3 CHAP attachment.- absent otherwise.
It is additive. The chapters have the same shape either way, and only this
single-book response (and /chapters) carries
it.
readalong (capability readalong) is the state of the book's
read-along map, the file a Bridge's readalong.align job wrote beside it (see
Read-along):
"readalong": { "state": "ready", "coverage": 0.996, "generated_at": "2026-11-02T10:11:12Z" }
"readalong": { "state": "stale", "reason": "ebook_changed" }
ready: the map fits the book, coverage the share of the ebook's text it lines
up (0 to 1), generated_at when it was made; fetch it from
/readalong. stale: it was made for another
ebook (reason ebook_changed) or other audio (audio_changed; the rules:
Read-along), so a client hides it until an admin
makes it again. Omitted when the book has no ebook, no map, or a map that fails the
server's checks. Only this single-book response (and the console's book page)
carries it, never a list row: working it out may read the ebook's digest.
direct_playable reports whether the book plays in browsers as the server
serves it (unknown codec ⇒ true). A book with a Matroska (.mka) file is
false (from 2.4.0), except one whose .mka files hold MP3 on a server with
ffmpeg configured: /stream serves those as
their remux to MP3, so it is true. Answering /item (or /chapters) also
starts those files' remuxes in the background, so the first stream usually
finds them ready. The web player streams a false book with ?transcode=1 when
the transcode capability is true; it does not retry through the transcoder after
a failed direct play. Durations/positions are seconds (float).
path may be the book's own path or a path inside it: a part of a folder book,
or a disc folder (or a file in one) of a book
joined from its disc folders. Either
resolves to the whole book, its rel_path the book's. The book must be in the
caller's scope too: a share granting only part of a book (one disc folder, one
file) gets the same 403 as a path outside the share, and nothing is read or
indexed on its behalf. The same holds for /chapters, /cover, /meta and /next;
/stream is scoped on the file path alone, so granted files still stream.
| Status | Meaning |
|---|---|
400 | missing path / invalid library id |
403 | no access to this path: the path, or the book it resolves to, is outside the caller's share scope |
404 | no 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 param | Type | Required |
|---|---|---|
path | string | yes |
{
"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,
"chapters_source": "community"
}
chapters_source:
"community"whenchaptersare a community recording's list fitted onto the book's audio (an admin chose them, or the files have no chapters of their own);"generated"when they are the chapters a Bridge found in the narration of a book with no chapters of its own (its.audiosilo/chapters.json);"id3_chap"for a podcast's validated MP3 CHAP attachment;- absent for other files' own chapters, and on servers before it.
The chapters keep the same shape either way, so an old client plays community or generated chapters as ordinary ones. See Community chapters and Generated chapters.
Same status codes as /item.
GET /api/v1/libraries/{id}/next
Session. What to play after a book, decided by the server so every player
follows a series the same way (next_book capability). The path is resolved and
authorized exactly like /item, and everything the
answer names is inside the caller's share scope.
| Query param | Type | Required |
|---|---|---|
path | string | yes - the book just finished (or any path /item accepts) |
{
"source": "community",
"next": { "library_id": 1, "path": "Brandon Sanderson/Mistborn/The Well of Ascension" },
"book": {
"id": 413,
"library_id": 1,
"rel_path": "Brandon Sanderson/Mistborn/The Well of Ascension",
"title": "The Well of Ascension",
"author": "Brandon Sanderson",
"series": "Mistborn",
"series_index": 2,
"duration": 105934.1
},
"work": {
"id": "the-well-of-ascension",
"title": "The Well of Ascension",
"position": "2",
"authors": [{ "id": "brandon-sanderson", "name": "Brandon Sanderson" }],
"web_url": "https://meta.audiosilo.app/work?id=the-well-of-ascension",
"local": { "library_id": 1, "path": "Brandon Sanderson/Mistborn/The Well of Ascension" }
}
}
| Field | Notes |
|---|---|
source | the step that produced next, or that decided nothing follows: community, series, folder or none |
next | {library_id, path}, the book to play next, always one the caller can open. Open it by its own library_id: a community answer can name a book in another of the caller's libraries. Absent when nothing follows |
book | the next book's indexed metadata in the list shape (no files, chapters or description); absent when next is a folder not indexed yet |
work | the next entry of the community rail that decided (see step 1), shaped like a /meta rail entry. With local beside a community answer; without local beside a series, folder or none answer when the rail names a next work the server could not place on one of the caller's books (a client can show it as "next in the series, not on this server"). Absent when no rail names one |
The server follows every series the book is in: its main series first, then
its other series (series_list) in their order. The community rails answer first,
but only when they can place the next entry on one of the caller's books.
Otherwise the local steps decide, in order, and the first one with an answer wins:
community: community metadata is on and the book's ASIN or ISBN matches a work with at least one series rail. Unlike/meta, this step never reads the book's recorded match.- Rail order. The rails are taken in the book's own order. First comes a rail named like its main series (by the rail's name or one of its orderings', ignoring case, accents, punctuation and spacing). Then come rails named like its other series, in list order, then the rest as the envelope lists them.
- The next entry on a rail. On each rail the server reads the entry after
the current work on the rail's main view: the smallest numeric position
above the current one. Unnumbered entries are skipped. An entry at a range
such as an omnibus
1-3counts by its start, and a current work at a range counts by its end, so after the omnibus comes4. A single entry wins a tie with a range starting at the same number. - No steps back. The server passes over any entry that comes at or before the current work on a rail ranked above it. So two orders of the same books (a chronological series and a publication-order one) can't step backwards or loop.
- The first rail with a next entry decides. A later rail's entry never
answers in its place. The server places that entry for the caller exactly as
/metaplaceslocal. When it is placed, the answer isnext+book+work. - An entry the server can't place. The caller's copy may be untagged, or
filed under a series named unlike the rail, or the caller may not have it.
Failing to place proves nothing, so the steps below answer, and the entry
rides along as
workwithoutlocal. A failure reading the caller's books also leaves the entry unplaced, as/metadegrades. - Nothing from this step (no
work) when the book has no ASIN or ISBN, has no match or no rails, or the metadata service fails. The same holds when no rail has a next entry: no current position is a number, the current work is last on every rail, or every later entry steps back. A rail can lag the library.
series: the book's series with a position above 0, main series first (seriesatseries_index, then each other series at its position there).- The candidate. For each series, the candidate is the book of that series
with the smallest higher position, within the caller's scope. It counts the
series in any spelling, as
/seriesgroups them, in the same library, by a book's main series or one of its other series at its position in that series. Ties go to a single position before a range starting there, then by path. - Ranges. A book at a range counts from its end: after an omnibus of 1-3, the next is 4. A candidate counts by its start: from book 2, an omnibus of 1-3 is no step forward.
- No steps back. The server passes over any book that comes at or before the current book in a series ranked above in which both books are numbered. A book inside the current omnibus in a series ranked above is passed over too.
- The answer. The first series with a later book that isn't passed over answers.
- The end of a series. When none has a later book but some other book in
one of them is numbered, the series has ended. In the series' own folder
(every other indexed book there shares a series with this one, and there is
at least one), a folder no scan has reached after the book (the pending rule
of step 3) answers as a
folderanswer withnextand nobook. So a series still being scanned is walked book by book. Otherwise, or with nothing pending, the answer is{"source": "series"}, the end of the series. - When nothing else is numbered, the step falls through.
- The candidate. For each series, the candidate is the book of that series
with the smallest higher position, within the caller's scope. It counts the
series in any spelling, as
folder: the book's parent folder, listed whole as the caller may open it (their share scope and the library's ignore rules).- The candidate. The answer is the first book or folder whose name sorts
after the current one, preferring a book. That is an indexed book or, from
2.5.0 (
markPendingNext), a folder no scan has reached that a scan would make a book of (library.MarkPendingBooks, the folder listing'spending_bookrule). Only folders before the first indexed book after the current one are read. - A pending answer has
nextbut nobook. Opening it indexes it on demand, as/itemdoes. - Name order. Names are compared the way the player's
localeCompare(numeric, base sensitivity) does: case and accents folded, numbers by value, soBook 2comes beforeBook 10. Ties go by path. Ordering is by name, notseries_index. - A folder that is not indexed is offered only when nothing in the folder, the current book included, is indexed (a folder mid-scan). Loose files that are not books are never offered.
- An unreadable folder falls through.
- The candidate. The answer is the first book or folder whose name sorts
after the current one, preferring a book. That is an indexed book or, from
2.5.0 (
none-{"source": "none"}.
After a podcast episode (a path in a podcast library) none of those steps
run. The answer is the next episode of its show in the
episode order, oldest first whichever way the show lists.
It is chosen among the episodes in scope that can still play (in the feed, or kept
on disk), and comes as a folder answer with next and book. At the show's end
the answer is {"source": "none"}. Episode file names say nothing about their order, so the
folder's name order is never used (catalog.NextPodcastEpisode).
| Status | Meaning |
|---|---|
400 | missing path / invalid library id |
403 | the path, or the book it resolves to, is outside the caller's share scope |
404 | library not found, or no book at that path |
500 | could not find the next book - a database failure |
GET /api/v1/libraries/{id}/meta
Session. Community metadata enrichment for a book: a description, production
details, and the series it belongs to. The server composes it from the community
metadata API (meta.audiosilo.app) and caches it. The
path is authorized against the caller's share scope exactly like /item. The route
is 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 finds the book's work in two steps (bookEnvelope):
- Its identifiers. The server looks the book's
asinorisbnup upstream. These are the effective fields on the indexed book: stored inbook_enrichment, or set by an admin edit, which wins. A book its identifiers match never reads its recorded match. - Its recorded match. When the book has neither identifier, or the upstream
does not know them, the server reads the book's recorded community match. That
is its
book_matchesrow, shown ascommunity_matchon the console's book page: thework_idandrecording_idan admin accepted or a match run applied. The server fetches that work (works/{id},meta.Service.EnrichMatch) and uses the match's recording as the envelope's recording, as a lookup would. When the match names no recording, or one the work no longer has, that is the work's first. A book with no match row is unmatched.
Either way the server folds the matched recording plus up to three
series rails into one envelope, one rail per reading-order family (see
Reading-order families). Every series-rail entry carries its own web_url, so a client links
to the metadata site without ever building a URL itself. Answers are cached in
memory and in the server's database, keyed by identifier or by the recorded match (see
Configuration), so a restart
serves them warm and a known book keeps its enrichment through a metadata-service
outage.
| Query param | Type | Required | Notes |
|---|---|---|---|
path | string | yes | |
include | string | no | previous adds previous[]. May repeat or be comma-separated; unknown values are ignored. meta_bundle capability |
spoilers | string | no | hide gates the envelope by the caller's saved progress (below); any other value is ignored. meta_bundle capability |
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…"
},
"community_description": {
"text": "Mark Watney is the first person stranded on Mars…",
"license": "CC-BY-SA-4.0"
},
"attribution": {
"credit": "AudioSilo Meta community contributors",
"license": "CC BY-SA 4.0",
"license_url": "https://creativecommons.org/licenses/by-sa/4.0/",
"source_url": "https://meta.audiosilo.app/work?id=the-martian"
}
},
"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://…",
"chapter_count": 26
},
"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",
"release_date": "2013-03-22",
"local": { "library_id": 1, "path": "Andy Weir/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. Every omitempty string or number field (subtitle,
first_published, description, abridged, runtime_min, release_date,
publisher, cover_url, chapter_count) is dropped when empty.
-
recording.chapter_countis the matched recording's chapter count, omitted when the metadata service does not know it. -
recording.librivox(2.7.0, additive) says where a LibriVox recording is published. It is present only when the community gives the recording a LibriVox project id (librivox_idabove 0), and absent on every other recording and on older servers:"librivox": {"id": 47,"url": "https://librivox.org/…","zip_url": "https://archive.org/compress/…"}idis the LibriVox project id.urlis the recording's page on librivox.org andzip_urlthe whole recording as one zip, from the community'slinks.librivoxandlinks.zip(see Thelinksfield). Each is optional, and the server keeps it only when it is an absolutehttporhttpsURL with a host (librivoxOf). It never builds either fromid, because LibriVox's pages are named by slug. A cached envelope gains the field once its entry is next refreshed. -
series[].positionis this work's position in that series. Positions are strings ("1", "2.5", "1-3.5"). -
series[].worksis the full ordered rail, including the current work. The client filters it out before drawing a "more in this series" row. -
Each rail entry (in
series[].works[]andseries[].orderings[].works[]) carriesrelease_date(2.6.0, additive). It is the work's first release, the earliest across its recordings and marketplaces, at the precision the community states it (YYYY,YYYY-MMorYYYY-MM-DD), and is omitted when unknown. A cached envelope gains it once its entry is next refreshed (within a day). The console's Series page dates its gaps by it, and upcoming releases classify by the same date.
Series rules
The community lists every series a work is in, and not each of them is a series
a book of the work is in. The server applies one set of rules
(internal/meta/seriesrules.go) to the rails above and to the
match dialog's candidates, which is what a
match writes onto a book:
-
a variant reading order (
ordering_of) is an alternate view of its rail, never a series of its own; -
a translation (
translation_of) of another series the work is in, whose original is in the work's language, and a series whose derivedlanguagediffers from the work's (primary subtags; an unknown language agrees), are left out; -
an edition is a series named exactly like another of the work's series plus a bracketed qualifier or a "narrated by" tail. The community API links no series to a recording, so the qualifier says which recordings hold it:
abridged: the abridged recordings;- a full cast or dramatisation: a recording with four or more narrators, or one named "Full Cast";
- "Narrated by X" or "Gesprochen von X": the recordings X narrates.
An "(Unabridged)" edition repeats its base, and a language edition or an ordering note holds no recording, so those are left out. A book is in an edition only as one of its recordings, and then it is its main series.
-
A name that only extends another ("Discworld: Tiffany Aching") is a series of its own.
The language and translation facts come from each series' record (series/{id}),
read once per series and cached as long as an enrichment.
Reading-order families
Some series come in more than one reading order: a primary series (usually
publication order) plus variant series whose ordering_of names it, such as a
chronological order or the author's recommended order. These are metaserve's
artifact schema_version 7 fields; see the metadata API. A primary
and its variants are one ordering family. The server collapses each family the
work belongs to into one rail rather than one rail per order (seriesRails /
familyMains in internal/meta/service.go):
- Family key is a membership's
ordering_of, or its ownidwhen that is empty. - Main view. The rail's top-level
id,name,positionandworksare the family's main view: the primary whenever the work is in it, else the variant that holds it (a book only a chronological order places keeps that order, the only one that places it). The choice readsordering_ofrather than trusting the upstream's membership order. - Alternates. The family's other orders ride along in
orderings[], in metaserve's family order (primary first, then variants by id), the main view excluded. Each is fetched from the upstreamseries/{id}, at most two per family (maxOrderingAlternates), and every main view is fetched before any alternate, so a slow upstream under the compose deadline costs alternates, never rails. - The three-rail cap counts families (
maxSeriesRails), not series, so a work in "Narnia" and "Narnia (Chronological)" spends one slot. - The cap goes to the book's own series (the series rules).
- Before the cap, an edition named for a kind of recording ("Discworld (abridged)", "Harry Potter (Full-Cast Editions)") is kept only when the envelope's recording is that kind. That recording is the one the lookup named, or the work's only one, never a guess of the first recording. A kept edition goes first, since it is the book's main series. Other editions are left out. Without this, a Discworld novel listed in six series, three of them "(abridged)" editions, lost its "Discworld: Tiffany Aching" rail to them.
- An edition named for anything else (an ordering note from a metaserve that
predates
ordering_of) keeps its place, so a pre-v7 upstream still yields the same rails. - A series known to be foreign (another language than the work, or a
translation of another of its series) is left out. That happens before the cap
when the server already holds its record, else once its record is fetched. It
then hands its slot to the next family, at most twice (
maxForeignRails). - One enrichment issues at most (3 + 2) main-view requests plus 3 x 2 alternates.
- Failures are partial, not fatal. A failed alternate ships the rail without it. Like a failed rail, it makes the envelope partial. A partial envelope is cached for the 2-minute transport-error TTL rather than 24 h, so the missing order reappears soon.
The extra fields are additive and omitempty:
{
"id": "narnia",
"name": "The Chronicles of Narnia",
"position": "1",
"ordering": "publication",
"works": [ ... ],
"orderings": [
{
"id": "narnia-chronological",
"name": "The Chronicles of Narnia (Chronological)",
"ordering": "chronological",
"ordering_of": "narnia",
"position": "2",
"works": [ ... ]
}
]
}
| Field | Meaning |
|---|---|
ordering | the main view's reading order (publication, chronological, recommended), omitted when unstated |
ordering_of | set only when the main view is itself a variant: the primary's id, so a client keys the family as ordering_of || id |
orderings[] | the alternate views: {id, name, ordering?, ordering_of?, position?, works}; works entries have exactly the rail's works shape |
orderings[].position | this work's position in that order - empty when that order does not place the work at all |
Why server-side: shipped native players lag the server. A player that predates reading orders reads only the top-level view, so it now sees one rail per family in the primary order, and its "previous books" catch-up can no longer offer a chronological order's earlier books (The Magician's Nephew, before The Lion, the Witch and the Wardrobe) as books to catch up on. Against a metaserve that sends no ordering fields every series is its own family and the rails are exactly what they were before, with one exception: a work listed at two positions of one series is now one rail, at its first position.
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). On this
route, for a book split finer than the work's own scale,
the server answers them on this book's chapters.
- Each character carries an
id(unique within the work, not global), aname, optionalaliasesandrole(protagonist/antagonist/supporting/minor), arevealposition (where the character is first disclosed, the spoiler gate), and an own-wordsdescription. 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
throughposition (safe to show once the listener has finished that chapter), an optionalscope(bookorseries; achapter: 0+seriesrecap is the "previously, in earlier books" summary), and own-wordstext. recap_summaryis the whole-book refresher, not keyed to a position:{ "in_short": …, "ending": … }, both fieldsomitempty. Both are spoilers:in_shortis the whole book in one own-words paragraph, ending included, andendingis by construction a full spoiler. A client must only reveal either deliberately (the player shows the current book'sin_shortonce it is finished or behind an explicit tap on a "Whole-book summary" row, and itsendingonce finished; a previous book's row, opened deliberately, showsin_shortand putsendingbehind its own tap). 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), which is not exposed to clients. A
client that ignores these fields is unaffected.
work.community_description ({ "text": …, "license": … }, license omitted
when unstated) is the community-written, spoiler-free description: part of the CC
BY-SA layer, kept apart from description (the CC0 core's blurb) because the two
carry different licences. It is absent for most works. A description generated
on this server never appears here: it rides in work.generated_description
(Generated content).
Attribution
work.attribution is the credit the CC BY-SA layer requires wherever it is shown:
| Field | Value |
|---|---|
credit | AudioSilo Meta community contributors |
license | CC BY-SA 4.0 |
license_url | https://creativecommons.org/licenses/by-sa/4.0/ |
source_url | the work's page on the metadata site (the envelope's web_url) |
It is present if and only if the work's community content carries CC
BY-SA material (characters, recaps, recap_summary or
community_description), so a client shows the credit exactly when it shows
content that needs it. Generated content never counts. The server writes
the legal text: a client renders these fields beside that content and never
composes its own. It is on every work the server returns: the envelope's work,
each previous[] work and /meta/work. With
spoilers=hide, a work left with no CC BY-SA content after gating loses its
attribution too.
Owned entries (local)
Every rail entry the caller owns carries local, { "library_id": …, "path": … }: the book to open for that entry. It appears in the main view
(series[].works[]) and in each alternate order (series[].orderings[].works[]),
and is absent where the caller has no copy. It is worked out for each request,
after the cache and on a copy of the rails, and is never stored: the cached
envelope is shared by every caller.
The candidates are the caller's books, in every library they can reach and only
the paths their shares grant, whose series matches a rail or ordering name with
case, accents, punctuation and spacing ignored (The Expanse matches the Expanse!). Each entry gets at most one book, the first that applies:
- the current work's entry: the book the request is for;
- a book the server already knows to be that work (from the community answers it has cached, in memory or else in its database, fresh or stale; never a new upstream call, so placement doesn't change with a restart);
- in an alternate order, the book the main view placed for the same work;
- a book of a series named like this view (the rail's or ordering's own name)
whose
series_indexequals the entry's numeric position. An entry at a range ("1-3") takes a book at that same range (series_index1,series_index_end3); an entry at one number takes a book at it, else a book whose range starts there.
A book known to be a particular work is placed by that identity only, never by its number, so a novella numbered like the next volume holds no slot. When two books qualify, one in the requested book's library wins, then library order, then path.
local is an extra: when the caller's books can't be read, the envelope is sent
without it (the failure is logged) rather than failing the lookup.
Positions on this book's chapters
A work may state the scale its positions are numbered on, as position_scale
(omitempty, the same on previous[] and /meta/work):
"position_scale": { "length": 5, "unit": "act", "partial": false }
length is the book's length in that unit (a chapter, an act, a section...), and
partial, optional, says the sidecars stop short of it on purpose. A client's gate
compares a position with the number of the chapter the listener is in, so numbers on a
coarser scale would come early: at track 6 of a 43-track Hamlet whose sidecars are on 5
acts, Act 5's recap reads as already past. So for the current work on this route,
when the work states a scale and the book has more chapters than length, the
server answers every reveal and through as a chapter of this book, and
omits position_scale (a present one always describes the numbers beside it). The rule
throughout is that later is safe and early is not:
| Position | Answered as |
|---|---|
0, either kind | 0 (before this book) |
reveal p: the character appears in unit p, and shows on reaching the number | the first chapter starting at or past the unit's start, fraction (p - 1) / length. 1 is chapter 1, so the opening cast shows from the start. When no chapter starts that late, one past the last chapter: no place reaches it, so it stays hidden until the book is finished |
through p: the recap covers up to the unit's end, and shows once past the number | the first chapter ending at or past fraction p / length, so it opens on entering the next chapter with the unit over |
through p >= length | the last chapter, shown only once the book is finished |
Each answer is the later of two estimates: by time (the chapters' book_offsets against
the book's duration) and by count (each chapter an equal share). So uneven tracks can't
bring a position forward, as they could with 41 tracks on 40 chapters. When the offsets
can't be trusted (out of order, all alike, or no length past the last start) the count
stands alone. On that Hamlet the reveals are 1, 10, 19, 27, 36 and the throughs 9, 18, 26,
35, 43. This is proportional: it can't know how long a real act runs.
The numbers pass through unchanged, position_scale included, when the work states no
scale, the book has no chapters, or it is no finer than the scale (the same count, fewer
chapters, a lone chapter), where they come late track for track. So a reveal can be past
a book's last chapter on either path, and a client treats it the same way: hidden until
finished. A translated answer is per
request and never cached, so two books with one ASIN each get their own. Generated content
carries no scale and is already numbered on the book, so it is untouched. Consequently a
label built from such a number names this book's chapter, not the work's.
Previous books and spoiler gating
include=previous adds previous: the works before this one in its series,
nearest first, at most five, each the same shape as work. They come from each
rail's main view only (an alternate order's earlier books are exactly the
reading-order spoiler the main view avoids): every entry whose numeric position is
below the current work's. A rail whose own position is not a number contributes
nothing, nor does an unnumbered entry; a work on two rails counts once. Each is
fetched and cached like /meta/work, and one that fails is
left out rather than failing the envelope. previous is omitted when there are
none.
spoilers=hide gates the envelope by the caller's own saved progress on this
book (the server's progress record, mapped onto the book's chapters by
book_offset; no saved progress counts as not started). When the offsets say nothing
about a place (every chapter at the same one) the book reads as not started, however far
along the progress is, never as the last chapter. Past the last start, a run of offsets
alike at the end (files of unknown length after the last known one) reads as the run's
first chapter. A work the gate leaves with no characters or recaps loses its
position_scale too:
| Content | Kept when |
|---|---|
| the current work, whole | the book is finished |
| a character | its reveal.chapter (already on this book's chapters) is at most the current chapter (at least chapter 1, so a book not yet started still shows its opening cast) |
| a recap | its through.chapter is 0, or below the current chapter (the chapter being heard is not over) |
recap_summary | the book is finished (in_short and ending both summarize the whole book) |
a previous[] work | always, except its recap_summary.ending |
These are the player's own rules (meta-gating.ts), but the player does not use
this param: it gates on the device against its live position, which can be ahead
of the last saved one. spoilers=hide is for clients that cannot gate
themselves.
When neither the book's identifiers nor its recorded match (if any) find a work
upstream, the response is 200 { "matched": false }. That is a normal, non-error result
that the client treats as "nothing to show":
{ "matched": false }
Generated content
The Bridge can generate characters, recaps and a description for a
book on this server. They live in the book's
.audiosilo/ files, which the scan checks and
indexes. The server lays them into the answer per request, after the shared
cache and local, on the request's own copy, never into the cache. Community
content wins per dimension:
-
A matched book. The server fills each dimension the community work lacks:
- no characters:
work.charactersare the generated ones; - no recap list:
work.recapsare the generated recaps. When it has norecap_summaryeither,work.recap_summaryis the generated{in_short, ending}(omitted when both are blank). A community work with a summary but no list keeps its own summary beside the generated list; - no
community_description:work.generated_descriptionis the generated description (see the generated description below).
work.generatedthen names what came from the files:"generated": { "characters": false, "recaps": true, "recap_summary": true, "description": true,"generated_at": "2026-10-16T09:12:44Z" }recapsistruewhen the recap list or the summary was generated. That is its meaning on an older server too, so an older client reads it unchanged.recap_summaryistrueonly whenwork.recap_summaryitself came from the files.falsebeside a generated list means the summary is the community's, so a client credits it to the community. A client that finds it absent (an older server) readsrecapsfor both.- The four booleans are always sent together, except that an older server may
lack
descriptionorrecap_summary. generated_atis the provenance's time, omitted when unknown. It isbridge.json'sgenerated_at, or itsdescriptionobject's when the description is all the object names, so a description written later by asidecars.describejob shows its own time.- The whole object is omitted when nothing was generated, or the community has it all.
- no characters:
-
A book with no match (neither its identifiers nor its recorded match find a work) but generated content answers
matched: falsewith a work of its own:idempty,titlethe book's,authors[], the generatedcharacters,recaps,recap_summaryandgenerated_description, andgenerated:{ "matched": false, "work": { "id": "", "title": "The Book", "authors": [], "language": "","characters": [ … ], "recaps": [ … ],"generated": { "characters": true, "recaps": true, "recap_summary": false, "description": false } } }A book whose only generated file is its description answers just that:
{ "matched": false, "work": { "id": "", "title": "The Book", "authors": [], "language": "","generated_description": { "text": "…" },"generated": { "characters": false, "recaps": false, "recap_summary": false, "description": true,"generated_at": "2026-10-16T09:31:02Z" } } } -
An upstream failure is a
502only when the book has no generated content; with some, the answer is the unmatched one above (the server's own content doesn't depend on the upstream).
The generated description is the book's own-words, spoiler-free
description, work.generated_description: { "text": … }, with no licence and no
attribution. It rides in its own field, never in community_description. The
shipped 2.0 app shows community_description first in About, over the book's own
description, and credits it to the community. A generated text there would replace
real blurbs on old apps, and those apps ignore the new field.
The server fills it only when the community work has no community_description.
The work's core description (the publisher's kind) doesn't block it, since the
player orders those itself: the book's own description, then the core one, then
the generated one.
Attribution is the community's alone. work.attribution is decided from
the community content before anything generated is laid in, so generated
characters, recaps or a description never make the server claim the CC BY-SA
credit, and a work whose only content is generated carries none. A client shows
the credit beside the community's dimension only (the player's communityCredit).
Spoiler gates. spoilers=hide gates the generated characters and recaps
exactly as it gates the community's. A gate that leaves an unmatched book nothing
answers plain { "matched": false }. The gate keeps the generated
description, which is spoiler-free by construction: the Bridge writes it from the
opening chapters' notes only. include=previous stays community only.
With metadata off, /meta is 404 as ever. Generated content needs metadata on,
as the generate action does. A book whose row names no generated file (generated
absent) isn't looked up, so this costs nothing for other books. The server reads
only the parts the community work leaves to fill and the book's signature
names. A work with community characters and recaps reads just the description.
Older players. A player that predates the field ignores everything beside
matched: false, so it shows nothing for an unmatched book. For a matched book
it shows the merged work as it would the community's: the generated dimension
without a mark, and the work's community credit beside it when the community
has the other dimension. The owner kept that behaviour as built. Newer players
mark the generated dimension and drop the credit there. No older player shows a
generated description, because it is a field they don't know.
| Status | Meaning |
|---|---|
200 | { "matched": true, … } on a match, or { "matched": false } (with a work when the book has generated content) otherwise |
400 | missing path / invalid library id |
403 | path outside the caller's share scope |
404 | no book at that path, or metadata lookup is disabled on this server (metadata capability false) |
500 | could not load book - the book or its recorded match could not be read. With spoilers=hide, also could not load progress - the caller's progress could not be read; the ungated envelope is never sent instead |
502 | metadata service unavailable - the upstream was unreachable or errored, the server holds no earlier answer for the book, and it has no generated content |
GET /api/v1/libraries/{id}/meta/cover
Session (media). A community cover from the book's /meta envelope, as a JPEG
thumbnail the server fetched: a series rail entry's cover_url (main view or any
ordering, so the previous[] books too) or the recording's. This is how the player
shows community covers. The web player's CSP takes images only from the server, so
it can't load a cover_url from its own host, and with this route a listener's
device never contacts the cover hosts at all. Gated by the meta_covers
capability, which follows metadata: when it is false the
route returns 404. Like /cover it takes media auth, so the session token may ride
as ?token= on an <img>.
| Query param | Type | Required | Notes |
|---|---|---|---|
path | string | yes | the book whose /meta envelope handed out the cover; authorized against the caller's share scope exactly like /meta |
url | string | yes | the cover_url, exactly as the envelope carries it |
size | int | no | 96 (from 2.5.1), 160, 320 (default) or 640: the thumbnail's longest side in pixels |
token | string | no | media-auth fallback |
Not an open proxy. The server looks up the book's envelope (the same lookup and
cache as /meta) and serves url only when it is, character for character, a
cover URL that envelope hands out. Anything else is 404 no such cover before
anything is fetched, so a caller can make the server fetch only images it could
already see named in a book it can reach. The fetch itself keeps the guards of
PUT …/cover/community: public
addresses only, at most 3 redirects, 15 seconds, at most 16 MiB.
Response 200: image/jpeg, the image decoded and re-encoded within size x
size pixels (never scaled up), so the upstream's bytes and content type never
reach the client.
- Thumbnails come from the server's in-memory thumbnail cache, the one
POST /admin/meta/coversuses (keyed by URL and size). Requests for the same thumbnail at once share one fetch. ETagis"community-<size>-<rev>-<hash>", the hash of the URL (revnames how the server makes thumbnails, as on?size=), andCache-Controlisprivate, max-age=86400(a community cover URL names one image; a new cover is a new URL). A matchingIf-None-Matchis a304without the image being fetched.- A failed fetch is not cached, but it is remembered for 1 minute: until then
the same URL is answered
502 cover_unavailablestraight away, without asking its host again, so a broken or hung host isn't fetched on every render. The console's batch ignores that window and always tries again. - An image that can't be decoded or is over 40 megapixels is cached as "no
cover" (
404). - Unlike
/coverand/stream, this route stays under the 30 s request timeout: it is a bounded thumbnail, not a stream.
| Status | Meaning |
|---|---|
200 | the JPEG thumbnail |
304 | If-None-Match matched |
400 | url is required; size must be one of [96 160 320 640] (an empty size= too); path is required / invalid library id |
401 | missing/invalid token |
403 | path outside the caller's share scope |
404 | no such cover - the book is unmatched (neither its identifiers nor its recorded match find a work) or its envelope hands out no such URL; no cover - the image isn't one the server can decode, or is over 40 megapixels; no book at that path; metadata lookup not enabled (meta_covers false) |
500 | could not load book - the book or its recorded match could not be read |
502 | metadata service unavailable - the book's envelope couldn't be had; code: "cover_unavailable" - the image couldn't be fetched (an address that isn't public, a timeout, too many redirects, an answer other than 200), or its fetch failed less than a minute ago |
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 param | Type | Required |
|---|---|---|
id | string | yes - 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). A found work is
also kept in the database cache, so it survives a restart and is served through an
upstream outage. An unknown id is remembered in memory only, except that a 404
for a work the database already holds replaces that row, so a work upstream has
dropped is not served again in a later outage.
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, including
community_description and attribution). 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. There
is no book here either, so positions are the work's own, beside their position_scale.
| Status | Meaning |
|---|---|
200 | { "work": … } |
400 | id is required - the id param is missing or blank; invalid id - it is longer than 200 bytes or contains control characters |
404 | no such work - the upstream has no work with that id |
404 | metadata lookup not enabled - the lookup is off on this server |
502 | metadata 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. Both also take a cast token as
the ?token= (capability cast), for the book it was minted for. 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 param | Type | Default | Notes |
|---|---|---|---|
path | string | required | library-relative audio file path |
download | 1 | - | sets Content-Disposition: attachment so browsers save the file (a remuxed .mka is named <stem>.mp3) |
transcode | 1 | - | re-encode to MP3 via ffmpeg for codecs browsers can't decode |
original | 1 | - | an .mka: serve the stored Matroska bytes, never the remux (for API tools and backups) |
t | float | 0 | with transcode=1: start the transcode this many seconds in |
rendition | watch | - | serve the file's watch rendition instead, whatever else the query says (capability renditions); with download=1 it is named <stem>.m4a |
token | string | - | session token (media-auth fallback), or a cast token for this file's book |
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 and 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. A HEAD with transcode=1 gets the headers without running ffmpeg.
Matroska holding MP3. While ffmpeg is configured, a request for an .mka
file without transcode=1 or original=1 is answered with a cached lossless copy
of its audio as plain MP3:
Content-Type: audio/mpeg,Accept-Ranges: bytes, and Range honoured;- chapters as ID3
CHAPframes; - a strong
ETag: "remux-<key>"and the copy's ownLast-Modified, so a validator kept from the stored Matroska never earns a304or a spliced range.
It is the same URL that served the Matroska before, so shipped clients stream the MP3 with no change. A podcast library's stream is never remuxed.
The server waits up to 2 seconds for a copy not made yet. Past that, or when the
server is short of disk, or the copy fails, that request gets the live MP3
transcode instead, as transcode=1 would. The copy goes on for the next request.
From 2.5.0, a download=1 request and a stream that finds every transcode slot
busy keep waiting for a copy still being made, up to 45 seconds
(remuxDownloadWait). So a download saves the lossless copy rather than the
transcode, and a busy server doesn't answer 503. Past that wait they are
transcoded after all.
An .mka holding another codec (AAC, Opus, FLAC) is served as stored,
audio/x-matroska. See
Remuxing Matroska.
| Status | Meaning |
|---|---|
200 / 206 | file bytes (Range honored for direct serving, a remuxed .mka and a watch rendition) |
400 | missing path / path escapes the root / path is too long (over 4096 bytes); code: "bad_rendition" for a rendition other than watch, or any rendition in a podcast library (the watch has no podcasts) |
401 | missing/invalid token |
403 | path outside the caller's scope |
404 | file does not exist, or isn't audio; code: "rendition_unavailable" when the server has no ffmpeg or no data directory (renditions is false) |
422 | code: "rendition_failed": ffmpeg refused this file, its copy came out more than 1 s short of the source, or the encode ran past its 2-hour timeout; remembered for an hour (or until the file changes), then tried again. Retry-After is the seconds the remembered refusal has left (1 to 3600) |
503 | transcode=1 but ffmpeg is not configured (check the transcode capability), or every transcode slot is busy (server busy transcoding; try again shortly, with Retry-After: 5; also a cold .mka answered with the transcode); the server is shutting down for a remux or an encode cut short by shutdown; code: "rendition_pending" (Retry-After: 15) for a rendition still being made; code: "rendition_error" (Retry-After: 60) for an encode that failed for a reason that isn't the file's (low disk, a run cut short, the file changed while it was read, the encode queue full) |
Watch renditions
Capability renditions. With rendition=watch the answer is a small
copy of the audio file for a watch to download and keep: AAC-LC, mono, 48 kb/s,
44.1 kHz, in MP4 (.m4a, the index first), about 430 MB for 20 hours. Its
duration is the source file's, so whole-book positions, chapters and file places
work on it unchanged. The server makes it once with ffmpeg into its data
directory (never into the library) and keeps it in a size-capped cache
(Watch renditions).
- Auth as the stream's: a session or API key header,
?token=, or a cast token for the file's book; the path is scope-checked as for any stream. - Ready:
200/206withContent-Type: audio/mp4,Accept-Ranges: bytes, Range andIf-Range, a strongETag: "rw1-<key>-<size>"and the copy's ownLast-Modified. The ETag names one encode, so a resumed download (RangewithIf-Range) never splices bytes of two encodes;Content-Dispositiononly withdownload=1. - Not ready: the request waits up to 45 seconds for the encode, then answers
503coderendition_pendingwithRetry-After: 15while the encode goes on in the background. A rendition is never a live transcode (a watch couldn't resume one). To prepare a whole book ahead, usePOST /renditions. - A rendition download is not listening: it opens no listening session and is not counted as a transcode.
POST /api/v1/libraries/{id}/renditions · GET /api/v1/libraries/{id}/renditions
Session or API key (header only: no ?token=). Capability renditions.
The watch renditions of a whole book. POST queues every
audio file whose rendition is missing and answers 202. GET queues nothing and
answers 200. A watch asks once, then downloads each file
from /stream?rendition=watch as it turns
ready.
POST | GET | |
|---|---|---|
path (query) | the book's path (a part or file path names its book, as on /item) | the same |
| kind | body {"kind": "watch"} | query kind=watch |
{
"kind": "watch",
"files": [
{ "path": "Author/Book/01.mp3", "state": "ready", "bytes": 21734912 },
{ "path": "Author/Book/02.mp3", "state": "pending" },
{ "path": "Author/Book/03.mp3", "state": "failed", "retry_after_seconds": 2710 },
{ "path": "Author/Book/04.mp3", "state": "missing" }
]
}
files lists the book's audio files in play order (a single-file book is its
one file), each path as /stream takes it, with its state:
state | Meaning |
|---|---|
ready | the rendition is cached; bytes is its size (sent only for ready) |
pending | queued or encoding (one encode runs at a time across the server) |
failed | ffmpeg refused the file, its copy came out short or with no audio track, or the encode ran past its 2-hour timeout (the stream answers 422 rendition_failed), tried again after an hour or once the file changes; retry_after_seconds is then the seconds until it is tried again (sent only for a remembered refusal, additive). Also no regular file at that path (gone, a folder: the stream answers 404; no retry_after_seconds) |
missing | not cached and not queued: always on a GET for a file never asked for; on a POST when the server's encode queue is full (at most 256 files wait or run across the server), so ask again later |
| Status | Meaning |
|---|---|
200 / 202 | the answer above |
400 | invalid request (a POST body that isn't JSON or has an unknown key); code: "bad_rendition" for a kind other than watch (a missing kind included), or a podcast library |
401 | no token, or not a session or API key |
403 | the path is outside the caller's access |
404 | no book at that path; code: "rendition_unavailable" when the server has no ffmpeg or no data directory |
GET /api/v1/libraries/{id}/cover
Session (media). A book's cover for a path, first match wins:
- a custom cover an admin uploaded
(
PUT /admin/libraries/{id}/cover), served from the database withCache-Control: private, no-cacheand anETag("cover-<id>", derived from when the cover was stored), so a replaced cover shows up at once: a client revalidates, and a matchingIf-None-Matchgets a304without the image being read from the database. Custom covers carry noLast-Modified, so a client that revalidates after the custom cover was removed can't get a304from an older sidecar file and keep showing the removed cover. A custom cover is served only while a book is indexed at its path, and a request for a part path inside a folder book gets that book's custom cover; - an indexed sibling cover file (served with
Cache-Control: private, max-age=86400); - embedded art extracted from the book's primary audio file (served with
Cache-Control: private, max-age=86400). ItsContent-Typeis sniffed from the image bytes (JPEG, PNG, GIF, WebP or BMP), never taken from the tag; embedded data that isn't one of those images counts as no art, whatever the tag claims.
The path is authorized against the caller's share scope before any of the three is tried, custom covers included.
| Query param | Type | Required | Notes |
|---|---|---|---|
path | string | yes | |
size | int | no | 96 (from 2.5.1; older servers refuse it, below), 160, 320 or 640: a JPEG thumbnail instead of the full art (cover_sizes capability; below) |
v | string | no | ignored by the server; clients append the book's cover_version as a cache buster |
token | string | no | media-auth fallback: a session token, or a cast token for this book |
Response 200: image bytes with the appropriate Content-Type; 404
(no cover) when there is no custom cover, no cover file and no embedded art.
Thumbnails (?size=). With size, the response is a JPEG of the same art
(custom cover, sidecar image or embedded art, in the same order) scaled to fit
within size x size pixels, never scaled up, from the server's in-memory
thumbnail cache, the one the admin console's
POST /admin/covers uses. Its longer side is at most
size pixels (a 2:3 portrait cover at 320 is 213 x 320). A JPEG already within
size may be sent re-encoded at quality 80 when that is smaller (see
Media). A source
larger than 40 megapixels, or one that can't be decoded, is a 404 here although
the full-art URL still serves it, so a client falls back to the URL without size
on a thumbnail 404.
96 is for a table row's small cover. The admin console asks for it through
POST /admin/covers, never this route, which takes it
because the two share their sizes. A server before 2.5.1 answers it 400, and
cover_sizes is true on both, so a client that talks to older servers (the
player) keeps to 160 and up.
ETagis"thumb-<size>-<rev>-<hash>".revnames how the server makes thumbnails: it changes when a server update makes other bytes of the same art, so a client fetches each thumbnail once more after such an update instead of keeping, or resuming a range of, the old bytes.hashis the art's own version: the value the book'scover_versiontakes from its first thumbnail on.If-None-Matchis matched like the server's other validators (a list, weak tags).- A matching request gets a
304without the image being read once the book'scover_coloris recorded for this art. Until then the server still reads the art (usually from the thumbnail cache) to record it, and then answers the304, so a client that only ever revalidates still gets the colour onto the book. Cache-Controlisprivate, no-cachefor a custom cover (it can be replaced at any moment, so a client revalidates every time) andprivate, max-age=86400for a sidecar or embedded cover, as for full art.- Neither header is sent on a
404, so "no cover" is never cached. - Each thumbnail (a cached one too) records on the book what it lacks for this
art: its
cover_colorand, at the first one, its art identity, which movescover_version. The write is bounded (250 ms, not tied to the request), so a busy database costs only the colour until the next thumbnail, never the response. - Art reads are bounded across all requests (8 at a time, with decodes bounded
separately), shared with
POST /admin/covers.
A client appends v=<cover_version> to every cover URL (with or without size)
so a cover replaced since the last fetch is a new URL to its image cache.
| Status | Meaning |
|---|---|
400 | size must be one of [96 160 320 640] - any other value, including an empty size= (an older server ignores size, so only send it when cover_sizes is true); missing path / invalid library id |
304 | If-None-Match matched (thumbnails, and custom covers) |
403 | the path, or the book it resolves to, is outside the caller's share scope |
404 | no cover: no art at all, no book at the path, or (with size) art that cannot be decoded or is over 40 megapixels |
500 | could not load cover - the art could not be read (an unreadable network mount) or a database failure; not cached, so the next request tries again |
Cast tokens as media auth
A Chromecast receiver plays a book from URLs the player hands it, so the player
never hands over its session token: it mints a cast token for the book with
POST /cast/token and builds the receiver's URLs with that
instead. The two routes above accept one as the ?token= (the middleware
requireBookMediaAuth; auth.ResolveCastRequest, one token lookup that decides
session, API key or cast) only when all of these hold:
- the request carries no
Authorizationheader (a cast token is never a bearer credential, nor accepted beside one); - the token is live (not revoked or expired, its user enabled, and the session that minted it still signed in; see Cast tokens);
- the request's library is the token's, and its
?path=(cleaned first, so..can't climb out) is the token's book or a file or folder inside it on a segment boundary: a token forAuthor/BookreachesAuthor/Book/01.mp3, neverAuthor/Book 2/01.mp3; - the path addresses the token's own book in the index (
api.castOwnBook): a folder book's folder can hold other books (SeriesholdingSeries/Book 2), and a cast token forSeriesnever reaches them; a path the index places in no book is refused too.
Any refusal is the same 401 as a bad session token. The handler then checks the
person's current share scope as for every request, so a book whose share was
taken away since the mint is a 403. A cast token is never accepted on any other
route, /meta/cover included.
An answer authenticated by a cast token carries
Cross-Origin-Resource-Policy: cross-origin in place of the server's usual
same-site: the receiver is a web app served from another site (gstatic.com)
that loads the files as <audio> and <img>, which the browser would otherwise
block. A session's or API key's media answer opts out the same way only when
cors_origins is set (from 2.4.1; see
Transport hardening). A cast token authenticates ?transcode=1&t=
like a session (the player asks for it for codecs a Cast receiver can't play);
each such stream holds one of the transcoder's slots while the receiver reads it,
and it marks the sending device's listening session as transcoded (the sender's
own progress saves make the session). The media rate limiter keys a cast token by its
token id like any credential, so it has a bucket of its own.
Ebooks and the reading place
The reader is gated by capability ebooks. A book whose folder holds its EPUB carries an
ebook field. The player fetches the file from
/ebook and keeps the reader's place in it with
/reading-progress. All three routes
address the book by its own path (?path=, as
/item), never the .epub's path or a track's. All
take Session auth from the Authorization header only. The scan decides which
file is a book's ebook; see A book's ebook. The web player's reader is the
document at /web/reader.html.
GET /api/v1/libraries/{id}/ebook
Session (a session token or an API key). The ebook of the book at ?path=, as
bytes:
| Query param | Type | Required |
|---|---|---|
path | string | yes: the book's own path (rel_path), not a part, a track or the .epub |
Response 200 (or 206 for a Range request): the file the scan recorded for the
book, opened afresh and checked to be a regular file inside the library (a link
swapped in since the scan is never followed out), served with http.ServeContent:
Range, an ETag from its size and mtime, Last-Modified. Headers:
| Header | Value |
|---|---|
Content-Type | application/epub+zip |
Content-Security-Policy | sandbox; default-src 'none' (navigating to the URL runs and loads nothing) |
Cache-Control | private, no-cache |
X-Content-Type-Options, Cross-Origin-Resource-Policy | the site-wide nosniff and same-site |
No ?token=. Unlike /cover and /stream, this route
never reads a token from the URL, and there is no URL token for it of any kind (a
cast token is refused either way). The web player
fetches the bytes with its bearer header and hands them to its reader document, and
the apps download them with the header into a local file they post to their reader
in chunks, so nothing ever holds a URL that carries a credential. The bytes are
exempt from the 30 s request timeout (an illustrated EPUB can be tens of megabytes on a slow
link), but the book lookup before them is not: a lookup past it is
503 request timed out (writeCatalogError on context.DeadlineExceeded), as the
timeout middleware answers elsewhere. The
route is limited with the authenticated API, per credential, not by the media
bucket (rate limiting).
| Status | Meaning |
|---|---|
200 / 206 | the EPUB's bytes |
400 | missing path / invalid library id |
401 | no Authorization header, or a bad token (a ?token= alone is a 401) |
403 | the book is outside the caller's share scope |
404 | this book has no ebook: the book has none (no .epub, several, an unreadable one), the file is gone or no longer a regular file inside the library, the path is inside the book but not its own (a track, the .epub in its folder), or the library is a podcast library; no book at this path when there is no book there; not found for a hidden or .audiosilo/ path |
500 | could not load the ebook: the file is there but can't be read right now (a permission, a flaky mount; logged) |
503 | request timed out: the book lookup ran past the request timeout |
GET /api/v1/libraries/{id}/readalong
Session (a session token or an API key), capability readalong. The read-along map of the book at ?path=: where each sentence of its ebook
is spoken in its audio, as a Bridge wrote it into the book's .audiosilo/ folder
(the format, audiosilo-readalong/v1, is in
Read-along).
| Query param | Type | Required |
|---|---|---|
path | string | yes: the book's own path (rel_path), not a part or a track |
Response 200: the map file's bytes as they are on disk.
| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
Content-Encoding | gzip when the request's Accept-Encoding allows it (Vary: Accept-Encoding) |
ETag | "<size hex>-<mtime ns hex>" of the file, "<size hex>-<mtime ns hex>-gz" for the gzipped answer (so a cache never pairs one encoding's validator with the other's bytes); a matching If-None-Match is 304, checked before anything is gzipped |
Cache-Control | private, no-cache (the Bridge may rewrite the map at any moment) |
Header auth only, as /ebook: ?token= is
never read, and a map is at most 16 MiB, so it stays under the request timeout.
Every request reads the file again: a regular file in a real .audiosilo folder,
never through a link, and read once more when the Bridge renamed a new map in as it
was opened. The server checks it against the book now, never trusting the
indexed copy for the bytes it sends:
- its head must pass the server's checks;
- its
ebook.sha256must be the digest of the book's ebook. The server uses the stored digest only while the file on disk still has the signature it was computed for, and otherwise hashes the file now; - its
audio.filesmust be the book's files.
| Status | Meaning |
|---|---|
200 | the map |
304 | not modified |
400 | missing path / invalid library id |
401 | no Authorization header, or a bad token (a ?token= alone is a 401) |
403 | the book is outside the caller's share scope |
404 | code: "no_readalong" (this book has no read-along): the book has no map, a map that fails its checks, or no ebook; the path is a part of the book, not its own; or the library is a podcast library. no book at this path when there is no book there |
409 | code: "readalong_stale" with reason (ebook_changed or audio_changed): the map was made for another ebook or other audio; an admin makes it again |
500 | could not load the read-along: the file is there but can't be read right now (logged) |
503 | request timed out: the book lookup ran past the request timeout |
GET /api/v1/libraries/{id}/reading-progress
Session. The caller's place in the ebook of the book at ?path= (a part path
reads its book's):
{
"reading_progress": {
"library_id": 1,
"path": "Charles Dickens/A Christmas Carol",
"cfi": "epubcfi(/6/14!/4/2/1:0)",
"progression": 0.4213,
"version": 3,
"device_id": "web-7f3c9a",
"device_name": "Pixel 8",
"updated_at": "2026-10-25T10:00:00.000Z"
}
}
or { "reading_progress": null } when the caller has none for the book. cfi is
the place as an EPUB CFI; progression the same place as a fraction of the book
(0 to 1); version the server's count of applied writes (bumped on every one);
device_id the client's id of the device that wrote it and device_name that
device's name as the server knows it from the saving token ("" when unknown).
updated_at is the client's time of the save, stored as fixed-width UTC
milliseconds. 400 / 403 as /ebook; 404 when there is no book at the path
or the book has no ebook.
The place is separate from listening progress
on purpose: neither ever moves the other. It is durable, path-keyed user state
(reading_progress, see the data model),
so it survives an index rebuild and moves with the book.
PUT /api/v1/libraries/{id}/reading-progress
Session. Saves the caller's place, keyed on the book's own path (a part path resolves to its book), by Sync v2's conflict rule.
| Body field | Type | Notes |
|---|---|---|
cfi | string | required: starts epubcfi(, at most 2048 bytes |
progression | float | required (absent or null is a 400, never read as 0): 0 to 1 |
device_id | string | required, non-empty, at most 200 bytes: this device's id (the conflict rule tells devices apart by it) |
updated_at | string | required, RFC 3339: when the save was made on the device (send the real time when replaying an offline queue). A time more than five minutes in the future is replaced by server time |
base_version | int | the stored version this device last saw (0: it has seen none). Absent: the save never diverges (below). Negative is a 400 |
device_name | - | accepted and ignored: the name is always the saving token's |
The body is decoded strictly: any other field is a 400 (an echoed
other_place is accepted, since its fields are
these; a whole stored row is not).
Response 200:
{
"reading_progress": { "library_id": 1, "path": "…", "cfi": "epubcfi(…)", "progression": 0.6,
"version": 5, "device_id": "phone-1", "device_name": "Pixel 8",
"updated_at": "…" },
"other_place": { "cfi": "epubcfi(…)", "progression": 0.31, "device_id": "web-7f3c9a",
"device_name": "Kitchen iPad", "updated_at": "…" }
}
reading_progress is the row stored after the save, whichever place won;
other_place comes only on a diverged save and is never stored.
The conflict rule (Sync v2)
catalog.ResolvePlace, one writer transaction with the read of the stored row:
- No row stored: the save applies (version 1).
- Diverged: a row is stored,
base_versionis sent and is not itsversion, and another device (device_id) wrote it, orbase_versionis0whatever the device (a device id outlives the places a client kept: signed out and back in, its start must not replace its own place further on). The device saved without seeing that row, so the server never moves the place back silently: it keeps the further place byprogression(the save applies when it is further, with a bumped version; otherwise the stored row stays, and a tie keeps the stored row) and answers the place it did not keep asother_place. The web player offers it ("On Pixel 8 you were at 60%. Jump there?"). - Anything else (the same device's own queued saves made on a version it saw,
a save made on the stored version, a save with no
base_version) is last-write-wins exactly asPUT /progress: the laterupdated_atwins, the version breaking a tie. A stale save is not an error: the stored row comes back, with noother_place. This is how moving back on purpose works: once the device has seen the newer row (and sends its version), its save applies.
A save records no listening session (reading isn't listening).
| Status | Meaning |
|---|---|
200 | the stored row, and other_place on a diverged save |
400 | invalid request (not JSON, an unknown field), or the reason: cfi must be an EPUB CFI, cfi is too long, progression must be from 0 to 1, device_id is required, device_id is too long, base_version must not be negative, updated_at must be an RFC3339 time; plus missing path / invalid library id |
403 | the book is outside the caller's share scope |
404 | no book at the path, or the book has no ebook (a client drops such a queued save: it can never succeed) |
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",
"started_at": "2026-06-20T21:05:12Z",
"device_name": "Sam's Pixel",
"other_place": {
"position": 10240.0,
"device_id": "watch-sam",
"device_name": "Apple Watch",
"updated_at": "2026-07-01T07:33:30.000Z"
}
}
]
}
device_name and other_place come with listening sync
(capability listening_sync), on every progress response (this list, the
single-book read, the PUT and PATCH answers and the sync answer), each omitted
when there is none. device_name is the name of the device that saved the row, as
the server knows it from that device's sign-in (the saving token's name, never a
request body's). other_place is a place a diverged sync event did not keep
(another device's listening made without seeing this row):
{position (whole-book seconds), device_id, device_name, updated_at}, kept on the
row until it is cleared (the offered place). A player offers it ("On Apple Watch you
were at 2:50:40. Jump there?"). A PUT stores its token's name as device_name and
never sets or clears other_place; both are ignored in its body.
started_at and finished_at (RFC 3339, UTC) are the book's start and finish
dates, on every progress response of a server with the progress_edit
capability (this list, the single-book read and the PUT echo). Each is
omitted when unknown or none: started_at on rows saved before the server
recorded it, finished_at while the book is unfinished. How they are stamped is
under PUT; a listener corrects them with
PATCH. On a sync, a listen stamps
started_at with its corrected start when none is kept, and a finish stamps
finished_at with its corrected time (an unfinish clears it).
GET /api/v1/libraries/{id}/progress
Session. Progress for one book. Response 200:
{ "progress": { … } }, or { "progress": null } when none exists.
The progress path
The three /libraries/{id}/progress routes (this read, PUT with its listening
session, and PATCH) keep a book's progress on the book's path
(api.progressPath over catalog.BookPathOfFile). The ?path= is scope-checked as
given (out of scope is the usual 403), then:
- the path of an indexed book, or of one of its track files
(
Author/Book/02.mp3), is keyed on that book's path, when the caller's scope allows the book. A file of a book nested in another book's folder belongs to the innermost book (Series/Book 2/01.m4bisSeries/Book 2's). The row in the response carries the book'spath, soPUT ?path=Author/Book/02.mp3answers"path": "Author/Book"; - anything else keeps the path as given, as before: a navigation folder, a book the index doesn't hold yet (a save never indexes on demand), a book above a grant that reaches only its part (the book outside the scope is never read or written), and a disc folder of a joined book, which keeps its own key (a download of that disc saves there, on the disc's own timeline).
A failure of that lookup is a 500 (could not load progress / could not save progress), never a save under another key. Rows an older server or client already
wrote under a part path stay where they are (they are not merged into the book's
row), and bookmarks, notes, history, favourites, ratings and the queue keep the path
they are given. POST /cast/token grants the book's path
too, but by the media routes' rule rather than this one: a disc folder resolves to
its joined book, a book not indexed yet is read on demand, and a book outside the
caller's scope is a 403.
PUT /api/v1/libraries/{id}/progress
Session. Upserts progress with last-write-wins reconciliation: the newer
updated_at wins; version breaks exact-timestamp ties. The comparison and the
write are one database transaction, so an older save can never overwrite a newer
one that landed in between. A stale write is not an error. The response
returns the effective stored progress, so clients converge.
| Body field | Type | Notes |
|---|---|---|
position | float | seconds, whole-book timeline |
duration | float | book duration as the client knows it |
finished | bool | |
playback_speed | float | values ≤ 0 are normalized to 1.0 |
version | int | send the last version you saw; 0 lets the server assign (stored + 1) |
device_id | string | free-form writer identifier |
updated_at | string | RFC 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), keyed on
the progress path.
Each save is also the heartbeat of the caller's listening session on the
book, which the server derives for the admin console (see
Admin: activity). That happens whether or not the save wins
last-write-wins, never changes the response, and a failure to record it is only
logged. The server also stamps the row's start and finish dates (started_at
on the first save, finished_at when finished turns on, cleared when it turns
off), taken from the save's own updated_at (server time when it is missing or
implausible). With progress_edit they ride on the response; any
started_at / finished_at in the PUT body is ignored (the save stamps
them), so a client changes them only through
PATCH.
On a server with listening_sync the PUT is otherwise unchanged (last-write-wins,
finished included, its listening session at server time), so a shipped player
behaves exactly as before. Three things are added:
- The save is logged as a
putevent in the listening-event log, in the same transaction. Its id isput-<16 hex>, and its statusappliedornot_appliedolder. - The row stores the saving token's name as
device_name. - A save that changes
finishedrecords when it did (finish_changed_at: the save'supdated_at, never past the server's clock), so an older syncfinishorunfinishcan't undo it.
A device_name or other_place in the body (a client echoing a row back) is
accepted and ignored. A device_id starting audible: (an Audible account's row
echoed back) is saved as none, so the place still reaches Audible through
GET /bridge/progress. A player that syncs listening
sends no PUT for its listening.
PATCH /api/v1/libraries/{id}/progress
Session. Capability progress_edit. The caller's own edit of their progress
on a book (?path= required, a track file's path editing its book's row: see
the progress path): mark it finished or unfinished, move the
position, or set or clear the start and finish dates. The same rules as an
admin's edit of someone's progress
(catalog.EditProgress), applied with the caller's own access. Any subset of:
| Body field | Type | Notes |
|---|---|---|
finished | bool | true moves the position to the end and stamps the finish now (unless finished_at is given); false (mark unfinished) clears the finish date and keeps the position (unless position is given) |
position | float | seconds; between 0 and the book's duration |
started_at | string | null | RFC 3339 or YYYY-MM-DD (the start of that day, server time); null clears it |
finished_at | string | null | RFC 3339 or YYYY-MM-DD (the end of that day, server time, or now if that is sooner); null clears it; needs the book to be (or become) finished |
A field left out stays as it is. With no progress on the book yet, an indexed
book in the caller's scope gets a new row, its start stamped now. An edit that
sets nothing ({}) writes nothing. It answers the current row, or 404 book_not_found when there is none; it never starts the book.
An exact (RFC 3339) date may be at most 5 minutes ahead of the server's clock, for a client clock running a little fast. A day-only date keeps a day of slack, since the client's today can be the server's tomorrow.
The write is stamped with the server's time and a higher version. The time is to
the sub-second, so a device save made just before the edit can't outrank it. Under
last-write-wins the edit beats what a device saved before it, while a device that
has the book loaded overrides it on its next save. It is not playback, so it
records no listening session. On a server with listening_sync
it also clears the row's other_place. When it changes finished, it records the
change's time (now), so an older sync finish or unfinish can't undo it.
Response 200: { "progress": { … } }, the player's progress object, with its
dates and the device_name of the device that saved the place, as the player's
progress reads carry it. An older server omits device_name.
| Status | Meaning |
|---|---|
400 | path is required; invalid request (a malformed body, an unknown key or an unparseable date); those dates or that position don't fit this book (no code) - a negative position or one past the book's end, an exact date more than 5 minutes ahead of server time (a day-only date more than 24 hours ahead), a finish before the start, or a finish date on a book that isn't (and isn't becoming) finished |
403 | no access to this path - outside the caller's current access, even when a progress row already exists there (unlike the admin edit, a listener can't tidy progress on a book they can no longer open) |
404 | code: "book_not_found" (no progress or book at this path) - no progress at that path and no indexed book there, or an empty edit on a book with no progress |
POST /api/v1/me/listening/sync
Session or API key (cast, calendar feed and Bridge tokens are 401; kids and
demo accounts may sync). Capability listening_sync. In Sync v2 a player
posts what it listened to as events, each with its own id. The server derives
the book's progress, the listening sessions and the
history from them. Each event is resolved on its
own and answered with a status, so one batch can carry a day of offline listening
across several books. A player uses it only while GET /server says
listening_sync: true. Otherwise it keeps PUT .../progress and
POST .../history, which stay unchanged for shipped apps.
{
"device_id": "lq3x9-ab12cd34",
"sent_at": "2026-10-25T07:41:03.120Z",
"events": [
{
"id": "8c1e0f6a-4b1d-4f5e-9a7c-2d3e4f5a6b7c",
"kind": "listen",
"library_id": 3,
"path": "Author/Book",
"from_pos": 14400.0,
"to_pos": 16800.0,
"from_file": "Author/Book/04.mp3",
"from_file_pos": 812.4,
"to_file": "Author/Book/05.mp3",
"to_file_pos": 1203.9,
"duration": 61200.0,
"playback_speed": 1.2,
"started_at": "2026-10-25T07:00:12.000Z",
"ended_at": "2026-10-25T07:33:30.000Z",
"start": "resume",
"base_version": 41
},
{
"id": "9d20c1b7-0a2e-4c8d-b1f3-5e6a7b8c9d0e",
"kind": "finish",
"library_id": 3,
"path": "Author/Book",
"at": "2026-10-25T07:33:30.000Z",
"base_version": 41
}
]
}
| Body field | Type | Notes |
|---|---|---|
device_id | string | required, 1-200 bytes: the device's own id (the player's getDeviceId()), the same one its progress rows carry. One starting audible: is refused: it is the Audible listening route's alone |
sent_at | string | RFC 3339: the device's clock at this send attempt (set per attempt, never when the batch was built) |
clock_offset | float | seconds, server minus device, as a relay that measured it would send (at most 1e9 either way) |
events | array | 0-500 events; [] (or none) is a valid clock probe, answered with server_time |
Send exactly one of sent_at and clock_offset. Unknown top-level fields are
refused.
Events. Every event has id (1-64 characters of [A-Za-z0-9_-], the device's
own, so a resend is recognised: the player's are UUIDs), kind, library_id
(> 0), path (the book's path, or one of its files or parts: the event is keyed on
the progress path, as PUT does) and an optional
base_version (>= 0: the progress version the device had acknowledged when it
made the event; left out, the event resolves by last-write-wins).
kind | Fields | Notes |
|---|---|---|
listen | from_pos, to_pos (required numbers), started_at, ended_at, start; optional from_file + from_file_pos, to_file + to_file_pos, duration, playback_speed | a span of playing: whole-book seconds on the device's timeline (finite, >= 0), RFC 3339 times with ended_at >= started_at at most 24 h apart. start is why the span began: resume (the loaded or resumed place), continue (carrying on after a cut), moved_back or jump (a deliberate move: a seek, a chapter, a bookmark, Undo, an offer's Jump), restart (starting again from 0). Each end may also carry its audio file (rel path) and the seconds into it (both of a pair or neither, seconds >= 0). duration is the device's length of the book (0 or absent: no fingerprint); playback_speed is in (0, 4] (0 or absent = 1) |
finish, unfinish | at (RFC 3339) | marking the book finished or not finished; no positions, and base_version is ignored |
Response 200:
{
"server_time": "2026-10-25T07:41:03.480Z",
"results": [
{
"id": "8c1e0f6a-4b1d-4f5e-9a7c-2d3e4f5a6b7c",
"status": "diverged",
"kept": "stored",
"other_place": {
"position": 16800.0,
"device_id": "lq3x9-ab12cd34",
"device_name": "Apple Watch",
"updated_at": "2026-10-25T07:33:30.000Z"
}
},
{ "id": "9d20c1b7-0a2e-4c8d-b1f3-5e6a7b8c9d0e", "status": "applied" }
],
"progress": [ { "library_id": 3, "path": "Author/Book", "position": 61200.0, "finished": true, "version": 43, "...": "..." } ]
}
results holds one entry per request event, in request order. progress holds the
progress row (the object of GET .../progress,
with device_name and other_place) of every book a non-rejected event touched,
read after the batch; [] when there is none. server_time is the server's clock,
fixed-width UTC milliseconds.
status | Meaning |
|---|---|
applied | the place moved to the event's end, or a finish/unfinish set the state |
duplicate | the id is already stored for this user, or an earlier copy in the same batch passed the checks: nothing was done again (a copy that was rejected doesn't count, so the next copy is checked on its own) |
diverged | the event was made without seeing the stored row (below); kept is event or stored, and other_place is the place not kept |
not_applied | stored, but the place didn't move; reason is older (an older write under last-write-wins, or a finish/unfinish not newer than the row's last finish-state change) or resume_behind (a resume/continue span behind the stored place, below; other_place is the event's place); behind only on the Bridge's Audible listening route (furthest only) |
rejected | not stored and changed nothing; reason is invalid (a field check failed, the event couldn't be decoded, or its path cleans to nothing or is over 4096 bytes, checked before its library is looked up), unknown_kind, too_old (it ended more than 90 days ago, after the clock correction), duration_mismatch (the device's duration is more than 10 s from the indexed book's), no_access (the path is outside the caller's access, a library they hold no grant on, or a podcast library their client doesn't see: what PUT answers with a 403) or not_found (an unknown library to an admin, or a hidden path); on the Audible listening route also not_synced (not a synced title's book), and duration_mismatch there means Audible's length disagrees |
Every status but rejected is final for the sender: the event is stored and a
resend is a duplicate. Every stored listen counts for sessions and history,
even when its place didn't move (listening that happened always counts).
| Status | Meaning |
|---|---|
400 | invalid request (not JSON, an unknown top-level field); device_id is required; device_id is too long; device_id is reserved (one starting audible:); send sent_at or clock_offset (neither, both, an unparseable sent_at, or a clock_offset beyond 1e9 either way); code: "too_many_events" (more than 500 events) |
401 | no token, or one that isn't a session or an API key |
413 | code: "too_large": the body is over 1 MiB (an older server answers 400); send fewer events per batch (the player halves its batch) |
500 | could not sync listening: nothing of the batch is stored, so a retry applies it once |
A bad event never fails the batch: each event is decoded on its own and
strictly (an unknown field inside it, a wrong type, or a listen without a number
for from_pos or to_pos is that event's rejected invalid), so one malformed
event can't block the rest of a device's backlog.
How a batch is applied
The handler checks the envelope, then resolves each distinct (library_id, path)
once, as PUT does (the scope check, then the progress path, then the indexed book's
length and its files' offsets in their current order), all before the write.
catalog.SyncListening then runs the whole batch in one writer transaction (none
when nothing is left to apply: an empty batch, or every event rejected or a
repeat of an earlier copy in the same batch; a replay of ids already stored still
opens one):
- The clock. The batch's shift is
received - sent_at(orclock_offset), added to every time in it, so a device whose clock is hours out still lands its listening at the true time. A corrected time past the server's now is pulled back to now, both ends of a span by the same amount, so its length stays. - Order. Events apply in corrected-time order (
ended_at, orat; ties by request position) and are answered in request order. - Fingerprint. Only when the sent path is the indexed book itself (not a file or
a part) and both lengths are known: more than 10 s apart is another edition
(
duration_mismatch). A path with no indexed book applies path-keyed with no check, as aPUTdoes. - File places. An end whose file is one of the book's files is placed at that file's offset in the current order plus the seconds in it (clamped to the file's length); otherwise the sent whole-book seconds are used. Each end on its own. So a span played on a download that still has an older file order lands in the same file. The stored event keeps the placed values.
- A listen's place resolves against the stored row by
the conflict rule (
catalog.ResolvePlace, with whole-book seconds as the progression and the span's corrected end as its time). The result isapplied,not_appliedolder, ordiverged(the further place kept).- Within one batch, a
base_versionof0on a book the batch already wrote resolves on that version, so a device's first spans in one post don't diverge from each other. - The backstop. A
resumeorcontinuespan ending more than 60 s before an unfinished stored place isnot_appliedresume_behind. So a device that started playing before it had the stored place (a cold start) never pulls the book back. A deliberate move (moved_back,jump,restart) skips the backstop. - A span that diverged from a place its device had seen (
base_version> 0, another device's row since) is real listening elsewhere. It is resolved instead: the further place is kept and the span's is offered. - A listen writes the position, the length (when known), the speed, the device,
its
device_name,updated_atandversion + 1. It never touchesfinished. updated_atis the corrected end, or the row's own when that is later. An apply, a diverged one included, never moves the row's time back, so an older app'sPUTdated in between can't win last-write-wins against it. The device's later spans compare with the end of its own last span, in this batch or a later one (see the next item).
- Within one batch, a
- The span chain across posts (migration
0062). The row keeps the end of the span its place came from (span_at) at its version. While no other writer has touched the row, the same device's next span in a later post compares with that end too. So a backlog split across posts (a413halving, a long outbox) isn't answeredolder. Across posts, a span with no base (base_version0) joins the chain only when it starts within 60 s after the chain's end or within 5 s of its place. Otherwise it is judged on its own, and may diverge. Send abase_versionon every span. Any other writer (aPUT, an edit, an import, a move or join) ends the chain. - The offered place. On
diverged, the place not kept is stored on the row asother_place, so a device that never saw this answer (the phone, after the watch synced) can still offer it. It is not stored when it is the kept row's own device or within 30 s of the kept place.- A device that sends a
base_versionat or after the version the offer was recorded at, and applies, clears it, because it had seen it. Afinish, an edit and a move or join clear it too. - A newer diverged event replaces it only when it records an offer of its own, by the same two conditions. Otherwise the offer stays.
- A
resume_behindspan that carries on (within 5 s) from its own device's offered place moves the offer to where it got to.
- A device that sends a
finishandunfinishapply only when newer than the row's last finish-state change, whoever made it: aPUT, an edit, an import or an earlier sync event. Otherwise they arenot_appliedolder. When they apply, both bump the version.finishmarks the book finished (finished_atits date to the second, kept when already finished). It moves the place to the end when the length is known and drops the offered place.unfinishclears the finish and keeps the place (asPATCHdoes), and takes a finished row for its own device.- A later listen never unfinishes a book. A device that never saw the finish keeps playing without undoing it.
- Sessions. A stored listen is folded into the token's session on the book at
its corrected times (
recordSpan, the rules in the data model). Afinishmarks the token's session on the book finished when its last span ended within 10 minutes before the finish; it opens none.unfinishrecords nothing. - History. A listen with at least 1 s listened extends the newest history row
on the book when that row ended up to 60 s before the listen began (2 s of
overlap allowed) and within 5 s of where it starts. Otherwise it adds a row. So a
run of 15 s or 60 s spans is one row, and offline listening reaches history at
the time it happened. A syncing player
posts no
POST .../history.
The event log. Stored events (migration 0061, listening_events) are kept 120
days (catalog.PruneListeningEvents, at startup and daily), while an event older
than 90 days is refused, so a replay of a pruned id with a clock shift up to 30 days
different still can't apply twice. The legacy PUT is logged there as a put
event. See the data model.
GET /api/v1/libraries/{id}/bookmarks · POST /api/v1/libraries/{id}/bookmarks
Session. List / add bookmarks for a book (?path= on both). GET lists them
by position.
GET response: { "bookmarks": [ … ] } (objects as below; [] when there are
none, never null).
POST body: { "position": 4211.5, "note": "great line", "label": "quote" }
(note and label 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",
"label": "quote",
"created_at": "2026-06-30T21:04:11.000Z"
}
label (capability annotations) is a client-defined machine key, never
display text: "" (none) or a lowercase key matching ^[a-z][a-z0-9_]{0,31}$
(catalog.checkBookmark). The server checks only that shape, never a set of
known keys (the player's are in Labels). A
server with annotations emits label on every bookmark ("" when none); an
older one never does, and refuses a POST that carries it.
note is at most 2,000 characters (Unicode code points, catalog.MaxBookmarkNote).
position must be a number, 0 or more (it isn't checked against the book's
duration). created_at is server time in a fixed-width UTC millisecond form
(2026-06-30T21:04:11.000Z), the form the across-books lists order by as text.
| Status | Meaning |
|---|---|
400 | path is required; invalid request (a malformed body or an unknown key); invalid position (negative, or not a number, such as "12"); invalid label (not "" or a machine key: Quote, fell-asleep, more than 32 characters); note too long (over 2,000 characters) |
403 | no access to this path |
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).
PATCH /api/v1/bookmarks/{id}
Session. Capability annotations. The owner's edit of one of their bookmarks
(catalog.EditBookmark). Any subset of:
| Body field | Type | Notes |
|---|---|---|
note | string | replaces the note; "" clears it; at most 2,000 characters |
label | string | replaces the label; "" clears it; a machine key as on POST |
A field left out (or sent as null) stays as it is; the position can't be
edited. The read, the checks and the write are one writer transaction, and only
the fields sent are checked, so a label-only edit works on a bookmark whose note
an older server let grow past 2,000 characters.
Response 200: the whole updated bookmark, unwrapped, the same object as POST
answers.
| Status | Meaning |
|---|---|
400 | invalid bookmark id (not a number); invalid request (a malformed body or an unknown key, such as position); nothing to change (neither field given, or no body); invalid label; note too long |
404 | bookmark not found - an unknown id, another user's, or one whose book is outside the caller's current access (a since-revoked share): never 403, so the answer doesn't confirm that someone else's bookmark exists. The row is kept, and is editable again if access comes back |
GET /api/v1/libraries/{id}/notes · POST /api/v1/libraries/{id}/notes
Session. List / add free-form notes for a book (?path= on both). GET lists
them by position.
POST body: { "position": 0, "body": "re-read ch. 12 for the foreshadowing" }
(position optional, 0 when left out; a number, 0 or more). 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:00.000Z",
"updated_at": "2026-06-28T10:00:00.000Z"
}
GET response: { "notes": [ … ] } (same object shape; [] when there are
none). created_at and updated_at are server time in the same fixed-width UTC
millisecond form as a bookmark's.
body is at most 10,000 characters (Unicode code points, catalog.MaxNoteBody):
over that is 400 body too long, here and on
PATCH. A negative position, or one that isn't a number,
is 400 invalid position, also on both.
DELETE /api/v1/notes/{id}
Session. Deletes one of the caller's own notes. 204 No Content.
PATCH /api/v1/notes/{id}
Session. Capability annotations. The owner's edit of one of their notes
(catalog.EditNote). Any subset of:
| Body field | Type | Notes |
|---|---|---|
body | string | replaces the text; at most 10,000 characters |
position | float | seconds on the whole-book timeline; must be 0 or more (it isn't checked against the book's duration) |
A field left out (or sent as null) stays as it is, and only the fields sent are
checked. Every edit stamps updated_at with server time; created_at never
changes. One writer transaction, as for bookmarks.
Response 200: the whole updated note, unwrapped, the same object as POST
answers.
| Status | Meaning |
|---|---|
400 | invalid note id (not a number); invalid request (a malformed body or an unknown key); invalid position (negative, or not a number, such as "12"); nothing to change (neither field given, or no body); body too long |
404 | note not found - an unknown id, another user's, or one whose book is outside the caller's current access (never 403, as for bookmarks) |
GET /api/v1/me/bookmarks
Session. Capability annotations. The caller's bookmarks across every book
they can still open, newest first (catalog.ListMyBookmarks), a page at a time.
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 100 | absent or ≤ 0 is 100; over 500 is 500 (an older server: over 500 falls back to 100) |
cursor | string | - | the previous page's next_cursor, passed back verbatim |
{
"bookmarks": [
{
"id": 12,
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"position": 4211.5,
"note": "great line",
"label": "quote",
"created_at": "2026-06-30T21:04:11.000Z",
"book": { "id": 412, "title": "The Final Empire", "author": "Brandon Sanderson", "…": "…" }
}
],
"next_cursor": "MjAyNi0wNi0zMFQyMTowNDoxMS4wMDBaADEy"
}
Rules shared with /me/notes and
/me/history:
- Order is newest first by the row's timestamp (
created_athere and for notes,ended_atfor history), then byid, newest first, to break ties. - Paging is keyset, on that
(timestamp, id)pair, as for book lists:next_cursoris present only when more rows follow, it is opaque, and a cursor that doesn't decode is400 invalid cursor. Ties in the timestamp neither repeat nor skip a row. - Only what the caller can open now. A row whose path is outside the caller's current access (a share since taken away) is kept in the database but left out. The filter is in the query, so a page is always full of rows the caller can see.
bookis the book in the list shape (nodescription), present while the path is indexed; a client renders a row without it by its path leaf. A page's books are read in one batch.- An empty list is
[], nevernull.
GET /api/v1/me/notes
Session. Capability annotations. The caller's notes across books, newest
created_at first (catalog.ListMyNotes); limit, cursor and the rules as
/me/bookmarks.
{
"notes": [
{
"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:00.000Z",
"updated_at": "2026-06-28T10:00:00.000Z",
"book": { "id": 412, "title": "The Final Empire", "author": "Brandon Sanderson", "…": "…" }
}
]
}
GET /api/v1/me/history
Session. The caller's listening spans across all books, newest ended_at
first (catalog.ListAllHistory). Spans an
imported history wrote (one per imported session)
are included, in the same shape.
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 100 | absent or ≤ 0 is 100; over 500 is 500 (an older server: over 500 falls back to 100) |
cursor | string | - | annotations servers: the previous page's next_cursor |
{
"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:00.000Z",
"ended_at": "2026-07-01T19:42:07.000Z",
"book": { "id": 412, "title": "The Final Empire", "…": "…" }
}
],
"next_cursor": "MjAyNi0wNy0wMVQxOTo0MjowNy4wMDBaADg4"
}
Every server has this route, without the capability. Without cursor it answers
the first page. A server with annotations adds each row's book and
next_cursor, and pages on cursor by the /me/bookmarks
rules; one without ignores cursor, sends neither, and always returns its one page.
GET /api/v1/libraries/{id}/history
Session. History for one book (?path= required; limit as above), newest
ended_at first, ties by id newest first. Response: { "history": [ … ] }
([] when there is none).
POST /api/v1/libraries/{id}/history
Session. Records a listening span (?path= required).
| Body field | Type | Required | Notes |
|---|---|---|---|
from_pos | float | no | span start position (seconds); not validated - defaults to 0 if omitted |
to_pos | float | no | span end position; not validated - defaults to 0 if omitted |
started_at | string | no | RFC 3339 (any offset, any fraction) |
ended_at | string | no | RFC 3339 |
Both times are stored, and returned, in the fixed-width UTC millisecond form
(2026-07-01T19:42:07.000Z), so the lists can order them as text. A span is
never refused for its times: when only one of them parses, both take it (a
zero-length span); when neither does (or both are left out), both are the
server's time now.
Response: 201 Created, empty body.
A player on a server with listening_sync posts no
spans here: the server writes history from its synced listening instead.
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). A book at a range
position also carries series_index_end, as on a book.
POST /api/v1/libraries/{id}/favourites · DELETE /api/v1/libraries/{id}/favourites
Session. Heart or un-heart a path (?path= required on both; one favourite
per user, library and path). Both are idempotent. POST answers 201 Created
(empty body), and DELETE answers 204 No Content.
From 2.5.0 a POST on a book no scan has reached yet (a pending folder) also
indexes it on demand (indexFavourite). So it shows on
/me/favourites, which reads the index, without being
opened first. It is best-effort. The read runs detached from the request, which
waits for it up to 5 s (favouriteIndexWait). The favourite is saved either way,
and a path that is no book (an author's folder) stays a bare favourite. Podcast
libraries are never indexed this way.
Up next, collections and ratings
Session (a session token or an API key). The listener's own lists, gated by
capability: queue for up next, collections for collections and the share
picker, ratings for ratings. Rules shared by every route in this section:
- A reference is a book path. List entries are addressed as
{ "library_id": 1, "path": "…" }: the book's ownrel_path(cleaned like every path). Entries are stored by path with no link to the index, like progress and favourites, so they survive a rescan and follow a moved book. bookon an entry is the book in the list shape (nodescription), present while the path is indexed. A client renders an entry without it by its path leaf.- Lists show only what the caller can open now. An entry whose path is outside the caller's current access (a share since taken away) is kept in the database but left out of every response and every count, as favourites are. It reappears if access comes back.
- Adding one book resolves it. A single add (
POST) and a ratingPUTaccept any path inside a book, like/item: a part of a folder book or a disc folder resolves to the whole book, a book not yet indexed is indexed on demand, and the book's own path is what is stored. Ownership (for a collection) and the body are checked first, so a stranger, a viewer or a bad request never triggers indexing. Alibrary_idof 0 or less is400 invalid library id; a library or path outside the caller's access is403 no access to this path(an admin naming a library that doesn't exist gets404); a path that isn't a book is404(book not foundon a list add,no book at this pathon a rating). - Replacing a whole list doesn't. A
PUTof a whole list must name its list:items(oruser_idsfor shares). A missing ornullkey is400(items is required,user_ids is required), because a whole replace deletes every row not listed. Only an explicit[]clears the list. A whole-list body may be up to 4 MiB (other bodies keep the default limit).- It indexes nothing. Each entry must be a book already indexed at exactly that path and inside the caller's current access. Any other entry is skipped, not an error. The response is the stored result, so a client reconciles with what it gets back. Duplicates collapse (the first wins).
- It is a whole replace. Every stored row not in the list is deleted, including rows the caller can't currently see, with one exception.
- The exception. A caller that the podcast client gate hides podcasts from (a 2.1.x or older player) keeps the stored podcast episodes it could see if it passed the gate. Each stays just after the listed entry it followed. When the new order would shuffle them, all go after the new list in their stored order. They count toward the list's cap, and the oldest of them go when the list and they together exceed it. So an old device reordering Up next can't drop a newer player's episodes.
- Episodes hidden for another reason (a revoked share, an unfollowed private show) are deleted as before. A replace from a podcast-seeing client deletes every unlisted row.
- Removing one entry is never scoped.
DELETE …?library_id=&path=removes only the caller's own row, so it needs no access check and a revoked path can still be cleaned up. It matches the path exactly (a part path is not resolved to its book, so pass the entry's storedpath), as does a ratingDELETE. It is idempotent (204whether or not the entry existed);400invalid library idorpath is requiredwhen either is missing. - Another user's id is a
404. Every route addressed by an id answers404for an id that isn't the caller's, never403, so the answer doesn't confirm that another user's collection exists. - Positions are the list as the caller sees it. A single add's
positionis a 0-based index in the visible, scope-filtered order theGETreturns: the book lands just before the visible entry now at that index, and hidden rows in front of that entry stay in front. Absent, or at least the number of other visible entries, puts it just after the last visible entry. Negative is400 invalid position. Hidden rows never move. For a book already listed,positionis where it ends up (visible[A, B]plus{A, "position": 1}gives[B, A]); withoutpositionit stays put. - Caps count visible entries. A new book on a list whose visible entries
already number its cap (500 for up next, 1,000 for a collection) is
409(queue_full,collection_full). When only hidden rows push the stored total over the cap, the oldest hidden rows (byadded_at) are deleted to make room, for good: if access comes back, only the survivors reappear. A move never evicts. A whole-listPUTis different: it replaces everything, hidden rows included, and more entries than the cap in its body (before duplicates collapse) is400 too many items. - Timestamps on these rows (
added_at,created_at,updated_at), and the goal'supdated_at, are fixed-width UTC with milliseconds, such as2026-10-01T09:01:00.000Z. - No demo or rate-limit special cases, except the two sharing routes below, which refuse demo accounts.
GET /api/v1/me/queue
Session. Capability queue. The caller's up next, in order.
{
"queue": [
{
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Well of Ascension",
"added_at": "2026-10-04T19:45:02.000Z",
"book": { "id": 413, "library_id": 1, "rel_path": "Brandon Sanderson/Mistborn/The Well of Ascension", "title": "The Well of Ascension", "…": "…" }
}
]
}
PUT /api/v1/me/queue
Session. Replaces the caller's whole up next with this order.
{ "items": [ { "library_id": 1, "path": "Brandon Sanderson/Mistborn/The Well of Ascension" } ] }
Entries follow the replace rules: duplicates
collapse, entries that aren't an indexed book in the caller's access are
skipped. Every stored row not in the list is deleted, including rows the
caller can't currently see, except the podcast episodes a gated (pre-2.2.0)
player keeps (see the replace rules).
Response 200: the queue, as GET.
| Status | Meaning |
|---|---|
400 | invalid request (a malformed body); items is required (missing or null); too many items - more than 500 entries in the body, counted before duplicates collapse |
POST /api/v1/me/queue
Session. Adds one book.
| Body field | Type | Required | Notes |
|---|---|---|---|
library_id | int | yes | |
path | string | yes | any path in the book; the book's own path is stored |
position | int | no | 0-based, in the queue as the caller sees it; absent or past the end = after the last visible entry |
A book already queued moves to position when one is given, and otherwise
stays where it is (so adding twice is harmless). See
positions and caps for hidden rows. Response
200: the whole queue, as GET.
| Status | Meaning |
|---|---|
400 | invalid request (a malformed body or a missing path); invalid library id; invalid position (negative) |
403 | no access to this path |
404 | book not found - no book at that path |
409 | code: "queue_full" (the queue holds at most 500 books) - the caller already sees 500 books in it |
DELETE /api/v1/me/queue
Session. Removes one book (?library_id=&path=). 204 No Content,
idempotent, with no access check. A path over 4096 bytes is 400 path is too long.
GET /api/v1/me/collections
Session. Capability collections. The collections the caller owns, then the
ones shared with them, each group most recently updated first.
{
"collections": [
{
"id": 7,
"name": "Long drives",
"description": "Big books for the summer trip",
"owner": { "id": 4, "username": "sam" },
"owned": true,
"shared_with": [ { "id": 6, "username": "alex" } ],
"item_count": 12,
"preview": [ { "id": 412, "library_id": 1, "rel_path": "Brandon Sanderson/Mistborn/The Final Empire", "title": "The Final Empire", "…": "…" } ],
"created_at": "2026-09-30T18:02:11.000Z",
"updated_at": "2026-10-04T20:15:40.000Z"
}
]
}
owned- the caller owns it. Otherwise it was shared with the caller, read-only, andownernames who did.shared_with- who the owner shares it with ([]when nobody). Sent to the owner only, never to a viewer.item_count- the items the caller can open now; a viewer never learns how many of the owner's items are out of their reach.preview- the first (up to) four of those items that are indexed, as list books, for a cover mosaic.updated_atmoves on a rename, a new description, and any change to the items. Sharing doesn't move it.shared_withis read only for the owner: a viewer's response never reads the share list at all.
Collections are shared read-only: only the owner renames, describes, edits the items or the shares; a viewer can read the collection and leave it. A viewer sees only the items their own current access allows, never the owner's.
POST /api/v1/me/collections
Session. Creates a collection owned by the caller.
| Body field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | trimmed; 1 to 100 characters (Unicode characters, not bytes); invalid UTF-8 or any control character is refused |
description | string | no | trimmed; up to 1,000 characters; invalid UTF-8 or a control character other than a line break (\n, \r) or tab is refused |
Response 201: { "collection": { … } }.
| Status | Meaning |
|---|---|
400 | invalid request (a malformed body); the name must be 1 to 100 characters, with no control characters; the description must be at most 1000 characters, with no control characters but line breaks and tabs |
409 | code: "collections_full" - the caller already owns 100 collections |
GET /api/v1/me/collections/{id}
Session. One collection and its items, in order, filtered by the caller's current access.
{
"collection": { "id": 7, "name": "Long drives", "owned": true, "…": "…" },
"items": [
{
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"added_at": "2026-09-30T18:03:00.000Z",
"book": { "id": 412, "title": "The Final Empire", "…": "…" }
}
]
}
404 collection not found when the caller neither owns it nor has it shared
with them; 400 invalid collection id for an id that isn't a number.
PATCH /api/v1/me/collections/{id}
Session. Renames or re-describes a collection (owner only): any subset of
name and description, with the rules of POST (a body with neither changes
nothing). Who may change it is settled before the fields are checked: a stranger
gets 404 and a viewer 403 not_owner whatever the name or description. Response
200: { "collection": { … } }.
| Status | Meaning |
|---|---|
400 | a malformed body, or a name or description outside the rules of POST |
403 | code: "not_owner" (only the collection's owner can change it) - the collection is shared with the caller, who can't change it |
404 | not the caller's collection and not shared with them |
DELETE /api/v1/me/collections/{id}
Session. For the owner, deletes the collection with its items and shares. For
a viewer, leaves it: only their own share is removed, and the owner's
collection is untouched. 204 No Content; 404 for anyone else.
PUT /api/v1/me/collections/{id}/items
Session. Replaces the collection's items with this order (owner only), body
{ "items": [ { "library_id": 1, "path": "…" } ] }, by the
replace rules of up next (a whole replace:
rows the owner can't currently see are deleted too, except the podcast episodes
a gated, pre-2.2.0 player keeps). Response 200: the
collection and its items, as GET /me/collections/{id}. Ownership is checked
before the size and the entries, so a stranger gets 404 (and a viewer 403)
rather than too many items; only a malformed body or a missing items is
refused before it.
| Status | Meaning |
|---|---|
400 | invalid request (a malformed body); items is required; too many items - more than 1,000 |
403 | code: "not_owner" |
404 | not the caller's collection and not shared with them |
POST /api/v1/me/collections/{id}/items
Session. Adds one book (owner only), with the body and semantics of
POST /me/queue: library_id, path and an optional
position; a book already in it moves to position when one is given.
Response 200: the collection and its items, as GET /me/collections/{id}.
Ownership is checked first, before the body: a stranger gets 404 and a viewer
403 not_owner before any 400, and neither triggers indexing. position is in
the collection as the owner sees it, with the queue's
position and cap rules.
| Status | Meaning |
|---|---|
400 | invalid request (a malformed body or a missing path); invalid library id; invalid position |
403 | code: "not_owner"; or no access to this path |
404 | collection not found - not the caller's collection and not shared with them; or book not found |
409 | code: "collection_full" (a collection holds at most 1000 books) - the owner already sees 1,000 books in it |
DELETE /api/v1/me/collections/{id}/items
Session. Removes one book (?library_id=&path=, owner only). 204 No Content, idempotent, with no access check on the path (one over 4096 bytes is
400 path is too long). 403
code: "not_owner" for a viewer, 404 for anyone else.
PUT /api/v1/me/collections/{id}/shares
Session. Replaces who the collection is shared with (owner only), body
{ "user_ids": [6, 9] } ([] stops sharing it; a missing or null key is
400 user_ids is required). Each id must be an existing, enabled, non-demo
account other than the owner, or someone it is already shared with. A viewer
disabled since stays valid, so the shared_with the owner was shown can be sent
back. A new disabled, demo or unknown id rejects the whole request.
A kid (a household member with member_kind: "kid") is a
valid id only for their own owner. From anyone else it is unknown user, whatever
the collection held before, so the answer never says who is a kid. A kid caller
can't share at all. After the demo check and the body, ownership is settled before
the ids are checked.
Response 200: { "collection": { … } }, its shared_with updated
(updated_at doesn't move).
| Status | Meaning |
|---|---|
400 | invalid request (a malformed body); user_ids is required; unknown user; a collection can be shared with at most 50 users (distinct ids) |
403 | code: "demo_account" - the caller is a demo account (checked first, so even for a collection that isn't theirs); code: "kid_account" - the caller is a kid; or code: "not_owner" |
404 | not the caller's collection and not shared with them |
A viewer sees the owner's username and the items they can open; nothing of
the owner's access is shared with them, except with the owner's own
household member on access: "collections", whose access is
the books of the collections their owner shares with them.
GET /api/v1/me/share-targets
Session. Capability collections. Who the caller can share a collection
with: every enabled, non-demo account other than the caller, by username. A kid
household member is listed only to their own owner.
{ "users": [ { "id": 6, "username": "alex" }, { "id": 9, "username": "robin" } ] }
This is the one place a non-admin can list every other account's username (a
shared collection also shows its owner, and
GET /household/listening the other people who
share). A demo
account gets 403 code: "demo_account", so a public demo can't
list the server's usernames.
GET /api/v1/libraries/{id}/rating
Session. Capability ratings. The caller's rating of a book (?path=, the
exact path as given, scope-checked like progress but never moved to a book's path
as progress is: use the book's own rel_path).
{
"rating": {
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"rating": 5,
"note": "The heist that becomes a revolution.",
"created_at": "2026-10-02T21:14:00.000Z",
"updated_at": "2026-10-03T08:01:37.000Z"
}
}
{ "rating": null } when the caller hasn't rated it. 400 missing path; 403
out of scope.
PUT /api/v1/libraries/{id}/rating
Session. Rates a book (?path=, any path in it: it resolves to the book and
is stored on the book's own path, by the
add rules).
| Body field | Type | Required | Notes |
|---|---|---|---|
rating | int | yes | a JSON integer from 1 to 5 (4.5 and "4" are refused) |
note | string | no | a short private note; trimmed; up to 500 characters after trimming |
The PUT replaces the whole rating: leaving note out clears a note saved
before. One rating per book per user; rating again moves updated_at. The body
is checked before the book is resolved, so a bad body never triggers indexing.
Response 200: { "rating": { … } }, note "" when there is none.
| Status | Meaning |
|---|---|
400 | missing path; invalid request (a malformed body); rating must be a whole number from 1 to 5 (also when absent or null); the note is longer than 500 characters |
403 | no access to this path |
404 | no book at this path |
DELETE /api/v1/libraries/{id}/rating
Session. Removes the caller's rating of a book (?path=, exact, scoped like
GET). 204 No Content, idempotent.
GET /api/v1/me/ratings
Session. Capability ratings. Every rating the caller has made that they can
still open, most recently updated first, each with its book when indexed.
{
"ratings": [
{
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"rating": 5,
"note": "The heist that becomes a revolution.",
"created_at": "2026-10-02T21:14:00.000Z",
"updated_at": "2026-10-03T08:01:37.000Z",
"book": { "id": 412, "title": "The Final Empire", "…": "…" }
}
]
}
Your listening
Session. Capability user_stats. The caller's own listening, the player-side
counterpart of the admin's Activity, computed from
the same listening sessions and daily roll-ups but only ever the caller's.
range is as on the admin routes: 7d, 30d, 90d, 1y (365 days) ending
now, a calendar year such as 2025, or year (the current calendar year in
server time); empty means 30d, and anything else is 400
code: "invalid_range". Days, hours and weekdays are in the server's time
zone, as on the admin Activity page.
GET /api/v1/me/stats
Session. The caller's listening over a period.
{
"stats": {
"range": "30d",
"from": "2026-09-04T19:42:08Z",
"to": "2026-10-04T19:42:08Z",
"timezone": "BST",
"utc_offset": 60,
"totals": { "listened": 102380.5, "sessions": 61, "books": 5, "finished": 2 },
"previous": { "listened": 88012.0, "sessions": 54, "books": 4, "finished": 1 },
"estimated": 0,
"days": [ { "date": "2026-09-04", "listened": 3021.3 } ],
"hour_weekday": [ [0, 0, "… 24 values …"], "… 7 rows, Monday first …" ],
"top_books": [ { "library_id": 1, "path": "Brandon Sanderson/Mistborn/The Final Empire",
"title": "The Final Empire", "author": "Brandon Sanderson", "listened": 38211.0 } ],
"top_authors": [ { "name": "Brandon Sanderson", "listened": 61022.4, "books": 3 } ],
"top_narrators": [ { "name": "Michael Kramer", "listened": 61022.4, "books": 3 } ],
"top_series": [ { "name": "Mistborn", "listened": 52011.9, "books": 2 } ],
"finished_books": [ { "library_id": 1, "path": "Brandon Sanderson/Mistborn/The Final Empire",
"title": "The Final Empire", "author": "Brandon Sanderson",
"finished_at": "2026-09-28T22:40:12Z" } ],
"playback": [ { "transcoded": false, "codec": "aac", "listened": 102380.5, "sessions": 61 } ],
"clients": [ { "app": "AudioSilo", "version": "1.4.2", "platform": "ios", "devices": 2 } ]
}
}
The fields mean what they mean in the admin's
activity, for the caller alone:
-
totals- the caller's figures for the period:listened(wall-clock seconds) andsessions;finished: books finished in the period, by the caller's own finish dates. It counts all of them, including books they can no longer open. It is a count with no paths, sototals.finishedcan exceedfinished_books' length;books: books listened to or finished in it. A book marked finished with no listening recorded still counts, sofinishedis never more thanbooks.
previousis the same for the period of equal length just before.estimatedis how many oftotals.listened's seconds are estimates. -
days- every day of the period, oldest first, zero days included;hour_weekday- 7 rows (0 = Monday) of 24 hours, raw sessions only. -
top_books,top_authors,top_narrators,top_series- up to 10 each, by listened time.top_seriesis only here; the adminactivityhas none. -
finished_books- the books the caller finished in the period, newest first, up to 100. -
playbackandclients- the caller's listening by codec and transcoding, and the caller's devices that listened, by app build.
Only the caller, and only what they can open now. Nothing about other
people is computed or sent: no listeners, by_user, top_users, funnel,
drop-offs, storage, coverage, growth, peak concurrency, inactive users or any
username. The top lists and finished_books pass through the caller's
current access: a book they can no longer open is dropped from top_books
and finished_books, and authors, narrators and series are ranked from the
books they can still open, so a path, title or author from a share since taken
away never comes back. totals, days and hour_weekday are the caller's own
time and counts, and include everything.
400 code: "invalid_range" (range must be 7d, 30d, 90d, 1y or a year).
GET /api/v1/me/listening
Session. The caller's listening per day over a period, without the rest of
the stats: the same days as /me/stats, for a year calendar or streaks
(which the client works out from days).
{
"range": "2026",
"from": "2025-12-31T23:00:00Z",
"to": "2026-10-04T19:42:08Z",
"timezone": "BST",
"utc_offset": 60,
"days": [ { "date": "2026-01-01", "listened": 3120.4 } ]
}
As GET /admin/listening with user_id set to the
caller, without by_user. 400 code: "invalid_range".
GET /api/v1/me/goal · PUT /api/v1/me/goal · DELETE /api/v1/me/goal
Session. The caller's listening goal: a number of books to finish each calendar year, one per account, kept on the server so every device shows the same goal.
GET answers:
{
"goal": { "books_per_year": 24, "updated_at": "2026-01-02T09:00:00.000Z" },
"year": "2026",
"finished": 17
}
goal is null when none is set. year is the server's current calendar year
and finished the books the caller has finished in it (server time, counted
like totals.finished: every finish, including books they can no longer
open), there whether or not a goal is set.
PUT sets the goal, body { "books_per_year": 24 }, and answers 200 as
GET: 400 books_per_year must be a whole number from 1 to 1000, or
invalid request for a malformed body or an unknown field. DELETE clears it:
204 No Content, idempotent.
Upcoming releases
Session. Capability upcoming (2.6.0). What is coming and what is missing in the
series the caller holds: preorders, recent releases and missing volumes, worked out
from the community metadata by a background job (internal/upcoming.Runner), so a
request reads only what the job stored and never asks the metadata service itself.
No Bridge is needed.
The player (2.6.0) shows it as Home's Coming up in your series, an Upcoming page and the
You hub's Upcoming section, merged across the listener's servers one row per work
(Upcoming releases), and dates the
series page's ghost volumes from release_date on /meta.
The job. It runs about an hour after the server starts, then once a day (give or take an hour), and a light pass once a burst of new or edited books settles (it looks up the new books' works and reads only community series it has never read). A run:
- gives each owned book with an ASIN or ISBN its community work id (the metadata cache's answer, else a lookup under the service's own budget), and looks again after 30 days (at most 500 of those month-old re-checks a run; a new or changed book is always looked up);
- gives each local series (library and folded series name) the community series it
is (one or more), from its books' works' series named like it (the
/metarails' naming rule, then give or take a leading article or a trailing "Series"), looked up again after 30 days or when the series gains books; - asks the metadata service's watch feed, 50 series a request, which series have news;
- reads
series/{id}for a series it has never read, one with news, or one read over a week ago; a404marks the series gone, dropped a week later; - classifies each local series against the community series (below) and replaces its items.
A failed read keeps what earlier runs stored (a series is replaced only by a read
that worked), and a failed run is tried again an hour later, up to three times.
With metadata off or the switch off, the job makes no call at all, and switching it
off stops a run in flight. In mirror mode
the local copy answers every read. The job's status (last run, last success, last
error, next run, series and item counts) is the read-only metadata.upcoming_status
on GET /admin/settings.
What counts. For each local series, a member of the community series the series doesn't hold is:
preorderwhen its first release is still ahead (at the precision the date states:2026-11is ahead for all of October);releasedwhen it came out within the window (upcoming.window_days, 90 days by default);missingwhen it is below the highest volume the series holds (an interior gap), or the single next volume after that highest one when it is out and is no preorder or recent release already.
A book whose community work is known holds that work's entry (a range: every entry
inside it), whatever its own series number says; a book whose work isn't known holds
the entry at its own series number. A box set counts as held when every volume
inside it is. Only whole-number positions (and ranges starting on one) can be
missing: a novella at 2.5 is never a gap, though it is a preorder or a release
like any other member.
GET /api/v1/me/upcoming
Session. Requires the upcoming capability; a kid's account gets 403
code: "kid_account". A demo account reads it like anyone else.
{
"status": "ok",
"generated_at": "2026-10-30T04:12:00.000Z",
"window_days": 90,
"items": [
{
"kind": "preorder",
"release_date": "2026-11-12",
"first_seen_at": "2026-10-29T04:10:41.000Z",
"work": {
"id": "the-expanse-10",
"title": "Book Ten",
"authors": [{ "id": "james-s-a-corey", "name": "James S. A. Corey" }],
"cover_url": "https://…",
"web_url": "https://meta.audiosilo.app/work?id=the-expanse-10"
},
"series": { "id": "the-expanse", "name": "The Expanse", "position": "10" },
"local": { "library_id": 3, "series": "The Expanse" }
}
]
}
| Field | Meaning |
|---|---|
status | ok (a run succeeded within the last 48 hours), pending (no run has succeeded yet: items is empty or what a light pass found) or stale (none has for 48 hours: the items are the last ones stored) |
generated_at | the last successful run; absent while pending |
window_days | the recent-release window the server counts by |
items[].kind | preorder, released or missing, as above. A preorder whose day has come since the last run already reads released |
items[].release_date | the work's first release (the earliest across its recordings and marketplaces), at the precision the community states it: YYYY, YYYY-MM or YYYY-MM-DD; absent when unknown |
items[].first_seen_at | when the job first listed the item (a client's "new" badge) |
items[].work | the community work: id, title, authors ({id, name}), cover_url (absent when none) and web_url (its page on the metadata site) |
items[].series | the community series and the work's position in it (a string: "10", "2.5", "1-3") |
items[].local | the caller's series view the item belongs to: library_id and the series' local spelling (series), to open it by name. No other DB id is sent |
Who sees what. A series is the caller's only through a book of it they can see
(their libraries and shares, catalog.UserScopes; the book's main series or one of
its other series). An item is dropped when the caller can see a copy of that work
anywhere, and each work is listed once (the first library in sort order). Podcast
libraries never count. Items come in order: preorders soonest first, then releases
newest first, then missing volumes by series and position.
Errors: a kid's 403 kid_account comes first (the route's own check), then 404
code: "metadata_off" while community metadata is off, 404 code: "upcoming_off" while the job is switched off
(upcoming.enabled, the console's Settings > Metadata). Either way the capability
is false, so a client that honours it never asks.
The calendar feed
Capability upcoming_calendar (from 2.6.0; true exactly when upcoming
is). A listener subscribes a calendar app to their own upcoming releases through
one secret link per user per server: GET /me/upcoming/releases.ics?token=<secret>.
The secret is a token of its own kind, feed, accepted on that route only
(see Calendar feed tokens), so a
leaked link reads that one person's release list and nothing else. The player's
card: The upcoming calendar.
GET /api/v1/me/upcoming/calendar
Session (an API key may read it). Whether the caller has a calendar link, never the link itself (the server keeps only its hash):
{ "active": true, "created_at": "2026-10-30T09:14:02.517Z" }
created_at (RFC 3339, UTC, milliseconds) is absent while active is false.
It is the mint's own created_at exactly, and later than the link it replaced,
so a client that kept a copy of a link compares the two to tell whether its copy
is still the live one (a link reset or turned off on another device is not).
Errors, in order: 401; 403 kid_account; 404 metadata_off; 404
upcoming_off.
POST /api/v1/me/upcoming/calendar
Session only. Makes the caller's calendar link, ending the one they had in the
same transaction (a reset). No body. 201, with Cache-Control: no-store:
{
"path": "/api/v1/me/upcoming/releases.ics?token=<secret>",
"created_at": "2026-10-30T09:14:02.517Z"
}
path is server-relative, not a URL: the server knows no reliable public
address, so the client puts it on the address a calendar app should fetch it
from (the player uses the server's away address when it has one, else the address
it reaches the server at). This is the only time the secret is answered: a client
that wants to show the link again keeps it as it keeps a session token.
Errors, in order: 401; 403 kid_account; 403 demo_account (a demo
account can't make a credential); 403 for an API key (a key never mints a
credential, Personal API keys); 404 metadata_off; 404
upcoming_off.
DELETE /api/v1/me/upcoming/calendar
Session only. Ends the caller's calendar link: 204 whether there was one or
not. Not gated on the two switches (ending a link only takes access away), so it
works while upcoming releases are off. Errors: 401; 403 kid_account; 403
for an API key.
GET /api/v1/me/upcoming/releases.ics
Feed token only, as ?token= (a calendar app can't set a header) or the
Authorization header. A session, API key, cast, bridge or pairing token is
401 here, and a feed token is 401 on every other route. It runs under the
media rate limit, per credential.
The token owner's GET /me/upcoming list, read with their
scopes at this request (a share taken away is felt at once), as an iCalendar
document (RFC 5545):
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//AudioSilo//Upcoming releases//EN
CALSCALE:GREGORIAN
X-WR-CALNAME:AudioSilo: upcoming releases (Den\, upstairs)
REFRESH-INTERVAL;VALUE=DURATION:PT12H
X-PUBLISHED-TTL:PT12H
BEGIN:VEVENT
UID:the-expanse-10@kx8Qz1c7m2Vw0aB3dEfGh
DTSTAMP:20261030T041200Z
DTSTART;VALUE=DATE:20261112
DTEND;VALUE=DATE:20261113
SUMMARY:Book Ten (The Expanse #10)
DESCRIPTION:By James S. A. Corey\nFirst release: 2026-11-12\nhttps://meta.a
udiosilo.app/work?id=the-expanse-10
URL:https://meta.audiosilo.app/work?id=the-expanse-10
TRANSP:TRANSPARENT
END:VEVENT
END:VCALENDAR
- Events. One all-day
VEVENTperpreorderorreleaseditem whoserelease_dateis a whole day (YYYY-MM-DD). Amissingitem (its date is a back-catalogue first release, often years past) and aYYYYorYYYY-MMdate (no honest all-day event) are left out.DTSTART;VALUE=DATEis the day andDTENDthe next.UIDis<work id>@<server_id>(audiosilowhen the server has none), so a date that slips moves the event rather than adding one.DTSTAMPis the list'sgenerated_at(else now), in UTC.SUMMARYis "Title (Series #position)", the same label as thebook_releasednotification;DESCRIPTIONis "By authors", "First release: date" and the work'sweb_url, which is also theURL.TRANSP:TRANSPARENT: an event never shows the listener as busy. - The calendar.
X-WR-CALNAMEis "AudioSilo: upcoming releases", with the server's name in brackets when it has one;REFRESH-INTERVALandX-PUBLISHED-TTLask for a poll every 12 hours. There is noMETHOD(a subscription needs none; with one, RFC 5545 asks theContent-Typefor a matchingmethodparameter and iTIP'sPUBLISHanORGANIZER). A server whose job never ran (pending), or a list with no such item, answers a calendar with no component, which calendar apps read as empty. - Text. CRLF line ends; lines folded at 75 octets, never inside a UTF-8
sequence; TEXT values escaped (
\\,\;,\,,\n; a CR, an LF or a CRLF is one line break; a tab is kept; other control characters and invalid UTF-8 are dropped); a URI with a space or a control character is left out. - Headers.
Content-Type: text/calendar; charset=utf-8,Content-Disposition: inline; filename="releases.ics",Cache-Control: private, max-age=3600and anETag("ics-"and the first 16 hex digits of the body's SHA-256): a poll that sends it back inIf-None-Matchgets a304. Never gzipped.
Errors, in order: 401 (no token, an unknown or ended one, or another kind);
403 kid_account (a leftover token of a person who is now a kid); 404
metadata_off; 404 upcoming_off. A disabled account's token is refused
(401) while it is disabled.
GET /api/v1/me/upcoming/cover
Session (media): a session token or API key, as the header or ?token= (never a
cast or feed token). Capability upcoming_covers (true exactly when upcoming
is). A JPEG thumbnail of the cover the caller's own /me/upcoming list hands out
for a work right now, so a client shows community covers from its own server
(the web player's CSP refuses another host, and the device never contacts the
cover's host).
| Param | Meaning |
|---|---|
work | required: the item's work.id |
size | optional: one of the cover sizes (96, 160, 320 or 640), 320 when unset |
It is not an image proxy: the server reads the caller's list for that one work
(the list's own rules: the caller's scopes, a work they hold a copy of dropped)
and serves only the cover_url it holds, from the database alone (no metadata
traffic). Then it is /meta/cover's tail: the
same guarded fetch (16 MiB cap, one fetch at a time per image), thumbnail cache,
and URL/size-based ETag. Its Cache-Control is private, no-cache: an unchanged
cover revalidates with 304, while a changed stored cover_url produces a new
image and ETag at the same work/size route. A client cache-buster is not required.
Errors, in order: 401; 403 kid_account; 404 metadata_off; 404
upcoming_off; 400 without work or with a size outside the list; 404
not_found for a work not in the caller's list (another person's series, one the
caller holds, none at all) or listed without a cover, before any fetch; 502
cover_unavailable when the image couldn't be fetched (also for a minute after a
failed fetch, as on /meta/cover); 404 (no code) when the fetched file is no usable
image.
Listening in the house
Session. Capability household. People on one server who choose to share see
what the others who share are listening to (the player's Home row and the book
page's "In the house" card). The rules live in catalog.HouseListening
(internal/catalog/household.go): opt-in and reciprocal (a caller who doesn't
share gets an empty listening list), with every privacy rule in
what other people see.
Polling only: there is no realtime channel (the player asks every minute while it is in the foreground). No admin route reads or changes anyone's choice.
GET /api/v1/me/household · PUT /api/v1/me/household
Session. The caller's own choice.
{ "share_listening": false }
PUT takes the same shape, { "share_listening": true }, and answers 200 as
GET. The others see only listening done since sharing was last turned on
(GET /household/listening); saving the same
choice again does not reset that. The field is required and the body is decoded
strictly: a missing field ({}), an unknown field or a wrong type is 400
invalid request. A demo account's PUT is 403 (code: "demo_account"; its GET works and reads
false), so a public demo never shows up in anyone's house. A personal API key may
set it: it is the person's own credential and mints nothing. A kid's account
(a household member) gets 403 code: "kid_account" on PUT,
and its GET always reads false, even if it shared before it was made a kid.
GET /api/v1/household/listening
Session. What the other people who share are listening to, newest first. Only listening done since a person last turned sharing on is shown.
{
"sharing": true,
"listening": [
{
"user_id": 3,
"username": "sam",
"library_id": 1,
"path": "Will Wight/Cradle/Unsouled",
"title": "Unsouled",
"author": "Will Wight",
"state": "playing",
"position": 5123.4,
"duration": 36120.5,
"chapter": "Chapter 4",
"chapter_index": 3,
"updated_at": "2026-10-09T19:59:30.000Z"
}
]
}
sharing- whether the caller shares. While it isfalse,listeningis[]. A kid household member never shares and never sees anyone ({ "sharing": false, "listening": [] }), and never appears as a row.state- from the age of the person's newest save:playingwithin 60 seconds,pausedwithin 10 minutes (the session gap), elserecent. A player that saves every 60 s can read aspausedfor a moment (data model).title- the index's title, else the path's last segment (a book no longer indexed);authoranddurationarenullwhen unknown (durationfalls back to the session's own).position- seconds into the whole book;chapterandchapter_index(0-based) are the chapter at that position, as the admin's live sessions show it: bothnullfor a book with one chapter or none, andchapteralonenullfor a chapter whose title names nothing (Track 01), wherechapter_indexstill says which.updated_at- the newest save, fixed-width millisecond UTC.
Household members
Session. Capability household_members. Any non-demo person who isn't a member
themselves (the owner) adds the people they live with from the player, with no
admin and no invite, while the server's and the admin's switches allow it (below). A
member is a real account (role: "user", made with no password; a grown-up may
set one later, a kid never holds one) whose users.managed_by is the owner, with a
member_kind (kid or grownup) and a member_access (all or collections) that
the owner sets (the admin can too, with
Assign to someone). What a member may listen to (the owner's
current access for all, the collections the owner shares with them for
collections, plus their own grants), and the one-level rule, are the member rule in
Auth & security.
Guards, in the order they answer:
- Anonymous is
401(requireAuth). - On the
{id}routes the ids are read first. One that isn't a number is400(invalid member id, orinvalid device idfor{device_id}). - Then
memberOwner: an API key is403(denyAPIKey; the list too), a demo account403code: "demo_account", a member caller403code: "member_cannot_manage". - Create, session and pair then spend the account
rate limit (
429). - The body comes next, decoded strictly: an unknown field, such as
role, is400 invalid request. - Last comes the route's own transaction.
Someone else's member (or no such account) is 404 member not found on every
{id} route, so the answer never says whether the id exists. Only adding
checks the server's switch, the owner's switch and the cap. Every other route keeps
working on the caller's existing members when the feature is off, so an owner can
always rename, sign out and delete them.
The member object (HouseholdMember), as the list, the create and the PATCH
answer it:
{
"id": 12,
"name": "Evie",
"username": "evie",
"kind": "kid",
"access": "collections",
"created_at": "2026-10-13T10:00:00.000Z",
"devices": 1,
"collections": [ { "id": 4, "name": "Evie's books", "items": 2 } ],
"last_listened_at": "2026-10-13T18:00:00.000Z"
}
name- the name the owner typed (users.display_name), else the username.usernameis derived from it at create (below) and never changes with a rename.collections- present (maybe[]) foraccess: "collections", absent forall: the owner's collections shared with the member,itemscounting the books the owner can reach now.devices- how many live sessions and API keys the member has.last_listened_at- the member's newest listening,nullwhen never.created_at- fixed-width millisecond UTC. The list is ordered by it, thenid.
GET /api/v1/me/household/members
The caller's members and whether they may add one:
{ "allowed": true, "max": 6, "members": [ { "id": 12, "name": "Evie", "…": "…" } ] }
It never refuses a signed-in session: a demo account and a member get 200 too.
When the caller may not add, allowed is false with a reason, checked in this
order (auth.MayAddMembers, the one ladder the create runs as well):
reason | Why |
|---|---|
demo | a demo account (members is []) |
member | the caller is a household member (members is []) |
feature_off | the server's household.members_enabled is off |
not_allowed | the admin turned off the caller's can_add_members |
Anyone else's members are listed whatever allowed says. max is
household.max_members. Being at the cap is not a reason: the client compares
members.length with max. An API key gets 403.
POST /api/v1/me/household/members
Adds a member.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | trimmed; 1-40 characters with something visible, no control or invisible characters (bidi controls and marks, zero-width spaces, the BOM, fillers; right-to-left scripts, ZWJ emoji and ZWNJ are fine); else 400 invalid_name |
kind | string | yes | "kid" or "grownup" |
access | string | yes | "all" or "collections" |
sign_in_here | bool | no | also sign the member in on the caller's device |
collection_ids | int[] | no | the caller's own collections to share with the member (below) |
In one transaction (auth.CreateMember) it checks the caller may add (demo, member,
the server's switch, the caller's switch, then the cap, in that order), then the name,
kind and access, derives the username, inserts the account (role user, an empty
password hash, the three member columns and the typed name), shares the collections
and, with sign_in_here, issues a session. Anything refused changes nothing.
-
The username is the name lowercased and folded to ASCII letters and digits:
Évie Mariebecomesevie-marie. Any run of anything else becomes one-, the result is at most 24 bytes, and it ismemberwhen nothing is left. The server tries, in order:- that name;
- if it is taken, that name with the owner's username folded the same way
(
evie-sam), skipped when nothing is left of it; - then the first part numbered (
evie-2,evie-3and on), with no cap.
It takes the first one no account has, ignoring case, read once inside the transaction. So there is always a free one, and the create never answers
username_taken. -
sign_in_hereissues a session for the member named as the caller's own device, recording the caller's token as itscreated_on_token_id(so the caller's device list names the member undermember_sign_ins, and?also_members=1can sign it out). -
collection_idsis the full set of the caller's collections to share with the member (anyaccess; the player sends it forcollections), applied inside the same transaction (catalog.SetMemberCollectionsthroughauth.ShareFunc). An id that isn't one of the caller's own collections is400 invalid_collectionsand nothing is applied. The 50-person share cap applies.[]is allowed: a kid can start with nothing.
Response 201:
{ "member": { "id": 12, "name": "Evie", "…": "…" },
"session": { "token": "…", "user": { "id": 12, "username": "evie", "managed_by": { "id": 4, "username": "sam" }, "member_kind": "kid", "display_name": "Evie", "…": "…" }, "server_id": "…" } }
session (only with sign_in_here) is the usual session envelope, the same shape as
/auth/exchange's, addresses included where the server
has them. A kid is meant to be signed in here; a grown-up is usually shown a pairing
code instead.
| Status | Meaning |
|---|---|
201 | { member, session? } |
400 | invalid request (malformed, an unknown field, a wrong type); code: "invalid_name"; a kind or access that isn't one of the two values (no code); code: "invalid_collections"; a collection can be shared with at most 50 users (a chosen collection already has 50 other people) |
401 | anonymous |
403 | API key; demo_account; member_cannot_manage; code: "feature_off"; code: "not_allowed" |
409 | code: "member_limit" (at max) |
429 | the self-service rate limit |
POST /api/v1/me/household/members/{id}/session
No body. Signs the member in on the caller's device, as sign_in_here does (named as
the caller's device, created_on_token_id the caller's token). Response 201: the
session envelope. Used for "They'll also use this phone" and "Sign in on this
device".
| Status | Meaning |
|---|---|
201 | the member's session envelope |
400 | invalid member id |
403 | API key; demo_account; member_cannot_manage |
404 | not the caller's member |
409 | code: "member_disabled": the admin disabled the member's account, so no token of theirs would work (nothing is issued) |
429 | the self-service rate limit |
POST /api/v1/me/household/members/{id}/pair
No body. Response 200: a PairingPayload for the member's own device, built like
/auth/pair's, with the same lifetime (pairingTTL, 10
minutes): single use, unlinked. It works for a kid (who can't call /auth/pair
itself). A device that signs in with it is the member's own: it records no
created_on_token_id.
| Status | Meaning |
|---|---|
200 | the pairing payload |
400 | invalid member id |
403 | API key; demo_account; member_cannot_manage |
404 | not the caller's member |
409 | code: "member_disabled" (as above; no pairing token is minted) |
429 | the self-service rate limit |
GET /api/v1/me/household/members/{id}/devices
Response 200: a bare array of the member's live sessions and API keys, each shaped as
a GET /me/devices row with current always false and no
member_sign_ins ([] when none). 400 invalid member id, 403 as above, 404
not the caller's member. No rate limit.
DELETE /api/v1/me/household/members/{id}/devices/{device_id}
Signs the member out of one device, in one transaction. 204; 404 member not found for anyone but the caller's member, then 404 device not found for a device
that isn't the member's live session or API key; 400 for an id that isn't a number;
403 as above. Audited member.device_revoke (device_id).
PATCH /api/v1/me/household/members/{id}
Changes a member: any of name, kind, access and collection_ids (the same rules
as the create; collection_ids replaces the full set: a share on each listed
collection, and the member removed from every other collection the caller owns;
other people's collections are never touched). {} is 400 nothing to change.
Response 200: the member object. Everything runs in one transaction
(auth.UpdateMember), with the ownership settled before the input is checked, so a
refused field changes nothing.
Switching a grown-up to a kid clears, in the same transaction, everything a kid
can't hold (Kids); their signed-in
sessions stay signed in. Switching kind leaves access as it was.
| Status | Meaning |
|---|---|
200 | the member, as it is now |
400 | invalid member id, invalid request, nothing to change, invalid_name, a bad kind or access, invalid_collections, a collection can be shared with at most 50 users |
403 | API key; demo_account; member_cannot_manage |
404 | not the caller's member |
DELETE /api/v1/me/household/members/{id}
Deletes the member's account and all of its state (the schema's cascade, as
DELETE /admin/users/{id}). 204; 400 invalid member id, 403 as above, 404 not the caller's member.
What a member may and may not do
| Route | A grown-up member | A kid |
|---|---|---|
| browse, search, play, progress, bookmarks, notes, Up next, ratings, a cast token | yes, within their access | yes, within their access |
their own collections (POST /me/collections, items) | yes | yes |
PUT /me/collections/{id}/shares | yes | 403 kid_account |
POST /auth/password, POST /auth/tokens, POST /auth/pair, POST /auth/recovery | yes | 403 kid_account |
PUT /me/household | yes | 403 kid_account (GET reads false) |
/me/household/members/** | 403 member_cannot_manage (the list: 200, reason: "member") | the same |
/admin/** | 403 (role user) | 403 (role user) |
Where the kid_account refusals are enforced, including a request that races the
owner's switch to kid: Kids.
A kid's password sign-in fails like any password-less account's (401 on
/auth/login). A member the admin disabled can't sign in at
all, like any disabled account, and their owner's session and pair routes answer
409 member_disabled. Audit: each route that changes something writes a
member.* event with the owner as actor and the member as target (see
Audit log).
Casting
Session. Capability cast. A Chromecast receiver plays a book from URLs the
player hands it; those URLs carry a short-lived, book-scoped, media-only cast
token instead of the player's session token (internal/auth/cast.go,
internal/api/handlers_cast.go). Where the token is accepted is
Cast tokens as media auth. For the web player to
cast, the per-document CSP of /web admits Google's Cast Web Sender:
script-src names its three scripts on https://www.gstatic.com by path, the only
remote sources in the policy (see Web UI).
POST /api/v1/cast/token
Session. Mints a cast token for one book.
{ "library_id": 1, "path": "Will Wight/Cradle/Unsouled" }
Response 201:
{
"token": "q3Xb0m…",
"expires_at": "2026-10-10T20:00:00.000Z"
}
The path resolves exactly as on the media routes, and a part path (one file, or a
disc folder) resolves to its book: the grant is recorded on the book's path, never
the part's, so the token covers every file of that book and no other book's.
The secret is returned once. expires_at falls on a whole second. A demo account
may mint (it can play); an API key may not. How long a cast token lives, how many a
person keeps, what ends it and how dead ones are pruned:
Cast tokens.
| Status | Meaning |
|---|---|
201 | { token, expires_at } |
400 | invalid request (a malformed body or an unknown field), path is required, path is too long (over 4096 bytes, before the library is looked up) |
401 | no session (a cast token can't mint another: it is never a bearer credential) |
403 | the library or the path is outside the caller's access; error.code: "not_a_book" (Only books can be cast.) for a path in a podcast library, since episodes are not cast (from 2.4.0; before, the same 403 with no code); or not available when authenticating with an API key (a key never mints another credential) |
404 | book not found (no book at the path), or library not found (an admin naming an unknown library; anyone else gets the 403) |
500 | could not start casting; access check failed, could not load library or could not load book on a database failure |
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",
"managed_by": null,
"member_kind": null,
"member_access": null,
"can_add_members": true,
"members_count": 2,
"display_name": ""
},
{
"id": 12,
"username": "evie",
"role": "user",
"…": "…",
"managed_by": { "id": 4, "username": "sam", "display_name": "" },
"member_kind": "kid",
"member_access": "collections",
"can_add_members": true,
"members_count": 0,
"display_name": "Evie"
}
]
}
Each user is the /me object plus the household member fields
(capability household_members) only the admin sees (api.adminUser):
| Field | Meaning |
|---|---|
managed_by | { id, username, display_name } of the owner for a household member, else null. The owner's display_name is their own typed name ("" unless they were once a member themselves): the console names an owner by it, else the username. The player's managed_by never carries it |
member_kind | "kid" or "grownup" for a member, else null |
member_access | "all" or "collections" for a member, else null |
can_add_members | the admin's per-person switch (default true) |
members_count | how many members this person has |
display_name | the name the owner typed for a member, always present ("" for everyone else; kept when the member is made independent, though /me then leaves it off) |
The same fields are on GET /admin/users/{id}'s user, on the PATCH answer and on
the create's.
POST /api/v1/admin/users
Create an account.
| Body field | Type | Required | Notes |
|---|---|---|---|
username | string | yes | |
password | string | admins only | optional for non-admins (pairing-only accounts); required for role: "admin" |
role | string | yes | "admin" or "user" |
Response 201: the created account as the admin's user object above (re-read after
the insert, so its stored defaults, can_add_members among them, are there). 409
username already taken (code: "username_taken") on a duplicate username; 400
with a specific message on a validation failure: username_required (an empty
username; 2.7.0 adds the code to the old message), admin_needs_password,
password_too_short (see error codes).
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" } ] },
{ "id": 5, "name": "Library: Audiobooks", "description": "Whole library",
"read_only": false, "whole_library_id": 1,
"paths": [ { "library_id": 1, "path": "" } ] } ],
"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"
}
],
"members": [ { "id": 12, "name": "Evie", "username": "evie", "kind": "kid", "access": "collections",
"created_at": "2026-10-13T10:00:00.000Z", "devices": 1, "disabled": false } ],
"member_collections": []
}
members- this person's own household members, oldest first ([]for none): each the owner's list's member (id,name,username,kind,access,created_at,devices; withoutcollectionsandlast_listened_at) plusdisabled.member_collections- for a member onaccess: "collections", the owner's collections shared with them ({ id, name, items },itemscounted by the owner's current access), else[].accessible_librariesincludes a member's derived access (the owner's libraries forall, the libraries their shared collections reach forcollections), read through the same member rule as the member's own requests, whilesharesstays the admin's direct grants only.- For a household member only,
owner_libraries(the libraries the owner reaches now, as the member'sallaccess would follow them) andowner_is_admin(an admin owner gives every library), so the console's Access tab needs no second detail.[]andfalsefor a member whose owner derives nothing (bad data). Absent for everyone else.
shares marks whole-library grants with whole_library_id (see
GET /admin/shares). 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 field | Type | Notes |
|---|---|---|
username | string | 2.7.0: renames the account, by the rule a new account's name follows: not "" (400 username_required), not another account's (409 username_taken). The account keeps its sessions, since tokens name the user by id. The console's first-run Your account step uses it |
role | string | "admin" | "user" |
password | string | "" clears the password (non-admins only); a non-empty one is refused when the request leaves the account a kid (beside Make independent, or an assign as a grown-up, it is fine). Replacing or clearing a password the account had ends its sessions (below) |
disabled | bool | reversible lockout; disabling revokes nothing but blocks all token use |
can_add_members | bool | the per-person switch: may this person add household members |
managed_by | int | null | an owner's id: "Assign to someone", with member_kind and member_access beside it. null: "Make independent" |
member_kind | string | "kid" | "grownup"; only with a managed_by id |
member_access | string | "all" | "collections"; only with a managed_by id |
The body is decoded strictly, and the whole of it is one transaction
(auth.UpdateUser): every field is checked against the account before anything is
written, so a refused field changes nothing, not even the fields before it. The
checks run in this order, each against the account as the ones before it leave it:
the password, the household, the role, the disabled state, then can_add_members.
So a password and "make admin" work in one request on a password-less account, and
"Make independent" and "make admin" work in one request on a member (with a
password beside them when the member has none, as every member starts and a kid
always is, or it is 400 admin_needs_password).
-
Assign to someone (
managed_by: <id>) makes the account a member of that owner with the given kind and access. Its own grants stay (the member rule is a union). Neither the owner's cap nor their switch applies: the admin decides. Moving a member to another owner works the same way. -
Joining another owner's household (an assign to an owner the account doesn't already have: a first assign, or a move) signs the person out of what someone else set up for them (Moving between households).
-
Assigning as a kid clears what a kid can't hold, as the owner's switch to kid does. A non-empty
passwordbeside it is400 kid_no_password;""is fine. -
Make independent (
managed_by: null) clears the three member columns: the account keeps only its direct grants, anddisplay_name. What it signs out is in Moving between households. A no-op on an account that isn't a member. -
A password reset ends the person's sessions. Replacing or clearing a password the account had ends their sessions exactly as a self-service change does, in the same transaction. Only the admin's own session survives, when the account is the admin's. A non-empty password an admin sets on their own account counts as their own choice (
auth.NoteOwnPassword), asPOST /auth/passworddoes. One another admin set for them does not.
Response 200: the updated user object (with the fields above).
| Status | Meaning |
|---|---|
400 | username_required (an empty username); admin must keep a password (admin_needs_password) / password too short (password_too_short); managed_by that isn't a positive id or null; code: "member_fields_required" (managed_by: <id> without both member_kind and member_access, or either of those without it); a kind or access that isn't one of the two values; code: "kid_no_password" (a non-empty password when the request leaves the account a kid: a kid with no household change in it, or an assign as a kid) |
404 | user not found (the account, or the owner named by managed_by) |
409 | username_taken (a rename to another account's name); would demote/disable the last enabled admin (last_admin); code: "member_not_admin" (making a member an admin, or assigning an admin as a member); code: "owner_is_member" (the owner is a member); code: "owner_is_demo" (the owner is a demo account); code: "member_is_demo" (the account being assigned is a demo account); code: "self_managed" (managed_by is the account itself); code: "has_members" (the account has members of its own) |
What user.update records: Audit log.
DELETE /api/v1/admin/users/{id}
Permanently deletes an account and all its durable state (sessions, auth
codes, progress, bookmarks, notes, history, listening sessions and daily
listening totals, share grants) via cascade; files on
disk are untouched. Response: 204 No Content.
The person's household members are deleted with them, in the
same transaction, except those named in keep_members, as comma-separated ids
(?keep_members=3,7), the parameter repeated (?keep_members=3&keep_members=7), or
both: every value counts. Each kept member becomes an ordinary account (the three
member columns cleared, so only their direct grants remain), as with Make
independent (audited on user.delete).
The checks answer in this order: an id that isn't a number, then a malformed
keep_members (400 invalid_keep_members), then a self-delete (400 cannot_delete_self), then the account (404), then, inside the transaction, the
last-admin rule and the keep list.
| Status | Meaning |
|---|---|
400 | self-delete refused (disable your own account instead) - cannot_delete_self; code: "invalid_keep_members" (a keep_members entry that isn't a positive id, or isn't one of this person's members: nothing is deleted) |
404 | user not found |
409 | last enabled admin - last_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 field | Type | Default | Notes |
|---|---|---|---|
label | string | "" | display label |
max_uses | int | 5 | explicit 0 = unlimited (negative values are clamped to 0) |
ttl_days | int | 1 | explicit 0 = never expires. The console's invite dialog asks for 7 unless changed |
Response 201, shown once:
{
"auth_code": "9M4K-P2TQ-WX7V-3RHD",
"invite_url": "https://books.example.com/connect#code=9M4K-P2TQ-WX7V-3RHD",
"max_uses": 5,
"expires_at": "2026-07-03T10:00:00Z"
}
max_uses is the device limit the invite was given (0 = unlimited) and
expires_at its expiry (RFC 3339, omitted when it never expires), so a client
shows the server's numbers rather than re-deriving them.
The code rides in the invite_url fragment, so it never reaches server
logs; the connect page auto-redeems it client-side.
404 if the user doesn't exist. 409 code: "kid_account" (not available for a kid's account) for a kid household member: a kid holds no invite, only their owner sets up
their devices (from the player). The refusal is read inside the minting transaction
(auth.refuseKid), so an owner's switch to kid can't slip between the check and the
invite.
POST /api/v1/admin/users/{id}/sign-out
The console's Sign out everywhere. It ends the person's sessions as a
password change does (the same tokens end and stay),
without changing the password. It keeps the admin's own session when the account
is the admin's. It also forgets the browsers they signed in from, so each one's next
password sign-in is announced as a new_device. API keys stay, revoked one at a time
with DELETE /admin/devices/{id}. No body.
Response 200, the number of the person's own sessions it ended (0 when there
were none; household members' sessions it also ended aren't counted):
{ "signed_out": 3 }
| Status | Meaning |
|---|---|
400 | invalid user id |
404 | user not found |
Audited as user.sign_out_everywhere with signed_out in the details
(Audit log).
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 lever for a leaked one, since
recovery codes are not listable; a password reset or
Sign out everywhere removes it too). No-op if none. 204 No Content.
POST /api/v1/admin/authcodes/{id}/rotate
Regenerates an existing invite's secret in place (the console's Rotate): the old
code dies, no new row is created, the use counter resets, and max_uses is
preserved with the expiry renewed for the invite's original window. In the same
transaction it retires the user's other still-redeemable invites, so the
rotated invite is their one active invite. Rotating an expired (or used-up)
invite revives it as that one. No body. Response 200: the same shape as
creation (auth_code, invite_url, max_uses, expires_at). 404 if the
invite doesn't exist. 409 code: "kid_account" when the invite's account is a kid's
(a used-up or expired invite kept as history from before the switch to kid can't be
revived into it).
DELETE /api/v1/admin/authcodes/{id}
Revokes (deletes) an issued invite immediately. 204 No Content.
GET /api/v1/admin/invites
Every account's invite codes in one list (the console's People > Invites page),
newest first, wrapped as { "invites": [ … ] }:
{
"invites": [
{
"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",
"user_id": 4,
"username": "sam"
}
]
}
Each entry is the same invite metadata as auth_codes in
GET /admin/users/{id} plus the account it pairs
(user_id, username). It never includes the code itself (only its hash
is stored) and never lists recovery codes. expires_at / redeemed_at are
omitted when unset; max_uses: 0 = unlimited. An empty server returns
{ "invites": [] }. 401 anonymous, 403 non-admin.
Admin: libraries & shares
All Admin.
GET /api/v1/admin/libraries
All libraries in display order, wrapped as { "libraries": [ … ] }, the
library object of GET /api/v1/libraries plus
admin-only fields:
{
"libraries": [
{ "id": 1, "name": "Audiobooks", "root": "/srv/audiobooks",
"kind": "books", "default_view": "hybrid", "sort_order": 0,
"book_count": 812, "available": true,
"scan": { "running": false, "total": 812, "done": 812, "indexed": 3,
"added": 1, "updated": 2, "moved": 0, "removed": 1 },
"scan_schedule": "every:6h",
"ignore_patterns": ["# publisher samples", "*.sample.mp3", "Extras/"],
"metadata_source": "tags",
"next_scan_at": "2026-10-04T15:00:00Z" }
]
}
book_count(int) - books indexed in the library.available(bool) - whether the root folder is reachable right now. It isfalsewhen the root is missing, unreadable, or doesn't answer within 2 seconds; when it is an empty folder while books are still indexed under it (what an unmounted network share looks like); or when the last scan stopped at the unavailable-root guard (a successful rescan clears that). A hung network mount can't stall the list: roots are probed in parallel, at most one probe per root runs at a time, each answer is cached for 15 seconds, and a probe already stuck past the timeout answers "not responding" at once for later requests.scan- the library's scan progress, the same object asGET /admin/libraries/{id}/scan. The console polls this list (every second while any library is scanning or waiting in the queue) instead of each library's scan endpoint, which remains available.scan_schedule(string) - the library's scan schedule:""(none),every:<N>h(N = 1, 3, 6, 12 or 24 hours after the last scan started) ordaily:HH:MM(server time zone). See Scheduled scans.ignore_patterns(string array,[]when none) - the library's ignore rules, one pattern per entry, comments (#lines) included.metadata_source(string) - where the library's books take their title, author, series and series position from first:"tags"(the default: the files' tags, with the path filling what they leave empty) or"path"(the folder layout,Author/Series/03 - Title, with the tags filling what it leaves empty). See Data model. Like the scan settings, it is not on the player'sGET /api/v1/libraries.next_scan_at(RFC 3339, UTC) - when the next scheduled scan is due; omitted without a schedule.
POST /api/v1/admin/libraries
Creates a library and queues its first scan in the
job queue (trigger manual; browsing via /fs
works immediately; the index fills in behind).
| Body field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | unique |
root | string | yes | server-local filesystem path (mount network shares first) |
kind | string | no | books (default) or podcasts; fixed after create |
default_view | string | no | defaults to "hybrid" |
scan_schedule | string | no | "" (default), every:<N>h or daily:HH:MM - see GET /admin/libraries. Stored in canonical form (every:06h is saved as every:6h) |
ignore_patterns | string array | no | ignore rules, one pattern per entry (an entry with line breaks is split into one pattern per line); lines are trimmed and blank ones dropped. At most 100 patterns of 200 bytes |
metadata_source | string | no | "tags" (default) or "path" - see GET /admin/libraries |
A new library goes last in the display order (sort_order one past the
highest, from 2.5.1). Response 201: the created library (the player's library object; the scan
settings are not echoed; read them from
GET /admin/libraries).
| Status | Meaning |
|---|---|
400 | name and root are required; invalid request; code: "invalid_schedule" (a schedule that isn't one of the forms above); code: "invalid_pattern" (too many patterns, one too long, one that matches nothing, or a malformed wildcard - the message names the line); code: "invalid_metadata_source" (a metadata_source other than "tags" or "path") |
409 | name already taken (code: "name_taken"); also name_taken when the server's config declares a library by this name at another folder (the next start would give it the declared folder; the library is deleted, say, and not back yet) |
Invalid kind is 400 invalid_kind. If either library is a podcast library,
equal, ancestor or descendant roots conflict (409 root_overlaps), using
cleaned absolute paths. This also applies when moving a books root over a
podcast root. Podcast libraries stay in this admin list regardless of the
player gate; book detection/export and admin book tools require books libraries
(409 not_book_library where applicable).
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, in the same enriched shape as
GET /admin/libraries (with book_count,
available, scan and the scan settings).
PATCH /api/v1/admin/libraries/{id}
Edits name, root, default_view, scan_schedule, ignore_patterns and/or
metadata_source (the body fields of create). An empty or omitted name,
root or default_view keeps its current value; an omitted scan_schedule,
ignore_patterns or metadata_source keeps it too, while "" / [] clears a
schedule or the ignore rules (sort_order is managed via /order).
Sending the existing kind is accepted; a different kind is
400 kind_immutable. Root changes enforce the same 409 root_overlaps rule
as create.
A library the server's config declares (config.yaml; no environment variable
declares one) keeps the name and
folder declared there, since every start upserts it by name
(launcher.syncLibraries) and the server never writes a library to
config.yaml. A different name or root on such a library is
409 config_library; the same values
resent write nothing, and its other settings stay editable. The console reads
the declared names from GET /admin/setup's config_libraries and locks the
fields. Renaming another library to a declared name at another folder is
409 name_taken.
A rescan is queued (trigger change) only when the root or the ignore rules
change, since those make the index stale. A rename, a new default view or a new
schedule doesn't rescan. Nor does a new metadata_source: every book of the
library is re-resolved from what the last scan stored, in the edit's own
transaction, before the response is sent, so no reader sees the new setting
with the old values. Admin edits and accepted community values stay on top.
Response 200: the updated library (without the scan settings, as for
create). When the edit queued a scan it adds job, the queued scan, as for
POST …/scan; otherwise job is omitted.
Errors are 400 (including invalid_schedule, invalid_pattern and
invalid_metadata_source), 404 and 409, as for create.
DELETE /api/v1/admin/libraries/{id}
Removes the library and everything indexed under it (books, files, chapters,
FTS rows). The cascade also removes every user's state keyed to it (progress,
bookmarks, notes, listening history and favourites), plus its folder overrides,
enrichment, metadata edits, custom covers and any share path rules pointing into
it. Its whole-library grant shares (whole_library_id = this library) are deleted
too, unless one also holds rules for another library, which it keeps granting. Audio files on disk are
untouched. 204 No Content.
A library the server's config declares (see
PATCH above) is 409 config_library (from
2.4.1). Every start upserts it by name, so it would come back empty, with
everyone's progress, bookmarks and notes in it gone. Remove it from
config.yaml (or the app embedding the server) and restart instead. The console
disables its Delete library... with the reason.
An id with no library is 404 not_found (library not found), with no audit row
(from 2.5.0; before, the delete answered 204 and recorded one).
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. On a folder with no audio
of its own whose audio is only in two or more disc folders directly in it,
"book" joins the discs into one book and the rescan carries their listening
state onto it (see Joined books); on
any other folder without audio it changes nothing. Clearing it splits the discs
out again.
Response 200: { "status": "override set", "path": "…", "mode": "collection" }.
400 for any other mode or a path over 4096 bytes (path is too long, before
the library is looked up); 404 library not found.
DELETE /api/v1/admin/libraries/{id}/folder-override
Clears the override (back to auto-detection) and rescans. ?path= required
(over 4096 bytes is 400 path is too long).
Response 200: { "status": "override cleared", "path": "…" }.
GET /api/v1/admin/libraries/{id}/export
Downloads the library's book list as a JSON file, in the envelope the
community metadata site (meta.audiosilo.app) imports
on its Watching page, so a user can mark which entries of a series they own.
Advertised by the export capability.
| Query param | Type | Default | Notes |
|---|---|---|---|
include | string | - | A comma list, for the admin's own scripts: paths adds each row's paths, tags its tags, description its description (see below); include=paths,tags,description asks for all three, and repeating the param (?include=paths&include=tags) counts too. Any other value is 400 include must be a comma list of "paths", "tags" and "description", so a typo never silently drops what was asked for |
copies | string | merged | merged writes copies of the same book as one row (see the per-book notes); separate writes one row per copy (see below). Any other value is 400 copies must be "merged" or "separate" |
Unlike every other endpoint this one answers with a file download rather than a plain JSON body:
| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
Content-Disposition | attachment; filename="audiosilo-<library-slug>-<YYYY-MM-DD>.json", with -private before .json when the export holds paths or tags (include=description alone keeps the shareable name) |
{
"format": "audiosilo-books",
"version": 1,
"source": "audiosilo-server",
"server_version": "1.4.2",
"library": { "id": 1, "name": "My Books" },
"exported_at": "2026-09-21T10:00:00Z",
"books": [
{
"title": "Die Trying",
"subtitle": "A Jack Reacher Novel",
"authors": ["Lee Child"],
"narrators": ["Dick Hill"],
"series": "Jack Reacher",
"series_position": "2",
"published": "1998-07-13",
"publisher": "Brilliance Audio",
"language": "English",
"abridged": false,
"genres": ["Thriller"],
"asin": "B002V0QK4C",
"isbn": "9780553505405",
"runtime_min": 612,
"chapters": 24
}
]
}
The file is meant to leave the server, so by default it carries
bibliographic facts only. It contains no path, size, codec, format or any
other filesystem detail, not even the library's root, and not the
book's tags, which are the owner's own notes ("Owned", "Read"). Genres are
facts about the book and stay. A regression test pins that, both ways: no path
or tag without its include, each with it.
With ?include=paths each row also carries paths, the path of every copy that
collapsed into the row (index order), so a script can tie it back to the books;
the envelope's library.id names the library they are in (one export is one
library, so no row repeats it). With ?include=tags each row carries its
tags:
{ "title": "Die Trying", "…": "the facts above", "tags": ["Owned"],
"paths": ["Lee Child/Jack Reacher/02 Die Trying", "Lee Child/Die Trying (MP3)"] }
With ?include=description each row carries its description. It is opt-in
for size rather than privacy: a description is long and usually a publisher's
blurb, so the file to share stays lean.
With ?copies=separate copies of the same book are not merged: each indexed
book is its own row with its own facts and (with include=tags /
include=paths) its own tags and a paths holding its own path alone, so a
script can act on one copy (move it, rewrite its tags) without the item
endpoint. A server from before copies ignores the param and answers merged
rows (it refuses include=description with a 400, though), so a script that
needs one copy per row checks that each row's paths holds one path:
{ "title": "Die Trying", "…": "this copy's facts", "tags": ["Owned"],
"paths": ["Lee Child/Jack Reacher/02 Die Trying"] }
{ "title": "Die Trying", "…": "that copy's facts", "tags": ["To check"],
"paths": ["Lee Child/Die Trying (MP3)"] }
The paths describe the server's folders (people's names, a share's layout) and
the tags are personal, so such a file is for the admin alone (hence the
-private filename): the console's Export with file paths and tags... asks
for include=paths,tags,description&copies=separate and warns not to attach it
to a public issue or upload it to the community site.
Per-book notes:
-
Every field except
titleis omitted when it is unknown or empty, so a sparsely-tagged library yields short entries rather than blank ones. -
authors/narratorsare lists. The index stores one string per book, so it is split only where it clearly holds several names: on;,&andandalways, and on a comma only when every resulting part still has at least two words. That keeps suffixed names such asAlexandre Dumas, pereand surname-first forms such asDumas, Alexandrewhole. -
series_positionis a string so half-positions survive:2for book two,2.5for a novella between books,1-3for an omnibus numbered as a range (the metadata site's importer reads it as one). Omitted when there is no position. -
series/series_positionare the book's main series.series_listis every series the book is in, the main one first when it has one, as[{ "name", "position" }](positionin the same string form, omitted when the book has none in that series). Present exactly when the player'sBook.series_listis, so a book in only its main series carries justseries. In a merged row the main series and its position come from one copy (a later copy only fills a missing position in the same series), andseries_listis every series any copy lists, the main one first, each once:{ "title": "The Colour of Magic", "authors": ["Terry Pratchett"],"series": "Discworld", "series_position": "1","series_list": [ { "name": "Discworld", "position": "1" },{ "name": "Rincewind", "position": "1" } ],"published": "1983-11-24" } -
runtime_minis the book's duration in whole minutes (rounded);chaptersis the number of indexed chapters. Both are omitted when unknown. -
asin/isbncome from the book's identifiers, including any a Bridge attached (PUT /bridge/libraries/{id}/enrichment). -
subtitle,published(YYYY,YYYY-MMorYYYY-MM-DD),publisher,language,abridged,genres, (withinclude=description)descriptionand (withinclude=tags)tagsare the effective values, as on the player'sBook(abridgedabsent while unknown;descriptionas/itemsends it). The metadata site's importer readssubtitle,publisher,languageandabridgedand ignores keys it doesn't know (genres,series_list,published,description,tags,paths), so the envelope'sversionstays1. -
A multi-file (folder) book is one entry. By default (
copies=merged) copies of the same book within the library also collapse to one entry, the same grouping search and "recently added" de-duplicate on. The entry takes each fact (published,descriptionandseries_listincluded) from whichever copy has it, and holds every copy's paths and tags.
404 { "error": "library not found" } for an unknown library; 403 for a
non-admin caller; 401 unauthenticated. The bearer token must be sent in the
Authorization header (the media-only ?token= fallback does not apply here).
curl -OJ -H "Authorization: Bearer $TOKEN" \
https://books.example.com/api/v1/admin/libraries/1/export
# For your own scripts: one row per copy, with paths, tags and descriptions
curl -OJ -H "Authorization: Bearer $TOKEN" \
"https://books.example.com/api/v1/admin/libraries/1/export?include=paths,tags,description&copies=separate"
POST /api/v1/admin/libraries/{id}/scan
Queues a rescan in the job queue (trigger
manual, started by the caller). Returns immediately: 202 Accepted with the
job that will scan the library: the waiting one if the library already waits,
or a new one queued behind a running scan of it:
{ "status": "scan started",
"job": { "id": 7, "kind": "scan", "library_id": 1, "library_name": "Audiobooks",
"trigger": "manual", "started_by": 1, "queued_at": "2026-10-04T09:12:44Z" } }
The job object is described under GET /admin/jobs.
404 library not found. (The job key is new; the status text is unchanged.)
POST /api/v1/admin/scan
Queues a scan of every library, in display order (trigger manual, started
by the caller); the queue runs them one at a time, with the same coalescing as
a single rescan. The console's "Check again" and "Rescan every library" are
this. No body. 202 Accepted: { "jobs": [ … ] }, one job per library (the
objects of GET /admin/jobs).
GET /api/v1/admin/libraries/{id}/scan
Progress of the library's (possibly running) scan:
{ "running": true, "queued": true, "total": 812, "done": 394, "indexed": 6,
"added": 2, "updated": 4, "moved": 1, "removed": 0 }
running- a scan of the library is running now.queued(omitted when false) - a scan of the library waits in the job queue: behind another library's scan, or to run again after the current one.total/done- books discovered / books checked so far (0until discovery finishes).added/updated/moved/removed- what the scan has changed so far;indexedisadded + updated(books written to the index, unchanged books are skipped).unavailable(omitted when false) - the last finished scan stopped at the unavailable-root guard, so nothing was pruned.
A scan an admin request queues (POST …/scan, creating a library, an edit
that changes the root or ignore rules, or setting or clearing a folder
override) reads as queued (or running) before that request
returns, so a status poll made right after it sees the scan.
GET /api/v1/admin/fs/dirs
The add-library folder picker: the subfolders of an absolute path on the
server's filesystem. ?path= is the folder to list; empty means the
filesystem root (/, or the working directory's drive on Windows).
{
"path": "/srv",
"parent": "/",
"dirs": [
{ "name": "audiobooks", "path": "/srv/audiobooks" },
{ "name": "Podcasts", "path": "/srv/Podcasts" }
]
}
- Folders only, never files: no sizes, owners or timestamps. Hidden (dot) folders are skipped; symlinks that resolve to folders are included.
- Sorted case-insensitively by name;
pathvalues are absolute, in the server's own path syntax, andpathitself is the cleaned request path. parentis omitted at a filesystem root.- At most 1,000 entries;
truncated: true(otherwise omitted) when there were more.
| Status | Meaning |
|---|---|
400 | path must be absolute (code: "path_not_absolute") - a relative path |
404 | folder not found or not readable (code: "folder_unreadable") - missing, not a folder, or unreadable by the server (the OS error is not echoed) |
401 / 403 | anonymous / non-admin |
Bounds rationale: an admin can already point a library at any folder and then browse it, so listing folder names reveals nothing new to that role.
GET /api/v1/admin/shares
All shares (with their path rules and who has them):
{
"shares": [
{
"id": 2,
"name": "Fantasy shelf",
"description": "Sam's corner",
"read_only": true,
"paths": [ { "library_id": 1, "path": "Brandon Sanderson" } ],
"member_ids": [4, 7]
},
{
"id": 5,
"name": "Library: Audiobooks",
"description": "Whole library",
"read_only": false,
"paths": [ { "library_id": 1, "path": "" } ],
"whole_library_id": 1,
"member_ids": [4]
}
]
}
A rule's path: "" means the whole library. member_ids lists the ids of the
users the share is granted to ([], never null, when none). The single-share
GET /admin/shares/{id} does not carry it.
whole_library_id (omitted for ordinary shares) marks the shares a
whole-library grant (POST /admin/library-access)
creates: the id of the library it grants whole. Clients list those as library
access rather than as named shares, by this field, not by the share's name or
rules (a share an admin fills with a whole library stays an ordinary share).
GET /admin/shares/{id} and the shares of
GET /admin/users/{id} carry it too. Migration 0015
added the column and backfilled the existing Library: <name> grant shares.
POST /api/v1/admin/shares
Creates a share, optionally with initial path rules (inserted atomically; a bad rule rolls the whole thing back).
| Body field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | unique |
description | string | no | |
read_only | bool | no | |
paths | array | no | [ { "library_id": 1, "path": "Brandon Sanderson" } ] |
Response 201: the full share (with paths). 409 name already taken
(code: "name_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
(name_taken).
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 field | Type | Required | Notes |
|---|---|---|---|
library_id | int | yes | |
path | string | no | "" = whole library |
POST also takes several rules at once, { "rules": [{ "library_id": 1, "path": "…" }, …] }
(1 to 1000), added in one transaction: all or none. The console uses it to add a
selection of books to a share.
Response: 204 No Content. 400 when a library_id is missing/zero (nothing is
added), or with code too_large for more than 1000 rules. 409 with code
library_grant on a whole-library grant (a share with whole_library_id): its
rules change only by granting or revoking the library
(POST /admin/library-access).
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.
The grant share is found by whole_library_id first, so it survives a library
rename. Failing that, an unmarked share the grant itself could have made is
reused as a fallback (and then marked). Such a share has the description
Whole library and is named Library: <name> (made before the column existed) or
Library: <name> (<library id>) (left by an interrupted grant).
Marked or not, a share is reused only when its one path rule is this library's
whole-library rule (""), or when it has no rules and nobody holds it. Anything
else is left exactly as it is, because adding the whole-library rule to it would
widen what its holders see or hand its other rules to the new user. That covers:
- an admin's own share that happens to be named
Library: <name>and covers a folder; - a grant since narrowed to a folder;
- a share that also covers another library;
- an empty share someone holds.
A share marked for a different library is never reused either, because that would hand this library to everyone who has that one.
When no share qualifies, a new grant share is made. If Library: <name> is
already taken (by a share that didn't qualify, or by another library's grant
after a rename), it is named Library: <name> (<library id>) instead. From
2.5.0, when that is taken too, it is named Library: <name> (<library id>) 2,
3 and so on (grantName, up to 20 names); the name is internal, since clients
show the library's own name. From 2.5.0 the new share is created with its
whole-library rule and its whole_library_id mark in one transaction
(createWholeLibraryShare), so an interrupted grant never leaves a share without
its rule, and the transaction looks for a grant share again first, so first grants
sent together share one share. Errors: 404 for an unknown library; 409 with
code: name_taken only if every one of those names is taken.
Admin: catalog
All Admin. The queries behind the admin console's Library and Book screens
(see Built-in web UI). They
see every library (no share scoping) and address books by
(library_id, path) like everything else. The internal book id only ever
travels inside an opaque cursor.
Metadata edits are overrides stored in the database, keyed by path: no file on disk is ever modified. Each edit is a lock: a rescan re-reads the file but re-applies the edit in the same transaction, so the edited value is what players, search and the export see until it is reverted. See Data model for how the layering works.
The overridable fields, with the rules a value must pass. Setting a field to
"" is an edit too (the field is held blank, whatever the scan finds); to go
back to the scanned value, revert it instead.
| Field | Rule |
|---|---|
title | required (cannot be emptied), at most 500 characters, no control characters |
subtitle, author, narrator, series, publisher | at most 500 characters, no control characters |
language | a language name or code (English, en-GB), at most 64 characters, no control characters. Refused: must be a language name or code, at most 64 characters |
abridged | "true", "false" or "" (unknown; stored as NULL), any case. Refused: must be "true", "false" or empty |
genres, tags | a JSON list of strings (["Fantasy","Epic"]), "" for none. Each label is trimmed with its inner spacing collapsed, blanks dropped, and kept once by its key (lowercased, spacing collapsed), the first spelling winning; at most 64 characters each, no control characters, at most 50 labels. Stored as canonical JSON without HTML escaping, "" for an empty list. Refused: must be a list of names, a label must be at most 64 characters, with no control characters, too many labels (at most 50). To change a list without replacing it, use add / remove (bulk edit) |
series_index | a number from 0 to 100000 (2, 2.5), or a range for an omnibus: two numbers apart by a hyphen or an en dash, spaces allowed (1-3, 1 - 3, 1–3), with 0 < start < end ≤ 100000 and end - start ≤ 100. Stored in its shortest form (2.5, 1-3), "" for none. Refused: invalid_override with the reason must be a number from 0 to 100000, or a range like 1-3 |
more_series | the other series the book is in beyond series: a JSON list of {"name": string, "position": number, "position_end"?: number} (position 0 = none, else 0 to 100000; position_end a range's end, by the series_index rules), at most 20; names trimmed and checked like series, blank names dropped, each name kept once (the first). Stored as canonical JSON ([{"name":"City Watch","position":1}], [{"name":"Box Sets","position":1,"position_end":3}]), "" for none. Only an edit or a community match sets it; tags give one series |
published | YYYY, YYYY-MM or YYYY-MM-DD, and a real date |
description | at most 20000 characters; line breaks and tabs kept, other control characters refused |
asin | 10 letters or digits (uppercased) |
isbn | an ISBN-10 or ISBN-13; hyphens and spaces are stripped, x uppercased |
Values are trimmed. The player's book JSON carries the effective published,
subtitle, publisher, language, abridged, genres and tags on every
book and description on /item only.
Every field reports a source: path (a scanned value equal to what the
folder and file names yield), tag (any other scanned value, from the
file's embedded tags), abs (from an Audiobookshelf metadata.json in the
book's folder), edited (typed by an admin), community (accepted from
a community-metadata match, or an ASIN/ISBN attached through
enrichment), or "" when the field has
no value. The scan does not store sources; path vs tag is worked out when
the book is read, by the same rule for every book, so a tag that happens to
match the folder name reads as path.
GET /api/v1/admin/books
A page of books across every library, filtered, searched and sorted, keyset-paginated over the chosen ordering (never OFFSET, so a deep page costs what the first does).
| Query param | Type | Default | Notes |
|---|---|---|---|
library_id | int | - | one library; a non-positive or non-integer id is 400 |
q | string | - | full-text search over title/author/series/narrator/genres/tags, the same prefix matching as /search; punctuation-only input filters nothing |
author · series · narrator | string | - | the effective value: series the main series, or one its more_series names, in any spelling (case, accents, punctuation and spacing aside, as /series groups them); author and narrator the whole credit or one of the people it names, exactly (as the player's list) |
genre · tag | string | - | a genre (tag) the book has, one value each, case-insensitive with spacing collapsed, as on the player's list |
format | string, repeatable | - | ?format=m4b&format=mp3; at most 50 values |
codec | string, repeatable | - | ffprobe codec name (aac, mp3, …); at most 50 values |
direct_playable | true|false | - | the book's codec plays in browsers (an unknown codec counts as playable) and no file of it is in a container no browser plays (.mka), judged on the stored files: an MP3-in-.mka book the stream remuxes still counts as false here |
has_cover | true|false | - | a sibling image, embedded art or a custom cover |
has_chapters | true|false | - | more than one chapter (every single-part book has one) |
matched | true|false | - | the book has an ASIN or an ISBN, or (from 2.6.0) a recorded community match without one (a book_matches row, see community_match) |
edited | true|false | - | the book has a metadata edit, or a chapter-title edit on a chapter it still has (one on an index a rescan dropped is dormant and doesn't count) |
min_duration · max_duration | number (seconds) | - | inclusive bounds; 0 means no bound |
added_after | date | - | inclusive lower bound on added_at: YYYY-MM-DD (used as is) or an RFC 3339 time (any offset; converted to UTC before comparing) |
added_before | date | - | exclusive upper bound, same formats |
issue | string | - | one Health issue kind's books: scan_error, suspect, split_discs (one row per split book: its first disc), no_cover, unmatched, unknown_asin, series_spellings, no_chapters, detailed_chapters or transcode (not duplicate, which comes as groups from /admin/issues/duplicates). Books an admin ignored for that kind are left out. Any other value is 400 unknown issue |
issue_ignored | true|false | - | with issue, list only the books an admin ignored for it; without issue it is 400 issue_ignored needs an issue |
sort | string | title | title | author | surname | series | narrator | published | added | duration | size |
order | string | asc | asc | desc |
limit | int | 60 | ≤ 0 or > 200 falls back to 60 |
cursor | string | - | next_cursor from the previous page |
Text sorts are case-insensitive. The author, surname, series, narrator
and published sorts put books with that field blank last in either
direction (order=desc reverses the named books, not where the blanks go). They
break ties as follows:
author: by author, then series, series position and title.surname: by the author's surname first (the first name of a co-credit; "Ursula K. Le Guin" and "Le Guin, Ursula K." under L, a "Jr." ignored), then author as written, series, position and title.series: by series, position, then title.narrator: by narrator, then title.published: oldest first by the book's release date, then series, position and title, with a bare year before that year's dates. The release date is itspublishedvalue, else the date its file tags give (usually the recording's, never shown on the wire). Withseries=,sort=seriesorders by each book's position in that series (its main one or one of itsmore_series), unnumbered last, then title.
A filter that can't be parsed (has_cover=yes, min_duration=-1,
added_after=last week, an unknown sort or order) is a 400 with a message
naming the parameter. It is never silently dropped, which would show an
unfiltered list as if it were filtered. More than 50 format or codec values
is a 400 too (too many format or codec values).
{
"books": [
{
"library_id": 1,
"library_name": "Audiobooks",
"path": "Andy Weir/The Martian",
"is_folder": true,
"title": "The Martian",
"subtitle": "",
"author": "Andy Weir",
"narrator": "R. C. Bray",
"authors": ["Andy Weir"],
"narrators": ["R. C. Bray"],
"series": "",
"series_index": 0,
"series_list": [],
"published": "2011",
"publisher": "Podium Audio",
"language": "English",
"abridged": null,
"genres": ["Science Fiction"],
"tags": ["Owned"],
"duration": 38040.5,
"format": "m4b",
"codec": "aac",
"direct_playable": true,
"size": 304112640,
"added_at": "2026-05-14T09:12:44Z",
"has_cover": true,
"custom_cover": false,
"cover_color": { "bg": "#1d2a44", "accent": "#e8a33c", "on_accent": "#000000" },
"cover_version": "3f9a1c07be",
"chapter_count": 27,
"file_count": 1,
"asin": "B00B5HZGUG",
"isbn": "",
"matched": true,
"edited": true,
"edited_fields": ["narrator"],
"scan_error": "probe_failed",
"scan_error_file": "Andy Weir/The Martian/The Martian.m4b",
"scan_error_detail": "[mov,mp4,m4a,3gp,3g2,mj2] moov atom not found; Invalid data found when processing input",
"chapters_source": "files",
"chapters_check": "refine"
}
],
"next_cursor": "eyJzIjoidGl0bGUiLCJ2IjpbIlRoZSBNYXJ0aWFuIl0sImlkIjo0MTJ9"
}
-
Every field is always present (empty string /
0/falsewhen unknown), exceptcover_color,cover_version, the four Health fields below,chapters_check,chapter_choiceandebook, which are omitted when empty or0.pathis the book path (the player'srel_path). -
custom_cover- an admin uploaded a cover;has_coverincludes it. -
series_list- every series the book is in,[{"name", "position", "position_end"?}]: its mainseriesfirst (atseries_index), then itsmore_series, each name once;[]for none.series_index_end(omitted otherwise) and an entry'sposition_endare a range's end, as on the player'sBook. -
cover_color- the colours read from the cover art, as on the player'sBook(bg, plusaccentandon_accentwhen the art has a vibrant colour that reads on it). Omitted until the server has read the current art's colour, from a thumbnail or in the background colour pass. The book page (GET /admin/libraries/{id}/book) carries it too. -
cover_version- from 2.5.1, the book'scover_version, the same value the player'sBookhas: a short token that moves when the cover art does, so a client can key cached thumbnails by it. Omitted for a book with no art identity yet. The duplicates groups' copies and the book page'sbookcarry it too. -
matched- the book has an ASIN or ISBN, or a recorded community match (a store listing with neither, from 2.6.0): the same rule as thematchedfilter (catalog.matchedExpr). The community chapter check finds a book by its ASIN or ISBN alone, so checkasin/isbnfor it, notmatched./metaalso reads the recorded match, so it can answer amatchedbook with neither identifier. -
abridged-true,false, ornullwhile nothing says (always present, unlike the player'sBook, which omits it).genresandtagsare[]for none. -
edited_fields- the fields with an override (an edit or an accepted community value),[]when none;editedis also true for a chapter-title edit alone. -
file_countis1for a single-file book. -
scan_error- the first problem reading the book's files on its last indexing:unreadable: a file couldn't be opened, or its read was cut short on a share that stopped answering (reading it timed out, ortoo many earlier reads are stuck (on this or another library), so it wasn't tried);empty_file: 0 bytes;probe_failed: ffprobe couldn't read it.
scan_error_fileis the library-relative file.scan_error_detailis the OS's, the scan's or ffprobe's own message, and may be empty forempty_file. An ffprobe message keeps every distinct line ffprobe printed, joined with"; ", at most 300 bytes, with FFmpeg's context address (@ 0x...) dropped. When a read of an already-indexed book is cut short, the book keeps its stored length, chapters and names, and only the problem is recorded. A recorded problem is re-checked on every scan, so it clears once the file reads (fixed permissions, a share back online). See Read problems. -
suspect_parts- set (to 2 or more) when a folder book's parts look like that many separate books (see Folders that may hold several books). -
chapters_source- where the book's chapters come from now:files(the scan's own),community(a community chapter list fitted onto the audio) orgenerated(the chapters a Bridge found in the narration). -
chapter_choice- the admin's choice of chapter source on the book page,filesorcommunity; omitted when it is automatic. Chapters found in the narration stand in only with no choice, so the console's Healthno_chaptersrows offer no Detect chapters for a book with one (and achapters.detectjob for it is409 chapters_chosen). -
ebook- from 2.5.0, the book's ebook as on the player'sBook:{"path", "size"}, omitted when the book has none. It comes frombooks.ebook_pathand, since0058,books.ebook_size, recorded with it. The book page'sbookcarries it too, for its Files card and hero chip and the Read-along card's gate. The book page'sbookalso carriesreadalong, as on the player's/item; a list row never does. -
chapters_check- the status of the book's last community chapter check:fill,titles,refine,restructure,same,length_mismatch,structure_mismatch,crosses_files,no_matchorunavailable; omitted when it was never checked. The console'sno_chaptersrows use it to say why community chapters weren't used. -
The cursor names the ordering it was minted for: replaying it with another
sortororder(or a malformed one) is400 invalid cursor. Changing the filters between pages is not detected, so restart from the first page when they change.next_cursoris omitted on the last page.
GET /api/v1/admin/books/facets
The facet panel for the same filter parameters as
GET /admin/books (sort, order, limit and
cursor don't apply). Each dimension is counted with every other filter
applied but its own, so a facet shows what each choice would give; total is
the count with all filters applied.
{
"total": 812,
"libraries": [ { "library_id": 1, "count": 744 }, { "library_id": 2, "count": 68 } ],
"formats": [ { "value": "m4b", "count": 701 }, { "value": "mp3", "count": 111 } ],
"codecs": [ { "value": "aac", "count": 690 }, { "value": "mp3", "count": 111 }, { "value": "", "count": 11 } ],
"direct_playable": { "yes": 806, "no": 6 },
"has_cover": { "yes": 790, "no": 22 },
"has_chapters": { "yes": 650, "no": 162 },
"matched": { "yes": 401, "no": 411 },
"edited": { "yes": 37, "no": 775 },
"genres": [ { "value": "Fantasy", "count": 214 }, { "value": "Science Fiction", "count": 98 } ],
"tags": [ { "value": "Owned", "count": 40 } ]
}
formats and codecs are ordered by count, largest first (a "" value is a
book whose codec was never probed). genres and tags count books by label key
(case and spacing aside), each named by the spelling most of its books use (ties
alphabetical), largest first with ties alphabetical, and hold at most the 100
most common; [] when no book has one. Each is counted with every filter but its
own (genre for genres, tag for tags). The q, author, series,
narrator, duration and added-date filters apply to every dimension. 400 for an
unparseable filter, as for the list.
POST /api/v1/admin/books/bulk
Applies the same field edit to many books in one transaction: every book is
edited or none is. Merging two spellings of an author is a bulk set of
author over their books.
| Body field | Type | Required | Notes |
|---|---|---|---|
books | array | yes | [ { "library_id": 1, "path": "Andy Weir/Artemis" } ], at most 1000 |
set | object | one of set / revert / add / remove / chapter_source | field → value, for the fields above |
revert | array | one of these | field names to put back to what the scan found |
add | object | one of these | label field (genres, tags) → labels to add to each book's own effective list: {"tags": ["Owned", "Book club"]}. A label the book already has (any case) is skipped. Cleaned and bounded as an edit of the field is |
remove | object | one of these | label field → labels to take out of each book's list, matched case-insensitively with spacing collapsed: {"tags": ["read"]} removes Read |
chapter_source | string | one of these | where every book's chapters come from: "community", "files" or "auto" (as on PATCH …/book). The console's Health "Use detailed chapters" sends "community" |
source | string | no | "edited" (default) or "community" |
{
"books": [
{ "library_id": 1, "path": "Sanderson, Brandon/Elantris" },
{ "library_id": 1, "path": "Brandon Sanderson/Warbreaker" }
],
"set": { "author": "Brandon Sanderson" }
}
Response 200: { "updated": 2 }, the number of distinct books edited
(entries naming the same library and path, after the path is cleaned, count
once).
| Status | Meaning |
|---|---|
400 | books is required; nothing to change; invalid request (malformed body or an unknown key); code: "too_large" for more than 1000 books; code: "invalid_override" with field for a value that fails its rule (field: "chapter_source" for a source other than the three), or with field: "chapters" for any chapter edit (chapter titles can only be edited one book at a time - chapter indexes are per book), or with field: "match" for a match (a community match is recorded one book at a time, an unlink included; from 2.6.0) |
404 | code: "book_not_found" - one of the paths is not an indexed book (nothing was changed) |
A field cannot be both set and reverted in one request (invalid_override).
Reverting a field that has no edit is a no-op.
add and remove exist because a bulk set of tags would give every book the
same list, wiping each one's own. For each book they merge into its current
effective list: the labels removed, then the new ones appended in the order given.
The result is written as an edited (or community) override of that field, only
where the list changes. Adding a tag a book already has leaves it untouched, with
no override written.
Each is refused with 400 code: "invalid_override" and field set when it:
- names a field other than
genresortags(only genres and tags take add); - names a field the request also sets or reverts
(
cannot be set or reverted and changed by add at once); - names one label in both (
a label cannot be added and removed at once); - would leave a book with more than 50 labels (
too many labels (at most 50)). The single-bookPATCH …/booktakesaddandremovetoo, by the same rules.
{
"books": [
{ "library_id": 1, "path": "Andy Weir/The Martian" },
{ "library_id": 1, "path": "Andy Weir/Artemis" }
],
"add": { "tags": ["Owned"] },
"remove": { "tags": ["To buy"] }
}
A bulk edit of series (without more_series) makes the
series swap one book at a time: only the
books whose more_series lists the new series swap; the others simply get the
new main series. One book whose old main series can't be listed refuses the whole
request: 400 code: "invalid_override" with field: "series", its error
ending with that book's path in parentheses.
GET /api/v1/admin/authors · GET /api/v1/admin/narrators
The distinct authors (or narrators) with their book counts and total duration,
plus spellings that look like the same person. ?library_id= narrows to one
library (all libraries when absent; a non-positive or non-integer id is 400).
{
"authors": [
{ "name": "Brandon Sanderson", "books": 14, "duration": 1204112.6 },
{ "name": "Sanderson, Brandon", "books": 2, "duration": 140221.0 }
],
"merge_suggestions": [
{ "names": ["Brandon Sanderson", "Sanderson, Brandon"], "suggested": "Brandon Sanderson", "books": 16, "other_books": 2 }
],
"unknown": 3
}
The narrators route uses the key narrators instead of authors.
- A name is one person: a credit counts for each person it names, so a
Michael Kramer & Kate Readingbook counts once for Michael Kramer and once for Kate Reading, and thenarratorfilter finds it by either. The split is deliberately shy:;,&andandalways split, a comma only when every part is a full name (two words or more), soAlexandre Dumas, pereandSanderson, Brandonstay one person. A name given twice in a credit counts once. Names are exact (kate readingis another spelling, for a merge) and sort case-insensitively. unknowncounts books whose credit names nobody (blank, or only joiners); they are not listed.- A merge suggestion groups names that compare equal once
Surname, Givenis turned round (only when the part before the comma is one word, soAlexandre Dumas, perestays whole) and case, spacing and punctuation are ignored (soJ.R.R. TolkienandJ. R. R. Tolkiengroup). Suggestions are over whole credits, since a merge rewrites the whole field of every book whose credit is one of the other spellings (never a co-credit that merely names one):other_booksis how many books that is,booksevery spelling's together. Letters of every script are kept, so names in non-Latin scripts get suggestions too and two different ones never group by accident.suggestedis the spelling with the most books (ties: theGiven Surnameform overSurname, Given, then alphabetical);booksis the group's total. The server never merges on its own; applying a suggestion is a bulk edit.
GET /api/v1/admin/series
Every series with the books the server holds in it. ?library_id= as for
authors.
{
"series": [
{ "name": "Mistborn", "author": "Brandon Sanderson", "books": 3,
"duration": 284110.2, "positions": [1, 2, 3], "extra_books": 0 }
]
}
author is the most common author among the series' books; positions lists
the distinct non-zero series positions held, ascending (so a client can mark the
gaps; a range holds every whole number inside it, as on the player's /series). A book counts in every series it is in, its main series and each of its
more_series, at its position there; extra_books is how many of books are in
the series through more_series. Spellings of one series are one entry, named as
on the player's /series; the console loads each
card's books with series=<name>&sort=series, which finds every spelling. Sorted
case-insensitively by name. Books with no series are not counted.
POST /api/v1/admin/books/works
Which community work each of many books is. This is how the console's Series cards
place an owned book on a community series rail by identity, whatever its
series_index says. Each book's ASIN (preferred) or ISBN is resolved to the id
of the work it belongs to, the same ids as a rail's series[].works[].id from
GET /libraries/{id}/meta. Requires the metadata
capability. Display only: no book is changed.
| Body field | Type | Required | Notes |
|---|---|---|---|
books | array | yes | [ { "library_id": 1, "path": "Andy Weir/The Martian" } ], 1 to 100 entries |
{
"books": [
{ "library_id": 1, "path": "Andy Weir/The Martian" },
{ "library_id": 1, "path": "Andy Weir/Artemis" }
]
}
Response 200, one entry per requested book, in request order (library_id
and path echo the request):
{
"works": [
{ "library_id": 1, "path": "Andy Weir/The Martian", "work_id": "the-martian", "failed": false },
{ "library_id": 1, "path": "Andy Weir/Artemis", "work_id": "", "failed": false }
]
}
work_idis""when the book has no ASIN or ISBN, the upstream has no match for it, its lookup failed, or no book is indexed at the path (an unknownlibrary_idtoo).failedistrueonly when that book's own lookup failed or ran out of time, so asking again later may resolve it. A clean "no match" is neverfailed, so a client asks again only about the failed books.- Each distinct identifier is answered from the first of: the book's cached
enrichment (a book a player has opened carries its work id there, a cached
"no match" too), the server's own cache of these lookups (the
l:key space, with the enrichment TTLs), then one upstream lookup. Concurrent requests for the same identifier share one lookup, upstream lookups from console requests are bounded, and the whole batch runs under the enrichment compose deadline, so lookups still queued when it fires come backfailed. A failure is never cached here. - These lookups never slow down or age a player's
/meta: enrichment never reads thel:key space nor waits for a console lookup.
| Status | Meaning |
|---|---|
400 | books is required (an empty list); invalid request (malformed body or an unknown key); code: "too_large" for more than 100 books |
401 / 403 | anonymous / non-admin |
404 | code: "metadata_off" - community metadata is turned off |
500 | could not load books - a database failure (an upstream failure is never an error, it is failed: true) |
GET /api/v1/admin/libraries/{id}/book
Everything the console's book page shows about one book. ?path= required (the
book path).
{
"book": { "library_id": 1, "path": "Andy Weir/The Martian", "title": "The Martian: Classroom Edition", "…": "the same object as a GET /admin/books row" },
"description": "Six days ago, astronaut Mark Watney became one of the first people to walk on Mars.",
"fields": {
"title": { "value": "The Martian: Classroom Edition", "source": "edited", "scanned": "The Martian",
"locked": true, "edited_by": "admin", "edited_at": "2026-10-03T09:30:12.48Z" },
"author": { "value": "Andy Weir", "source": "tag", "scanned": "Andy Weir", "locked": false },
"series_index": { "value": "", "source": "", "scanned": "", "locked": false },
"asin": { "value": "B00B5HZGUG", "source": "community", "scanned": "", "locked": false }
},
"chapters": [
{ "index": 0, "title": "Sol 6", "scanned_title": "Chapter 1", "edited": true,
"file_path": "Andy Weir/The Martian/The Martian.m4b", "start": 0, "end": 1843.2, "book_offset": 0 }
],
"files": [
{ "path": "Andy Weir/The Martian/The Martian.m4b", "seq": 0, "duration": 38040.5,
"format": "m4b", "codec": "aac", "size": 304112640, "bitrate": 63955 }
],
"listeners": [
{ "user_id": 4, "username": "sam", "position": 12043.6, "duration": 38040.5,
"finished": false, "updated_at": "2026-10-01T19:42:07Z",
"started_at": "2026-09-12T20:03:10Z", "finished_at": null }
],
"shares": [
{ "share_id": 2, "name": "Sci-fi shelf", "path": "Andy Weir" },
{ "share_id": 5, "name": "Library: Audiobooks", "path": "", "whole_library_id": 1 }
],
"folder": { "path": "Andy Weir/The Martian", "override": "" },
"indexed_at": "2026-10-03T09:12:01.33Z",
"chapter_source": "files",
"chapter_choice": "",
"community_chapters": {
"status": "refine",
"work_id": "the-martian",
"recording_id": "the-martian-1",
"detail": {
"local_duration": 38040.5, "community_duration": 38052.1,
"ratio": 0.9997, "worst": 0.9981,
"local_chapters": 27, "community_chapters": 41,
"anchors": 26, "snapped": 14, "approximate": 0,
"omitted": ["Preview: Artemis"]
},
"checked_at": "2026-10-03T09:20:44.10Z"
},
"community_checking": false,
"match_query": "The Martian: Classroom Edition Andy Weir",
"community_match": { "work_id": "the-martian", "recording_id": "the-martian-1", "how": "accepted",
"matched_at": "2026-10-03T09:19:57Z",
"web_url": "https://meta.audiosilo.app/work?id=the-martian" },
"generated_description": { "text": "Mark Watney, a botanist on the third crewed mission to Mars, ...",
"generated_at": "2026-10-16T09:31:02Z" }
}
-
fieldscarries all sixteen overridable fields, in the order of the table above (the example shows four). Each has the effectivevalue, itssource, thescannedvalue (what the scan found, the revert target),locked(an admin edit holds it), and for an editedited_by(the username; omitted once that account is deleted) andedited_at.series_indexis a string here ("2.5","1-3"for a range,""for none),abridged"true"/"false"/"", andgenres/tagstheir canonical JSON list ("[\"Owned\"]",""for none). -
chapters- every chapter with its effectivetitle, thescanned_titleand whether an edit renames it. -
files- each audio file (a single-file book lists itself), with its owncodecand a derivedbitratein bits per second (0when the duration is unknown). -
listeners- every user's progress on the path, most recent first, with the start and finish dates ofGET /admin/users/{id}/progress(started_at/finished_at,nullwhen not known). -
shares- each share whose rules include the path, with the rule that does (path: ""= the whole library). -
folder- the folder whose detection decides the book's shape (the book's own folder, or the folder a single-file book sits in;""= the library root) and its folder-detectionoverride("book","collection"or""). -
chapter_source- wherechapterscome from now:"files"(the scan's own),"community"(the community list below, fitted onto the audio) or"generated"(the chapters a Bridge found in the narration, itschapters.json; see Generated chapters). -
chapter_choice- the admin's choice of source:"files","community", or""when it is automatic (the community's chapters are used only for afill: a book with no chapters of its own; chapters found in the narration only with no choice, when the community's don't apply). It is never"generated". It is not a metadata edit, so it is not infieldsand doesn't make the bookedited. -
generated_chapters_fit- present andtruewhen the book has a storedchapters.jsonthat fits its files as they are now, so it would stand in with no choice whenever the community's don't apply: always whilechapter_sourceis"generated"; otherwise it is checked only while a choice is made (the community's own fit is not weighed, so with acommunitychoice it can betruewhile removing the choice would keep the community's). Over afileschoice the console's Chapters card then offers Use automatic with a note that found chapters are kept out. Omitted otherwise. -
community_chapters- the book's last community chapter check,nullwhen it has none (a book with no ASIN or ISBN is never checked):status- a fit (fill,titles,refine,restructure,same), a reason it failed (length_mismatch: another edition;structure_mismatch: the book's own chapters don't line up;crosses_files: a community chapter runs from one file into the next),no_match(no community recording for the ASIN/ISBN) orunavailable(the recording has no chapter list).work_id,recording_id- the community recording checked against; omitted forno_match.detail- what the fit found (omitted forno_matchandunavailable):- the two timelines' lengths (
local_duration,community_duration, seconds); ratio, the length-weighted median of local over community length across the anchored stretches, andworst, the stretch furthest from 1. Both are omitted when unmeasured;- the two chapter counts (
community_chaptersas fitted, afteromitted); anchors: the book's boundaries pinned to community ones;snapped: placed boundaries moved onto a pause;approximate: ones that couldn't be, and may be a few seconds off;- when they apply:
omitted[](community chapter titles not in this copy),title_diffs[]({index, current, community}, fortitles) andstraddle({title, at, from, to}, forcrosses_files).straddleis the chapter a file boundary falls inside, the boundary's place on the book's timeline, and the files either side.
- the two timelines' lengths (
checked_at- when the check was recorded (or last renewed: a recheck that finds the same list for the same audio only moves it). The fitted list itself is not sent; once used, it is the book'schapters.stale- present andtruewhen the book has changed since the check (other audio, other identifiers): it is not used until the book is checked again.
-
community_checking- a check of this book is running now (POST …/book/community-chapters, or the background pass); poll the page until it isfalse. -
community_check_failed- present andtruewhen the book's last check failed (the community service didn't answer, or the check ran out of time):community_chaptersis then the check before it. -
match_query- the text the console's match dialog opens its search box with (meta.SearchPrefill). Always present (""for a book with nothing to say). It is the book's title and author, with these exceptions:- When the tags look swapped or junk, or the title is a whole
Author; Series; Book NNleaf (an untagged book's), the folders' reading of the path (metadata.FromPathLayout) replaces them. - Swapped means the title is the author folder's name, or the author, cleaned of
series and numbering, is the path's title. A tag that matches its own path
fact is never swapped (
Dune/Duneby Frank Herbert). - A junk title (
Unknown,Untitled,Track 01,03) or a junk author (Unknown,Various Artists) is replaced on its own. - A fact the path doesn't give keeps the tag's value.
- A path with no title (
Stormlight Archive/03, orBruno Miller; Dark Road; Book 01-05) gives the series and its position or range instead (Dark Road 1-5; the series alone forBook XX).
It only fills the box. The match itself always sends the book's own facts, and
qis what the admin then searches for. - When the tags look swapped or junk, or the title is a whole
-
community_match- the book's recorded community match, from 2.6.0 (thebook_matchesrow; see the data model). Omitted when the book has none. There is no backfill, so a book matched before 2.6.0 has none until it is matched again. Its fields:work_idandrecording_id(omitted for none): what an accept or a match run's apply took, or the recording a book was added from LibriVox as;how:"auto"for a run's confident item applied unasked,"accepted"for the match dialog, a review item the admin included, or a book added from LibriVox. A refresh that heals a merged work keeps it;matched_at;web_url: the work's page on the community site, omitted when no service is configured.
It is recorded whether or not the match gave the book an ASIN or ISBN. For a store listing with neither, it is what makes the book
matched. The console's Community card shows it, with Unlink, whenever it is present (from 2.7.0; in 2.6.0 only for a book with no ASIN or ISBN). -
generated_description- the description the Bridge generated for the book (its checked.audiosilo/description.json), withgenerated_atthe timebridge.json'sdescriptionobject gives (omitted when unknown). Omitted when the book has none. It is read only when the book's attachment signature names a description, and never the characters or recaps. The console's Community card shows it in its Description part.
| Status | Meaning |
|---|---|
400 | invalid library id; path is required (missing, or a path that cleans away to nothing) |
404 | library not found; code: "book_not_found" - no book is indexed at that path |
PATCH /api/v1/admin/libraries/{id}/book
Sets or reverts metadata edits (and chapter titles) on one book, then returns
the updated book page, the same body as
GET …/book. ?path= required. Everything in
one request is applied in one transaction.
| Body field | Type | Notes |
|---|---|---|
set | object | field → value, for the fields in the table above |
revert | array | field names to put back to what the scan found |
add · remove | object | label field (genres, tags) → labels to add to, or take out of, the book's own list, as on the bulk edit (same rules and refusals) |
source | string | "edited" (default) or "community" (accepted from a match) - recorded on every field this request sets |
chapters.set | object | chapter index (as a string key) → new title; a title cannot be empty (revert it instead), at most 500 characters. The rename is stored against that chapter's file and start, not its index, so it stays on the same chapter if a rescan inserts or drops others |
chapters.revert | array | chapter indexes to put back to the scanned title |
chapter_source | string | where the book's chapters come from: "community" (the fitted community list, when the last check fitted), "files" (the scan's own) or "auto" (remove the choice: community only for a fill, else chapters a Bridge found in the narration when they fit). Stored as a path-keyed chapter_choices row, so it survives rescans and moves with the book; switching back restores the files' chapters exactly, renames included. "generated" is not a choice: "files" turns found chapters off and "auto" brings them back |
match | object | from 2.6.0: { "work_id", "recording_id"?, "asin"?, "isbn"? }, the community match the edit was taken from. Needs source: "community", and may come alone (no set). { "work_id": "" } unlinks the book. One book at a time: the bulk edit refuses it. More under the table |
The match field. The server records match as the book's
community_match (how accepted) in the edit's
transaction. The console's dialog sends it:
- with every accept of ticked fields;
- alone (after any ticked cover) for a book with no ASIN or ISBN;
- after a ticked cover when the pick is the book's recorded match (work and recording). With nothing ticked the dialog's accept is off.
A cover-only accept of any other book sends none. From 2.7.0, asin and isbn
are the identifiers the match offered, ticked or not ("" or absent for none).
They are stored as the fields store theirs, so a
refresh never fills one back. An unlink ignores them.
Unlinking (from 2.6.0). match: { "work_id": "" }, from any edit (no
source needed), removes the book's recorded match, every source: "community"
value and its community cover (as clearing the community
matches does for a library, before the rest
of the edit applies), so it is matched: false again unless it has an ASIN or
ISBN of its own. An admin's own edits and uploads stay. There is no undo.
Only an explicitly empty match.work_id unlinks. Both ids lose leading and
trailing whitespace and other invisible characters (from 2.7.0: format characters
such as U+200B, U+FEFF and U+2060, variation selectors, default-ignorables such
as U+3164, and U+2800); interior ones are kept. A nonempty work_id that holds
nothing else is 400 invalid_override with field: "match"; nothing in the
request is written. A recording_id that holds nothing else is stored as none.
Unlink returns 409 match_run_busy only while a run that covers the book's
library (or every library) is applying (from 2.7.0). In 2.6.0 it did so for any run that was
matching or applying. The message is "a match run is applying to this library;
stop it or wait for it to finish before unlinking".
- A run that is only
matching, or one over another library, doesn't block it. Every apply checks each item against the book as it is then, and a community cover is written only to the book it was planned for (cover_superseded, see the items). - The status check and clear share the edit's writer transaction, so a refused Unlink also refuses any other edits in the request. Ordinary field edits remain allowed.
- After a stop request, Unlink stays blocked until the apply's workers finish, including cover writes.
- A stuck run (see the statuses) is settled first and
the Unlink tried once more, so a stuck run never blocks it. A failed settle
still answers
409.
{
"set": { "title": "The Martian: Classroom Edition", "published": "2011" },
"revert": ["narrator"],
"chapters": { "set": { "0": "Sol 6" }, "revert": [3] }
}
A revert restores the value from what the last scan stored, with no rescan and no disk access. Reverting an ASIN/ISBN falls back to any enrichment for it.
Making an other series the main one swaps them. When an edit sets series to a
series the book's more_series lists, in any spelling (case, accents, punctuation
and spacing aside), and does not set or revert more_series itself, the server
swaps the two:
- The old main series, at its old
series_index, takes that entry's place inmore_series. A book with no main series just drops the entry, and another entry already naming the old main series gives way. series_indexbecomes the entry's position, unless the edit sets or revertsseries_indexitself.- A range moves with it both ways. A main series at
1-3becomes an entry withposition_end3, and an entry with one becomesseries_index"1-3". - Reverting
seriesswaps back the same way when the series it returns to is listed.
The swapped more_series and series_index are written as the edit's own
overrides, so they read as edited and can be reverted like any other edit. The
exception is a value equal to what the book has without an edit, which goes back
to following the scan instead. For series_index that holds only when the new
main series is the series the file's number belongs to. So swapping and reverting
series leaves the number read off the file again, as it was.
- An edit that sets
more_seriesnever swaps, and neither does a community edit (source: "community"), because a match lays out the series itself. - If the old main series can't be listed (a name too long for a list entry, or a
position out of range), the edit is refused with
400invalid_overrideandfield: "series". - The console's Book page drafts the same swap before saving, so the fields and the
save dialog show it, and its save sends the drafted
more_seriesandseries_indexitself. As that edit namesmore_series, the server swaps nothing and writes the values as edits, even one equal to the file's. Revertingseriesafterwards is an ordinary revert, so it swaps back as above.
| Status | Meaning |
|---|---|
200 | the updated book page |
400 | nothing to change (empty body); invalid request (malformed JSON or an unknown key); code: "invalid_override" with a field key for an edit the server refuses - see below |
404 | library not found; code: "book_not_found" |
409 | code: "match_run_busy" - Unlink while a run covering the book's library is applying; the whole request is refused |
{ "error": "asin: must be 10 letters or digits", "code": "invalid_override", "field": "asin" }
invalid_override covers:
- an unknown field (
fieldnames it, e.g.cover_path); - a value that fails its rule;
- a field both set and reverted;
- a series swap whose old main series can't be listed (
field: "series"); - a
sourceother than the two above (field: "source"); - a
chapter_sourceother than the three above (field: "chapter_source"); - chapter problems (
field: "chapters"): a chapter the book doesn't have, an empty title, one index both renamed and reverted; - a
matchthe server refuses (field: "match"): a nonempty work id withoutsource: "community", a whitespace-only work id, amatchwith nowork_id(absent or null), an id over 200 characters or holding a control character, or from 2.7.0 amatch.asinormatch.isbnits field would refuse.
An unknown key inside match is invalid request.
An edit is audited as book.edit (chapter_source in its details when
sent, and match, the work id, "" for an unlink). It also wakes the community chapter checks,
so a newly set ASIN or ISBN is checked soon after.
POST /api/v1/admin/libraries/{id}/book/community-chapters
Checks the book against its community recording's chapter list now, in the
background, and returns the book page (the same body as
GET …/book) with community_checking: true. ?path= required. A long book's pause search takes a while, so poll the
book page until community_checking is false; the check's outcome is then in
community_chapters, and chapters / chapter_source reflect it (a fill
is used at once unless the admin chose the files' chapters). A book already
being checked is not checked twice. Not audited: it changes nothing an admin
chose (picking a source is a PATCH).
| Status | Meaning |
|---|---|
200 | the book page, community_checking: true |
400 | invalid library id; path is required |
401 / 403 | anonymous / non-admin |
404 | code: "metadata_off" - community metadata is turned off; library not found; code: "book_not_found" - no book is indexed at that path |
GET /api/v1/admin/libraries/{id}/book/match
Community works a book might be, from the community metadata service, for the
console's match dialog. Requires the metadata capability.
?path= required.
| Query param | Type | Notes |
|---|---|---|
q | string | free text, matched alongside the book's own facts; at most 300 characters |
asin · isbn | string | look an identifier up directly, at most 20 characters each; normalized first (an ASIN uppercased, an ISBN without hyphens and spaces) |
The server asks the community service's structured match,
GET /api/v1/works/match, with the
book's facts as separate guesses, since tags and folder names are each often
wrong:
- what the book's library path says (
metadata.ReadPathLayout, layout only): the top folder as anauthorguess, the folder holding the book as aseriesguess, and the book's folder or file name as atitleguess, sent as named (metaserve reads numbering such asSharpe - 08 -and takes the position from it). Disc and track folders (CD1,Track 01) are dropped first, and a plain-number leaf (Stormlight Archive/03) is the series' volume rather than a title; - the book's tagged title, author and series (the tagged series only when it names another series than the folder), the tagged position (only when no different series folder goes up with it, since one position applies to every series guess), and its runtime;
language: the book's, else its language tag (meta.NormalizeLanguage: the first of several, a BCP-47 tag's primary subtag, a placeholder such asundorN/Aread as none), else, from 2.6.0, its library's (below), else none. Never the marketplace's:metadata.regionnames a store, not what language a library is in. A library's language is derived, with no setting (matchrun.LibraryLanguage): the one at least 90% (LanguageMinShare) of its books stating a language agree on, at least 20 (LanguageMinBooks) of them, counting every book of the library by its own language else its tag's (catalog.LanguageCounts), with spellings folded to one ISO 639-1 code (English,eng,en;Multilingualsays none). A wrong guess would score the book's own work as another language's, hence the high bar;- up to 4
narrator: the narrator credit's names (names.Split), less a name that is the book's author (mostly a composer tag filled with the author, which says nothing of the recording); q, and the ASIN/ISBN: the typed ones, or the book's own when nothing was typed, or, with neither of its own, its hints from an Audiobookshelf import and its tags (matchrun.QueryFor, the order). metaserve looks them up itself.
An older metaserve ignores language and narrator.
Each value is cut to 256 bytes, as metaserve reads it. A typed asin/isbn
with no q only looks that identifier up. A book with nothing to match on
(no title, author, series or usable identifier) gets an empty answer without a
request. At most 6 results are expanded into full works with their
recordings. Results are not cached (an admin action, so the fan-out is bounded
instead: work fetches share the same concurrency limit as GET /meta/work). The
series rules read each candidate series' record once (at most 16
per request, within 5 seconds, none for a work with no language and one series),
and those facts are cached.
Older community service. A metaserve that predates works/match answers it
as a missing route (a 404, since it reads match as a work id; a 405; or a
200 without results). The server then runs the previous lookup and
works/search (the typed text, else the tagged title cleaned of its series name
and edition fluff plus the author), concurrently, scored as described below, and
remembers the missing route for 15 minutes before trying works/match
again. A 503 (over metaserve's match budget) or any other 5xx from
works/match is an outage, answered 502, never a fallback.
{
"candidates": [
{
"work_id": "the-martian",
"title": "The Martian",
"authors": [ { "id": "andy-weir", "name": "Andy Weir" } ],
"language": "en",
"first_published": "2011",
"description": "Stranded.",
"series": [],
"cover_url": "https://meta.audiosilo.app/covers/the-martian.jpg",
"web_url": "https://meta.audiosilo.app/work?id=the-martian",
"recordings": [
{
"id": "rec1",
"narrators": [ { "id": "r-c-bray", "name": "R. C. Bray" } ],
"runtime_min": 634,
"release_date": "2013-03-22",
"publisher": "Podium Audio",
"asins": ["B00B5HZGUG"],
"asin_refs": [
{ "region": "uk", "asin": "B00B5HZGUG" },
{ "region": "us", "asin": "B00B5HZGUG" }
],
"asin_region": "uk",
"isbns": ["9780553418026"],
"series": []
}
],
"recording_id": "rec1",
"default_recording_id": "rec1",
"score": 100,
"reasons": { "identifier": "asin" }
}
]
}
-
Sorted by
score(0-100), best first. Fromworks/matchit is metaserve's score: 100 for an identifier hit, otherwise the better of its structured-facts score and its typed-text score.reasons(optional) passes metaserve's account through unchanged, each field present only when the request let it be judged:titleandtext(similarity, 0-1),author(full,surnameornone),series(position,name,conflictornone),runtime(the relative difference of the closest recording,0.02= 2%),identifier(asinorisbn),narrator(full,surnameornone, sent narrators only) andlanguage(sameorother, when both are known).recording_idis the recording an identifier named. At one score an identifier hit comes first. -
On the fallback search an identifier hit scores 100 (with
reasons.identifier) and names the recording it resolved to inrecording_id; a search hit has noreasons, and its score weighs title agreement (55), author (30) and runtime (15: within 3% of a recording's runtime counts fully, within 10% half); a fact the book or the work lacks is left out rather than counted as a mismatch. The title is compared both as tagged and cleaned of series name and edition fluff, whichever fits better (only the book's side is cleaned, so a work's own "(Dramatized Adaptation)" still tells it apart). An author credit counts as a full match when it folds equal to one of the work's authors or contains every word of a multi-word name (soBrandon Sanderson, Mary Robinette Kowalmatches both); otherwise the word overlap counts. Comparisons keep letters of every script. -
serieslists the series a book of the work is in whatever its recording ({ "id", "name", "position" }, each once, at its first position), by the series rules: no alternate reading orders, translations, other-language series or editions. Each recording'sseriesis the list for a book that is that recording, its main series first: an edition named for it ("Harry Potter (Full-Cast Editions)" for the full-cast recording) in front of the work's series. The console's dialog offers the chosen recording's list, the book's own series first when the work is in it.subtitle,language,first_published,description,cover_url,recording_idand the recordings' optional fields are omitted when empty;asins/isbnsare always arrays. -
A recording's
asinsare its distinct ASINs best first for this server: the preferred marketplace's (metadata.region), then the US store's, then the rest in the service's order (by region), soasins[0]is the one to attach.asin_refskeeps each with its Audible marketplace (us,uk,ca,au,de,fr,es,it,jp,in,br), as the service lists them; a publisher-direct ASIN sold under one id in several stores appears once per region.asin_regionnames the store ofasins[0]. -
default_recording_idis the recording the book most likely is (meta.DefaultRecording). The console's dialog and a bulk run both start from it. The server picks:- the recording an identifier named;
- else, among the recordings one of the book's narrators reads that fit its
length (
meta.PlausibleRuntimeOff; all of them when none does), the one whose runtime is closest to the book's. A recording selling in the preferred marketplace wins a tie.
An ASIN an identifier hit was found by leads its marketplace's in
asins, so a book the store lists under two keeps its tags' when that store is the preferred one.Dramatizations. A plain book never takes a dramatization this way, with these exceptions:
- an identifier or one of its narrators names it;
- from 2.6.0, the work holds only dramatizations and one is within max(1%, 2 minutes) of the book's length, which says the book is that production (see the contract).
A book is not plain when its title, series, folders, typed text or narrator credit says it is a dramatization (
GraphicAudio,full cast,dramatized). From 2.6.0 its author credit can say so too (Michael J. Sullivan - Graphic Audio), or a genre that names a dramatized form (Radio Drama,Audio-Drama,Audio Play,Hörspiel;meta.MatchQuery.statesDramatization). When a work holds only dramatizations of other lengths, this field is absent anddramatization_only: trueis set, so the dialog preselects no recording.The length check in a match run (from 2.6.0). A match run then checks the length (
meta.FitRecording); a refresh or a repick never does, because its identifier is the book's own. The check applies when the recording is more than 10% (meta.PlausibleRuntimeOff) off the book's length, none of the book's narrators reads it, and another recording of the same work is within 10%. The run then proposes the recording this rule picks among those,autowhen it has an ASIN or ISBN (an identifier hit whose length now fits). A hint, from the tags or an Audiobookshelf import, can name another edition of the right work.- A narrator reading the named recording keeps it:
autowithin 20%, else held aslength_differs(see the outcomes). - A plain book never moves onto a dramatization the rule above refuses.
- The dialog keeps the identifier's recording.
-
No hits is
200 { "candidates": [] }.
cover_url is an image on its own host (Audible's CDN, Open Library), which
the console's CSP doesn't load, so the console shows it through
POST /admin/meta/covers.
Accepting a candidate takes up to two existing writes: the fields you take
from it, the ASIN and ISBN included, with
PATCH …/book and source: "community",
and, when the cover is taken too, keeping it as the book's custom cover with
PUT …/cover/community. To match
many books at once, see Bulk community matching.
| Status | Meaning |
|---|---|
400 | query too long; invalid library id; path is required |
404 | code: "metadata_off" - community metadata is turned off; library not found; code: "book_not_found" |
502 | metadata service unavailable - the community service failed (including a 503 or 5xx from works/match) and no candidate could be returned. Partial failures still return what was found: on the fallback, if the identifier lookup or the text search fails but the other leg yields candidates, those are returned, and a candidate whose work fails to load is left out. When nothing is returned, any failure along the way (either leg, or loading a hit's work) is a 502, not an empty list - the failed leg may well have found the book |
Bulk community matching
The console's Match automatically (Health > Not matched). A match run
matches many books against the community service in the background, exactly as
…/book/match matches one: the same facts,
and the preferred marketplace's ASIN. It differs in three ways: it asks for only
the best two works per book, reads the details of the first alone, and asks six
books at a time (see
What a run asks of the community service).
A run only looks. It records each book's best candidate and what it offers for
the admin to review, and nothing changes until the run is applied. Applying
writes community overrides (as PATCH …/book
with source: "community" does) and, for a book with no cover, the community
cover (as PUT …/cover/community),
so every change is reversible.
From 2.6.0 a match run's apply also records each book's match (its
community_match, as the dialog's match
does), under every scope, whenever the proposal's work or recording isn't the
book's already. how is auto for a confident item and accepted for an included
review item. A refresh only heals a match the book already has (a refresh by the
recorded match, below; it keeps the how), and a repick records none.
Admin only. Every endpoint answers 404 code: "metadata_off" while community
metadata is off. One run works at a time (server-wide), and the newest 10 runs are
kept, with their books.
A run is one of three kinds:
match: the books not matched. That is no ASIN, no ISBN and, from 2.6.0, no recorded community match: the Health page's "Not matched", less those ignored there.repick: the books whose ASIN is a community override, never one from an Audiobookshelfmetadata.json, an admin's edit or an enrichment write. Each is looked up, and when its recording now sells in the preferred marketplace under another ASIN, that is offered.refresh: every matched book.- A book with an ASIN or ISBN, whoever set it, is looked up by its own identifier first, the ISBN when the ASIN finds nothing.
- From 2.6.0, a book with neither, or whose identifiers are all unknown and none is an admin edit, is looked up by its recorded community match when present (see the refresh by the recorded match, below).
- The record the lookup names is the book. It is
autowhatever its score, unless its recording's length is more than 10% from the book's, which makes itreview. From 2.6.0 the limit is 20% when the book's own narrator credit names a reader of that recording (matchrun.readsRecording). A placeholder credit never counts, as the lookup sends no narrator. So a book a run applied on its narrator's say-so stays up to date. - Unknown identifiers with an admin edit or no recorded match are
nonewithdetail: "identifier_unknown", never a fuzzy match by title.
A refresh fills in, or with overwrite replaces, the details an identifier alone
didn't bring (an enrichment write, an ids apply). It also cleans up the series an
earlier match wrote (see the overwrite scope below). It never changes an
identifier the book has.
Only books a scope would change are recorded. A book already up to date leaves no
item, as a repick records only books with a new ASIN. Each refresh proposal found
by an identifier names it (proposal.found_by, "asin:<ASIN>" or
"isbn:<ISBN>"), as each repick proposal names the ASIN it looked up (from 2.4.0;
a repick recorded earlier has none). Applying it changes nothing on a book that no
longer has that identifier because it was matched again since, by hand.
A refresh by the recorded match (from 2.6.0). A book with neither an ASIN
nor an ISBN is looked up by its community_match.
- The fallback. This is also the fallback when the identifier lookup would
otherwise give
identifier_unknown: the ASIN, and then the ISBN when present, found no record, and neither nonempty identifier is an admin edit (source: "edited"). Tag, scan and community identifiers still allow the fallback. An admin-edited identifier keepsnone/identifier_unknownand writes nothing. A service error or a successful lookup never triggers the fallback, even when the identifier resolves to a different work. - The lookup uses
meta.MatchQuery.WorkID: the service'sworks/{id}, with a retired slug's301followed online and in mirror mode. found_byis"work:<work id>"or"work:<work id>/<recording id>". A fallback after unknown identifiers appends";asin:<ASIN>;isbn:<ISBN>", including empty values. Applying it then requires both identifiers to remain unchanged, as well as the recorded work and recording. Neither identifier may have become an admin edit since the lookup, even with the same value.- The recording. The recorded recording is kept while the work still lists it,
else the likeliest by length, as for an identifier. It is classified as an
identifier's record is:
auto, orreviewwhen its length is off. - A missing work. A work the community no longer has (
404) isnonewithdetail: "work_unknown", and the book keeps its match. - What it fills. The proposal may fill an ASIN or ISBN the recording gained
since, dropped once the book has one of its own. A merged work's new slug is a
change by itself (the change's
match), healing the recorded match. A book matched by its own identifier with no recorded match never gets one from a refresh. - Applying it is a no-op once the book was matched again since, to another work or recording.
- Offered identifiers (from 2.7.0). The proposal also drops the identifier the recorded match offered when the admin unticked or reverted it, so a book with nothing else to change is not listed. Another value the recording gained still fills, and applying it records it as offered. A match recorded without them (before 2.7.0, or by an older console) fills as before.
Each book's outcome:
| Outcome | Meaning |
|---|---|
auto | Confident (matchrun.Classify): the best candidate scores 90 or more, at least 10 ahead of the next, its recording has an ASIN or ISBN (from 2.6.0, one with neither on firmer ground: see below the table), and, when both lengths are known, the recording's runtime is within 10% of the book's (the closest recording of a work can still be another edition), or, from 2.6.0, within 20% (meta.NarratedRuntimeOff) when the book's narrator reads it (matchrun.narratorReads: reasons.narrator is full and the proposed recording's own credit names one of the book's narrators, meta.ReadBy; a placeholder credit such as Full Cast, GraphicAudio, Various or Unknown never counts, meta.PersonNarrator, as it agrees with every recording carrying it). An identifier hit (reasons.identifier: in a match run always a hint, an Audiobookshelf import's or the tags') needs no lead, unless the next candidate is one too (review; a guard, as metaserve names one work per request today). From 2.7.0 the book must also agree with it (matchrun.identifierAgrees): reasons.title, when judged, at least 0.65 (IdentifierTitleAgree), or at least 0.9 (IdentifierTitleSame) when reasons.series is conflict, as the same title at another position is the series numbered another way (a conflict with no title judged is held). Else review. So a wrong hinted ASIN naming a book with a similar title within 10% of the length is still auto, and so, from 2.6.0, is one naming another edition of the right work when a recording of it within 10% takes its place (meta.FitRecording, the dialog's rule). Applied unless the admin leaves it out. A repick's offers are all auto. |
review | A candidate that isn't confident. Applied only if the admin puts it in. One held because its recording's length is more than 10% from the book's, or 20% when the book's narrator reads it (a match run's, as for auto, or a refresh's), carries detail: "length_differs" (from 2.4.0), worded with the item's book.duration and the proposal's runtime_min. One whose work holds only dramatizations the book doesn't state it is, none of them the book's by length (default_recording_id, from 2.6.0), carries detail: "dramatization_only" (DetailDramatizationOnly) and no recording, so nothing is attached unasked. One found by a hinted ASIN or ISBN (its tags' or an Audiobookshelf import's) the book doesn't agree with (see auto, from 2.7.0) carries detail: "identifier_disagrees" (DetailIdentifierDisagrees), ahead of dramatization_only and length_differs when either would also apply. |
none | No candidate; or, in a match run, a best candidate scoring under 60 (matchrun.ReviewFloor) on metaserve's scale, with detail: "weak_match" (DetailWeakMatch), its score kept for the review's wording and its proposal empty (from 2.6.0). A candidate without reasons (an older metaserve's fallback search, on another scale) stays review. The match dialog has no such floor: it lists its candidates (up to 6) however low they scored. |
error | The service failed for this book (detail: "metadata_unavailable"), from 2.7.0 also when the best candidate's work can't be read for any reason but a 404 (a gone work gives way to the next). Ten in a row stop the run. |
A store listing with no ASIN or ISBN (from 2.6.0, LibraForge E6,
matchrun.anchorFits). Nothing else names its edition, so it is auto only when
the best candidate scores 90 or more and a plain 10 points ahead (no
identifier-hit lead), metaserve's reasons.author is full and
reasons.series is position, and both lengths are known with the recording
within 2% (AnchorRuntimeOff) of the book's (no narrator widening). From
2.7.0 an identifier hit there must also agree with the book, as under auto. Any
other is review. Applying it records the match (how: "auto"), which is what
makes the book matched. On the LibraForge benchmark this moved 186 books from
review to confident, none of them wrong.
Run statuses. A run moves matching → ready → applying → applied:
ready: waiting for review. From 2.7.0 this is also a run the service's outage stopped early, witherror: "metadata_unavailable".cancelled: stopped while matching.failed: stopped by itself.errorismetadata_offwhen community metadata was turned off, orinternal; before 2.7.0 alsometadata_unavailable.interrupted: the server stopped mid-match. A run stopped mid-apply goes back toreadyat the next start, its applied books marked.
From 2.7.0 a stuck run is settled the same way: matching becomes interrupted,
and applying becomes ready. A stuck run is one left marked matching or
applying with nothing working on it, because its finishing write failed. The
server settles it when a run starts or applies, on
Stop, and on an Unlink it blocks.
What a run asks of the community service
The service is shared and nearly all of a run's time online is round trips, so from
2.6.0 a run (internal/matchrun/runner.go, internal/meta/pacer.go) asks for as few
as it can.
- One round trip per book. A
matchrun sendsworks/match?limit=2&expand=work(MatchQuery.BestOnly, which the dialog never sets), and the answer'sworkandseries_factsare all it reads of the best candidate (bestCandidates). Of the next it reads only the score and whether an identifier found it (Classify), so that one is never fetched. - Fallback. A best result with no
work(a metaserve before v0.24.0, one out of budget, a read that failed) costs theworks/{id}and series reads for that result alone, and nothing at all when it scores underReviewFloorand no identifier found it, as it would be dropped anyway. That is never a failure, and its outcomes are the same. A best that is gone (404) gives way to the next. From 2.7.0 one that can't be read for any other reason is an outage at once, and the book anerroritem, so a weaker next candidate never stands in for it. A metaserve with noworks/matchtakes the search path under Older community service, on its own scale. Measured through a proxy adding 80 ms to a local metaserve, over 4,044 books, a run took 3.44 requests a book before this, 1.00 withexpandand 2.37 on the fetch path. - 6 workers, about 60 requests a second.
workersonly fill the round trips:maxRequestsPerSecond(60, against metaserve's per-client 100) is what holds a run under the limit, whichever path it takes. Each run has ameta.Pacerin its context, and the remote leg's transport (pacedTransport) spaces its requests evenly with no saved-up burst, each retry and redirect taking a slot. So only the run is paced: the dialog and a listener's lookups beside it are not, and a run takes noworkSemslot from them. A local copy (metadata.mode: mirror) answers before that transport, so it takes no slot; there the workers are simply how many of the server's cores a run uses. A request that fails over to the remote service does take one. - A
429or503is waited out. Inside a run both wait forRetry-After(1 s when it gives none, capped at 10 s) and ask again, up to 3 times, never starting a wait the book's 15-second budget would cut short. Each wait is logged atINFOwith the run's total, and the run's end logs the total once more. Outside a run a503is an outage at once (the dialog's502), as someone is waiting on the answer. See configuration. - A run stops after 10 consecutive errors. A book still failing after that is an
erroritem, andmaxFailStreak(workers+ 4 = 10) consecutive ones stop the run. It stands above the workers because one moment's failure meets them all at once (a lost connection takes every stream on it). Any book the service answers for resets the count. From 2.7.0 a run stopped this way endsreadywitherror: "metadata_unavailable": the books it answered are reviewed and applied as usual, and itserroritems and the books it never reached stay unmatched, so the next run takes them. Before 2.7.0 it endedfailed, which can't be applied (409 match_run_not_ready).
refresh and repick runs share the workers, the pacing, the waits and the stop.
They use identifier or recorded-work lookups (lookup, works/{id}, and for a
refresh the series facts), never works/match, so they keep their several round
trips per book.
GET /api/v1/admin/match-runs
The kept runs, newest first, and the marketplace a new run would prefer.
{
"runs": [
{
"id": 3,
"library_id": null,
"mode": "match",
"region": "uk",
"status": "ready",
"started_by": 1,
"started_by_name": "admin",
"started_at": "2026-10-07T21:00:00Z",
"finished_at": "2026-10-07T21:04:12Z",
"total": 570,
"done": 570,
"scope": "",
"apply_total": 0,
"apply_done": 0,
"applied_at": null,
"counts": { "auto": 412, "pending": 412, "review": 98, "none": 58, "error": 2,
"applied": 0, "skipped": 0, "failed": 0 }
}
],
"region": "uk"
}
library_id null = every library (library_name names one). total/done
count the books matched, apply_total/apply_done the last apply (from 2.7.0
apply_total can shrink during a refresh's
apply). counts
are the items by outcome, pending the auto ones not applied yet, and
applied/skipped/failed what applying did. scope is the last apply's.
error (omitted when empty) is why a run stopped by itself: on a failed run, and
from 2.7.0 metadata_unavailable on a run an outage stopped early (ready, and
kept through applying and applied; additive, as error was only on failed
runs before).
POST /api/v1/admin/match-runs
{"library_id"?: 2, "mode"?: "match" | "repick" | "refresh"} (omitted, or no body at all:
every library, a match run). A book of a library deleted while the run works
is left out. Starts the run in the background and answers 202 with it (as
in the list). The admin's console polls
GET …/match-runs/{id} while it works.
Audited as book.match_run, book.asin_repick or book.match_refresh (run,
books, region).
| Status | Meaning |
|---|---|
400 | invalid request; mode must be "match", "repick" or "refresh"; code: "no_region" - a repick with no metadata.region set |
404 | library not found |
409 | code: "match_run_busy" - a run is matching or applying |
GET /api/v1/admin/match-runs/{id}
One run, as in the list (404 when it isn't kept).
GET /api/v1/admin/match-runs/{id}/items
A page of the run's books, the best match first (score descending, a tie in
the order they were recorded): ?outcome=auto|review|none|error (omitted: all),
?after=<item id> (the previous page's next_after; the page reads on from that
item's score, so every page follows one order), ?limit= (1-200, default 50).
{
"items": [
{
"id": 41,
"run_id": 3,
"library_id": 1,
"path": "Andy Weir/The Martian",
"outcome": "auto",
"score": 96,
"runner_up": 61,
"proposal": {
"work_id": "the-martian",
"recording_id": "bray",
"title": "The Martian",
"authors": "Andy Weir",
"narrators": "R. C. Bray",
"runtime_min": 634,
"web_url": "https://meta.audiosilo.app/work?id=the-martian",
"cover_url": "https://m.media-amazon.com/images/I/x.jpg",
"asin_region": "uk",
"values": { "title": "The Martian", "author": "Andy Weir", "narrator": "R. C. Bray",
"published": "2011", "asin": "B0UK000001" }
},
"applied": "",
"book": { "title": "The Martian (Unabridged)", "author": "Andy Weir", "duration": 38052.4 },
"changes": {
"ids": { "fields": ["asin"], "cover": false },
"fill": { "fields": ["narrator", "published", "asin"], "cover": true },
"overwrite": { "fields": ["title", "narrator", "published", "asin"], "cover": true }
}
}
],
"next_after": 0
}
-
proposal.valuesis what the best candidate offers for each overridable field, in stored form (a value an override would refuse is left out), as the match dialog's compare table would offer it: its chosen recording is the identifier's, else the closest runtime (a recording selling in the preferred marketplace wins a tie), and its ASIN is that recording's best for this server, named byasin_region. -
bookis the book as it is now, with itsdurationin seconds (omitted when unknown);gone: truewhen nothing is indexed at the path any more. -
matched_since: true(a match run only) when the book is matched now (an ASIN or ISBN, or from 2.6.0 a recorded community match) and wasn't when the run looked: matched since, by hand or otherwise. Itschangesare empty, and an apply leaves it alone under any scope. -
changesis what applying would write under each scope the run's mode takes: fields in display order, set or reverted, and whether the cover is taken. The server works it out against the book now by the same rules the apply uses, so the review can't disagree with it.- Which scopes an item carries: a
matchrun's items carryids,fillandoverwrite. Arefreshrun's carryfillandoverwrite, because it writes no identifier the book has. Arepickrun's carry onlyids(its ASIN, whatever the scope). - From 2.7.0 a
refreshitem the run recorded with nothing to change under a scope shows no change under it (no fields, no cover), whatever the book would take now. An apply under that scope never takes it (seePOST …/apply). - From 2.6.0 a change also carries
match: true(omitted otherwise) when applying records the book's community match: a new match, or a refresh healing a merged work. The console lists it as "the community match" when no ASIN or ISBN is written beside it. - When a scope changes the book's series, its change also carries
series: the book's seriesfrom(now) andto(after), each[{ "name", "position", "position_end"? }]with the main series first (position0 = none;position_enda range's end). So a series it removes is visible:
"overwrite": {"fields": ["series", "more_series"],"cover": false,"series": {"from": [ { "name": "Harry Potter (Full-Cast Editions)", "position": 2 },{ "name": "Harry Potter (Narrated by Stephen Fry)", "position": 2 } ],"to": [ { "name": "Harry Potter", "position": 2 } ]}} - Which scopes an item carries: a
-
appliedis what applying did:"": not yet;applied;skipped, withdetailnothing_to_change,matched_sinceorbook_gone;failed, withedit_failedorcover_failed.
An
applieditem can carrycover_failedwhen its fields went on but its cover couldn't be fetched. A match item that an apply's stop cut short after its fields went in stays""withdetailcover_pending, so the next apply takes its cover. If the book was unlinked since, that apply writes nothing (nothing_to_change).From 2.7.0
cover_supersededmeans the cover was not written because the book changed since the apply planned it: a cover uploaded or found by a rescan, another match accepted, an ASIN or ISBN edited, or an Unlink. The server checks this before the fetch. The write checks again, in its own transaction, that the book still has no cover, the recorded match and the ASIN and ISBN the apply planned. It is final:appliedwhen the item's fields went in, elseskipped.
POST /api/v1/admin/match-runs/{id}/apply
{"scope": "ids" | "fill" | "overwrite", "exclude"?: [item ids], "include"?: [item ids]}. Applies the run's auto items, less exclude, plus the
include items (review ones the admin chose), each only while not yet
applied, in the background; answers 202 with the run (applying). What a
book gets, under each scope:
| Scope | Writes |
|---|---|
ids | The ASIN and ISBN, where the book has none (and, from 2.6.0, the recorded match). |
fill | Also every field the book has empty, and the community cover when it has no cover (a checked book with none, and no custom cover). Replaces only one thing the book has: a title that is the community's plus clutter (matchrun.tidyTitle, below). |
overwrite | Also every field whose value differs, as accepting the dialog's default ticks does; the cover only when it has none. The book's series are replaced by the community's for its recording (the series rules): a series, series_index or more_series value the community wrote earlier that it no longer offers (a series it deleted or merged, an edition or a translation) is reverted to what the files say. |
Title tidy (matchrun.tidyTitle). It applies under fill only, to any applied
item of a match or refresh run. The review lists it, as changes comes from the
same Plan. The community title goes over the book's when the book's title isn't
an admin edit and, with the community title's words taken out, what is left of it
is only:
- the community subtitle;
- the series names, the community's and the book's own, as
match.SeriesFormsspells them; clutterWords: book, vol, volume, no, unabridged, novel, edition, series, audiobook, store labels such as litrpg and fantasy, and the joining words of, the, a, an;- numbers that are the positions (the community's and the book's own, in every
series) or that the community title states (
statedNumbers: digits, ordinals, number words, roman numerals).
Any other word or number keeps the file's title. "part" names a split of the book,
"abridged" another edition, and "Book 2" of a work at position 1 another volume. A
title of the community's words and nothing more is replaced only when it is all in
one case. A reverted title has no edited source, so a later fill can tidy it
again.
The rules an apply follows:
- Under every scope, a field an admin edited (
source: "edited") is never replaced, a value the book already has isn't rewritten, and a series position is written only beside the series it numbers (the book's own, or the one being set). - Only
overwritereverts, and only community values. A series an admin typed stays, and a revert never removes an edit made after the run looked (aPATCH …/bookrevert sent withsource: "community"removes only a community value). - A series position is put back from the files only beside the files' own series.
- A community position that is a range (an omnibus,
"1-3") is written as one:series_index"1-3", or amore_seriesentry'sposition_end. One the field refuses (a span over 100) is no position. - A retired work id is a redirect the server follows, so a merged record's series are the ones written.
- A repick writes its ASIN whatever the scope. It does so only while the book's
ASIN is still the community override it found and still the ASIN it looked up
(
found_by), so a book matched again by hand since, to another record, keeps that match. From 2.7.0 a book with a recorded match records the repick's ASIN as offered. - A refresh takes only
filloroverwrite(400forids), because a refresh is for the details.
Applying is audited as book.match_apply (run, books, scope). Applying
ready again after a stopped apply takes the rest.
A refresh's no-op items (from 2.7.0). The server leaves out a refresh item
with nothing to change under the scope, so a client need not exclude each one.
An item the run recorded with nothing to change under that scope (its review
shows no fields and no cover under it) is never taken, even if the book changed
since, so nothing is written that the review didn't show. One that had a change
when recorded but has none now is planned again and dropped from apply_total, in
batches of up to 100, the rest before the run settles. Either way it stays
unapplied (applied "", like an excluded item, so an auto one still counts as
pending), not skipped, and apply_done ends at apply_total. The 202's run
and the audit's books count every item taken.
An item recorded before 2.7.0 is planned again as before. A match or repick
item with nothing to change is still skipped with nothing_to_change.
| Status | Meaning |
|---|---|
400 | invalid request (including a body over 1 MiB); scope must be … (also a scope the run's mode doesn't take: ids for a refresh); code: "too_large" - more than 20,000 ids in include. Exclusions are bounded by the body size only. |
404 | no such match run |
409 | code: "match_run_busy" - a run is matching or applying; code: "match_run_not_ready" - this run isn't ready |
POST /api/v1/admin/match-runs/{id}/cancel
Stops the run while it works: matching stops (cancelled, nothing changed);
applying stops after the books in hand (ready again, the rest still to
apply). Turning community metadata off stops an apply the same way. From 2.7.0
a stuck run (see the statuses) is settled at once.
204; 409 code: "match_run_not_running" when it isn't marked
working. Audited as book.match_stop (with "orphan": true for a settled stuck
run).
DELETE /api/v1/admin/community-matches
Undoes the community matches so the books can be matched from fresh (Server >
Settings > Community metadata > Danger zone). ?library_id= limits it to one
library; without it, every library. In one transaction it:
- removes every
source: "community"override, whether a match dialog or a run wrote it; - removes every community cover, one kept from the match dialog or by a run;
- from 2.6.0, removes every recorded community match (
book_matches); - rebuilds each book's effective metadata and cover;
- removes the match runs with their reviews: every run, or for one library its runs and its books' items in runs over every library.
An admin's own edits and uploaded covers, the tags and
identifiers stored in book_enrichment all stay, so a book whose tags carry an
ASIN or ISBN is still matched afterwards. It sends nothing out, so it works
with community metadata off (and with no service configured).
{ "books": 128, "covers": 41, "runs": 3 }
books counts the books that had a community value, cover or recorded match, covers the
community covers removed, runs the runs removed. Audited as
book.match_clear with the same counts (target: the library's name, empty for
every library).
| Status | Meaning |
|---|---|
400 | invalid library_id |
404 | library not found |
409 | code: "match_run_busy" - a run is matching or applying (stop it or wait, then try again) |
PUT /api/v1/admin/libraries/{id}/cover · DELETE /api/v1/admin/libraries/{id}/cover
Uploads (PUT) or removes (DELETE) a book's custom cover. ?path=
required. The PUT body is the raw image (any Content-Type header is
ignored; the type is sniffed from the bytes). The cover is stored in the
database, never in the library folder, so it is part of any database backup
and follows the book on a move. The ordinary
cover endpoint serves it ahead of the book's own
art.
curl -X PUT -H "Authorization: Bearer $TOKEN" --data-binary @cover.jpg \
"https://books.example.com/api/v1/admin/libraries/1/cover?path=Andy%20Weir/The%20Martian"
Responses 200: { "status": "cover set", "path": "…" } /
{ "status": "cover removed", "path": "…" }. Removing a cover that isn't there
is not an error. Setting or removing a cover moves the book's
cover_version at once, so its cover_color stops
being sent until the next thumbnail of the new art records one.
| Status | Meaning |
|---|---|
400 | could not read the image; invalid library id; path is required |
404 | library not found; code: "book_not_found" (PUT only - the path must be an indexed book) |
413 | code: "too_large" - the image is larger than 5 MiB |
415 | code: "unsupported_image" - not a JPEG, PNG or WebP image |
PUT /api/v1/admin/libraries/{id}/cover/community
Keeps a community cover (a match
candidate's or recording's cover_url) as the book's custom cover: the
server fetches the image and stores it exactly as an
upload
(in the database, never in the library folder; DELETE …/cover removes it).
It is recorded as a community cover, so
clearing the community matches removes
it, where an upload stays.
Requires the metadata capability. ?path= required.
| Body field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | an absolute http/https URL, at most 2048 characters |
{ "url": "https://covers.openlibrary.org/b/isbn/9780553418026-L.jpg" }
The fetch connects to public addresses only: every connection's resolved
address, a redirect's included, is checked when it is made, so a loopback,
private, link-local or otherwise reserved address is refused (no proxy is
used). It follows at most 3 redirects and gives up after 15 seconds. The image
must be a JPEG, PNG or WebP; one with more than 40 megapixels is refused from
its header, and one over 5 MiB (the upload limit) is re-encoded as a JPEG
within 1600 x 1600 pixels first. The book is checked before anything is
fetched. A success is audited as book.cover_set with source: "community"
and, like an upload, moves the book's
cover_version.
Response 200: { "status": "cover set", "path": "…" }.
| Status | Meaning |
|---|---|
400 | url must be an absolute http(s) URL; invalid request (malformed body or an unknown key); invalid library id; path is required |
401 / 403 | anonymous / non-admin |
404 | code: "metadata_off" - community metadata is turned off; library not found; code: "book_not_found" |
413 | code: "too_large" - the image is over 16 MiB, or over 40 megapixels |
415 | code: "unsupported_image" - not a JPEG, PNG or WebP image |
502 | code: "cover_unavailable" - the image couldn't be fetched (an address that isn't public, a timeout, too many redirects, an answer other than 200) |
POST /api/v1/admin/covers
Cover thumbnails for many books in one request. This is how the admin console shows
covers in its grids, without a session token in any image URL. Each cover is
resolved like GET /libraries/{id}/cover: a
custom cover, then the sidecar image, then embedded art. It never indexes a
path on demand.
| Body field | Type | Required | Notes |
|---|---|---|---|
books | array | yes | [ { "library_id": 1, "path": "Andy Weir/The Martian" } ], 1 to 60 entries |
size | int | no | the longest side of the thumbnail in pixels: 96 (from 2.5.1, for a table row), 160, 320 (default) or 640 |
{
"books": [
{ "library_id": 1, "path": "Andy Weir/The Martian" },
{ "library_id": 1, "path": "Andy Weir/Artemis" }
],
"size": 320
}
Response 200, one entry per requested book, in request order:
{
"covers": [
{ "library_id": 1, "path": "Andy Weir/The Martian", "data": "data:image/jpeg;base64,/9j/4AAQSkZJRg…", "version": "3f9a1c07be" },
{ "library_id": 1, "path": "Andy Weir/Artemis", "data": "" }
]
}
datais a JPEG thumbnail as adata:URL, scaled to fit withinsizexsize(never scaled up; transparency is flattened onto white). It is""when the book has no art, the art can't be read or decoded, or no book is indexed at the path (an unknownlibrary_idtoo).version- from 2.5.1, with every thumbnail (omitted whendatais""): thecover_versionthe book's admin row has once this request's colour is recorded. It can differ from the version on the row the client read: the cover may have been replaced since, or this is the book's first thumbnail, which moves its version to the art's own. A client that keeps thumbnails should file each under thisversion, never under the row's, so the next row it reads finds it.- Thumbnails are cached in server memory, keyed by the art's version (a custom cover's upload time, a file's size and modification time), so a replaced cover is picked up on the next request. A source image larger than 40 megapixels is refused from its header (it reads as no art).
- Each thumbnail also records the book's
cover_color(and, at its first thumbnail, the art identity behindcover_version) when the book lacks them for this art, exactly asGET /libraries/{id}/cover?size=does.
| Status | Meaning |
|---|---|
400 | books is required (an empty list); size must be one of [96 160 320 640]; invalid request (malformed body or an unknown key); code: "too_large" for more than 60 books |
401 / 403 | anonymous / non-admin |
500 | could not load covers - a database failure (an unreadable image is never an error, it is "") |
POST /api/v1/admin/meta/covers
Thumbnails of community cover images. This is how the console's match dialog shows
each candidate's cover_url (from
GET …/book/match), which its CSP
can't load from the image's own host. Requires the metadata
capability. The server fetches each image as
PUT …/cover/community does
(public addresses only, at most 3 redirects, 15 seconds each).
| Body field | Type | Required | Notes |
|---|---|---|---|
urls | string array | yes | 1 to 12 cover URLs |
size | int | no | the longest side of the thumbnail in pixels: 96 (from 2.5.1), 160, 320 (default) or 640 |
{
"urls": [
"https://meta.audiosilo.app/covers/the-martian.jpg",
"https://covers.openlibrary.org/b/isbn/9780553418026-L.jpg"
],
"size": 160
}
Response 200, one entry per requested URL, in request order:
{ "covers": ["data:image/jpeg;base64,/9j/4AAQSkZJRg…", ""] }
- Each entry is a JPEG thumbnail as a
data:URL, scaled as forPOST /admin/covers, or""when the image couldn't be fetched or decoded (a URL that isn't http(s), one too large, or not an image, too). - Thumbnails are cached in the same server memory cache, keyed by URL and
size, and shared with the player's
/meta/cover. An image that couldn't be decoded is cached as""; a fetch that failed is not, so the next request tries again (this batch ignores the minute/meta/coverwaits after a failed fetch). Requests for the same thumbnail at once share one fetch. - The whole batch has 20 seconds: an image that hasn't arrived by then is
answered
""rather than holding the rest back. Fetches are bounded across all requests.
| Status | Meaning |
|---|---|
400 | urls is required (an empty list); size must be one of [96 160 320 640]; invalid request (malformed body or an unknown key); code: "too_large" for more than 12 URLs |
401 / 403 | anonymous / non-admin |
404 | code: "metadata_off" - community metadata is turned off |
Admin: health & jobs
All Admin. The queries behind the console's Health > Issues and Health > Jobs
screens. The server computes issues from the index on request. The only thing
stored is an admin's "ignore this", path-keyed. Scans run through
one job queue and each is recorded in
scan_runs.
GET /api/v1/admin/issues
Every issue category's numbers, the libraries whose folder is offline, and when a scan last finished.
{
"categories": [
{ "kind": "scan_error", "count": 2, "ignored": 0,
"samples": [ { "library_id": 1, "path": "Damaged/The Damaged Book.m4b", "title": "The Damaged Book" } ] },
{ "kind": "duplicate", "count": 1, "ignored": 0, "samples": [ … ] },
{ "kind": "no_cover", "count": 14, "ignored": 3, "samples": [ … ] }
],
"offline": [
{ "library_id": 2, "name": "NAS", "root": "/mnt/nas/books", "books": 812, "listeners": 4 }
],
"checked_at": "2026-10-04T09:12:44Z"
}
-
categories- one entry per kind, always in this order:scan_error,suspect,split_discs,duplicate,no_cover,unmatched,unknown_asin,series_spellings,no_chapters,detailed_chapters,transcode. Some of them need a word:series_spellingsis a book whose main series another book of its library spells another way: the same series in a different case, accent, punctuation or spacing (see/series). Every book of such a series is counted, and/admin/issues/series-spellingslists them per series.detailed_chaptersis a book whose last community chapter check found finer community chapters that fit (refine) and whose chapter source no admin has chosen. The console's fix is a bulk edit withchapter_source: "community".split_discsis a book split across disc folders (a CD rip read as one book per disc), counted once and listed by its first disc. The console's fix sets abookfolder override on the folder holding the discs, which joins them. Its discs are never also grouped asduplicate.unmatchedis left out while community metadata is off, since there is nothing to match against.unknown_asinis a book whose own ASIN the community database doesn't know, as the unknown-ASIN report last found it, with no ISBN and no community match. It still counts as matched, so it is neverunmatchedtoo. It is left out while the report doesn't run (metadata.report_unknownoff, community metadata off, or a demo server), since only the report's lookups find such a book. A client that doesn't know the kind can skip it.
What puts a book in each kind is in Issues.
-
count- books that need attention, not counting the ones an admin ignored (forduplicate, the number of groups);ignored- how many an admin ignored (groups forduplicate). -
samples- up to three books to show (the newest, or each group's first copy),[]when none. -
offline- libraries whose root can't be read right now (the same probe asavailableonGET /admin/libraries), with their indexedbooksand how many people havelistenersprogress in them: what the scanner kept, since nothing is pruned while a root is offline.[]when none. -
checked_at- when the newest finished scan of any library ended (""when none has; interrupted runs don't count).
GET /api/v1/admin/issues/duplicates
Groups of books that look like the same book within one library (copies in
different libraries are usually deliberate and are grouped only with across).
| Query param | Type | Notes |
|---|---|---|
library_id | int | one library; a non-positive or non-integer id is 400 invalid library_id |
ignored | true | return only the groups an admin said are different books (instead of the open ones) |
across | 1 or true | also group copies in different libraries (2.4.0, the console's "Include other libraries"): each copy's row names its library_id and library_name, and with library_id the groups holding one of that library's books are kept, their copies from any library. Without it nothing changes; the Health duplicate count never uses it |
limit | int | from 2.5.1: groups per page, at most 500 (a larger value is 500; one that is not a positive integer counts as absent). A page can end early (below), so page on next_cursor, not on the count |
cursor | string | from 2.5.1: the previous page's next_cursor, passed back verbatim; a malformed one is 400 invalid cursor |
{
"groups": [
{
"reason": "same_files",
"ignored": false,
"books": [
{ "library_id": 1, "path": "Lewis Carroll/Alice's Adventures in Wonderland", "title": "Alice's Adventures in Wonderland",
"…": "the rest of a GET /admin/books row", "listeners": 2 },
{ "library_id": 1, "path": "Inbox/Alice (copy)", "…": "…", "listeners": 0 }
]
}
],
"total": 132,
"next_cursor": "THV3aXMgQ2Fycm9sbC9BbGljZSdzIEFkdmVudHVyZXMgaW4gV29uZGVybGFuZAAx"
}
-
reason- why the books were grouped, from the joins that formed the group. "Lengths match" means within a minute or 2%. Forsame_bookan unknown length (no ffprobe) matches only another unknown one; for a shared ASIN or ISBN an unknown length is no evidence either way, so it never makes the lengths differ.reasonMeaning id_length_differsThe books were grouped by a shared ASIN or ISBN but their lengths don't match (the group's shortest and longest known lengths are compared): one may be matched to the wrong recording, or a copy is incomplete. Not plain copies: the console marks none as the one to keep. same_filesEvery book in the group has the same audio (same fingerprint and size). From 2.5.1 a group that identical audio joins only in part takes the reason of what joins the rest (before, two identical copies were enough). same_idTwo of the books share an ASIN or ISBN and their lengths match. same_bookThe books share author, title and narrator and their lengths match. A group joined in several ways takes the first reason of this table that any of its joins gives:
id_length_differsoutranks evensame_files, as such a group holds a different recording beside its identical copies. An older server sayssame_bookfor both identifier reasons; a client should read areasonit doesn't know likesame_book. -
books- each copy as aGET /admin/booksrow pluslisteners(people with progress on it), the copy worth keeping first (better format, then a single file, then the higher bitrate, then more listeners). Underid_length_differsthe order says nothing about which to keep. -
ignored- every copy in the group is ignored forduplicate. Without?ignored=trueonly groups withignored: falseare returned; with it, only the ignored ones. An ignored group becomes open again by itself when a new copy joins it. -
Groups are ordered by library and the kept copy's path, and paged by keyset on that order (from 2.5.1):
cursornames the last group's kept copy, so a group that comes or goes between pages moves no other group across the boundary. The groups are found afresh for every page. A group whose kept copy changes between two page loads (a better copy arrived) can sort elsewhere, so a client paging through should show a group once even if a later page brings it again. -
total- how many groups there are in all, on every page (counted after theignoredfilter, so every ignored group can be reached).next_cursoris omitted on the last page. -
With
limit, a page also stops before a group that would take it past 300 copies, unless the page is still empty. Each copy is a full admin row, so without this a few very large groups (identical audio across a big library) would make one answer megabytes. A single group of more than 300 copies is a page of its own. So a page can hold fewer thanlimitgroups and still have anext_cursor. -
Without
limit(an older console, or a script that doesn't page) the answer is the first 500 groups whole, with no bound on their copies, as before, plustotaland, past 500, anext_cursor.
GET /api/v1/admin/issues/series-spellings
The series a library's books spell more than one way, one entry per series and
library, from the same rule as the series_spellings count.
| Query param | Type | Notes |
|---|---|---|
library_id | int | one library; a non-positive or non-integer id is 400 invalid library_id |
ignored | true | return the series with a book an admin ignored for series_spellings (instead of those with a book not ignored) |
{
"groups": [
{
"library_id": 5,
"suggested": "He Who Fights with Monsters",
"spellings": [
{ "name": "He Who Fights With Monsters", "books": 8 },
{ "name": "He Who Fights with Monsters", "books": 5 }
],
"books": 13
}
]
}
suggested- the spelling to offer, by the rule/seriesnames a series by (the spelling its matched books use most, else the one most of its books use, ties alphabetical), counted over this library's books whose main series it is. So it can differ from the name/seriesor/admin/seriesgives when other libraries or other-series lists spell it too. The console offers it first.spellings- every spelling the library's books give it as their main series, with its books, most books first (ties alphabetical). Ignored books' spellings are listed too, so a rename leaves none behind.books- all of the books in that library whose main series it is (a book that lists it only among its other series is not counted, and a rename doesn't touch it).- A series can be in both views while some of its books are ignored and some not.
At most 200 entries, ordered by
suggested(case-insensitive), then library.
The console's fix is a bulk edit setting series on
the books of the other spellings, as an author merge is; ignoring a series is a
series_spellings ignore of every one of its books.
POST /api/v1/admin/issues/ignore · DELETE /api/v1/admin/issues/ignore
POST stops listing books under one issue kind; DELETE lists them again (the
console's Undo and "Show again"). For duplicates, ignore every copy of a group
("They're different books").
{ "kind": "no_cover", "books": [ { "library_id": 1, "path": "Sun Tzu/The Art of War" } ] }
kind- one of the ten kinds above.books- 1 to 1000{library_id, path}refs. The books needn't be indexed: the rows are path-keyed durable state (issue_ignores), so an ignore survives rescans and rebuilds and moves with the book. A ref in a library that doesn't exist is skipped.- Idempotent both ways. Response
204 No Content.
| Status | Meaning |
|---|---|
400 | invalid request; books is required; every book needs a library_id and a path; unknown issue; code: "too_large" for more than 1000 books |
POST /api/v1/admin/libraries/{id}/book/rescan
Reads one book's files again now (tags, ffprobe, cover, read problems) and
returns its book page, the same body as
GET /admin/libraries/{id}/book. ?path=
required. It runs outside the job queue (one book is quick), so it never waits
behind a library scan. The re-read runs detached from the request, for up to 10
minutes (and never past server shutdown), so on a slow share it is still saved
even if the response times out; the book's next look shows it. The console uses
it for "Read the files again" on a book's page and "Read again" on Health's
unreadable files.
| Status | Meaning |
|---|---|
200 | the book page, re-read; check book.scan_error to see whether the problem is gone |
400 | invalid library id; path is required |
404 | library not found; code: "not_indexable" - there is no book at that path any more (gone from disk, not a book, or skipped by the library's ignore rules) |
GET /api/v1/admin/jobs
The scan running now (with live progress), the scans waiting behind it, and every scheduled library's next scan.
{
"running": {
"id": 7, "kind": "scan", "library_id": 1, "library_name": "Audiobooks",
"trigger": "manual", "started_by": 1,
"queued_at": "2026-10-04T09:12:40Z", "started_at": "2026-10-04T09:12:41Z",
"run_id": 31,
"progress": { "running": true, "total": 812, "done": 394, "indexed": 6,
"added": 2, "updated": 4, "moved": 1, "removed": 0 }
},
"queued": [
{ "id": 8, "kind": "scan", "library_id": 2, "library_name": "Kids",
"trigger": "schedule", "started_by": null, "queued_at": "2026-10-04T09:13:00Z" }
],
"schedules": [
{ "library_id": 2, "library_name": "Kids", "schedule": "every:6h", "next_at": "2026-10-04T15:13:00Z" }
]
}
running- the running job, ornullwhen the queue is idle.progressis the library's scan progress (the object ofGET …/scan);run_idis its scan run once the run is recorded.queued- waiting jobs in the order they will run ([]when none). Jobs live in memory: their ids restart from 1 with the server, and a waiting job doesn't survive a restart.- A job's
kindis always"scan";triggerismanual(an admin asked),schedule,startuporchange(a library or folder setting changed);started_byis the admin's user id,nullfor a schedule or startup. schedules- every library with a schedule, with itsschedulestring andnext_at(RFC 3339, UTC);[]when none.
The console polls this every second while a job runs or waits, every 15 seconds otherwise.
DELETE /api/v1/admin/jobs/{id}
Drops a queued job, or stops the running one. A stopped scan ends during its
folder walk or at its next book, before the prune step, so nothing is
removed from the index; its run is recorded as cancelled. A prune already
under way is never interrupted (it is one transaction) and finishes first.
Dropping a queued scheduled scan skips that slot of the schedule. 204 No Content. 400 invalid job id; 404 no such job (it may have finished).
GET /api/v1/admin/scan-runs
Recorded scans, newest first, without their logs.
| Query param | Type | Default | Notes |
|---|---|---|---|
library_id | int | - | one library; 400 invalid library_id for a non-positive or non-integer id |
before | int | - | a run id: only older runs (the next page) |
limit | int | 50 | ≤ 0 or > 200 falls back to 50 |
{
"runs": [
{ "id": 31, "library_id": 1, "library_name": "Audiobooks", "trigger": "manual",
"started_by": 1, "started_by_name": "admin",
"started_at": "2026-10-04T09:12:41Z", "finished_at": "2026-10-04T09:13:03Z",
"status": "ok", "books": 812, "added": 2, "updated": 4, "moved": 1, "removed": 1, "errors": 1 }
],
"next_before": 31
}
status-running,ok,partial(part of the folder couldn't be read, so nothing was removed),unavailable(the root was unreachable; nothing removed; also a root that didn't answer within 2 seconds),failed(nothing removed),cancelled(stopped by an admin; nothing removed) orinterrupted(the server stopped mid-scan).books- books discovered on disk;added/updated/moved/removed- what the scan changed (a moved or renamed book counts once, asmoved; a book joined from its disc folders, or split back into them, is a reshape counted in neitheraddednorremoved);errors- books whose files had a read problem, plus index writes that failed.started_by_nameis omitted for a schedule or startup scan, or once the account is deleted (started_byis thennull).finished_atisnullwhile the scan runs.next_beforeis present when the page is full: pass it asbeforefor the next page.- Only the newest 100 runs of each library are kept.
GET /api/v1/admin/scan-runs/{id}
One recorded scan, the same object with its log:
{
"id": 31, "library_id": 1, "library_name": "Audiobooks", "trigger": "manual", "…": "…",
"log": [
{ "at": "2026-10-04T09:12:41Z", "level": "info", "kind": "started", "path": "/srv/audiobooks" },
{ "at": "2026-10-04T09:12:41Z", "level": "info", "kind": "discovered", "count": 812 },
{ "at": "2026-10-04T09:12:42Z", "level": "info", "kind": "moved",
"path": "Old Folder/Book", "to": "New Folder/Book" },
{ "at": "2026-10-04T09:12:55Z", "level": "warn", "kind": "problem",
"path": "Damaged/The Damaged Book.m4b", "code": "probe_failed",
"detail": "[mov,mp4,m4a,3gp,3g2,mj2 @ 0x7f8c] moov atom not found; Invalid data found when processing input" },
{ "at": "2026-10-04T09:13:03Z", "level": "info", "kind": "removed", "path": "Gone/Book" },
{ "at": "2026-10-04T09:13:03Z", "level": "info", "kind": "finished", "count": 812 }
]
}
Each event has at, level (info, warn or error) and kind, plus the
facts the kind needs: path, to (a move's destination), code (a read
problem's code, as scan_error), detail (a tool's or the OS's own message)
and count. The kinds:
started,discovered,unreadable,moved;joined: a disc book atpathjoined into the book atto.codeislength_unknownwhen its listening state stayed with the disc because an earlier disc's length is unknown;split: the joined book atpathread as its discs again;problem,error,removed,partial;truncated: a log keeps at most 300 events, at most half of them problems, and this line counts the ones dropped;- then one closing event saying how the scan ended:
finished,unavailable,failed,cancelledorinterrupted.
A removed book leaves nothing in the index but its removed line here (see
Prune).
404 no such scan; 400 invalid run id.
Admin: activity
All Admin (a non-admin gets 403). Listening sessions, signed-in devices and
an admin's edits of someone's progress: the API behind the admin console's
Activity screens, People > Devices and the progress menus on a person's page
and a book's page.
The server derives sessions from the progress saves players already make. On a
server with listening_sync it derives them from the listening a player
syncs, at the time it happened, offline listening
included. The rules (session gap and continuation, listened time, what isn't
counted) are in
Listening sessions.
A session with no listening recorded yet (a single save, such as a "mark
finished") is left out of both session routes and of the stats, so a session
appears from its second save. A device is
a signed-in token: a session (a paired phone, a browser) or a personal API key,
never a pairing token. Its app comes from the
X-AudioSilo-Client
header.
The session object, shared by both session routes:
{
"id": 412,
"user_id": 4,
"username": "sam",
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/The Final Empire",
"title": "The Final Empire",
"author": "Brandon Sanderson",
"device_id": 57,
"device_name": "Pixel 9",
"client": { "app": "AudioSilo", "version": "1.4.2", "platform": "android" },
"started_at": "2026-10-04T19:02:11.000Z",
"last_at": "2026-10-04T19:41:56.000Z",
"start_position": 10620.4,
"position": 12043.6,
"duration": 88347.4,
"speed": 1.25,
"listened": 1138.9,
"codec": "aac",
"transcoded": false,
"finished": false,
"backfilled": false,
"imported": false,
"state": "playing",
"chapter": "Chapter 12",
"chapter_index": 12,
"ip": "192.168.1.24"
}
title/authorcome from the index;""when the book isn't indexed (any more), in which casepathnames it.device_idis the token's id, theidthatGET /admin/deviceslists.device_nameandclientare copied onto the session when it is recorded, so they survive the device's sign-out.clientisnullfor an app that never named itself (players released before the header).- Times are UTC with milliseconds.
started_atis the first save of the session,last_atthe newest. start_position/positionare the book positions (seconds) at the first and newest save;durationandspeedare the newest save's.listenedis wall-clock seconds of playback in the session, not book time.codecis the book's indexed codec when the session started (""unknown);transcodedis true when the same device streamed a file of this book with?transcode=1during the session.backfilledis true for a session made at the upgrade to migration 0021 from the players' own listening spans, from before the server recorded sessions:device_idis0,device_name"",clientnullandcodec"". It is also true for a session imported from another server. Neither kind counts inclientsorplayback.imported- written by a listening import:backfilledtoo,device_id0,client.app"Audiobookshelf"(with the ABS client's version andios,android,webor""as the platform),device_namethe device ABS recorded,codec""andspeed1. The console labels sessions from this flag.finished- a save in the session marked the book finished.state-playing(a save within the last 60 seconds),paused(within 10 minutes) orended. A player that saves every 60 s can read aspausedfor a moment (data model). A session can come back fromended: a late save whose position advanced by about the time that passed (a phone that kept playing without saving) continues it, up to 12 hours after its last save.chapter,chapter_indexandipare present on live sessions only.chapter_indexis the 0-based place of the chapter atpositionandchapterits title, tidied the way the player tidies a filename-shaped one (the audio extension dropped, underscores as spaces, a trailing bitrate tag removed).chapteris omitted when the title names nothing (empty, a bare number such as024, a track or disc number such asTrack 01; a title in another script always names something), so a client says "Chapterchapter_index+ 1" instead. A numbered title such asChapter 10orPart 7is kept as written: in a book that opens with a prologue the thirteenth chapter can be titledChapter 12, as in the example. A book with a single chapter (the whole book) has neither field.ipis the device's newest address.
GET /api/v1/admin/sessions/live
Who is listening now: the open sessions (a save within the last 10 minutes),
newest first, one per device. A device that moved on to another book shows
only the book it is on now. Each carries ip, and chapter_index and chapter
as described above for a book with more than one
chapter.
{ "sessions": [ { "id": 412, "username": "sam", "state": "playing", "…": "…" } ] }
sessions is [] when nobody is listening.
GET /api/v1/admin/sessions
Sessions newest first by when they started (ties by id; open ones included), a page at a time. Not by id: an imported session is old but has a newer id than every session recorded here.
| Query param | Type | Default | Notes |
|---|---|---|---|
user_id | int | - | one listener |
library_id | int | - | one library |
path | string | - | one book; needs library_id |
before | int | - | the id of the previous page's last session: the sessions after it in this order (the next page) |
before_at | string | - | that session's started_at (RFC3339), with before: the page continues from there even if the session is gone since |
limit | int | 50 | ≤ 0 or > 200 falls back to 50 |
{ "sessions": [ { "id": 412, "…": "…" } ], "next_before": 363 }
next_before is the id to pass as before for the next page, null on the
last page. Send the last session's started_at as before_at too (the console
does): the page then continues exactly where the last one ended, even if that
session is gone since (retention, an undone import). Without before_at, a gone
before session continues from the nearest session recorded here, which can
repeat a few imported ones. 400 invalid user_id / invalid library_id /
invalid before (not a positive integer) / invalid before_at (not a time, or
without before); 400 path needs library_id.
GET /api/v1/admin/devices
Signed-in devices: live (not revoked, not expired) session tokens and API keys
of one person (?user_id=) or of everyone, most recently seen first.
{
"devices": [
{
"id": 57,
"user_id": 4,
"username": "sam",
"kind": "session",
"name": "Pixel 9",
"client": { "app": "AudioSilo", "version": "1.4.2", "platform": "android" },
"created_at": "2026-07-02T11:20:31Z",
"last_seen": "2026-10-04T19:41:56Z",
"last_ip": "192.168.1.24",
"current": false
}
]
}
kind-sessionorapi.nameis the device name the player sent at sign-in (an API key's label).client- the app the token last reported,nulluntil a request names one (players released before the header staynull).last_seen- the newest authenticated request,nullbefore any.last_ip- that request's address (""before any). Both are overwritten each time; no history of addresses is kept.current- the token making this request.
devices is [] when there are none. 400 invalid user_id for a non-positive
or non-integer id.
DELETE /api/v1/admin/devices/{id}
Signs one device out by revoking its token, whoever owns it. The person's
other devices stay signed in. 204 No Content.
| Status | Meaning |
|---|---|
400 | invalid device id |
404 | device not found - no live session or API key with that id (already signed out, a pairing token, or unknown) |
409 | code: "current_device" - the token making this request; sign out of the console instead |
GET /api/v1/admin/users/{id}/progress
One person's progress on every book, most recently saved first. Not filtered by shares (admin view).
{
"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-10-04T19:41:56Z",
"title": "The Final Empire",
"author": "Brandon Sanderson",
"started_at": "2026-09-12T20:03:10Z",
"finished_at": null
}
]
}
The progress fields plus title / author ("" when
not indexed) and the dates. started_at is the time of the first save (null
for rows saved before the server recorded it). finished_at is the time of the
save that last marked it finished, or of the admin edit that did. It is null
while unfinished, and finished rows from before were given their last save time. 400 invalid user id; 404 user not found.
PATCH /api/v1/admin/libraries/{id}/progress
An admin's edit of someone's progress on a book (?path= and ?user_id=
required). Any subset of:
| Body field | Type | Notes |
|---|---|---|
finished | bool | true moves the position to the end and stamps the finish now (unless finished_at is given); false clears the finish date and keeps the position (unless position is given) |
position | float | seconds; between 0 and the book's duration |
started_at | string | null | RFC 3339 or YYYY-MM-DD (the start of that day, server time); null clears it |
finished_at | string | null | RFC 3339 or YYYY-MM-DD (the end of that day, server time, or now if that is sooner); null clears it; needs the book to be (or become) finished |
A day-only finish date counts from the end of its day so that a book started and finished on the same day fits: a start at 15:00 and a finish "today" is a finish after the start, not at the midnight before it.
A field left out stays as it is. An edit that sets nothing ({}) writes nothing.
It answers the current row, or 404 book_not_found when there is none. An exact
(RFC 3339) date may be at most 5 minutes ahead of the server's clock, and a
day-only date has a day of slack.
When the person has no progress on the book yet but it is indexed, the row is
created (its start stamped now), but only if the person can see the book. That
is their own access (whole libraries and shares; an admin target sees everything),
not the access of the admin making the call. Otherwise the edit is refused with
409 no_access and nothing is written. Progress the person already has stays
editable after their access is taken away, so an admin can still tidy it.
The write is stamped with the server's time (to the sub-second) and a higher
version. So it beats what a device saved before it under last-write-wins, while a
device still playing the book overrides it on its next save. Like the listener's own edit, it clears the
row's other_place and records when it changed finished.
An edit of a row an Audible account wrote (device_id audible:<account>)
clears its device id, so the edit reaches Audible and Audible's next place
can't undo it unseen.
Response 200: { "progress": { … } }, the object of
GET /admin/users/{id}/progress, plus the
device_name of the device that saved the place, when known. An older server
omits device_name.
| Status | Meaning |
|---|---|
400 | invalid user_id; path is required; invalid request (malformed body, an unknown key or an unparseable date); those dates or that position don't fit this book - a finish date on an unfinished book, a finish before the start, an exact date more than 5 minutes ahead of server time (a day-only date more than a day ahead), or a position outside the book |
404 | user not found; library not found; code: "book_not_found" - no progress at that path and no indexed book there |
409 | code: "no_access" - no progress at that path, and the person can't see the book; give them access first |
409 rather than 403, because the admin is allowed to make the call (a 403
from an /admin route tells the console its own session lost the admin role).
A path with neither progress nor an indexed book is 404 book_not_found whatever
the person's access.
Admin: listening imports
The console's Server > Settings > Import: another server's listening
history copied into a person's own, so someone moving to AudioSilo keeps their
stats. It also keeps Audiobookshelf's ASIN and ISBN for the books here as
match hints (book_hints). v1
reads Audiobookshelf (ABS) only and is admin only: no capability flag, no player route (the console ships inside the server). The
work is internal/importer (the ABS client, matching, planning, the background
fetch); the rows are catalog/imports.go; the handlers
(api/handlers_import.go) are transport only. What an applied import writes
is in the data model.
The flow: connect (check the address and token, list the ABS users) →
start (one import per mapped user, fetched in the background) → review
(poll until review; optionally change the cutoff, re-planned from the stored
fetch) → apply (one transaction) → optionally undo.
The ABS token is sent in the connect and start bodies only. It is held in
memory by the running fetch and is never stored, logged or returned; a fetch
the server stopped mid-way can't resume (it fails as interrupted). The ABS
client is read-only (GETs), checks GET /status says audiobookshelf (and
isInit) before sending the token anywhere, accepts only http/https
addresses without credentials, a query or a fragment, follows redirects only to
the same host (never https → http, at most 4 of them), trusts only the
certificates the system trusts (a self-signed one fails as abs_certificate, and
there is no setting to skip the check), caps every response size
and times out each request (3 minutes) and the whole fetch (30 minutes). Its
errors are fixed English sentences with a code; an ABS response body never
reaches a response or a log. There is no private-address block: ABS usually
lives on the LAN or a container network, and only admins reach these routes.
Import statuses: fetching → review (or failed) → applying →
applied → undone (by an undo, or by a later import replacing it). At
startup (catalog.InterruptImports) an import left fetching becomes failed
with error_code interrupted, and one left applying goes back to review
(the apply is one transaction, so nothing of it was written).
An import reads:
{
"id": 7,
"user_id": 4,
"username": "sam",
"source": "abs",
"source_url": "http://audiobookshelf",
"source_user": "sam",
"status": "review",
"cutoff": "2026-03-14T18:22:05Z",
"cutoff_utc_offset": 0,
"created_at": "2026-10-08T09:12:44Z",
"applied_at": null,
"error": "",
"error_code": "",
"summary": {
"items": 212,
"matched": { "path": 180, "asin": 9, "isbn": 0, "title": 14 },
"unmatched": 9,
"sessions": 4310,
"skipped_after_cutoff": 57,
"listened": 2215440.5,
"estimated": 86400,
"progress": 198,
"finished": 161,
"bookmarks": 73,
"first_listen": "2021-02-03T20:11:09Z",
"last_listen": "2026-03-13T22:40:51Z",
"identifiers": { "books": 640, "asin": 612, "isbn": 41, "by_title": 22, "already_matched": 95 }
}
}
| Field | Meaning |
|---|---|
source_url | the ABS address as normalised (importer.ParseBaseURL: trimmed, no trailing slash, a pasted web-app link under /audiobookshelf/... cut back to /audiobookshelf); never a credential |
source_user | the ABS username, filled in when the fetch finishes ("" before) |
cutoff | sessions starting at or after it are skipped; RFC 3339 UTC to the second (rounded down); null = none. Always the resolved value, never "auto" |
cutoff_utc_offset | the server's offset from UTC at the cutoff, in minutes (that date's, daylight saving included); null without a cutoff. A client shows and edits the cutoff in server time with it (a chosen day is the server's midnight), whatever its own zone |
created_at, applied_at | RFC 3339 UTC; applied_at null until applied |
error, error_code | "" unless failed. error is a safe English sentence; error_code is abs_unreachable (also a timeout), abs_certificate, abs_unauthorized, not_abs, fetch_failed or interrupted |
summary | null while fetching or when the fetch failed; planned at review (recounted by a cutoff change) and rewritten at apply with what was written |
The fetched history is kept (gzipped, in import_payloads) only while the
import can still be reviewed: it is deleted once the import is applied, undone
or deleted.
summary counts:
items: the ABS books the user has any history for (a session, progress or a bookmark);matched: the matched ones by tier, andunmatchedthe rest;sessions: the sessions imported (matched books, before the cutoff, with listening). Those already past the session retention are written as daily totals and still counted here;skipped_after_cutoff: the sessions the cutoff drops;listened: their seconds;estimated: the seconds added as estimates (none for a book ABS has as finished without one listening session);progress: the progress rows created or moved on;finished: the books given a finish (or a missing finish date) from ABS;bookmarks: the bookmarks added;first_listen/last_listen: the earliest and latest imported session start (nullwithout sessions);identifiers: the books here the import gives Audiobookshelf's ASIN or ISBN as a match hint (see Identifiers below).bookscounts them,asinandisbnthe books given each,by_titlethose paired by title and author rather than by path, andalready_matchedthe paired books left out because they are matched here already. Absent from a summary planned before the server carried identifiers, and all zero once such an import is planned again, as its stored fetch holds none. A client treats a missingidentifiersas none.
GET /admin/imports/{id} adds the books that will be skipped, most listened
first, at most 500:
{
"id": 7,
"…": "…",
"unmatched_items": [
{ "title": "The Way of Kings", "author": "Brandon Sanderson",
"listened": 163020.4, "sessions": 61, "reason": "split_discs" }
]
}
reason | Meaning |
|---|---|
no_match | nothing here matched |
contested | two ABS items still in ABS (each with its own progress) resolved to the same book here, so both are skipped (one of them would be wrong), and so is any deleted item on that book |
no_access | the only match is a book the target user can't access |
split_discs | the ABS item is a folder this server splits into one book per disc folder (books.split_parent); the history can't be divided, so join the discs first |
Matching (importer.matcher), best evidence first, only among the books
the target user can access (a match only among the others is no_access):
- Path: whole components compared from the end, Unicode-normalised (NFC), so the same relative path, or one a suffix of the other (the libraries rooted at different depths; the ABS side compares its absolute path, so the folders above its library folder count). A folder on one side matches the single file alone in that folder on the other. The closest unique candidate wins; a tie falls through.
- ASIN, then ISBN (normalised; the first accessible book in library order).
- Title, author and series:
pkg/match.Bestamong the books of an author whose normalised name contains, or is contained in, the item's.
Path and title matches also need the lengths to agree (within 5% of the longer, plus 2 minutes; an unknown length agrees with anything). An item ABS deleted is known only from its newest session's metadata, so it skips the path tier; it has no progress or bookmarks (any ABS still lists are dropped), so deleted items that resolve to one book all match it, with at most one item still in ABS, and their sessions merge. The apply matches again, so a book added since the review is found.
Identifiers (match hints)
The fetch also keeps every ABS book with a valid ASIN or ISBN in the libraries the
token sees (Payload.Identified, JSON identified, whoever's history it is in).
Review and apply pair them with books here (importer.identifiersFor):
- among every book here, whatever the target user can access, as the hints are the server's, not the person's;
- by the path tier, then the title and author tier, never by ASIN or ISBN
(those are what is being carried). A title pairing is guarded later by the match
run's agreement check (
matchrun.identifierAgrees); - leaving out a book two identified ABS books pair with (contested), an ABS folder
this server splits into disc books, and a book already matched here
(
catalog.matchedExpr: its own ASIN or ISBN, or a recorded match), which is counted asalready_matched.
The imports of one fetch carry the same identifiers, whichever person each is
for. An apply writes them to book_hints (source abs, import_id the import),
replacing each book's hint whole, so the import applied last owns a book's hint
(ownership). They never become books.asin or isbn and never reach the
player wire. A hinted book stays unmatched until a match run's apply or the match
dialog writes a match, which is why the console offers Match automatically once
an import is applied. How a match sends them is
QueryFor's rule.
Cutoff values, in a start or a PATCH: "auto" (each user's first
listening recorded here, catalog.ListeningStart: their earliest non-imported
session or non-estimated rolled-up day; null when they have none), null
(no cutoff), an RFC 3339 time, or a YYYY-MM-DD day (its start in server
time). Anything else is 400 invalid_import (cutoff must be "auto", null or a date). The cutoff gates sessions and estimates only; progress and
bookmarks are merged whatever it is.
All routes are Admin. The import routes' error envelopes carry the codes
below (invalid request, invalid user_id and invalid import id are plain
400s without a code).
POST /api/v1/admin/imports/abs/users
{"url": "http://audiobookshelf", "token": "…"}. Checks the address is ABS and
the token works, and lists its users: every user with an admin or root token or
API key (GET /api/users), or only the token's own with a user's token (ABS
answers that list 403, so GET /api/me). Each carries the AudioSilo user
with the same username (any case), if any.
{
"version": "2.37.1",
"users": [
{ "abs_id": "c1a5e0f2-…", "username": "sam", "type": "user", "suggested_user_id": 4 },
{ "abs_id": "root", "username": "root", "type": "root", "suggested_user_id": null }
]
}
| Status | Meaning |
|---|---|
200 | the server's version and its users |
400 | invalid request; code: "invalid_url" - not an absolute http/https URL, or it carries credentials, a query or a fragment; code: "invalid_import" - the token is empty, over 8 KiB or holds a line break or NUL (ABS is not contacted) |
502 | code: "abs_unreachable" (no answer, a timeout or a 5xx), "abs_certificate" (the https handshake refused ABS's certificate: untrusted, often self-signed, for another name, or expired; the console points at ABS's plain http:// address), "not_abs" (/status didn't answer as an initialised Audiobookshelf), "abs_unauthorized" (the token was refused) or "fetch_failed" (an unreadable answer) |
Not audited (it changes nothing).
POST /api/v1/admin/imports/abs
{
"url": "http://audiobookshelf",
"token": "…",
"users": [ { "abs_user_id": "c1a5e0f2-…", "user_id": 4 } ],
"cutoff": "auto"
}
Records one import per mapping (fetching), all or none, and fetches them in
the background on the server's lifetime context: /status, every book
library's items the token can see (GET /api/libraries,
/api/libraries/{id}/items?limit=0), then per user their progress and
bookmarks (GET /api/users/{id}, or /api/me for a user's own token) and their
sessions (/listening-sessions, 200 a page). Podcast libraries, episodes and
sessions with no listening are left out. cutoff omitted means "auto".
Answers 202 {"imports": [...]} (as above, fetching); poll
GET …/imports/{id} until the status leaves
fetching. Audited per import as import.start (target: the username;
import, source).
| Status | Meaning |
|---|---|
400 | invalid request; code: "invalid_url"; code: "invalid_import" - the token is missing (the API token is required) or over 8 KiB or holding a line break (the API token is not valid), no mappings or more than 100, a mapping without abs_user_id, the same user_id twice, an unknown user_id, or a bad cutoff |
409 | code: "import_running" - one of the users already has an import fetching or applying |
Nothing reaches ABS before the 202: an unreachable server or a refused token
shows up as a failed import.
GET /api/v1/admin/imports
{"imports": [...]}, newest first: everyone's, or one person's with
?user_id=. 400 invalid user_id.
GET /api/v1/admin/imports/{id}
The import with its unmatched_items. 404 code: "import_not_found".
PATCH /api/v1/admin/imports/{id}
{"cutoff": "auto" | null | "<RFC 3339>" | "YYYY-MM-DD"} (required). Plans the
import again from the history it already fetched (nothing is fetched) and
answers 200 with the import and its unmatched_items. Not audited (nothing is
written outside the import).
| Status | Meaning |
|---|---|
400 | invalid request (no cutoff); code: "invalid_import" - a bad cutoff |
404 | code: "import_not_found" |
409 | code: "import_not_ready" - it isn't in review |
POST /api/v1/admin/imports/{id}/apply
Applies a review import. The import moves to applying, then one transaction:
- undoes the user's previous
appliedimport from the same source, which becomesundone, so a re-import never double counts; - matches and plans again against the user's state;
- writes it: sessions, a
listening_historyspan per session, estimates, progress, bookmarks and the match hints; - marks it
applied.
An import that writes no listening for the person (identifiers at most) applies when
the person has no applied import from the source. When they have one, step 1 would
take it back and replace it with nothing, so the apply is refused
(catalog.ErrImportNoListening), the transaction rolls back and the previous import
stays applied. Listening is a session, a rolled-up day, a bookmark, or a progress row
the import creates or moves on (position, finish or finish date); a row whose only
change is an earlier start date is not. The console turns Apply off for that
case (wouldOnlyUndo). Undo the applied import first to replace it.
A session already older than the session retention (activity.session_days as
set when the apply runs) is written as the listening_daily totals the daily
prune would make of it, rather than as a session. So it shows in the stats and
history but not in GET /admin/sessions. A progress row an Audible account wrote
(device_id audible:<account>) that the apply moves on loses that device id and
name (importer.mergeProgress, as an edit does), so the import's place reaches
Audible. The request's cancellation doesn't stop it. A failure puts it back in
review. Answers 200 with the import (applied, summary as written). Audited as import.apply (import,
sessions, listened).
| Status | Meaning |
|---|---|
404 | code: "import_not_found" |
409 | code: "import_not_ready" - it isn't in review, or it writes no listening for the person while they have an applied import from the source (the message says to undo that one first) |
POST /api/v1/admin/imports/{id}/undo
Takes an applied import back out. Its sessions, rolled-up days, estimates,
history spans, bookmarks and match hints are deleted by import_id (a hint
belongs to the import that wrote it last, see
ownership). Matches already applied
stay. A bookmark the person edited or labelled since is kept and becomes theirs.
Each progress row it changed is restored to what it was before, unless it changed since through a player's save
or an edit, in which case it stays. Answers 200 with the import (undone).
Audited as import.undo (import). A row restored from one an Audible account
wrote comes back under no device (progressPrior.restored), never as
Audible (<label>) again, so the feed sends the undo to Audible too.
| Status | Meaning |
|---|---|
404 | code: "import_not_found" |
409 | code: "import_not_applied" |
DELETE /api/v1/admin/imports/{id}
Removes an import that isn't applied, with its fetched history if it still has one. A fetching
one can be deleted (its fetch then writes nothing). 204. Not audited.
| Status | Meaning |
|---|---|
404 | code: "import_not_found" |
409 | code: "import_applied" - undo it first; code: "import_not_ready" - it is being applied |
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; an empty list, never
null, when nobody has listened yet).
{
"total_books": 1284,
"total_libraries": 2,
"total_users": 5,
"libraries": [
{ "id": 1, "name": "Audiobooks", "kind": "books", "book_count": 1201 },
{ "id": 2, "name": "Kids", "kind": "books", "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"
}
]
}
Each libraries[] entry carries its kind (books or podcasts, from
2.4.0), so the dashboard's "Books per library" lists books libraries only.
?range=: listening activity
| Query param | Type | Default | Notes |
|---|---|---|---|
range | string | - | 7d, 30d, 90d, 1y (365 days) ending now; a calendar year such as 2025 (from 2000 to this year; the current year ends now); or year, the current calendar year in server time. An empty value means 30d |
With range present the response is only an activity object
({"activity": {…}}), the Activity page for the period, without the overview
fields above; without it the response is exactly the one above. An unknown range is
400 code: "invalid_range". Days, hours and weekdays are counted in the
server's time zone.
{
"activity": {
"range": "30d",
"from": "2026-09-04T19:42:08Z",
"to": "2026-10-04T19:42:08Z",
"timezone": "BST",
"utc_offset": 60,
"totals": { "listened": 412380.5, "sessions": 214, "listeners": 4, "books": 23, "finished": 3 },
"estimated": 0,
"previous": { "listened": 388012.0, "sessions": 199, "listeners": 4, "books": 19, "finished": 2 },
"days": [
{ "date": "2026-09-04", "listened": 9021.3,
"by_user": [ { "user_id": 4, "listened": 7200.0 }, { "user_id": 6, "listened": 1821.3 } ] }
],
"hour_weekday": [ [0, 0, "… 24 values …"], "… 7 rows, Monday first …" ],
"top_books": [ { "library_id": 1, "path": "Brandon Sanderson/Mistborn/The Final Empire",
"title": "The Final Empire", "author": "Brandon Sanderson",
"listened": 38211.0, "listeners": 2 } ],
"top_authors": [ { "name": "Brandon Sanderson", "listened": 61022.4, "books": 3 } ],
"top_narrators": [ { "name": "Michael Kramer", "listened": 61022.4, "books": 3 } ],
"top_users": [ { "user_id": 4, "username": "sam", "listened": 201330.2,
"sessions": 96, "books": 9, "finished": 2 } ],
"funnel": { "started": 31, "reached_25": 22, "reached_50": 15, "reached_75": 9, "finished": 6 },
"drop_offs": [ { "library_id": 1, "path": "Old Books/The Long One", "title": "The Long One",
"chapter_index": 3, "chapter": "Chapter 4", "listeners": 2, "scan_error": false } ],
"playback": [ { "transcoded": false, "codec": "aac", "listened": 380112.5, "sessions": 190 },
{ "transcoded": true, "codec": "opus", "listened": 32268.0, "sessions": 24 } ],
"peak_concurrent": { "streams": 3, "at": "2026-09-21T20:14:03Z" },
"clients": [ { "app": "AudioSilo", "version": "1.4.2", "platform": "ios", "devices": 3 },
{ "app": "", "version": "", "platform": "", "devices": 1 } ],
"growth": [ { "date": "2026-09-04T19:42:08Z", "books": 1270 },
{ "date": "2026-10-04T19:42:08Z", "books": 1284 } ],
"storage": {
"bytes": 912345678901,
"by_library": [ { "library_id": 1, "name": "Audiobooks", "bytes": 880000000000, "books": 1201 } ],
"by_format": [ { "key": "m4b", "bytes": 700000000000, "books": 811 } ],
"by_codec": [ { "key": "aac", "bytes": 760000000000, "books": 990 } ]
},
"coverage": { "books": 1284, "identified": 802, "with_chapters": 1100, "with_cover": 1250 },
"inactive_users": [ { "user_id": 9, "username": "old-tablet", "last_seen_at": "2026-06-01T10:00:00Z" } ]
}
}
Listening (totals, days, hour_weekday, the top_* lists, playback,
peak_concurrent, clients) comes from listening sessions that recorded some
listening, and for days older than the
raw retention from the daily totals they were rolled up into. A session's
listened time is spread evenly over the hours between its first and last save,
and only the part inside the period counts.
rangeechoes the period (yearanswers with the year it resolved to, such as"2026", so a client whose clock is on the other side of New Year from the server learns the server's year);from/toare its bounds (RFC 3339, UTC;tois rounded up to the next whole second).timezoneis the server zone's abbreviation attoandutc_offsetits offset from UTC in minutes.totals-listened(wall-clock seconds),sessions(sessions that started in the period),listeners(people who listened),finished(the distinct books with a finish date in the period: a book two people finished counts once) andbooks(the books listened to or finished in the period, so a book marked finished or imported with no listening recorded still counts, andfinishedis never more thanbooks).previousis the same for the period of equal length just beforefrom, for deltas.estimated- how many oftotals.listened's seconds are estimates: listening from before the server recorded sessions that the players' spans didn't cover (see Listening from before sessions), or where an imported history's progress went further than its sessions. Estimates count intotalsand thetop_*lists, never indaysorhour_weekday. Backfilled sessions (from spans, or imported) count everywhere butclientsandplayback.days- one entry per day of the period, oldest first, zero days included;by_userlists each listener's seconds that day ([]on a quiet day).hour_weekday- listened seconds as 7 rows (weekday, 0 = Monday) of 24 hours. Raw sessions only: a rolled-up day has no hours.top_books,top_authors,top_narrators,top_users- up to 10 each, by listened time. Authors and narrators are the whole field value (unlike the Library's people lists, which split co-credits: shipped players match these names to books by the whole value);bookscounts distinct books.top_users[].finishedcounts that person's books finished in the period andtop_users[].booksthe books they listened to or finished in it, counted liketotals.funnel- people x books with a progress save in the period, by how far each got (the current position, so a restarted book counts where it is now; a finished book counts as 100%).drop_offs- up to 5 chapters where at least two people stopped the same book: unfinished progress with no save for 30 days.chapter_indexis 0-based;chapteris its title tidied as on a live session (""when it names nothing, where the console says "Chapterchapter_index+ 1");scan_errorsays the book has a read problem the Health page lists. Independent of the period.playback- listening by how it played: direct ortranscoded, percodec(""unknown), withlistenedin the period andsessionsstarted in it.peak_concurrent- the most sessions open at once in the period (streams), and when (at,nullwith none).clients- the devices that listened in the period (raw sessions only), counted once per app build, using the app each session recorded at the time (a later upgrade doesn't rewrite the past).app: ""is a client that never named itself (released before the header).growth- the number of books indexed now that had appeared on disk by each sampledate(RFC 3339): daily for a period up to a month, weekly up to a quarter, monthly beyond, ending atto. Books removed since aren't counted back.storage- the collection now: totalbytes,by_library(in library order),by_formatandby_codec(key: ""= unknown).coverage- books now, and how many are matched (identified: an ASIN or ISBN, or from 2.6.0 a recorded community match, thematchedrule; the console words it "Matched to the community"), more than one chapter (with_chapters) and a cover (with_cover).inactive_users- enabled, non-demo accounts with no authenticated request for 60 days (an account that never made one counts once it is that old;last_seen_atis thennull), longest idle first. Independent of the period.
Every list in activity is [], never null, when empty.
GET /api/v1/admin/listening
Admin. Listening per day over a period, of everyone or of one person: the
same days the Activity page reports, without
computing the rest of it. The console's Overview reads it for the year
calendar, and a person's page for their listening year (range=year, with
utc_offset to place their finish dates in the server's year).
| Query param | Type | Default | Notes |
|---|---|---|---|
range | string | 30d | as /admin/stats?range=: 7d, 30d, 90d, 1y, a calendar year, or year (the server's current year) |
user_id | int | - | one person's listening; everyone's without it |
{
"range": "2026",
"from": "2025-12-31T23:00:00Z",
"to": "2026-10-04T19:42:08Z",
"timezone": "BST",
"utc_offset": 60,
"days": [
{ "date": "2026-01-01", "listened": 3120.4,
"by_user": [ { "user_id": 4, "listened": 3120.4 } ] }
]
}
range, from, to, timezone and utc_offset as in activity (year
answers with the year itself); days is one entry per
day of the period, oldest first, zero days included, in server time. With
user_id, listened and by_user hold only that person's listening.
400 code: "invalid_range"; 400 invalid user_id; 404 user not found.
Admin: settings
The server settings the admin console's Server > Settings edits. The
envelope is keyed by section, and each setting's id is <section>.<name>; the
settings table in internal/config/settings.go says which config.yaml key
and AUDIOSILO_* variable each one is, and whether it needs a restart (see
Changing settings at runtime).
GET /api/v1/admin/settings
Admin. Returns the current settings.
{
"general": {
"name": "Hearthside",
"public_url": "https://books.example.com",
"lan_url": "http://192.168.1.20:8080",
"update_check": true,
"session_days": 400
},
"network": {
"bind": "0.0.0.0:8080",
"tls_mode": "selfsigned",
"tls_home_http": true,
"tls_hosts": [],
"trusted_proxies": ["10.0.0.2/32"],
"cors_origins": []
},
"players": {
"web_dir": "/app/web",
"web_player": "dir",
"apple_app_ids": [],
"android_package": "",
"android_sha256": []
},
"metadata": {
"enabled": true,
"base_url": "https://meta.audiosilo.app",
"region": "uk",
"mode": "remote",
"upcoming": true,
"report_unknown": false,
"available": true,
"region_chosen": true,
"upcoming_status": {
"last_run_at": "2026-10-30T04:12:00.000Z",
"last_success_at": "2026-10-30T04:12:00.000Z",
"next_run_at": "2026-10-31T04:40:12.000Z",
"series": 14,
"items": 9
},
"report_status": { "state": "never", "last_sent": 0 }
},
"demo": {
"enabled": false,
"library": "",
"max_users": null,
"max_users_default": 200,
"idle_ttl": "24h"
},
"household": {
"members_enabled": true,
"max_members": 6,
"max_members_default": 6,
"max_members_min": 1,
"max_members_max": 50
},
"backups": {
"schedule": "daily:03:00",
"keep": 7,
"dir": ""
},
"modules": {
"contributions": false
},
"locked": { "players.web_dir": "AUDIOSILO_WEB_DIR" },
"restart_settings": [
"network.bind", "network.tls_mode", "network.tls_home_http", "network.tls_hosts", "players.web_dir",
"metadata.base_url", "metadata.mode", "demo.enabled", "demo.idle_ttl", "backups.dir"
],
"restart_pending": []
}
Every value is the saved one (what config.yaml now holds, with the
environment's values for keys a variable sets); a restart setting saved since
the server started is listed in restart_pending until a restart puts it in
effect. Lists are never null.
| Field | Meaning |
|---|---|
general.name | display name; "" means "AudioSilo" (what GET /server then reports) |
general.public_url | "" = derived from each request's host |
general.lan_url | the home address; "" = derived from a request on a home-network host |
players.web_dir | read-only: changed only in config.yaml or AUDIOSILO_WEB_DIR |
players.web_player | read-only: where /web is served from - "embedded" (baked into the build), "dir" (from web_dir) or "" (not mounted) |
metadata.region | the preferred Audible marketplace ("" = none; see Configuration) |
metadata.region_chosen | read-only (2.7.0): false only while no marketplace was ever chosen: none in config.yaml or the environment, and no admin ever saved metadata.region here (the server_state key metadata_region_chosen, written by any save carrying the field, "" included). A failed read counts as chosen. While it is false, the console's setup flow preselects the marketplace nearest the admin's browser (its languages, then its time zone) and Settings offers it. An older server sends no field, and the console offers nothing |
metadata.mode | "remote" (ask base_url for each book) or "mirror" (keep a local copy and answer from it; see Mirror mode). A restart setting: a saved change is listed in restart_pending, and GET /admin/system's metadata.mode keeps the running mode until the restart |
metadata.upcoming | the upcoming releases job's switch (config upcoming.enabled, default true). Applies at once: switched on (with metadata on) it runs soon when no run has succeeded for a day; switched off it stops a run in flight. The live upcoming capability is metadata and this |
metadata.upcoming_status | read-only (2.6.0): how the job's runs went, absent without a metadata service. last_run_at, last_success_at (both absent before the first run; equal when the last run worked), last_error (the last failed run's error, cleared by a success), next_run_at, series (local series matched to a community series) and items (what they lack in all). The console's Settings > Metadata words it |
metadata.report_unknown | the opt-in unknown-ASIN report's switch (2.6.0; config metadata.report_unknown, default false). Applies at once: switched on (with metadata on) it runs now unless a run was made in the last 23 hours, the service is in its quiet week after not_offered, or (mirror mode) the local copy isn't ready yet; switched off it stops a run in flight |
metadata.report_status | read-only (2.6.0): the report's last run, absent without a metadata service and on a demo server. state is never (no run yet), ok (the run worked; last_sent ASINs were accepted, 0 = nothing new to send), not_offered (the base_url is plain http off this machine, so nothing is sent; or the service took no reports, and it is left alone for 7 days) or failed (a lookup failed, or the service failed or refused a report; tried the next day, and ASINs in reports it accepted before that still count in last_sent); last_run_at (absent before the first run) and last_sent. The console's Settings > Metadata words it |
metadata.available | read-only: a metadata service exists (base_url was a valid absolute http(s) URL when the server started), so enabled can be turned on. The live metadata capability is enabled && available |
demo.max_users | null = the default cap (max_users_default); 0 = no limit |
demo.idle_ttl | "" = 24h |
household.members_enabled | people may add household members from the player (default true); off refuses new ones only (403 feature_off) |
household.max_members | members per person, 1-50 (default 6); outside the range is 400 invalid_setting (field: "household.max_members") |
household.max_members_default, household.max_members_min, household.max_members_max | read-only facts: the cap's default (6) and range (1 to 50), so the console names the range and checks a value before saving |
backups.schedule | "" (off), daily:HH:MM or weekly:DAY:HH:MM in the server's time zone; stored in canonical form (see Backups) |
backups.keep | how many scheduled backups are kept, 1-365 (manual and before-restore backups stay until deleted; the newest three before-upgrade backups are kept apart) |
backups.dir | read-only: changed only in config.yaml or AUDIOSILO_BACKUP_DIR; "" = <data>/backups (GET /admin/backups' status.dir has the folder in use) |
modules.<id> | each managed module's toggle, by module id (false by default; config key modules.<id>.enabled, variable AUDIOSILO_MODULES_<ID>_ENABLED). Applies at once, with no restart. GET /admin/modules says more about each |
locked | setting id → why the console can't change it: the AUDIOSILO_* variable that set it (before 2.7.0 also "launcher", a launcher override, which only the archived desktop manager set) |
restart_settings | the setting ids read only at start (fixed) |
restart_pending | restart settings whose saved value differs from the one the server started with |
PATCH /api/v1/admin/settings
Admin. Changes settings, writes them to config.yaml and applies them.
The body has the GET envelope's shape with only the settings to change:
{ "general": { "name": "Hearthside" }, "network": { "trusted_proxies": ["10.0.0.2"] } }
A change is all or nothing: each value is normalized and checked (the
rules are in
Changing settings at runtime),
then the config as a whole is validated, and one refused setting leaves
everything as it was. Settings that apply at once do so for the next request;
a restart setting is saved and appears in restart_pending. The answer is the
GET envelope with the new state, so trusted_proxies above comes back as
["10.0.0.2/32"].
A refusal names the setting in field (its id), with the reason in error
written so a form can show it under the field:
{ "error": "must be host:port, like 0.0.0.0:8080 or :8080", "code": "invalid_setting", "field": "network.bind" }
| Status | code | Meaning |
|---|---|---|
200 | saved; body is the settings envelope | |
400 | not a JSON object of sections | |
400 | invalid_setting | a value of the wrong type, one its normalizer refuses, a config that doesn't validate (e.g. tls_mode: "autocert" without tls_hosts, demo on without a library), a demo.library that names no library (field: "demo.library"), a change that would leave config.yaml invalid on its own because a value it needs comes only from an AUDIOSILO_* variable (the file keeps its own values for those; e.g. demo.enabled: true while the library is only in AUDIOSILO_DEMO_LIBRARY; the error names the variable), a general.session_days outside 30-3650, a household.max_members outside 1-50, or turning metadata.enabled on when no metadata service exists (available is false; field: "metadata.enabled" - set a valid metadata.base_url and restart first). Also JSON null for a setting that can't be unset (anything but demo.max_users, whose null means the default), refused with "enter a value" |
400 | unknown_setting | a section or name that isn't a setting (including the read-only extras such as metadata.available) |
400 | setting_read_only | players.web_dir or backups.dir |
409 | setting_locked | a setting an environment variable or the launcher sets (see locked) |
500 | config.yaml couldn't be written; nothing changed |
Turning metadata.enabled on or off 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.
Turning general.update_check off stops the update check
at once, and a new backups.schedule or backups.keep is handed to the backup
service at once (the next scheduled backup is counted again). Turning a
modules.<id> on or off starts or stops that module's background work at once (the
save doesn't wait for a stopping module), turns its routes on or off (404 module_off while off), gates the Bridge jobs it owns
and changes its capability flag. Every save is
recorded in the audit log as settings.update, each
changed setting with its old and new value.
GET /api/v1/admin/modules
Admin. Every managed module, in the order the console lists them, with where it stands. The console's Settings > Modules reads this (every 15 seconds, and after each settings save).
{
"modules": [
{
"id": "contributions",
"enabled": true,
"active": false,
"requires_bridge": "contribute",
"bridge_state": "no_feature"
}
]
}
| Field | Meaning |
|---|---|
id | the module's id: its capability flag on GET /server, its setting modules.<id> and the console's copy |
enabled | the toggle, as the server runs now |
active | the capability flag: enabled and the Bridge requirement met |
requires_bridge | the Bridge feature the module needs (one of bridge.features); omitted for a module that needs no Bridge |
bridge_state | omitted with requires_bridge. offline: no paired Bridge has sent a heartbeat in the last two minutes; no_feature: the online Bridge doesn't offer the feature (for contribute: it can't contribute, having no GitHub token GitHub accepts, whether its Accounts sign-in, AUDIOSILO_BRIDGE_GITHUB_TOKEN or gh); ready: it does |
The registry also includes podcasts, with requires_bridge: "podcast", and
audible, with requires_bridge: "audible"; every module ships disabled. Setting it uses
{"modules":{"podcasts":true}} on PATCH /admin/settings.
modules is [] on a server with none. Whether a toggle is locked is the settings
envelope's locked["modules.<id>"], not repeated here; a toggle is changed with
PATCH /admin/settings ({"modules": {"contributions": true}}). A member's token gets 403. An older server answers 404.
Admin: system, updates and logs
What the console's Health > System, Server > About and Server > Logs show. All
Admin; a member's token gets 403.
GET /api/v1/admin/system
Admin. Everything the server depends on, in one answer:
{
"name": "Hearthside",
"server_id": "kx8Qz1c7m2Vw0aB3dEfGh",
"version": "1.16.0",
"go_version": "go1.25.1",
"os": "linux",
"arch": "amd64",
"install": "docker",
"started_at": "2026-10-04T08:12:31Z",
"data_dir": "/data",
"database": { "bytes": 18874368, "schema": "0019_audit_notifications.sql" },
"tools": [
{ "name": "ffmpeg", "path": "/usr/bin/ffmpeg", "version": "6.1.1", "source": "local" },
{ "name": "ffprobe", "path": "/usr/bin/ffprobe", "version": "6.1.1", "source": "local" }
],
"metadata": {
"enabled": true,
"available": true,
"base_url": "https://meta.audiosilo.app",
"health": { "reachable": true, "latency_ms": 84, "checked_at": "2026-10-04T09:40:02Z" },
"mode": "remote"
},
"tls": {
"mode": "autocert",
"hosts": ["books.example.com"],
"certificates": [
{ "host": "books.example.com", "issued": true, "subject": "books.example.com",
"issuer": "R11", "not_before": "2026-09-01T00:00:00Z", "not_after": "2026-11-30T00:00:00Z",
"self_signed": false, "dns_names": ["books.example.com"] }
]
},
"libraries": [
{ "id": 1, "name": "Books", "root": "/library", "available": true,
"disk": { "total": 4000787030016, "free": 1210012344320 } }
],
"web_player": "embedded",
"update": { "enabled": true, "current": "1.16.0", "latest": null, "update_available": false,
"comparable": true, "checked_at": null, "error": "", "install": "docker" },
"backups": { "dir": "/data/backups", "running": false, "last": null,
"latest": { "name": "audiosilo-20261004-020000Z-scheduled.db", "size": 1339392,
"created_at": "2026-10-04T02:00:00Z", "kind": "scheduled" },
"next": "2026-10-05T03:00:00+01:00" }
}
| Field | Meaning |
|---|---|
name | the display name ("AudioSilo" when unset) |
version | as in GET /server: stamped from the release tag ("dev" for a local build, "dev-<short sha>" for an :edge image) |
install | "docker" (a container: /.dockerenv or /run/.containerenv exists), "binary" (a release build) or "source" (version dev); the console words its "how to update" on it |
started_at | when the server started |
database | bytes: pages in use × page size (the WAL isn't counted); schema: the newest applied migration |
tools | ffmpeg then ffprobe. path "" when off or not found; version from -version (cached per path; "" if it didn't say); source "local" (configured, next to the binary or on PATH), "downloaded" (in <data>/tools), or "" |
metadata | enabled is the live switch; available and base_url are the service the server started with (a saved new base_url waits for a restart) |
metadata.health | the service's /healthz: reachable, latency_ms, checked_at and error when it didn't answer. Cached for a minute, and asked only while the lookup is on; null while it's off. In mirror mode the request takes the same path as every lookup, so the local copy answers it once the copy is ready, and base_url until then |
metadata.mode | the mode the server runs with, "remote" or "mirror" (a saved change to the setting waits for a restart) |
metadata.mirror | mirror mode only (absent in remote mode, and when the server has no metadata service): the local copy's status, the object below |
tls | the boot tls.mode and tls.hosts, and the certificates it serves, read from their files (never generated or requested here): the self-signed pair, or one per host from the autocert cache (issued: false until Let's Encrypt has issued it). Empty for mode off. error is set when a certificate file couldn't be read |
libraries[] | each library's root, whether it answers (the same bounded probe as the scanner's), and disk (total, free to the server, in bytes) or null when the root doesn't answer or the OS doesn't say |
web_player | as in the settings envelope |
update | the update status |
backups | the backups' status as in GET /admin/backups (folder, running, last attempt, latest backup, next scheduled one); null only for a server built without the backup service (tests), never one the launcher starts |
Nothing here reaches outside the server except the metadata health check (in
mirror mode, only while no local copy is ready). The slow parts (a tool's first
-version, the health check, the root probes) run side by side, each with its
own bound.
The local copy's status (metadata.mirror)
In mirror mode the
server keeps a local copy of the community metadata. Its status, here, in
GET /admin/meta/mirror and in the answer of
POST /admin/meta/mirror/check:
{
"state": "downloading",
"tag": "data-v2026.10.08-1a2b3c4-5d6e7f8",
"built_at": "2026-10-08T05:14:03Z",
"schema_version": 7,
"size_bytes": 1755340800,
"checked_at": "2026-10-08T08:00:12Z",
"downloaded_at": "2026-10-08T08:01:40Z",
"progress": { "done": 183500800, "total": 443547136 },
"fallback": false
}
| Field | Meaning |
|---|---|
state | "empty" (no copy and no failed attempt yet), "downloading" (a download is running, over a copy or not), "opening" (a copy is being opened, which takes seconds: the one on disk just after a start, with fallback true, or a finished download before it replaces the current copy, which answers meanwhile, so fallback stays false), "ready" (a usable copy answers) or "error" (no usable copy, and the last attempt failed). A failed update over a working copy stays "ready", with error set |
tag | the data release the copy came from (data-vYYYY.MM.DD-<core7>-<community7>); absent without a copy |
built_at | when audiosilo-meta built the artifact |
schema_version | the artifact's schema version |
schema_newer | true when that is newer than this server's code understands (query.MaxSchemaVersion): the copy still answers, any query that breaks on it goes to base_url, and the server wants updating. Absent otherwise |
size_bytes | the copy's size on disk |
checked_at | the last check of the release list (successful or not); absent before the first |
next_check_at | when the next check is due (the Schedule in Mirror mode); absent while a check is running |
downloaded_at | when the copy was downloaded |
progress | only while downloading: done and total compressed bytes (total 0 when the release declares no size) |
error | the last failed check, as the server words it (e.g. not enough disk space: need 2.5 GB, have 1.1 GB); kept while a working copy answers, cleared by the next successful check |
fallback | true while lookups go to base_url because no usable copy is loaded |
The timestamps and tag, built_at, schema_version, size_bytes and
downloaded_at are omitted when they have no value. fallback is always
present.
GET /api/v1/admin/update
Admin. The update check's state (the console's Overview reads it for the "version available" link):
{
"enabled": true,
"current": "1.15.0",
"latest": {
"version": "v1.16.0",
"name": "v1.16.0",
"url": "https://github.com/KodeStar/audiosilo-server/releases/tag/v1.16.0",
"published_at": "2026-10-01T12:00:00Z"
},
"update_available": true,
"comparable": true,
"checked_at": "2026-10-04T08:13:31Z",
"error": "",
"install": "docker"
}
| Field | Meaning |
|---|---|
enabled | the update_check setting |
current | the running version (release builds report it without the tag's v) |
latest | the newest release the last successful check found, or null before one |
update_available | latest is newer than current; always false when comparable is false |
comparable | current is a release version (MAJOR.MINOR.PATCH, with or without a v, optionally -prerelease); false for a local build (dev) or a development image (dev-<short sha>) |
checked_at | the last request, successful or not; null before the first |
error | why the last request failed: "rate_limited", "unreachable", "bad_response", or "" |
install | as in GET /admin/system |
The checker asks GitHub a minute after the server starts and then at most once a day, only while the check is on; see Update check for exactly what it sends.
POST /api/v1/admin/update/check
Admin. Checks now and answers with the new status (the shape above). Within a
minute of the last request (or while one is in flight) it answers with that
request's result instead of asking GitHub again. A failed request is not an
error here: it is reported in error.
| Status | Meaning |
|---|---|
200 | the update status |
409 update_check_off | the update check is turned off |
GET /api/v1/admin/meta/mirror
Admin. The local metadata copy's status
on its own, without the rest of GET /admin/system (no tool versions, disk
probes or health check): what Health > System
polls while the copy is busy.
| Status | Meaning |
|---|---|
200 | the copy's status |
404 metadata_off | community metadata is switched off |
409 not_mirror_mode | the server isn't keeping a local copy: it runs in remote mode (a saved metadata.mode: mirror waits for a restart), or the copy's folder couldn't be prepared |
POST /api/v1/admin/meta/mirror/check
Admin. Asks the mirror to look for a newer copy now (the console's Check
now). It never waits for the check. It wakes the mirror and answers 202 with
the copy's status, whose
next_check_at is now, because the check is due. The check runs in the
background. If there is a newer copy, its download shows in state and
progress. While a check is already running, the route does nothing more.
The check is the scheduled one: the conditional release-list request, then a
download only when the newest data release isn't the copy held (see
Mirror mode). Not
recorded in the audit log, since it changes nothing an admin chose.
| Status | Meaning |
|---|---|
202 | the check was asked for; body is the copy's status |
404 metadata_off | community metadata is switched off (checked first, in either mode) |
409 not_mirror_mode | the server isn't keeping a local copy (as above) |
GET /api/v1/admin/logs
Admin. The newest log lines. The server keeps every record at info and
above in memory (internal/logring, the newest 2000), from when it started;
nothing is written to disk.
| Query param | Type | Default | Notes |
|---|---|---|---|
level | string | all | info, warn or error: lines at that level or above (all, debug and "" mean every line) |
q | string | - | only lines whose message or a key=value attribute contains it, ignoring case (cut at 200 characters) |
after | int | - | only lines with seq greater than this: the live tail's cursor (last_seq of the previous answer) |
limit | int | 500 | at most this many, the newest matching ones (1-1000) |
{
"entries": [
{ "seq": 41, "time": "2026-10-04T09:40:12.511Z", "level": "info",
"message": "scan complete",
"attrs": [ { "key": "library", "value": "Books" }, { "key": "books", "value": "8" } ] }
],
"last_seq": 41,
"truncated": false
}
entriesare oldest first, and each entry'sattrsis always an array (empty when the line has none).seqincreases by one per line logged, so the console polls withafter=<last_seq>and appends what comes back.last_seqis the newest line in memory whether or not it matched.truncatedis true when matching lines were left out: older ones pastlimit, or lines afterafterthat were already dropped from memory. Anafterbeyond the newest line (a cursor from before the server restarted, sinceseqstarts over at 1) is answered as a fresh first page, markedtruncated, so a live tail picks up the new process.- Attributes in a group have dotted keys (
req.path). An attribute whose dotted key names a secret has the value[redacted]. A secret is a word such astoken,password,code,key,secret,cookieorauthorization, so a group namedtokenhides all its members. Long messages and values are cut.
| Status | Meaning |
|---|---|
200 | the lines |
400 | level isn't one of the above, or after isn't a number |
Admin: backups
The console's Server > Settings > Backups: copies of the database in the
backups folder, made on the backups.schedule or on request, and a restore
applied at the next start. All Admin. How backups are made, kept and
restored is in
Backups, audit log and notifications.
A backup is identified by its file name (audiosilo-<UTC time>-<kind>.db);
a name that isn't a backup's (backup.validName) or isn't in the folder is
404 backup_not_found.
GET /api/v1/admin/backups
Admin. The backups in the folder (newest first), the service's state, and any restore waiting for, or applied at, a start:
{
"backups": [
{ "name": "audiosilo-20261004-174512Z-manual.db", "size": 1347584,
"created_at": "2026-10-04T17:45:12Z", "kind": "manual" },
{ "name": "audiosilo-20261004-020000Z-scheduled.db", "size": 1339392,
"created_at": "2026-10-04T02:00:00Z", "kind": "scheduled" }
],
"status": {
"dir": "/data/backups",
"running": false,
"last": { "at": "2026-10-04T18:45:12.402+01:00", "ok": true, "trigger": "manual",
"name": "audiosilo-20261004-174512Z-manual.db" },
"latest": { "name": "audiosilo-20261004-174512Z-manual.db", "size": 1347584,
"created_at": "2026-10-04T17:45:12Z", "kind": "manual" },
"next": "2026-10-05T03:00:00+01:00"
},
"restore": { "pending": null, "last": null }
}
| Field | Meaning |
|---|---|
backups[].kind | scheduled, manual, before-restore (the copy of the database a restore replaced) or before-upgrade (the copy a start with pending migrations made first, from 2.5.0). A file put in the folder under another valid name reads as manual, dated by its modification time |
backups[].created_at | from the name (UTC, to the second) |
status.running | a backup is being made now |
status.last | the newest attempt since the server started (null before one): trigger scheduled or manual, and on failure ok: false with error disk_full, permission_denied or failed (the cause is in the log) |
status.latest | the newest backup in the folder that isn't a before-restore or before-upgrade copy, whenever it was made (null when there is none) |
status.next | the next scheduled backup, in the server's time zone; null when the schedule is off |
restore.pending | the restore waiting for the next start: {name, requested_at, requested_by, schema} (schema is the backup's newest migration), or null |
restore.last | how the last restore went, written at start: {name, applied_at, requested_by, ok, error, safety_copy}. error (when ok is false) is missing, unusable, newer or failed, and the database was left as it was; safety_copy names the before-restore backup (or, if the database couldn't be copied, the file it was renamed to in the data folder). A restore marker that couldn't be read is reported as error: "failed" with an empty name. null when no restore ever ran |
POST /api/v1/admin/backups
Admin. Starts a manual backup in the background and answers 202 with the
GET envelope, whose status.running is already true. Poll GET while it
stays true; the outcome is then status.last. Audited (backup.create).
| Status | Meaning |
|---|---|
202 | started; body is the envelope |
409 backup_running | a backup is already being made |
GET /api/v1/admin/backups/{name}
Admin. Downloads a backup: Content-Type: application/vnd.sqlite3,
Content-Disposition: attachment; filename="<name>", Cache-Control: no-store,
served with http.ServeContent (ranges supported). The path is outside the
API's 30-second request timeout, like streaming (a restore or a delete stays
bounded by it). Every download (GET; a HEAD isn't) is audited
(backup.download): the file holds every account's password and token hashes.
| Status | Meaning |
|---|---|
200 | the file |
404 backup_not_found | no such backup |
DELETE /api/v1/admin/backups/{name}
Admin. Deletes a backup from the folder. A restore waiting for this backup is
cancelled first. Audited (backup.delete).
| Status | Meaning |
|---|---|
204 | deleted |
404 backup_not_found | no such backup |
POST /api/v1/admin/backups/{name}/restore
Admin. Checks the backup and marks it to be restored at the next start
(<data>/restore.json), replacing any restore already waiting. Nothing
changes until the server restarts. The answer is the GET envelope with
restore.pending set. Audited (backup.restore, with the backup's schema).
The check here is quick (readable, an AudioSilo schema this server knows); the
full integrity check (PRAGMA quick_check) runs at the next start, which
reports a damaged backup as a refused restore (restore.last.error
"unusable") and leaves the database as it was.
| Status | Meaning |
|---|---|
200 | scheduled; body is the envelope |
400 invalid_backup | the file can't be read as an AudioSilo database (no schema record or accounts table) |
400 backup_too_new | it was made by a newer server (it has a migration this one doesn't ship) |
404 backup_not_found | no such backup |
DELETE /api/v1/admin/restore
Admin. Cancels the restore waiting for the next start. 204 whether or not
one was waiting; audited (backup.restore_cancel) when one was.
Admin: notifications and events
The console's Server > Settings > Notifications (where the server sends what happens) and the top bar's bell (the event feed). All Admin. Event kinds, payloads, the webhook signature and the delivery rules are in Notifications.
A destination's url and secret are write-only: they are never returned.
A destination reads:
{
"id": 1,
"kind": "webhook",
"name": "Home Assistant",
"enabled": true,
"events": ["book_added", "scan_failed", "library_unavailable", "update_available", "backup_failed"],
"created_at": "2026-10-04T17:45:20.117Z",
"updated_at": "2026-10-04T17:45:20.117Z",
"last_at": "2026-10-04T17:45:20.204Z",
"last_ok": true,
"last_error": "",
"address": "http://192.168.1.5:8123/api/webhook/audi…",
"has_secret": true
}
| Field | Meaning |
|---|---|
kind | webhook, ntfy or discord; can't change after creation |
events | the event kinds sent there, in notify.Kinds order |
last_at, last_ok, last_error | the newest delivery or test: null before the first; last_error is timeout, unreachable, http_<status> or failed (never the address or the answer) |
address | the redacted address (notify.Redact): scheme, host and path with the last segment cut to four characters and … (just … when it has four or fewer), a query as ?…, user info dropped |
has_secret | a signing secret (webhook) or access token (ntfy) is saved |
GET /api/v1/admin/notifications
Admin. {"targets": [...], "events": [...], "kinds": [...]}: the
destinations (oldest first), the event kinds the server knows (book_added,
scan_failed, library_unavailable, new_device, invite_redeemed,
update_available, backup_failed, bridge_job_finished, bridge_job_failed)
and the destination kinds.
POST /api/v1/admin/notifications
Admin. Adds a destination and answers 201 with it:
{ "kind": "ntfy", "name": "My phone", "url": "https://ntfy.sh/hearthside-alerts",
"secret": "", "enabled": true, "events": ["scan_failed", "backup_failed"] }
enabled defaults to true; secret and events may be left out. The fields
are checked by notify.Clean (see
Destinations). Audited
(notify.create, with its kind and events, never its address).
| Status | Meaning |
|---|---|
201 | added; body is the destination |
400 | not a JSON object |
400 invalid_target | a field the server refuses; field names it (kind, name, url, secret or events), error says why in English words a form can show, and reason says why as a code a client words itself (kind_unknown, kind_fixed, name_required, name_too_long, name_control, url_required, url_too_long, url_invalid, url_discord, url_ntfy, secret_too_long, secret_control, secret_discord, secret_again, event_unknown; new ones may be added, so fall back to error). A *_too_long reason also carries max, the limit in characters. |
409 too_many_targets | the server has 20 destinations already |
PATCH /api/v1/admin/notifications/{id}
Admin. Changes the fields sent (name, url, secret, enabled,
events); an absent field keeps its value, so the address and secret survive
an edit that doesn't send them. "secret": "" clears the secret. A saved
secret doesn't follow the address to another server: a new url with a
different scheme, host or port and no secret in the body is
400 invalid_target with field: "secret" and reason: "secret_again" (send the secret again, or "");
a new path on the same server keeps it. Answers 200
with the destination. Sending a different kind is 400 invalid_target with
field: "kind" and reason: "kind_fixed"; other refusals as for POST; 404 (no code) when there is
no such destination. Audited (notify.update, recording address_changed /
secret_changed rather than either value).
DELETE /api/v1/admin/notifications/{id}
Admin. Removes a destination: 204, or 404 when there is none. Audited
(notify.delete).
POST /api/v1/admin/notifications/{id}/test
Admin. Sends one test message now (no retries, not added to the feed),
records the outcome on the destination, and answers:
{ "ok": false, "error": "unreachable", "target": { "id": 2, "last_ok": false, "last_error": "unreachable" } }
(target is the whole destination as above.) error is "" when it arrived,
and "failed" from a server built without the notification service (never
one the launcher starts).
A failed delivery is not an HTTP error here: the answer is 200 either way,
404 when there is no such destination.
GET /api/v1/admin/events
Admin. The event feed (the bell), newest first. Events are kept for 90 days.
| Query param | Type | Default | Notes |
|---|---|---|---|
before | int | - | only events with a smaller id: the previous page's next_before |
limit | int | 20 | at most 100 (a larger one is cut to 100; zero or less means 20) |
kind | string | - | only events of this kind (one of the event kinds) |
{
"events": [
{ "id": 12, "at": "2026-10-04T17:48:03.220Z", "kind": "new_device",
"data": { "user": "maya", "device": "Maya's iPhone", "app": "AudioSilo 1.4.2" } },
{ "id": 11, "at": "2026-10-04T17:44:58.901Z", "kind": "book_added",
"data": { "library": "Books", "library_id": 1, "count": 8,
"titles": ["Alice's Adventures in Wonderland", "Through the Looking-Glass", "The Adventures of Sherlock Holmes", "The Hound of the Baskervilles", "The Call of the Wild"] } }
],
"next_before": 0
}
data per kind is in Events (a
failed scan's detail is here, for the bell, but never sent to a destination);
next_before is 0 on the last page. A before that isn't a non-negative
number, or a kind that isn't an event kind, is 400.
Admin: audit log
GET /api/v1/admin/audit
Admin. What admins changed, newest first (the console's Server > Audit log). Kept for 365 days, at most 100,000 events. Which actions are recorded is in Audit log.
| Query param | Type | Default | Notes |
|---|---|---|---|
actor_id | int | - | one account's actions |
area | string | - | an action's first part (user, member, invite, library, book, share, device, progress, issue, settings, backup, notify, import); [a-z_]{1,32} |
q | string | - | the target or the actor's name contains it, ignoring case; at most 200 characters |
before | int | - | the previous page's next_before |
limit | int | 50 | at most 200 (a larger one is cut to 200; zero or less means 50) |
{
"events": [
{ "id": 31, "at": "2026-10-04T17:45:20.117Z", "actor_id": 1, "actor_name": "admin",
"via": "session", "action": "settings.update", "target": "",
"details": { "changes": [ { "setting": "backups.schedule", "from": "daily:03:00", "to": "weekly:sun:03:00" } ] } },
{ "id": 30, "at": "2026-10-04T17:41:02.550Z", "actor_id": null, "actor_name": "",
"via": "system", "action": "backup.restore_applied",
"target": "audiosilo-20261004-020000Z-scheduled.db",
"details": { "requested_by": "admin", "safety_copy": "audiosilo-20261004-174100Z-before-restore.db" } }
],
"next_before": 0
}
| Field | Meaning |
|---|---|
actor_id, actor_name | the admin (for a member.* event, the owner who did it in the player), copied when it happened (kept when the account is renamed or deleted); null and "" for the server itself |
via | session (the console or another signed-in app), api (a personal API key) or system |
action | <area>.<verb>, like user.update; a console words the ones it knows and shows the code for the rest |
target | what it was done to, as a person reads it: a username, a library's name, <library>: <path> for a book, a backup's file name, a destination's name; "" when the action has none |
details | the change's facts (which fields, from what to what, counts). Never a secret, never an IP address |
| Status | Meaning |
|---|---|
200 | the page |
400 | actor_id or before isn't a number, area doesn't match, or q is over 200 characters |
Admin: support card
The admin console's Overview card that points at GitHub Sponsors
(catalog/support.go, api/handlers_support.go). Only the console asks: the
player never sees it, and there is no capability flag. Both routes are Admin:
401 without a valid session or API key, 403 for a member's token. What the
console does with them is in
The admin console.
GET /api/v1/admin/support
Admin. Whether the card shows now:
{ "show": true }
show is false after an "I've donated" (for good), while a "Not now" lasts,
and on a server that hasn't been in use long enough: it turns true 30 days
after the earliest non-demo account's created_at
(catalog.SupportAfterDays), or after 7 (SupportMinDays) once 10 books
(SupportAfterFinished) have been finished by non-demo accounts since then. A
server with no non-demo account (setup not finished) never shows it. The
answer is worked out on every request; nothing is cached.
POST /api/v1/admin/support
Admin. An admin's answer, stored for the whole server (in server_state,
key support_card; see Data model). Taken on
trust: nothing is checked, nothing leaves the server, and it unlocks nothing.
{ "action": "snoozed" }
action | Effect |
|---|---|
"donated" | hides the card for good |
"snoozed" | hides it for six months from now (catalog.SupportSnoozeMonths) |
{ "show": false, "until": "2027-04-09T14:16:29Z" }
show is always false (the card is hidden either way, so nothing is
recomputed). until (RFC 3339) is when a snooze this request stored ends, and
is absent after "donated", or when nothing changed: once a server holds a
donation, a later "donated" or "snoozed" stores nothing (a snooze never
turns "for good" back into a snooze) and answers { "show": false }. The
console words its toast from until, not from the button pressed. A change is
recorded in the audit log as settings.support, with
choice and, for a snooze, returns_at; a request that changed nothing is not.
| Status | Meaning |
|---|---|
200 | the answer was taken (or there was nothing to change) |
400 | invalid request (no body, not JSON, or a field other than action), or action must be "donated" or "snoozed" |
401 | no valid session or API key |
403 | not an admin |
Admin: first-run setup
The admin console's "Set up your server" flow (catalog/setup.go, library/preview.go, library/mounts.go,
api/handlers_admin_setup.go). The flow writes every setting through the
ordinary admin routes (settings, library
create and edit); these routes keep only its
progress and answer a read-only preview of a books folder. Only the console
asks: the player never sees them, and there is no capability flag. All three
are Admin: 401 without a valid session or API key, 403 for a non-admin.
What the console does with them is in
The admin console. A server from
before the flow answers 404.
GET /api/v1/admin/setup
Admin. The flow's progress, and what its steps need to know about this server and this request:
{
"state": {
"finished": false,
"dismissed": false,
"steps": { "folder": "done", "naming": "skipped" }
},
"request": {
"host": "nas:8080",
"scheme": "http",
"proxied": true,
"peer": "172.18.0.5",
"peer_trusted": false
},
"config_libraries": ["Audiobooks"],
"mount_hints": ["/audiobooks", "/podcasts"],
"printed_password": true
}
stateis server-wide (theserver_statekeysetup_state; see Data model). The step ids, in the console's order, areaccount,folder,naming,metadata,reach,abs,backups,bridgeandphone(2.7.0 addedaccountfirst and movedphonelast), each"done"or"skipped"; an absent id is a step not yet seen. A server with no row (a fresh one) answersfinished: false,dismissed: false,steps: {}; migration0053stored an upgraded server as finished and dismissed.requestis this request as the server saw it. The server never trustsX-Forwarded-HostorX-Forwarded-Proto, sohost(theHostheader) andscheme(httpsonly for a TLS connection to the server itself) are the direct connection's; the console compares them with the address the browser uses.proxiedis true when the request carried any forwarding header, and only then ispeersent: the address that connected (the proxy), fornetwork.trusted_proxies.peer_trustedsays whether that address is already trusted.config_librariesare the names of the librariesconfig.yamlor the environment declares (re-added at every start).mount_hintsare folders mounted into the server that may hold books, read from/proc/self/mountinfoon Linux: folders that exist, not system paths (/,/proc,/sys,/tmp,/dev,/run,/boot,/snapand anything under them or under/etc, by whole path segments, so/devicesis offered), not pseudo filesystems, not the server's data folder, sorted, at most 20.[]on other systems, when the table can't be read, and once the flow is finished, unless its folder step was skipped and can still be reopened from the Overview card (only that step offers them).printed_password(2.7.0) is true while the caller is the admin the first start made and still has the password it printed in the log (auth.PrintedPassword, theserver_statekeyfirst_admin). The console's Your account step then asks for a new password. It is false for an admin who chose theirs, on the--setuppage or since, afterreset-admingave that admin a new password, and when the read fails (the step then asks for nothing).
| Status | Meaning |
|---|---|
200 | the envelope |
401 / 403 | anonymous / non-admin |
PUT /api/v1/admin/setup
Admin. Records the flow's progress. Every field is optional and only the
fields sent change; steps merges (an id it names takes the new outcome, the
others keep theirs), in one writer transaction.
{ "finished": true, "steps": { "bridge": "skipped" } }
Answers 200 with the whole state as GET returns it:
{ "finished": true, "dismissed": false, "steps": { "folder": "done", "bridge": "skipped" } }
Each request is recorded in the audit log as
settings.setup, its details the fields it sent. Sending dismissed: true
hides the Overview's "Finish setting up" card for every admin.
| Status | Meaning |
|---|---|
200 | saved; the state |
400 | invalid request (no body, not JSON, or a field other than finished, dismissed and steps), or invalid_setup_step (a step id the flow doesn't have, or an outcome other than "done" / "skipped"; nothing is saved) |
401 / 403 | anonymous / non-admin |
POST /api/v1/admin/setup/preview
Admin. What a books library at a folder would hold, and a sample of its
books named both ways (from the files' tags and from the folder names), so
the flow can suggest the library's metadata_source before the library
exists. Read-only: it writes nothing to the index, returns names and counts
only (never a file's contents) and is not audited.
{ "root": "/audiobooks", "ignore_patterns": ["Extras/"] }
ignore_patterns is optional and checked as a library's skip rules are.
{
"root": "/audiobooks",
"counts": { "audio_files": 812, "folder_books": 64, "file_books": 3, "capped": false },
"same_root": null,
"samples": [
{
"rel_path": "Arthur Conan Doyle/Sherlock Holmes/2 - The Sign of the Four",
"tags": { "title": "The Sign of Four (Unabridged)", "author": "Doyle, Arthur Conan",
"narrator": "David Clarke", "series": "Sherlock Holmes", "position": "2" },
"path": { "title": "The Sign of the Four", "author": "Arthur Conan Doyle",
"narrator": "David Clarke", "series": "Sherlock Holmes", "position": "2" },
"abs": false
}
],
"blanks": { "tags": 4, "path": 1 },
"suggested": "path"
}
- The folder is checked as the add-library folder picker checks it: an
absolute path the server can open and list.
rootcomes back cleaned. - The root is checked against the other libraries as library create checks
it, so a folder inside, or containing, a podcast library's folder is refused
with
409 root_overlaps. Two books libraries at one folder are allowed, sosame_rootnames a library already at exactly this folder (a warning for the console to show), elsenull. - The walk discovers books as a scan does (folder books and single-file
books, the same detection, no folder overrides), bounded at 5,000 folders or
10 seconds.
counts.cappedis true when the walk stopped early, so there are more than it counted; it says nothing else. - The samples are up to 8 books spread evenly over the books found in path
order, read four at a time as a scan reads names (the primary file's tags,
the folder's Audiobookshelf
metadata.json; no fingerprint, chapters or covers), all under the preview's overall limit of 15 seconds. A sample read still running then is ended and left out, so the answer has fewer samples; that never setscapped. tagsandpathare the names a library whosemetadata_sourceis"tags"or"path"would show for the book (the same resolution the index uses, so the preview and the library never disagree): each side falls back to the other for what it leaves empty.positionis a series position as text ("2","1-3"for a range).absis true when the book's folder has ametadata.json, which goes over both.blankscounts, over the samples, the names each source leaves empty on its own (before any fallback): an empty title, author or series, and a missing position beside a series. The tags count a generic title ("Track 01") as none; the narrator is never counted, since both modes take it from the tags.suggestedis the source with fewer blanks,"tags"on a tie (also when there are no samples).
| Status | Meaning |
|---|---|
200 | the preview |
400 | invalid request (no body, not JSON, an unknown field), root is required, path_not_absolute (a relative root) or invalid_pattern (a skip rule the server refuses) |
401 / 403 | anonymous / non-admin |
404 | folder_unreadable - missing, not a folder, or unreadable by the server (the OS error is not echoed) |
409 | root_overlaps - overlaps a podcast library's folder |
The Bridge
Capability bridge. The Bridge is an optional companion program that writes to
the libraries and runs the heavy jobs, so the server stays read-only
(internal/bridge, auth/bridge.go, api/handlers_bridge.go; the design is in
The Bridge). The server never connects to it. It keeps
the jobs admins ask for and the Bridge's last heartbeat, and the Bridge calls in.
There are two sets of routes:
- The console's, under
/admin/bridge: Admin, like every admin route (an admin's API key works on them, except onPOST /admin/bridge/pair, which mints a credential). - The Bridge's own, under
/bridge: Bridge token. A bridge token (a token of kindbridge, fromPOST /bridge/pair) is accepted on these routes only, in theAuthorization: Bearerheader only (never as?token=), and only while the admin who paired it is still an enabled admin. Every other route refuses it with401, and these routes refuse every other credential (a session, an API key, a cast token) with401.
On the Bridge's routes only a missing or refused token is 401 (missing bearer token, invalid or expired token): the Bridge reads it as "my token is refused",
stops calling in and tries again every 5 minutes. A failure to check the token
(the database) is 503 could not check the token, try again, which the Bridge
retries. The heartbeat and the claim also check the token again inside their own
write. So a call authenticated just before a pairing or an unpair revoked its token
(a long poll above all) saves nothing, claims nothing and gets 401. A bridge
token's calls never touch the token's last_seen, because they are not its admin's
activity.
GET /api/v1/server: the bridge capability and block
capabilities.bridge is true only while a Bridge is paired and its last
heartbeat is under two minutes old (the Bridge beats every 30 seconds). While it
is, a top-level bridge object sits beside capabilities, which stays all
booleans. The object is sent for a signed-in caller only: a session or an API
key in the Authorization header, of any role but a demo account's. The check
records nothing on the token (auth.Peek), and a missing or refused token counts
as anonymous, never a 401. An anonymous, refused, demo or bridge token gets the
flags alone. The console and the player's admin-only generate action, the clients
that read the block, ask with their token.
{
"capabilities": { "…": true, "bridge": true },
"bridge": { "version": "0.2.0", "features": ["bridge", "sidecars"] }
}
-
version- the version the Bridge reported. -
features- the job families the Bridge runs that this server knows: the part of each known kind before its dot.bridge.verifygivesbridge.sidecars.generate,sidecars.describeandsidecars.contributegivesidecars.chapters.detectgiveschapters,meta.correctgivesmeta, andreadalong.aligngivesreadalong.- The podcast kinds give
podcast, andaudible.pollandaudible.importgiveaudible. librivox.fetchgiveslibrivox.contributeis added while the Bridge can file contributions (the last heartbeat'ssetup.can_contribute, else itsestimates.sidecars.can_contribute). Thecontributionsmodule needs it.
The list is sorted, each once, always a list (
[]for none), nevernull. A kind the Bridge advertises that the server doesn't know adds nothing.
The server keeps the last heartbeat it read for up to 5 seconds, so GET /server,
which every client calls often, rarely reads the database for it. A pairing or an
unpair made on this server drops it at once; a demoted admin's Bridge shows as
offline here within those 5 seconds.
With no Bridge online the flag is false and there is no bridge key, for any
caller. An older server has neither, and some older servers send the block to
every caller. A client never assumes the Bridge: it checks the flag and
the feature it needs.
GET /api/v1/admin/bridge
Admin. The pairing, the last heartbeat and every server library with the Bridge's mapping of it. The console's Settings > Bridge reads this.
{
"paired": true,
"paired_at": "2026-10-15T12:31:07Z",
"paired_by": { "id": 1, "username": "admin" },
"pairing": null,
"online": true,
"heartbeat_at": "2026-10-15T12:40:37Z",
"version": "0.1.0",
"kinds": ["bridge.verify"],
"features": ["bridge", "contribute"],
"libraries": [
{
"id": 1, "name": "Audiobooks", "root": "/audiobooks",
"mapping": {
"library_id": 1, "mount_path": "/audiobooks", "mapped": true, "writable": true,
"checked_path": "Andy Weir/Project Hail Mary", "error": ""
}
},
{
"id": 2, "name": "Kids", "root": "/kids",
"mapping": {
"library_id": 2, "mount_path": "/kids", "mapped": false, "writable": false,
"checked_path": "", "error": "the library has no indexed book to compare against yet"
}
}
],
"workers": [],
"ui_url": "http://192.168.1.10:8091",
"estimates": {
"sidecars": {
"asr_seconds_per_audio_hour": 900, "agent_usd_per_audio_hour": 1.6,
"agent_usd_per_ebook": null, "samples": 3, "agent_backend": "claude",
"agent_ready": true, "subscription": true, "can_contribute": true,
"monthly_budget_usd": 100, "month_spent_usd": 12.3
}
},
"setup": {
"items": [
{ "id": "paired", "done": true, "required": true },
{ "id": "libraries", "done": false, "required": true, "detail": "1 of 2 libraries writable", "library_ids": [2] },
{ "id": "asr", "done": true, "required": true, "detail": "local" },
{ "id": "agent", "done": true, "required": true, "detail": "claude" },
{ "id": "budget", "done": false, "required": true, "detail": "$100 a month" },
{ "id": "github", "done": true, "required": false, "detail": "octocat" }
],
"can_contribute": true,
"github_login": "octocat"
}
}
paired- a bridge token is live: not revoked, and its admin still an enabled admin.paired_atandpaired_by({id, username},nullonce that account is gone) only while it is.pairing-{ "expires_at": "…" }while a pairing code made in the console is waiting to be exchanged, elsenull. A code can wait while a Bridge is paired (pairing another one).online- the last heartbeat is under two minutes old (the same rule as the capability).heartbeat_at,version,kinds,features,workersandui_urlare the last heartbeat's, present only while paired and after the first heartbeat (kinds,features,workersare[]before; the others absent).featuresare worked out as onGET /server,contributeincluded.libraries- every server library ([]with none), each withmapping: the Bridge's last check of it (the fields of the heartbeat'slibraries), ornullwhen it reported none for that library (or none at all yet).workers- the transcription workers the Bridge reports (name,version,platform,last_seen); always[]today.ui_url- the Bridge's own page on the local network, which the console links to (an absolutehttp(s)URL, checked when the heartbeat arrives).estimates- the last heartbeat's costing figures, omitted when it sent none (or they were dropped as out of bounds). The console's agent, Paid by, This month and Estimates from rows read them.setup- the last heartbeat's setup checklist as the server kept it (itemsin the Bridge's order, eachid,done,required,detailwhen there is one andlibrary_idsfor the libraries still to fix;can_contribute;github_login, the GitHub account signed in on the Bridge's Accounts tab, absent while it files with a token from its environment orgh; admins only:GET /servernever carries it). Omitted when the Bridge sent none (an older Bridge, or a server it hasn't been told accepts it yet) or the block didn't decode. The console's Setup card reads it.
POST /api/v1/admin/bridge/pair
Admin, a session only. Makes a pairing code for a Bridge to exchange at
POST /bridge/pair. Response 201:
{
"code": "7KQ2-M9XD-4TPA-W3HN",
"expires_at": "2026-10-15T12:46:07Z",
"server_url": "https://audiosilo.example.com"
}
The code is shown once (the server keeps only its hash) and lasts 15 minutes.
It is case-insensitive and its hyphens are optional, like an invite code. Making a
code replaces one still waiting; a Bridge already paired keeps working until a new
one exchanges the code. server_url is the address to give the Bridge: the
server's home address when it has one (its address on
the local network, plain http for a server with a self-signed certificate, which
a Bridge can't verify), else the address the server builds its links with (the
configured public_url, or the address the request came in on). Audited as
bridge.code.
| Status | Meaning |
|---|---|
201 | { code, expires_at, server_url } |
401 | no valid session |
403 | not an admin, or not available when authenticating with an API key |
DELETE /api/v1/admin/bridge
Admin. Unpairs the Bridge: revokes every bridge token and forgets the Bridge's
status and any code waiting. The Bridge's next call gets 401. Its jobs stay: a
queued job waits for the next Bridge, and a job it was running is queued again when
its lease runs out (see Leases). 204 No Content.
Audited as bridge.unpair.
The job object
Every job route, the console's and the Bridge's, answers jobs in one shape:
{
"id": 12,
"kind": "bridge.verify",
"library_id": 1,
"path": null,
"params": {},
"state": "done",
"progress": { "step": "writable", "message": "Checking the book's folder can be written" },
"result": {
"library_id": 1, "mount_path": "/audiobooks", "path": "Andy Weir/Project Hail Mary",
"files": [
{ "path": "Andy Weir/Project Hail Mary/Project Hail Mary.m4b",
"size": 912345678, "expected_size": 912345678, "ok": true }
],
"writable": true, "elapsed_ms": 412
},
"error": "",
"requested_by": { "id": 1, "username": "admin" },
"priority": 0,
"attempts": 1,
"lease_until": null,
"cancel_requested": false,
"created_at": "2026-10-15T12:41:02Z",
"updated_at": "2026-10-15T12:41:03Z",
"claimed_at": "2026-10-15T12:41:02Z",
"finished_at": "2026-10-15T12:41:03Z"
}
library_idandpath- what the job is about, path-keyed like every durable record: a book's indexed path,nullfor a job about a whole library (or about no library). A job follows its book when the scanner sees it move, and goes with its library.params- the kind's parameters as the server normalised them:bridge.verifystores ahold_secondsof 0 as{};sidecars.generatestores every field with its default;sidecars.describe,chapters.detectandreadalong.alignstore{};sidecars.contributestores itsdimensionsin order;meta.correctstores the checked changes with the server'ssource.
progressandresult- JSON objects the Bridge reported,{}until it does. The server stores them as they are. Their shapes are documented per kind:sidecars.generate's in Characters and recaps;sidecars.describe's in A description on its own;chapters.detect's in Chapterless books;readalong.align's in Read-along;sidecars.contribute's andmeta.correct's results in Contributions. Their progress is a{step, message, done, total}with the stepssharinganddone, orchecking,filinganddone.
error- why it failed or was parked (""otherwise).state-queued,claimed,running,done,failed,needs_attentionorcancelled(see Job states).requested_by- the admin who queued it,nullonce that account is deleted.attempts- how many times it has been claimed;lease_until- when the current claim's lease runs out (nullwhen no claim holds it);cancel_requested- an admin asked the Bridge to stop it.- Times are RFC 3339 UTC, whole seconds.
Job states
| State | Meaning |
|---|---|
queued | waiting for the Bridge |
claimed | the Bridge took it (leased) and hasn't reported it running yet |
running | the Bridge reported it running (leased) |
needs_attention | the Bridge parked it: it still holds it, with no lease |
done · failed · cancelled | ended (finished_at set) |
A kind, a library and a path have at most one open job (queued, claimed, running or needs attention) at a time: queuing the same again returns that job.
Leases, attempts and cancelling
A claim leases the job for 2 minutes, and every report from the Bridge renews
the lease (the Bridge reports at least every 30 seconds while it works, every
10 seconds after a report that didn't get through). A lease
that runs out puts the job back in the queue, dropping the dead attempt's
progress, result and error, unless it was the third attempt (the job
fails, error "the Bridge stopped reporting on every attempt") or an admin had
asked to cancel it (it is cancelled); those two keep what was reported. A claim
waiting for a job is woken when a lease runs out, so a requeued job is picked up at
once. Leases are checked whenever a job
is claimed, reported on, cancelled or read, so a Bridge that died mid-job leaves
the job queued again (or ended) by the time anyone looks.
Cancelling a queued or parked job ends it at once. Cancelling a claimed or running
one sets cancel_requested, which the Bridge reads in the answer to its next
report and stops; if it never reports again, the job is cancelled when the lease
runs out.
GET /api/v1/admin/bridge/jobs
Admin. Jobs, newest first.
| Param | Meaning |
|---|---|
library_id | only this library's jobs (a positive integer, else 400 invalid library_id) |
path | only jobs about this book path |
state | only jobs in this state |
kind | only this job kind; without it podcast.poll is omitted (kind=podcast.poll explicitly includes routine checks); podcast.fetch and podcast.retain are listed by default |
before | the previous page's next_before (a positive integer, else 400 invalid before) |
limit | page size, 1 to 200 (anything outside that: 50) |
{ "jobs": [ { "id": 12, "kind": "bridge.verify", "…": "…" } ], "next_before": 12 }
jobs is [] when there are none. next_before is present only when there is
another page (the server reads one row past the page to know): pass it as
before.
Subscribe job params, errors and results are redacted in listings and detail reads; a private feed URL appears only in the Bridge's claim answer. See Podcast jobs.
POST /api/v1/admin/bridge/jobs
Admin. Queues a job. podcast.subscribe, podcast.poll, podcast.fetch,
podcast.retain, audible.poll, audible.import, audible.sync and
librivox.fetch are server-internal and refused here with 400 (no code): use
listener follow/check, the admin storage routes,
Check now,
Back up,
Add from LibriVox or the scheduler instead.
Book-writing kinds on a podcast library return 409 not_book_library;
podcast kinds are only ever queued for a podcast library (follow and check answer
409 not_podcast_library for a books library before queuing).
{ "kind": "bridge.verify", "library_id": 1, "path": "Andy Weir/Project Hail Mary", "params": { "hold_seconds": 0 } }
| Field | Meaning |
|---|---|
kind | the job kind: one the server knows and the paired Bridge advertised in its last heartbeat (kinds are allow-listed twice) |
library_id | the library, when the kind is about one (every kind needs it) |
path | an indexed book in that library (optional or required, per kind); the job is about the book's own path |
params | the kind's parameters, checked per kind (unknown fields refused), at most 16 KiB as sent (meta.correct: 128 KiB) |
The request body is at most 132 KiB (the largest params cap,
bridge.MaxParamsBytes, plus 4 KiB); a longer one is 400 invalid request before
it is read whole.
The kinds:
-
bridge.verifyneedslibrary_id, takes an optional bookpath(without one the Bridge checks the library's sample book, the one its mapping check uses: seeGET /bridge/libraries/{id}/book), andparams{ "hold_seconds": 0-600 }(optional; 0 is stored as{}), how long the Bridge waits before it finishes, so a cancel or a stopped Bridge can be tried mid-job. What it does and reports is in Pairing and jobs. -
sidecars.generategenerates a book's characters and recaps and writes them into its.audiosilo/folder. See Characters and recaps.- It needs
library_idand a bookpath, and takesparams{ "source": "auto" | "audio" | "ebook", "contribute": "local" | "issue", "work_id": "…" }, all optional. They are stored as{"source": "auto", "contribute": "local"}plus awork_idwhen given (at most 200 bytes, no control characters). - It writes into the library, so it is queued only for a library the last
heartbeat reports mapped and writable.
contribute: "issue"is queued only while the Bridge can contribute (the heartbeat'ssetup.can_contribute, elseestimates.sidecars.can_contribute). - Queuing it while a
sidecars.describejob is open for the book cancels that job, since this run writes the description too. A queued or parked job is cancelled at once, a running one by asking the Bridge. - It is refused while a
chapters.detectjob is queued or working for the book (detect_open). Cancelling that job would throw its transcription away, and a generate job queued after it reuses that. It cancels a parkedchapters.detectjob in the same way, since this run finds the chapters itself. - It is likewise refused while a
sidecars.contributejob is queued or working for the book (contribute_open), because it would replace what is being filed. It cancels a parked one, or a queued one held while thecontributionsmodule is off (no claim hands it out).
- It needs
-
sidecars.describewrites a book's own-words description from the notes the Bridge kept when it generated the book (no new speech recognition) into its.audiosilo/folder. It needslibrary_idand a bookpath, and takesparams{}or none (any field is refused; stored as{}). It writes, so the same mapped-and-writable rule applies, and it is refused while asidecars.generatejob (generate_open) or achapters.detectjob (detect_open: both rewrite the book'sbridge.json) is open for the book. See A description on its own. -
chapters.detectfinds the chapters of a book with no chapter markers in its narration (speech recognition and detection only, no agent) and writes them into its.audiosilo/folder aschapters.json, which the server then uses as the book's chapters. It needslibrary_idand a bookpath, and takesparams{}or none (any field is refused; stored as{}). It writes, so the same mapped-and-writable rule applies. It is refused while asidecars.generatejob is open for the book (generate_open: that run finds them itself), while asidecars.describejob is (describe_open), and for a book whose chapter source an admin chose (chapters_chosen: the list would never be used). See Chapterless books. -
readalong.alignmakes a book's read-along map. The Bridge reuses the transcript it kept for the book (else transcribes it; no agent), lines the words up with the book's ebook, and writesreadalong.jsoninto its.audiosilo/folder. See Read-along.- It needs
library_idand a bookpathin a books library, and takesparams{}or none (any field is refused; stored as{}). - It writes, so the same mapped-and-writable rule applies.
- It is refused for a book the index holds no ebook for (
no_ebook, checked before the queue). It is also refused while asidecars.generate(generate_open) orchapters.detect(detect_open) job is open for the book, parked ones included. - A new
sidecars.generatejob is refused while a read-along job is queued or working (readalong_open), and cancels a parked one. A newchapters.detectjob is refused while one is open. - Re-aligning a book with a ready map is allowed.
- Its progress is
chapters.detect's shape (stageincludingreadalong_aligning). Its result is{library_id, path, file, coverage: {text, audio}, sections, spans, audio_gaps, heal_windows, transcribed, ebook_sha256}. - A map under 50% text coverage fails the job and writes nothing.
- It needs
-
sidecars.contributefiles the characters and recaps the Bridge generated for the book earlier on this server and kept (its own copy, not the book's.audiosilo/files), to the community metadata database as intake issues ("Share with the community"). It needslibrary_idand a bookpath, and takesparams{ "work_id": "…", "dimensions": ["characters", "recaps"] }, both optional (dimensionsfrom those two, each once, stored in that order; absent or empty means both;work_idas forsidecars.generate). It writes nothing into the library, so it works on any library. It is refused while asidecars.generatejob is open for the book (generate_open). Result:{library_id, path, work_id, contributed, covered}, wherecontributedis{characters?, recaps?}(the issue URLs) andcoveredlists the dimensions the community already had. See Contributions. -
meta.correctfiles an admin's edits of the book's details to the community metadata database as one correction-batch issue ("Suggest corrections"). It writes nothing into the library. It needslibrary_idand a bookpath, and takesparams{ "work_id": "…", "recording_id": "…", "asin": "…", "isbn": "…", "region": "…", "changes": [{ "field": "title", "current": "…", "corrected": "…" }], "evidence": "…" }, withwork_id,changesandevidencerequired. The rules:- Changes: 1 to 25, each
fieldonce fromtitle,author,narrator,series,series_index,more_series,published,isbn. - Values: each trimmed, one line, at most 500 characters, no control
characters.
corrected(as the community reads it, below) must be set and differ fromcurrentas sent. evidence: CRLF is read as LF, the text is trimmed, and line breaks and tabs are kept. At most 2000 characters, both as written and as the issue carries it (a line starting with#loses its indent and counts one more). Not_No response_, which the community reads as a blank field, and not reading as nothing.- The lengths count the text as written, not decoded.
recording_idis a community slug.- The params stay within this kind's 128 KiB cap (
params too large). - Characters, as the community reads them. The server checks the values and
the evidence with each HTML character reference ending in
;decoded; one naming whitespace reads as a space. None may hold a control character, the evidence's line breaks and tabs aside (… holds a control character). None may hold a bidirectional embedding, override or isolate control,‮included (… holds a bidirectional override character; U+202A-U+202E, U+2066-U+2069). The joiners and marks right-to-left scripts need are allowed. - Each change must be an edit made on this server: the field's source is
editedon the book andcorrectedis its value now, else400 not_edited. - The server stores the book's own spelling of each corrected value and stamps
sourceasaudiosilo-server/<version>, whatever was sent. A sentsourcemust still be at most 128 bytes, with no control characters.
Result:
{issue_url, issue_number, changes, auto: [fields], maintainer: [fields]}. See Contributions. - Changes: 1 to 25, each
sidecars.contribute, meta.correct and a sidecars.generate job with
contribute: "issue" belong to the contributions module.
- While the module is off they are
404 module_off, checked first, before the book. One already queued is held (the Bridge isn't given it) until the module is on again, though a newsidecars.generatejob for the book cancels a heldsidecars.contributeone. - While it is on they are
409 cannot_contributeunless the Bridge can contribute.
A job being cancelled (cancel_requested) refuses nothing (it ends without
writing). Which open kind refuses, or gives way to, which is one table:
Kinds are allow-listed twice.
The Bridge may be offline: the job waits for it. Response 201 with the new job,
or 200 with the open job already there for that kind, library and path (no
second job is made). That open job may be one still stopping after a cancel
(cancel_requested: true): nothing new can be queued for it until it ends, and
the console says so. A new job is audited as bridge.job.
| Status | Meaning |
|---|---|
201 | the job, queued |
200 | the open job of that kind for that library and path |
400 | invalid request (not JSON, an unknown field, a body past the bound above); or a check of the kind, each message starting invalid: : unknown kind "…", library_id is required, this kind takes no library or path, this kind takes no path, path is required, params too large, or a params error (hold_seconds must be 0-600, source must be auto, audio or ebook, contribute must be local or issue, a work_id too long or with a control character, an unknown field, a dimensions entry that isn't characters or recaps or is repeated, a meta.correct params error (the kind's checks above): no work_id or evidence (or evidence reading as nothing), a field that can't be corrected or is listed twice, a corrected value that is empty or reads the same as the current one, a change or the evidence past its bounds (counted as written), a value or the evidence holding a control character (the evidence's line breaks and tabs aside) or a bidirectional override character, written or as a character reference that reads as one, a recording_id that isn't a community slug, an identifier too long); code: "not_edited" with field - a meta.correct change of a field that isn't an edit made on this server, or whose corrected value isn't the book's value now |
404 | library not found; code: "book_not_found" - the path is not an indexed book; code: "module_off" - a job of the contributions module (sidecars.contribute, meta.correct, sidecars.generate with contribute: "issue") while the module is off |
409 | one of the codes listed under the table |
The 409 codes, each sent as code:
kind_unavailable: no Bridge has reported, or the paired one doesn't run that kind. An older Bridge may not runsidecars.describe,chapters.detect,sidecars.contribute,meta.correctorreadalong.align.library_not_writable: a kind that writes, for a library the last heartbeat doesn't report mapped and writable.cannot_contribute:sidecars.generatewithcontribute: "issue",sidecars.contributeormeta.correctwhile the Bridge can't contribute.generate_open:sidecars.describe,chapters.detect,sidecars.contributeorreadalong.alignwhile asidecars.generatejob is open for the book ("a generate job is still open for this book; queue this once it has finished").contribute_open:sidecars.generatewhile asidecars.contributejob is queued or working for the book. A parked one, or a queued one held while thecontributionsmodule is off, is cancelled instead.detect_open:sidecars.generatewhile achapters.detectjob is queued or working for the book, orsidecars.describeorreadalong.alignwhile one is open ("a chapter detection job is still open for this book; queue this once it has finished").describe_open:chapters.detectwhile asidecars.describejob is open for the book.chapters_chosen:chapters.detectfor a book whose chapter source an admin chose (filesorcommunity), checked before the queue ("an admin chose this book's chapter source, so chapters found in the narration would never be used; set it back to automatic first").readalong_open:sidecars.generatewhile areadalong.alignjob is queued or working for the book (a parked one is cancelled instead), orchapters.detectwhile one is open ("a read-along job is still open for this book; queue this once it has finished").no_ebook:readalong.alignfor a book the index holds no ebook for, checked before the queue ("this book has no ebook to align with its narration").
library_not_writable, cannot_contribute and the open-job codes are checked
after the dedupe, so an open job of the same kind for the book is still returned
(200).
GET /api/v1/admin/bridge/jobs/{id}
Admin. One job (the job object). 400 invalid job id; 404 job not found.
POST /api/v1/admin/bridge/jobs/{id}/cancel
Admin. Cancels a job: at once when it is queued or parked, else by asking the
Bridge (cancel_requested; see Leases). Answers
200 with the job as it is now. Audited as bridge.cancel.
| Status | Meaning |
|---|---|
200 | the job (cancelled, or still running with cancel_requested: true) |
400 | invalid job id |
404 | job not found |
409 | code: "finished" - the job already ended |
GET /api/v1/admin/bridge/estimate
Admin (an API key works). What queueing sidecars.generate for a book would
take: the confirmation dialogs (the console's and the player's) read it, and the
console's book card and series batch read its brief form (the card's
Write a description reads its can_describe and has.*.description too).
How each figure is worked out is in
Characters and recaps.
| Param | Meaning |
|---|---|
library_id, path | the book (required; an indexed book's path) |
kind | sidecars.generate (the default) or readalong.align (its own answer, below) |
brief | 1: no look into the book's folder for an ebook (source is audio, no ebook) and no scan of the series (earlier is []): the cheap answer for a label or a batch |
{
"kind": "sidecars.generate",
"library_id": 1,
"path": "Brandon Sanderson/The Stormlight Archive/Words of Radiance",
"source": "audio",
"asr_hours": 12.06,
"agent_cost_usd": 77.16,
"subscription": true,
"agent_ready": true,
"can_contribute": false,
"figures": true,
"duration_seconds": 173621,
"writable": true,
"can_describe": true,
"has": {
"community": { "characters": false, "recaps": false, "description": false },
"local": { "characters": false, "recaps": false, "description": false }
},
"earlier": [
{ "library_id": 1, "path": "Brandon Sanderson/The Stormlight Archive/The Way of Kings",
"title": "The Way of Kings", "series_index": "1", "asr_hours": 11.43, "agent_cost_usd": 73.15 }
]
}
| Field | Meaning |
|---|---|
source | ebook when the book has an ebook the Bridge would read (exactly one regular, non-hidden .epub directly in a folder book's folder; <stem>.epub beside a single-file book), else audio |
ebook | that .epub, library-relative; omitted for audio |
duration_seconds | the book's length (0 when unknown) |
asr_hours | expected transcription wall time: duration_seconds / 3600 * asr_seconds_per_audio_hour / 3600, rounded to hundredths; 0 for an ebook, without figures, or for audio of unknown length |
agent_cost_usd | expected agent spend, rounded to cents: the length times agent_usd_per_audio_hour, or agent_usd_per_ebook; null when the Bridge has no history for that source yet (or sent no figures): no estimate, never a free run |
subscription, agent_ready, can_contribute | the Bridge's: its agent credential is a subscription (the cost is notional), the agent container is ready, and it can offer results to the community (contribute: "issue"; the heartbeat's setup.can_contribute, else its estimates'). can_contribute is false while the contributions module is off |
figures | the last heartbeat carried estimates. Without them every field above but can_contribute is a default, so a false agent_ready means "not known yet" |
writable | the Bridge last reported the library mapped and writable (else queueing is 409 library_not_writable) |
can_describe | the Bridge is online and runs sidecars.describe (an older Bridge may not). With brief=1 too |
has.community | what the book's community work carries (metadata on and the book matched; false otherwise): characters, recaps (a recap list: a whole-book summary alone doesn't count, since generated recaps still fill the list beside it) and description, which is its community_description or its core description (the player shows either before a generated one, so a generated description would never be read) |
has.community_unknown | true when the community couldn't be asked right now, so has.community is no answer (never read it as "none"); omitted otherwise |
has.local | what the book's indexed .audiosilo/ files carry: characters, recaps and description |
earlier | the books of the same library before this one in its first numbered series (any spelling), with no recaps generated here and, metadata on, none in the community (a book the community couldn't be asked about is left out): the nearest 10, in reading order, each with its own asr_hours and agent_cost_usd (costed from its audio). [] for none, and with brief=1 |
open_job | the book's open sidecars.generate job, else the open job of another kind that refuses a generate job now, whose kind says so: a queued or working chapters.detect, sidecars.contribute (not a queued one held while the contributions module is off) or readalong.align job. Omitted when there is neither |
| Status | Meaning |
|---|---|
200 | the estimate |
400 | library_id and path are required; no estimate for that kind |
401 / 403 | no admin credential |
404 | library not found; code: "book_not_found" - no indexed book at that path |
409 | code: "kind_unavailable" - no Bridge has reported, or it doesn't offer the kind |
With kind=readalong.align the answer is the read-along confirmation's:
{
"kind": "readalong.align",
"asr_hours": 2.98,
"figures": true,
"writable": true,
"ebook": { "path": "Jack London/The Call of the Wild/The Call of the Wild.epub", "size": 412345 },
"readalong": { "state": "ready", "coverage": 0.996, "generated_at": "2026-11-02T10:11:12Z" }
}
| Field | Meaning |
|---|---|
asr_hours | one transcription of the book's whole audio at the Bridge's asr_seconds_per_audio_hour, as for generate (a loop-repair round and the heal's windows can add to it). Much less when the Bridge still has the book's transcript (a re-align takes minutes); never an agent |
figures, writable | as above |
ebook | the book's ebook ({path, size}), null when it has none: queueing is then 409 no_ebook |
readalong | the book's map now, as Book.readalong; null for none |
open_job | the book's open readalong.align job, else the open sidecars.generate or chapters.detect job it would wait for; omitted when there is none |
POST /api/v1/bridge/pair
Public, rate limited. The Bridge trades a pairing code from
POST /admin/bridge/pair for its token.
{ "code": "7KQ2-M9XD-4TPA-W3HN" }
Response 201:
{ "token": "b8Hq…", "server_id": "kx8Qz1c7m2Vw0aB3dEfGh", "server_name": "AudioSilo" }
In one transaction the server checks the code (its hash, its expiry, and that the
admin who made it is still an enabled admin), uses it up, revokes every earlier
bridge token and clears the last Bridge's status: one Bridge per server, and a
new one starts from nothing. The token belongs to that admin and is shown once.
Failures count against the same per-address limit as
POST /auth/redeem (10 failed attempts in 15 minutes); a
success resets it. Audited as bridge.paired, by the server itself since no admin
made the request (with no details: the audit log keeps no addresses).
| Status | Meaning |
|---|---|
201 | { token, server_id, server_name } |
400 | code is required |
401 | invalid or expired pairing code (wrong, used or expired, or its admin is no longer an enabled admin) |
429 | too many attempts, try again later, with Retry-After (seconds until the lockout ends) |
A server without the Bridge routes answers 404 (or 405); the Bridge reads that
as "this server is too old for a Bridge".
PUT /api/v1/bridge/status
Bridge token. The heartbeat, every 30 seconds: what the Bridge is and what it found. The server keeps the last one only (one row, never a history).
{
"version": "0.1.0",
"kinds": ["bridge.verify"],
"libraries": [
{ "library_id": 1, "mount_path": "/audiobooks", "mapped": true, "writable": true,
"checked_path": "Andy Weir/Project Hail Mary", "error": "" }
],
"workers": [],
"ui_url": "http://192.168.1.10:8091",
"estimates": {
"sidecars": {
"asr_seconds_per_audio_hour": 900,
"agent_usd_per_audio_hour": 1.6,
"agent_usd_per_ebook": null,
"samples": 3,
"agent_backend": "claude",
"agent_ready": true,
"subscription": true,
"can_contribute": false,
"monthly_budget_usd": 100,
"month_spent_usd": 12.3
}
}
}
| Field | Meaning |
|---|---|
version | the Bridge's version (at most 128 bytes) |
kinds | the job kinds it runs, each family.name in lower case, listed once (at most 64) |
libraries | its last check of each library it has checked (at most 512, each library_id once and above 0): mount_path where it looked, mapped (a known book's files were there with the server's sizes), writable (a probe file could be written and removed there), checked_path (the book it compared), error (why it isn't mapped or writable, "" when it is); the three texts at most 1 KiB each |
workers | the transcription workers connected to it (at most 64; name, version, platform, last_seen, at most 128 bytes each; [] today) |
ui_url | its own page on the local network: an absolute http(s) URL without credentials, or "" |
setup | optional, sent only after a reply listed it in accepts: the Bridge's setup checklist, { items: [{ id, done, required, detail?, library_ids? }], can_contribute, github_login? }. Read leniently like estimates (an unknown key ignored, a block that doesn't decode dropped, never refused) and bounded: the first 16 items kept (the rest left out); an item whose id doesn't match ^[a-z][a-z0-9_]{0,31}$, or repeats one, is left out (an unknown id is kept); detail cleaned to 256 bytes; in library_ids a zero, negative or repeated id left out (at most 512 kept); a github_login that isn't GitHub's shape dropped. Its can_contribute wins over the estimates'. The ids a Bridge sends, their order and meaning: The setup checklist |
estimates | optional: its figures for costing a job before it is queued, one block per job family. sidecars: asr_seconds_per_audio_hour (ASR wall time per hour of audio, learned or a seed), agent_usd_per_audio_hour and agent_usd_per_ebook (agent spend from finished books, null before any), samples (the finished books they come from), agent_backend ("" when none), agent_ready (the agent container answers and has a credential), subscription (that credential is a subscription login), can_contribute (it can file community intake issues), monthly_budget_usd (0 = no cap) and month_spent_usd. Read leniently: an unknown key is ignored, and a block that doesn't decode, or holds a number below 0 or past its bound, is dropped, never refused (agent_backend is cleaned like the other text) |
The structure is checked and refused when wrong: a malformed kind, a repeated
kind, a library id that is repeated or not above 0, too many entries, or a ui_url
that isn't an absolute http(s) URL (the console renders it as a link). The free
text (the version, each mapping's paths and error, the workers' fields) is
cleaned instead of refused: broken UTF-8 becomes U+FFFD, each control character
a space, and the text is cut to its limit. Much of it comes from the outside world
(a file's name, an OS error), and a refused heartbeat would leave the Bridge
offline. The body is at most 64 KiB. The answer lists the server's libraries, so
the Bridge can map the ones it hasn't checked yet, and accepts, the
optional protocol features this server accepts:
{
"server_time": "2026-10-15T12:40:37Z",
"libraries": [ { "id": 1, "name": "Audiobooks", "root": "/audiobooks", "kind": "books" } ],
"accepts": ["setup", "claim_min_priority"]
}
Every library in the reply includes kind: "books"|"podcasts"; the Bridge
treats a missing kind from an older server as books. Podcast support adds
podcast.subscribe, podcast.poll, podcast.fetch and podcast.retain to the existing kinds list without a
new heartbeat request field.
claim_min_priority negotiates a claim query option, not a heartbeat block.
The heartbeat itself is decoded strictly (an unknown field is a 400), so a
Bridge sends an optional block only after a reply listed it, and drops them all and
resends once on a 400 (an older server answers no accepts). See
Optional blocks.
| Status | Meaning |
|---|---|
200 | { server_time, libraries, accepts } |
400 | invalid status (not JSON, too large, an unknown field), or a structure the checks refuse (invalid: kind "…", invalid: library 0, invalid: ui_url must be an http(s) URL, …) |
401 | no bridge token or a refused one, including one a pairing or an unpair revoked after the request arrived |
503 | the token couldn't be checked, or (from 2.5.1) the heartbeat couldn't be saved (could not save the status; a 500 before): the Bridge retries |
GET /api/v1/bridge/jobs/next
Bridge token. Claims the next job: a long poll.
| Param | Meaning |
|---|---|
wait | seconds to wait for a job when none is queued, 0 to 25 (more is 25; default 0) |
kind | repeatable: only these kinds (the ones the Bridge has a free lane for) |
min_priority | optional single signed base-10 int32 floor; only send after heartbeat accepts lists claim_min_priority. Invalid/empty/repeated/out-of-range is 400 invalid_request; omitted means no floor |
The server picks from the kinds the last heartbeat named and the server knows,
narrowed to the kind values when there are any. It hands out the highest
priority, then oldest, queued job of those kinds, leased for 2 minutes and with
attempts one higher, and answers 200 with the job.
- With no job it waits up to
wait, then answers204. A job queued meanwhile, or one put back by a lapsed lease, wakes it. - When no kind is left after the narrowing, it answers
204at once. - When the server is stopping, it ends the wait with
204.
The token is checked again with every claim, so a Bridge that a pairing or an unpair
replaced during its long poll gets 401, never a job. A claim with nothing to
do (no job to hand out, no lease run out, no private follow request whose feed
URL the server lost) writes nothing on the server.
The priority floor is applied in the claim transaction before lease/attempt
mutation, alongside kinds, module and token checks. The reserved Bridge
dispatcher requests kind=podcast.fetch&min_priority=100&wait=25; ordinary
claims omit the floor. An undelivered untouched claim is released without
spending an attempt on write/flush failure or Bridge disconnect. A delivered
reply is retained across subsequent shutdown/deadline. See
claims and leases for
admission, capacity fallback and immediate cancel/join/reissue on eligibility
changes.
| Status | Meaning |
|---|---|
200 | the job, now claimed |
204 | no job |
400 | code: "invalid_request" for invalid min_priority |
409 | code: "no_heartbeat" - send a heartbeat first |
401 | no bridge token or a refused one (also when it was revoked during the wait) |
503 | the token couldn't be checked, or the request deadline expired |
PATCH /api/v1/bridge/jobs/{id}
Bridge token. The Bridge's report on a job it holds: progress, a new state, the result, an error. Every report renews the lease.
{
"attempt": 1,
"state": "running",
"progress": { "step": "stat", "message": "Comparing the files", "done": 2, "total": 4 }
}
| Field | Meaning |
|---|---|
attempt | required: the attempts its claim returned. A report from an earlier claim (one whose lease ran out and was claimed again) is refused |
state | optional: running, done, failed, needs_attention or cancelled (from claimed, running or needs_attention) |
progress | optional: a JSON object, at most 16 KiB |
result | optional: a JSON object, at most 64 KiB |
error | optional: text, cleaned rather than refused (control characters become spaces, line breaks are kept, cut at 2 KiB) |
A field left out keeps its value. A claimed or running job's lease is renewed;
needs_attention drops the lease (the Bridge still holds the job); done,
failed and cancelled end it. The answer is the job as stored: its
cancel_requested asks the Bridge to stop.
| Status | Meaning |
|---|---|
200 | the job |
400 | invalid job id; invalid update (not JSON, over 128 KiB, an unknown field); or a check, each message starting invalid: : progress must be a JSON object (or result), progress too large (or result), or a state it can't report (state "queued" from "running") |
409 | code: "not_held" - this claim no longer holds the job: it ended, was cancelled, its lease ran out and it was queued or claimed again, or it no longer exists (gone with its library). The Bridge drops it. This route never answers finished (only the console's cancel does) |
GET /api/v1/bridge/libraries/{id}/marker
Bridge token. Reads the mapping marker for an empty or populated podcast
library (the route accepts either kind). Response 200 {"nonce":"<32 lowercase hex>"}
requires <root>/.audiosilo/library.json to be a regular file no larger than
4 KiB, with version 1, matching library_id, and a valid nonce; the marker
folder cannot be a symlink. The server reads only and never creates it.
Missing/invalid marker is 404 no_marker, missing library 404 library_not_found.
See Library mappings.
Bridge podcast retention
Paired Bridge bearer token only. Session/API/cast tokens and query tokens
are refused, including an admin's token. These are core reads and return
Cache-Control: no-store; they expose no per-user listening data.
GET /api/v1/bridge/libraries/{id}/podcasts/retention?path=<show>&show_id=<id>
returns 200 {retention:Policy}. A missing/invalid path or show_id is
400 invalid_request, missing library 404 library_not_found, books library
409 not_podcast_library, missing show 404 show_not_found, and a different
current show id 409 identity_changed.
POST /api/v1/bridge/libraries/{id}/podcasts/retention/check is a read-only
check with strict JSON, capped at 4 KiB:
{"show_id":"a1b2c3d4e5f60718","path":"Fixture Show/2026-10-19-1cc306d831.mp3","generation":2,"sha256":"7be5836ba147da1215098df9f7a34f33b157d07fd92dd801928c9caef17e6d2b","bytes":16493}
Generation must be positive, SHA-256 64 lowercase hex and bytes a positive
int64. The indexed present generation/hash/size and show id must match.
Missing episode is 404 episode_not_found, mismatch 409 identity_changed;
bad input is 400 invalid_request, books library 409 not_podcast_library,
missing library 404 library_not_found. Success includes all these keys:
{
"library_id":3,"show_path":"Fixture Show","show_id":"a1b2c3d4e5f60718",
"path":"Fixture Show/2026-10-19-1cc306d831.mp3","generation":2,
"sha256":"7be5836ba147da1215098df9f7a34f33b157d07fd92dd801928c9caef17e6d2b","bytes":16493,
"checked_at":"2026-10-21T08:00:00Z","policy":{"mode":"keep","keep":3,"days":7},
"followers":2,"all_finished":true,"all_finished_at":"2026-10-12T08:00:00Z",
"starred":false,"queued":false,"active_stream":false,"fetch_open":false,
"outside_keep_window":true,"delete_after":"2026-10-28T08:00:00Z","eligible":false
}
all_finished_at and delete_after are RFC 3339 strings or null; other
flags are booleans, followers an integer. The server combines one consistent
DB snapshot with live stream marks; no names, user ids or positions leave it.
The Bridge requires complete valid fields and a matching local hash/size,
rechecks just before deletion and denies on error. Freshness is at most five
seconds measured from request dispatch by its own monotonic clock, independent
of the server's checked_at. See aggregate rules.
GET /api/v1/bridge/libraries/{id}/book
Bridge token. One indexed book's files with the size and modification time
the server sees now, for the Bridge's mapping check and its jobs. ?path= names
the book. Without it the answer is the library's sample book, the mapping
check's: the first whole book (every file present on the server's disk) among
the library's first five by title, so one book deleted before a scan notices
doesn't make the whole library look unmapped; the first of them when none is
whole.
{
"library_id": 1,
"path": "Andy Weir/Project Hail Mary",
"files": [
{ "path": "Andy Weir/Project Hail Mary/Project Hail Mary.m4b", "size": 912345678, "mod_time": 1781234567, "duration": 58008.4 }
],
"book": {
"title": "Project Hail Mary", "authors": ["Andy Weir"], "narrators": ["Ray Porter"],
"series": "", "series_index": "", "asin": "B08G9PRS1K", "isbn": "",
"duration": 58008.4, "work_id": "project-hail-mary",
"ebook": { "path": "Andy Weir/Project Hail Mary/Project Hail Mary.epub", "size": 1048576 }
}
}
book is present only when a path was asked for (never on the sample-book
answer): what a job about the book reads. The effective values; authors and
narrators are the credits split as the server splits them ([] for none);
series_index is the position as the server formats it ("3", "1-3", ""
for none); duration is in seconds; work_id is the community work the
metadata cache already knows for the book's ASIN or ISBN, "" otherwise (the
cache only, never an upstream call; "" while metadata is off). ebook is the
book's ebook as the server indexed it (the reader's file, the one a
readalong.align job aligns), omitted when it has none.
File paths are relative to the library root. A single-file book (one audio file,
indexed without separate parts) is listed as its own one file. size and
mod_time (Unix seconds) are read from the disk on the request; a file the server
can't stat keeps its indexed size and mod_time 0. duration is the
file's indexed length in seconds (a single-file book's is the book's), from which a
read-along map's whole-book times are built; an older Bridge ignores it. Only an indexed book answers:
this never indexes a path on demand.
| Status | Meaning |
|---|---|
200 | { library_id, path, files, book? } |
400 | invalid library id; path is too long (over 4096 bytes) |
404 | library not found; code: "no_book" - no indexed book at that path (or none in the library) |
Without a path, a podcast library always returns 404 no_book, so an older
Bridge cannot map it through a sample episode. Use the marker route above.
POST /api/v1/bridge/rescan
Bridge token. Reads one path again now, after the Bridge changed files there.
{ "library_id": 1, "path": "Andy Weir/Project Hail Mary", "mode": "full", "from": "Andy Weir/Hail Mary" }
| Field | Meaning |
|---|---|
library_id, path | required: the path to read again |
mode | full (the default when absent) or attachments |
from | with full only: the book's path before a move the Bridge made |
fullindexes whatever is at the path, a new book included (re-reading its audio), and answers200 { "path": "…" }with the book's indexed path. Withfrom, the server also carries the book's durable state from there exactly as a scan's move detection would (listening state, edits, covers, enrichment, Bridge jobs, the favourites of the folders it left), then drops the old row, but only when no book is atfromon disk any more and the two fingerprints match; otherwisefromis ignored (logged), never an error. So a rescan after a rename no longer defeats move detection.attachmentsre-reads only the book's.audiosilo/files, never its audio: the Bridge's call after writing characters, recaps, a description, chapters or a read-along map. The path is the book's, or a path inside it. A changedchapters.jsonre-resolves the book's chapters at once. It answers what is now indexed. An older server may omitdescription,chaptersorreadalong:
{ "path": "Andy Weir/Project Hail Mary", "attachments": { "characters": true, "recaps": true, "description": true, "chapters": false, "bridge": true, "readalong": false } }
For a podcast library, path names a show folder or an already indexed
episode whose show is refreshed from feed.json. Response is
200 {"path":"<show>","episodes":N}; missing/invalid feed is
404 not_indexable. mode:"attachments" and any supplied from are 400.
No arbitrary audio is indexed. Podcast feed details, committed audio presence,
publication barriers and matching CHAP attachments are read without probing or
hashing audio. A pending journal leaves its episode unready.
The re-read runs detached from the request for up to 10 minutes, like the console's rescan of one book. A path through a hidden (dot) folder is never indexed. The body is at most 8 KiB.
| Status | Meaning |
|---|---|
200 | { path } (full), or { path, attachments } (attachments) |
400 | library_id and path are required; mode must be full or attachments; invalid from; from is only for a full rescan |
404 | library not found; code: "not_indexable" - nothing indexable at that path (gone, not a book, in a hidden folder, outside the library, or skipped by its ignore rules), or for attachments no indexed book there |
Audible accounts
The code is in api/handlers_audible.go, api/handlers_audible_sync.go,
catalog/audible.go, catalog/audible_import.go, catalog/audible_sync.go and
internal/audible. The design is in Audible. The Bridge
signs in to Audible accounts and holds every credential. The server never talks to
Audible, and keeps only what the Bridge reports:
- each account's mapping (a person, a books library);
- the titles its polls found;
- what to back up: an account's policy and placement template, a title's "Not
this book" and destination, and the backups it queued (
audible.importjobs, see Backing up a title); - whether an account's listening places sync with Audible (Audible position sync).
Two sets of routes, as for the Bridge:
- The Bridge's, under
/bridge: Bridge token, in theAuthorizationheader only; every other credential (a session, an API key, a cast or feed token) is401. They are not behind theaudiblemodule's toggle, so the Bridge's page can sign in and map accounts while the module is off. - The console's, under
/admin/audible: Admin, an admin's session or personal API key (a member's session or API key403; a bridge, cast or calendar feed token401, and so is any token in?token=). All butDELETE /admin/audible/accounts/{account}answer404 module_offwhile theaudiblemodule is off; the delete works either way.
An account id is the Bridge's own, ^[a-z0-9]{8,32}$. On every route that names
one, an id that doesn't match is 404 unknown_account.
The account object
{
"id": "k3v9x0p2m4q8r1t5",
"label": "Chris (US)",
"marketplace": "us",
"user_id": 3,
"user": { "id": 3, "display_name": "Chris" },
"library_id": 1,
"library": { "id": 1, "name": "Books" },
"paused": false,
"policy": "manual",
"sync": false,
"poll_hours": 6,
"created_at": "2026-11-02T10:00:00Z",
"updated_at": "2026-11-02T10:00:00Z",
"last_poll_at": "2026-11-02T10:01:12Z",
"last_queued_at": "2026-11-02T10:00:05Z",
"template": "",
"policy_since": "2026-11-02T10:01:12Z",
"last_sync_at": null,
"refused_at": null,
"titles": { "total": 30, "available": 22, "preorder": 4, "unavailable": 4, "gone": 0, "matched": 3 },
"backup_estimate": { "count": 18, "bytes": 5299200000 }
}
| Field | Meaning |
|---|---|
marketplace | the Audible store, one of the server's metadata regions (us, uk, de, ...) |
user | the person it is mapped to (display_name falls back to the username) |
library_id, library | the books library its titles are matched against; both null and paused: true once that library was deleted |
paused | its library was deleted: no poll is queued and Check now is 409 account_paused until the Bridge maps it again |
policy | all, series or manual: what the account backs up by itself after a poll (every new title, new titles in a series the person has, only what an admin picks). The Bridge sets it when it creates the account; after that only PATCH /admin/audible/accounts/{account} changes it |
sync | whether the account's listening places sync with Audible: the console's alone (PATCH {sync}), off for a new account, turned off when the Bridge maps the account to another person |
poll_hours | how often the scheduler queues its poll (6) |
last_poll_at | when the Bridge last finished a poll (its final titles call), null before the first |
last_queued_at | when a poll was last queued (by the scheduler or Check now), null before the first |
template | the account's placement template for its backups, "" for the Bridge's placement beside the series' other books |
policy_since | when its automatic policy began to act: a policy backs up only titles first seen after it. Set at the account's creation, when an automatic policy is turned on, and moved to the account's first complete poll |
titles | GET /admin/audible/accounts only: the titles counted by state, and how many are matched to a book here (matched leaves out a match marked "Not this book") |
last_sync_at | when its last audible.sync finished having read Audible's places, null before the first (and after a mapping to another person) |
refused_at | when a sync failed audible_refused (Audible refused the account's device): no sync is queued while it is set. Any PUT /bridge/audible/accounts/{account} (a sign-in again maps the account) or a complete poll clears it. A refused sync the Bridge claimed in an earlier second than that clearing (it ran before the fresh sign-in, but reported after) doesn't set it again |
backup_estimate | GET /admin/audible/accounts only: what Back up everything would queue now, count titles and about bytes (each title's runtime_s x 8000, 64 kbps AAXC: approximate) |
GET /api/v1/bridge/users
Bridge token. Who an Audible account may be mapped to: enabled adults and admins,
grown-up household members included; never a kid household member, a demo account
or a disabled account. No emails. Cache-Control: no-store.
{ "users": [ { "id": 3, "username": "chris", "display_name": "Chris", "admin": true } ] }
PUT /api/v1/bridge/audible/accounts/{account}
Bridge token. Records the Bridge's mapping of an account it signed in to,
creating it or mapping it again (a paused account resumes; its titles and poll
times stay, and so do its policy and template: the body's policy is a new
account's only, the console sets it after that). The body is strict JSON, at most
4 KiB:
{ "label": "Chris (US)", "marketplace": "us", "user_id": 3, "library_id": 1, "policy": "manual", "sync": false }
label is cleaned and cut to 100 characters ("" is fine: the console then names
it after the person and the store). sync is read and ignored. A new account is
created with sync off, and a PUT never turns it on. A PUT mapping
the account to another person turns its sync off, forgets its last_sync_at
and cancels its open audible.sync ("sync with Audible was turned off for the
account"); every PUT clears refused_at.
| Status | Meaning |
|---|---|
200 | { "account": Account } (without titles) |
400 | invalid_request (the JSON); invalid_policy; invalid_marketplace; not_books - the library is a podcast library |
404 | unknown_account - an id the Bridge couldn't have made; unknown_user - no such user, or one an account may not be mapped to (above); unknown_library |
DELETE /api/v1/bridge/audible/accounts/{account}
Bridge token. The Bridge removed the account: it goes, with its titles, and its
open poll, sync and backups are cancelled ("the Audible account was removed"). 204, whether or not
the server knew it (the Bridge forgets locally first and may call again).
PUT /api/v1/bridge/audible/accounts/{account}/titles
Bridge token. A page of one poll's titles: up to 500 a call, strict JSON, at most
8 MiB. A poll sends all its pages under one poll_id (1 to 64 letters, digits,
., -, _), the last with final: true.
{
"poll_id": "j12.1.9f3a2c7e",
"final": false,
"titles": [
{
"asin": "B0EXAMPLE1",
"title": "The Example",
"authors": ["A. Writer"],
"series": [{ "name": "Examples", "position": "3" }],
"release_date": "2026-11-20",
"runtime_s": 36000,
"state": "preorder",
"reason": "Not released yet (pre-order)",
"match": { "library_id": 1, "path": "A. Writer/The Example", "how": "fuzzy" },
"planned": null
},
{
"asin": "B0EXAMPLE2",
"title": "The Example (Dramatized)",
"authors": ["A. Writer"],
"series": [],
"release_date": "",
"runtime_s": 30600,
"state": "available",
"reason": "contested",
"match": null,
"contested_path": "A. Writer/The Example",
"planned": {
"library_id": 1,
"path": "A. Writer/The Example (Dramatized)/The Example (Dramatized).m4b",
"file": "The Example (Dramatized).m4b",
"kind": "standalone",
"siblings": 0,
"state": "joins",
"conflict": "",
"template": ""
}
}
]
}
-
titlesis required: a call without it is400, never "the account owns nothing" (an empty library sends one final call with[]). -
asinmust be an ASIN (upper-cased);stateisavailable,preorderorunavailable(goneis the server's);release_dateisYYYY-MM-DDor"";runtime_s0 to 1,000,000. These are refused400 invalid_request. -
reasonsays why anunavailableorpreordertitle can't be downloaded, or iscontestedon anavailableone with nomatch: another title claimed the same book. -
contested_path(optional, omitted when none) is the book a contested title tied for: a clean book path or., asmatch.path. It is stored for the account's library (not when the title's plan names another one: the account was mapped elsewhere while the poll ran) and cleared when a match is stored for the title. -
plannedisnullor where a backup of an unmatched title would go, as the Bridge planned it during the poll (Planned destinations):library_id> 0;patha clean relative file path within the path limit, andfilea name with no/(both""only whenstateiserror);kindseries_existing,series_neworstandalone(""only forerror);statenew,joins,existsorerror;siblings0 to 100000;template(the template the plan used) at most 500 bytes;conflictfile,book,audioor"", or forerrorthe reason (cleaned and bounded).
Anything else is
400 invalid_requestfor the whole call, except apathover the path limit, which is stored as anerrorplan. A stored match drops the plan, and so does the final call marking the titlegone. -
Free text is cleaned and bounded, never refused:
title(500),reason(256), each author and series name (200), a position (32); authors past the first 20 and series past the first 10 are dropped. -
matchisnullor names a library, the book's path asGET /bridge/libraries/{id}/booksgives it (a clean relative path, or.for a book that is the library's root) andhow(asinwhen the book's ASIN is the title's, elsefuzzy). A match into a library that is missing or not a books library is stored as none. The match is path-keyed with no FK: a rebuild keeps it, a book's move carries it, and deleting the library clears it. -
Each title replaces what an earlier poll said of it, keeping
first_seenand the console's and an import's columns. A backup's match (howimport) stays while its book is indexed and the call names no other match for the title. -
On the final call:
- every title of the account this
poll_iddidn't send becomesgone; - the account's
last_poll_atis now, and on its first complete poll so is itspolicy_since; - its
refused_atis cleared, since Audible answered the account; - then, while the module is on and an online Bridge runs
audible.import, the server queues the backups the account's policy and pending preorder requests ask for. It spends at most 10 s on this. A failure is logged and the next poll asks again, and the call still answers200.
- every title of the account this
| Status | Meaning |
|---|---|
200 | { "stored": N } |
400 | invalid_request - the JSON, no titles, over 500, a bad poll_id, or a title refused (above) |
404 | unknown_account |
GET /api/v1/bridge/libraries/{id}/books
Bridge token. A books library's indexed books by path, a page at a time, for the
Bridge's Audible matching. ?limit= 1 to 500 (default and cap 500; not a positive
number is 400), ?after= the previous page's next. Cache-Control: no-store.
{
"books": [
{
"path": "Andy Weir/Project Hail Mary",
"title": "Project Hail Mary",
"authors": ["Andy Weir"],
"narrators": ["Ray Porter"],
"series": [],
"asin": "B08G9PRS1K",
"isbn": "",
"duration": 58008.4
}
],
"next": "QW5keSBXZWlyL1Byb2plY3QgSGFpbCBNYXJ5"
}
The effective values: authors and narrators split as the server splits credits
([] for none), series every series the book is in, the main one first, each with
its position as stored ("3", "1-3", ""); duration in seconds. next is
null on the last page.
When the main series has no position, this listing takes it from a code at the
start of the book's folder name, or its file name for a single-file book
(metadata.FolderCodePosition, called by bridgeSeries): one to five letters, an
optional space, then a number of one to three digits with an optional decimal part,
or two such numbers joined by a hyphen or an en dash. The code must end where a
word does (DF15.5x gives none). The letters must be the series' initials (case
aside), all or some in order:
DH06 - Land of Magicin "Dragon Heart" is"6",DF15.5 Brief Casesin "Dresden Files""15.5",BDM01 - ...in "Beneath the Dragoneye Moons""1";- a range code goes out as the range:
LAM05-06is"5-6"; HALO 07in "Halo" (a franchise's own numbering), a number of 0, an inverted range (LAM06-05) and a third joined number (LAM05-06-07) give none.
Only this listing reads folder codes; the scan's positions and every other route don't (see the Bridge's matching rules).
| Status | Meaning |
|---|---|
200 | { books, next } |
400 | invalid_request - a bad limit or cursor; not_books - a podcast library |
404 | unknown_library |
PUT /api/v1/bridge/libraries/{id}/enrichment
Bridge token. Gives a book the ASIN of the Audible title the Bridge matched to it
without doubt. ?path= names the book (as the books list gives it); the body is
{"asin": "B0..."}. It is stored as the book's enrichment (durable, path-keyed, as
a community match's ASIN is) and on the index row at once. Only a write moves the
content generation: the same ASIN again is a no-op 204.
| Status | Meaning |
|---|---|
204 | stored, or the book had that ASIN already |
400 | invalid_request - no path (or over the path limit), no valid asin |
404 | book_not_found - no book of a books library indexed at that path |
409 | asin_set - the book has another ASIN, or an admin's ASIN edit (a cleared one too): a backfill never goes under one |
GET /api/v1/admin/audible/accounts
Admin, module on. Every account, by label, each with its titles counts (see
the account object): 200 { "accounts": [Account] }, []
for none.
GET /api/v1/admin/audible/titles
Admin, module on. The titles, sorted by title (any case), then account and ASIN, keyset-paged.
| Param | Meaning |
|---|---|
account | one account's titles |
state | available, preorder, unavailable or gone (another value is 400) |
matched | 1 (or true) only titles matched to a book here (not marked "Not this book"), 0 (or false) only the rest |
q | in the title, any author as written, or an exact ASIN (at most 200 characters) |
after | the previous page's next |
limit | 1 to 200, default 50 |
{
"titles": [
{
"account": "k3v9x0p2m4q8r1t5",
"asin": "B0EXAMPLE1",
"title": "The Example",
"authors": ["A. Writer"],
"series": [{ "name": "Examples", "position": "3" }],
"release_date": "2026-11-20",
"runtime_s": 36000,
"state": "preorder",
"reason": "Not released yet (pre-order)",
"match": null,
"rejected": false,
"ignored": false,
"no_sync": false,
"dest_path": "",
"first_seen": "2026-11-02T10:01:10Z",
"updated_at": "2026-11-02T10:01:10Z",
"import_job_id": null,
"import": { "job_id": null, "state": "pending", "code": "" },
"needs_attention": false,
"planned": null,
"contested": null
}
],
"next": ""
}
next is "" on the last page. The backup fields:
| Field | Meaning |
|---|---|
match.how | asin, fuzzy (a poll's) or import (a done backup's) |
rejected | marked "Not this book": never backed up by Back up everything or a policy; a match it has isn't counted as on this server |
ignored | no route sets it yet; Back up everything and the policy skip an ignored title, and it doesn't sync |
no_sync | marked "Don't sync this book": neither direction of the account's sync touches its book. Separate from rejected; backups never read it |
dest_path | the console's destination for the title's backup, "" for the account's placement |
import_job_id | the title's latest backup job, null for none |
import | the title's backup: {job_id, state, code} with state the job's (job states) and code a failed or parked job's code (not_released, no_rights, audible_refused, not_writable, exists, disk_full, offline_url_expired, unknown_account, drm_free (Audible offers the title only as a DRM-free MP3, which the Bridge doesn't back up yet), or ""); {job_id: null, state: "pending"} for a Back up on a preorder waiting for its release; null for none |
needs_attention | a preorder still not available 14 days after its release_date: the account is no longer polled hourly for it |
planned | where a backup would go, as the Bridge planned it at its last poll (the object the titles PUT takes); null when matched, not planned, or the account is no longer mapped to the plan's library (paused or mapped again: none until its next poll) |
contested | for a title left unmatched as contested, {library_id, path, with: [{asin, title}]}: the book it tied for and the account's other titles (not gone) contested over that book or matched to it, by title, at most 10; null otherwise, or while the account isn't mapped to that library |
| Status | Meaning |
|---|---|
200 | { titles, next } |
400 | invalid_request - a state or matched value not listed above, a limit that isn't a positive number, a q over 200 bytes, or a bad cursor |
404 | module_off |
POST /api/v1/admin/audible/accounts/{account}/poll
Admin, module on. Check now: queues the account's audible.poll at a
person's priority. Polls are deduped per account: when one is open already, it is
returned, raised to this priority if the scheduler queued it lower. The answer is
the job. A new poll is in the audit log as bridge.job (with
account).
| Status | Meaning |
|---|---|
201 | a new poll |
200 | the open one |
404 | unknown_account; module_off |
409 | account_paused - its library was deleted; kind_unavailable - no Bridge has reported, or the paired one doesn't run audible.poll |
audible.poll is internal: POST /admin/bridge/jobs
refuses it with 400. Its params are {"account", "template"} (template the
account's placement template, omitted when "", at most 500 bytes and never
parsed: the Bridge plans unmatched titles by it), with no library or path; Check
now on a poll still queued gives it the account's current template. It is quiet
(no notifications) and held while the module is off. The
module's scheduler queues each due account's poll every poll_hours at priority
-10 while an online Bridge offers the kind. Only the newest ended poll of each
account is kept.
POST /api/v1/admin/audible/titles/{account}/{asin}/backup
Admin, module on. Back up one title: queues its audible.import
at a person's priority (0), with params built from the account and the title
({account, asin, library_id, dest_path, template}: the account's library and
template and the title's dest_path, as they are now). No body. A preorder isn't
queued: the request is stored and queued (at the same priority, with no requester)
by the first poll that reports the title available.
| Status | Meaning |
|---|---|
202 | { "pending": false, "job_id": N }: queued (or the title's open backup, when a request at the same moment queued it first) |
200 | { "pending": true, "job_id": null }: a preorder, backed up on release |
404 | unknown_account; unknown_title (also an {asin} that isn't an ASIN); module_off |
409 | already_matched - the title is on this server (matched, not marked "Not this book"); import_open - its backup is waiting, running or parked, or a preorder request is pending; not_available - the title is unavailable or gone; account_paused; kind_unavailable - no Bridge has reported, or the paired one doesn't run audible.import; library_not_writable - the Bridge can't write to the account's library |
A contested title, and one whose last backup is done but which is no longer on
this server, can be backed up this way (the admin decides). A queued backup is
audited as bridge.job (with job, kind, account, asin), a pending one as
bridge.audible.backup (with account, asin, pending).
POST /api/v1/admin/audible/accounts/{account}/backup-all
Admin, module on. Back up everything: queues an audible.import
for every title of the account that is available, not on this server, not
rejected or ignored, not contested (reason contested), with no backup
waiting or running, and not already backed up (its latest backup done and the
book its result names still indexed). A failed or cancelled backup is queued
again. Priority -20, below the podcast scheduler's fetches (-10), so a bulk
backup never holds back new episodes; the jobs are the admin's. No body.
| Status | Meaning |
|---|---|
200 | { "queued": N } (0 with nothing to back up) |
404 | unknown_account; module_off |
409 | account_paused; kind_unavailable; library_not_writable - nothing was queued: the Bridge can't take them |
It stops at the first refusal of the queue; when some were queued before it, the
answer is still 200 with their count (the rest is logged). Audited as
bridge.audible.backup_all (with account, queued) when it queued any. The
count and size it would queue are the account's backup_estimate.
PATCH /api/v1/admin/audible/titles/{account}/{asin}
Admin, module on. Not this book and a title's destination, and Don't sync this book. Strict JSON, at most 8 KiB; a field left out is left as it is:
{ "rejected": true, "dest_path": "Wen Liang/Mapmakers/MM03 - The Cartographer's Return", "no_sync": false }
rejectedtruemarks the title "Not this book" (and drops a pending preorder request);falseclears it. A done backup clears it too.dest_pathis where in the account's library its backup goes,""for the account's placement: a file path, or a folder (a last segment without an audio extension) the title's file goes in. At most 1024 bytes, clean (no.., no leading or trailing/, no.segment), no hidden, dot-led segment, no backslash or control character. It is copied into a backup when it is queued; the Bridge checks it again against its mapping.no_synctruekeeps the title's book out of the account's position sync, both ways;falselets it sync again.
A rejected or no_sync sent queues the account's audible.sync at once (when
the account syncs and an online Bridge offers the kind), with the titles that sync
now, so the Bridge drops a left-out title straight away; a failure to queue is
logged and the next scheduled sync carries the change.
| Status | Meaning |
|---|---|
200 | the title (not wrapped) |
400 | invalid_request - the JSON (an unknown field too); invalid_dest_path - with what is wrong in error |
404 | unknown_account; unknown_title; module_off |
Audited as bridge.audible.title (with account, asin, and rejected,
dest_path and no_sync when sent), even when nothing changed.
PATCH /api/v1/admin/audible/accounts/{account}
Admin, module on. An account's settings: its backups and whether its places sync. Strict JSON, at most 8 KiB; a field left out is left as it is:
{ "policy": "series", "template": "{Author}/{Series}/{ {PaddedSeq} - }{Title}", "sync": true }
policy:all,seriesormanual. Changing it toallorseriessetspolicy_sinceto now, so the policy acts only on titles first seen after it;manualleaves it.template: the placement template,""for the Bridge's placement beside the series' other books. At most 500 bytes, in the Bridge's template grammar (its variables, mods and the segment rules the server checks).sync:trueturns the account's position sync on and queues its firstaudible.syncat once (when an online Bridge offers the kind);falseturns it off and cancels its open sync ("sync with Audible was turned off for the account"; a running one posts nothing more: the listening route answers403 sync_off).
The account's person and library stay the Bridge's.
| Status | Meaning |
|---|---|
200 | { "account": Account } (without titles and backup_estimate) |
400 | invalid_request - the JSON (an unknown field too); invalid_policy; invalid_template - with what is wrong in error |
404 | unknown_account; module_off |
Audited as bridge.audible.account (with account, and policy, template and
sync when sent).
The audible.import job
Internal (refused by POST /admin/bridge/jobs), owned by the audible module
(held while it is off), deduped per account and title, a writing kind (it needs a
writable mapping of the account's books library), and notified when it ends done
or failed (named by the title). Its params, progress steps, result and failure
codes are in Backing up a title. A done job's
result becomes the title's match (how import) when it names a book indexed in
the job's library that is the title's own; removing the account cancels its open
backups.
Audible position sync
The Bridge keeps an account's listening places in step with Audible for the
account's person, both ways, furthest wins (see
Position sync).
Audible's places come in through audible.sync and the listening route below. The
person's places go back through the progress feed, which the Bridge's push loop
reads every minute.
- An account syncs while the module is on, its
syncis on, it isn't paused,refused_atis unset and its person may still be synced for (not disabled, a kid or a demo account). - A title syncs when it is matched, and not
rejected,ignored,no_sync,goneor apreorder. A poll may match a preorder to another edition on disk, and Audible holds no place for an unreleased title. - A preorder is in neither direction. It is not in the job's params, it is
not_syncedon the listening route, and it is not in the progress feed. Once it is released (preordertoavailable), its book's place is offered to Audible again, as for a title back fromgone. - Only places move. No listening session,
listening_dailyor history row is ever written for Audible's listening, and nothing is recorded against the bridge token.
Both routes are bridge token, header only, and share these refusals:
| Status | Meaning |
|---|---|
403 | sync_off - the audible module is off, the account's sync is off, or the account is paused (its library deleted); user_unavailable - the account's person was disabled, made a kid or a demo account since it was mapped: map the account to someone else |
404 | unknown_account - no such account, an id the Bridge couldn't have made, or one whose person was deleted (the account goes with them) |
The audible.sync job
Internal (refused by POST /admin/bridge/jobs), quiet, owned by the audible
module (held while it is off), deduped per account (a queued one promotes: a
newer queue gives it the newest titles), only the newest ended one of each account
kept, with server-built strict params {account, titles}. The scheduler queues
one every 15 minutes for each account that syncs; turning sync on, and a title's
rejected or no_sync change, queue it at once. Done having read Audible's places
sets the account's last_sync_at; failed audible_refused sets refused_at, and
none is queued until a Bridge PUT or a complete poll clears it (a refused sync
claimed before that clearing marks nothing). Failed audible_sign_in (Audible
didn't accept the sign-in just now) is an ordinary failure. Its params,
queueing, steps, result and failure codes are in
Position sync.
POST /api/v1/bridge/audible/accounts/{account}/listening
Bridge token. The account's places on Audible, for its person. Strict JSON,
sent_at required (RFC 3339, the Bridge's clock), events in the
Sync v2 shapes and limits (at most 500 a batch, 1 MiB):
{
"sent_at": "2026-11-03T09:15:00Z",
"events": [
{
"id": "aud-0f3c9a1e5b7d2c4e6a8b0d1f3e5c7a9b",
"kind": "listen",
"library_id": 1,
"path": "A. Writer/The Example",
"from_pos": 5400.0,
"to_pos": 5400.0,
"started_at": "2026-11-03T08:58:41Z",
"ended_at": "2026-11-03T08:58:41Z",
"start": "jump"
}
]
}
The Bridge's event ids are stable per place
(how), so an unchanged
place is a duplicate at every sync.
The route builds the batch itself, as device audible:<account> named
Audible (<label>) (the store, upper-cased, for an account with no label), with
no token and furthest only; no request body can ask for either. Each event's
book is resolved under the person's access (outside it: rejected
no_access) and must be the match of one of the account's synced titles (else
rejected not_synced). It is rejected duration_mismatch when none of the
book's titles has an Audible runtime_s within 5% of the longer plus 2 minutes of
the book's length: the indexed duration, else the duration on the person's own
progress row for the book (the progress feed's rule). An unknown length on either
side agrees. Then:
- a
listenmore than 1 s ahead of the stored place applies over any device's row, whatever its time: the row'supdated_atnever moves back, and its playback speed and offer are kept. A player reconciles as for any other device's place, so it moves to it or offers it under the row'sdevice_name; - one at or behind the stored place, on a finished row, or heard at or before the
row's last finish change (a place from before a finish or a restart here) is
not_appliedwithreasonbehind, storing nothing on the row and making no offer; - a place past the book's end by more than 5% plus 2 minutes is
rejectedduration_mismatch; one just past it is the end; - a
finishis any finish; anunfinishisbehind.
| Status | Meaning |
|---|---|
200 | the Sync v2 answer, {server_time, results, progress} |
400 | a bad envelope, as POST /me/listening/sync's (sent_at is required; code: "too_many_events") |
413 | code: "too_large" |
403 / 404 | above |
GET /api/v1/bridge/progress
Bridge token. The person's places the Bridge may push to Audible, a page at a
time: ?account= (required), ?since= (the previous answer's next; none for
the start), ?limit= (default 200, at most 500). Cache-Control: no-store.
{
"rows": [
{
"library_id": 1,
"path": "A. Writer/The Example",
"asin": "B0EXAMPLE1",
"position": 5520.4,
"duration": 36012.0,
"finished": false,
"updated_at": "2026-11-03T09:20:11.042Z",
"device_id": "lq3x9-ab12cd34",
"version": 44
}
],
"next": "eyJxIjo0NCwicyI6IkIwRVhBTVBMRTEifQ",
"more": false
}
The rows are the person's progress rows on the books of the account's synced titles that the person can still reach, one per title: two titles on one book are two rows. Two kinds of row are left out:
- the rows the account itself wrote (
device_idaudible:<account>), so nothing echoes; - per title, a row whose own title's
runtime_sdisagrees with the book's length (5% of the longer plus 2 minutes, the listening route'sduration_mismatch). The book's length is the indexed duration, else the row's storedduration, and two unknown lengths agree. With two titles on one book, only the agreeing one is offered.
The rows are ordered by progress.change_seq, then ASIN, after the opaque cursor
since. change_seq is the order this server wrote progress rows in: a
server-wide counter that only goes up. It is also bumped when a title is newly
matched, synced again or its runtime changes, so that book's place is offered
again. So a row written late with an earlier updated_at (an offline backlog, a
late finish, a client's clock behind) is still fed. updated_at is the row's own.
next is the cursor of the last row read, a left-out one included (since again
when there is none). The Bridge keeps it between polls. more is true for a full
page read, whose rows may be fewer, so ask again at once.
| Status | Meaning |
|---|---|
200 | { rows, next, more } |
400 | invalid_request - no account, a bad limit or cursor |
403 / 404 | above |
DELETE /api/v1/admin/audible/accounts/{account}
Admin, not behind the module's toggle. Removes an account the Bridge can no
longer remove (one from a Bridge since unpaired or replaced, or whose removal on the
Bridge couldn't reach the server), exactly as the Bridge's delete does: its titles
go, its open poll and sync are cancelled, and so are its open backups. It signs nothing out of
Amazon: that is the Bridge's. 204, known or not; audited as bridge.audible.remove (with account)
when there was one. A bridge token is 401.
Add from LibriVox
The code is in api/handlers_librivox.go, meta/librivox.go,
bridge/librivox.go and catalog/librivox.go. The design is in
Add from LibriVox. An admin searches the community
metadata database's LibriVox works, opens one, and adds a recording to a books
library through the Bridge's librivox.fetch. The server never asks
librivox.org.
All three routes are Admin: an admin's session or personal API key. A
listener's session or API key is 403; no token, and a bridge, cast or feed
token, is 401. They answer 404 metadata_off while community metadata is
off, before anything in the request is checked, and 502 (metadata service unavailable, no code) when
the metadata service can't answer. There is no managed module and no /server
flag: a client offers them when the Bridge's features list librivox (see
GET /server).
GET /api/v1/admin/librivox/search
The works with a LibriVox recording that match q, best first, from metaserve's
works/search?source=librivox.
| Param | Meaning |
|---|---|
q | required; the title or author to look for, at most 300 characters |
lang | optional; one language tag (en, pt-br), sent lowercased |
limit | optional; 1 to 50, default 20 |
{
"results": [
{
"work_id": "johnny-crows-garden",
"title": "Johnny Crow's Garden",
"authors": [{ "id": "l-leslie-brooke", "name": "L. Leslie Brooke" }],
"language": "en",
"release_date": "1903"
}
]
}
release_date is always a string, "" when unknown.
| Status | Meaning |
|---|---|
200 | the results ([] for none) |
400 | q missing or too long; lang not a language tag; limit outside 1 to 50 |
404 | metadata_off |
502 | the metadata service can't answer |
GET /api/v1/admin/librivox/work
One work and its LibriVox recordings only (those with a project id), by
?id=<work_id>.
{
"work": { "id": "johnny-crows-garden", "title": "Johnny Crow's Garden", "authors": [{ "id": "…", "name": "…" }], "language": "en" },
"recordings": [
{
"recording_id": "…",
"librivox_id": 192,
"narrators": [{ "id": "…", "name": "…" }],
"runtime_min": 12,
"chapter_count": 3,
"cover_url": "https://archive.org/…jpg",
"url": "https://librivox.org/…",
"in_library": [{ "library_id": 3, "path": "L. Leslie Brooke/Johnny Crow's Garden" }],
"job": { "id": 41, "state": "running" }
}
]
}
in_librarylists the indexed books whose recorded community match names this exact recording ([]for none). A work-level match never counts. See The book's identity.jobis the newest openlibrivox.fetchof the project, in any library, ornull. A lease that ran out is settled first, so a fetch that failed on its last attempt is not listed.
| Status | Meaning |
|---|---|
200 | the work and its recordings |
400 | id missing or not a work id |
404 | not_found - no such work; metadata_off |
502 | the metadata service can't answer |
POST /api/v1/admin/librivox/fetch
Adds a LibriVox project to a books library:
{ "library_id": 3, "librivox_id": 192 }
The server resolves the project's work, recording and title with metaserve's
lookup?librivox_id=, and queues librivox.fetch with them in its params
({librivox_id, work_id, recording_id, library_id, title}; title is optional
and additive). A lookup that names no recording is not_found: the server never
takes a work's first recording instead.
| Status | Meaning |
|---|---|
202 | { "job": Job }: the queued job, or the open one for the same library and project |
400 | a malformed body, or library_id or librivox_id missing |
404 | library not found (no code); not_found - no such project in the community metadata; metadata_off |
409 | in_library - the recording is already on this server, in any library, with the first book's library_id and path in the body; not_book_library - a podcast library; kind_unavailable - no Bridge is paired, or the paired one doesn't run librivox.fetch; library_not_writable - the Bridge's last heartbeat doesn't report the library mapped and writable |
502 | the metadata service can't answer |
An offline Bridge still gets the job queued, and it waits. A queued job is audited
as bridge.job (with job, kind, library and librivox_id).
The librivox.fetch job
Internal (refused by POST /admin/bridge/jobs with 400), a writing kind for a
books library, no module, deduped per library and project, and notified when it
ends done (named by the book its result names), failed or parked (named by the
params' title when no book was placed). Its params, progress steps, result and
failure codes are in The job.
The result is {library_id, path, librivox_id, files, bytes, adopted}. adopted
is additive: false when the Bridge placed the folder in this run, true when it
found the book already placed, absent from an older Bridge.
A done job's result becomes the book's recorded community match (how
accepted, set_by the admin who asked) when it names a book indexed in the
job's own library. adopted decides whether that replaces a match already at
the path (The book's identity).
Its bridge_job_finished event carries librivox_match (matched, kept or
unmatched; see Bridge event data).
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 page
Only active when the launcher enabled it (--setup / pkg/launcher); a normal
headless deployment never exposes this surface. The page 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. The page asks only for the account; the admin
console's first-run setup flow does the rest. See
Web UI.
GET /setup
Public (gated). Serves the setup page while it is open. Otherwise
303 See Other → /admin, whether it was never enabled or an admin already
exists (2.7.0; an older server answers 404 when it was never enabled).
POST /setup
Public (token-guarded). Creates the first admin account, nothing else: the library is added in the console's flow.
| Body field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | the one-time setup token |
username | string | no | defaults to "admin" |
password | string | yes | admins must have a password |
The body is decoded strictly: any other field is 400 and creates nothing.
That includes the old library_name/library_root pair, which only the
archived desktop manager sent. Success is 201 { "user": { … } }.
| Status | Meaning |
|---|---|
400 | an unknown field (the old library fields included), or the password rules |
403 | invalid setup token |
409 | setup not available (already completed or never enabled) |
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.