Cross-repo contract
The server defines the JSON and HTTP contract. The frontend and the Bridge mirror it by hand, with no codegen. This page lists every seam: every place where a change in one repo forces a change, or a deliberate decision, in another. Each seam gives, in order, what couples the repos, the files and symbols on each side, and what a change requires. Detail that a seam needs only sometimes follows in its sub-sections.
The normative contract is ~/dev/audiosilo/CROSS-REPO.md in the workspace
root. When a seam changes, update CROSS-REPO.md first, in the same logical
change as the code, then bring this page in line. If this page and CROSS-REPO.md
ever disagree, CROSS-REPO.md wins. Its sections are numbered differently from this
page's seams, so this page writes "seam N" for its own sections and
"CROSS-REPO.md §N" for the workspace file.
Canonical reference points on each side of the main seam:
- Server route table:
internal/api/api.go - Frontend client (mirrors it 1:1):
src/api/client.ts - Frontend wire types:
src/api/types.ts - The Bridge's mirrored routes:
internal/link(in audiosilo-bridge, a public repository)
1. Path is the identity: (library_id, rel_path)
What couples: the deepest shared invariant. Content is addressed by
(library_id, rel_path), with the path in a ?path= query param, never by DB id
(see Invariants §1).
Server:
catalog.GetBookByPathresolves(library, path)to a book, indexing it on demand.library.SafeJoinguards every user-derived filesystem access.- Durable user state is path-keyed, with no FK to
books. GET,PUTandPATCH /libraries/{id}/progressread and write the book's row when given the book's own path or one of its track files (api.progressPath). This holds for any client. Any other progress path keeps its key, and the server does not re-key other path-keyed user state.POST /cast/tokengrants the book's path. It resolves a part path (a disc folder too) the way the media routes do.
Frontend:
- Every content call in
src/api/client.tspasses?path=. The helpers are insrc/lib/paths.ts. - Client state persists keyed by
(library_id, path). - The book route can be given a track file or a disc folder inside a book, which
/itemresolves to the book above it. From 2.4.1 the route then moves to the book's ownrel_pathbefore it renders (useCanonicalBookPath). The page asks for progress, plays and casts by the book path only.
See the progress path for the server's rules.
A change requires: a change to path semantics (normalization, casing, or what a "book path" and a "file path" mean) changes both sides at once. It also changes the move-tracking fingerprint logic. Re-check seam 6 too, because a track URL must stay a real file path.
Folders not indexed yet
/fs marks a folder pending_book: true when the scanner would read it as a book
but the index doesn't hold it yet. The field is optional and has no capability, so an
older player ignores it.
- The player's
FsEntrymirrors the field. - A pending row opens the book route like an
is_bookfolder. - The book screen's read (
/item) indexes the folder on demand.
2. JSON envelopes (hand-mirrored, no codegen)
What couples: every response shape.
| Response | Shape |
|---|---|
| Auth | { token, user } |
/me | the user directly |
| Lists | wrapped: { libraries }, { books, next_cursor }, { progress }, { bookmarks }, { notes }, { history }, { favourites } |
The all-books /me/bookmarks, /me/notes and /me/history | the list wrapper plus an optional next_cursor |
| Chapters | { chapters, files, duration } |
| Errors | { error } |
The user object carries has_password and has_recovery, which drive the
frontend's sign-out warning.
Server: internal/api/handlers_*.go define the JSON.
Frontend:
src/api/types.tsre-declares the shapes.src/api/client.tsunwraps the envelopes. Errors becomeApiError(status, error).src/api/hooks.tsexposes React Query hooks.
A change requires: the full wire-change checklist. A field rename touches the server and every client that reads that shape.
Admin metadata edits don't move the player wire
The book JSON's metadata fields carry the effective values: scan, then enrichment, then any admin override. A player receives the edited title, author and the rest with no new field to mirror.
Of the columns the edits introduced:
publishedis on every player book.descriptionis onGET /libraries/{id}/itemonly, because it can be long, so list pages leave it out.has_cover, the scanned values and per-file codec stay off the player's book JSON (json:"-"oncatalog.Book). They surface only through the admin-only catalog API (/api/v1/admin/booksand friends), which the admin console in the server repo consumes.
More book metadata, genres and tags
Every Book (list, search, recent, /item and /next alike) also carries these
fields, all omitempty:
subtitle,publisherandlanguage.abridged, a bool that is absent while unknown and is never read as unabridged.genresandtags, string lists.
GET /libraries/{id}/books takes genre= and tag= (catalog.labelFilters). Each
takes one value, is case-insensitive, and narrows the list. Search matches genres and
tags too.
There is no capability. An older server sends none of the fields and ignores the params, so the player shows them only when they arrive:
- The Library genre and tag filters (
books/label-filter.tsx) appear only when a loaded book has a label. The player computes them on the device from the loaded books and keeps them in the URL asgenre=andtag=. - The book page shows the
subtitleunder the title, and the labels as chips in the About card (openLabelBooks), only when the server sends them.
The player mirrors the fields in types.ts Book and the params in client.ts
BookListQuery.
The library export (GET /admin/libraries/{id}/export) is consumed by the meta site.
It gains five of these fields, plus published and series_list, and its version
stays 1. The site's parser reads subtitle, publisher, language and abridged and
ignores the rest. The default export still carries no path and no tag. Two options
are opt-in, for the admin's own scripts:
?include=paths,tags,description.?copies=separate, one row per copy instead of one per book.
See GET /admin/libraries/{id}/export
for the options, and GET /libraries/{id}/books
for the field sources and rules.
Browse and player data shapes
These shapes are additive. The frontend mirrors them in types.ts, client.ts and
hooks.ts, each behind a capability flag (seam 8). The browse screens, Home and the
player (the cover wash, the end of a book) read them.
- Every
Bookgainspublished. - Every
Bookgainscover_color({bg, accent?, on_accent?}). The server sends it only while it matches the current art. - Every
Bookgainscover_version, a hash of the book's cover art identity. It is a cache buster rather than a content hash. GET /libraries/{id}/authorsand/narratorsanswer{ authors|narrators, unknown }. The client normalises both toPeopleList { people, unknown }./seriesanswers{ series }.- These browse lists are scope-filtered.
/bookstakesnarrator=(useLibraryBooks).GET /libraries/{id}/series/books?name=A&name=B(series_books) answers{ series: [{ name, books, next_cursor? }] }(SeriesBooks). Each entry is exactly the first page of/books?series=<name>&memberships=1.client.seriesBooksPagebatches the Series cards on screen into it.GET /libraries/{id}/nextanswers{ source, next?, book?, work? }(NextBook, seam 14).
The share-scope SQL behind all of these (pathFilterSQL) is a case-sensitive
byte-range prefix, which matches Scope.Allows exactly.
The listener's own state
The listener's own state is mirrored the same way and gated per feature (seam 8). Up next, collections, the book menu, Home, the end credits and the You hub (Your listening, Year in listening, the Account page's devices) read it.
| State | Routes and shapes |
|---|---|
| Up next | /me/queue, { queue } |
| Collections, shared read-only with named users | /me/collections/**, { collections } and { collection, items }; /me/share-targets, { users } |
| Ratings | /libraries/{id}/rating, { rating }; /me/ratings |
| The caller's own progress edit | PATCH /libraries/{id}/progress: mark finished or unfinished, edit the dates. Every progress response carries started_at and finished_at. |
| Personal stats | /me/stats, { stats }; /me/listening; /me/goal, a books-per-year goal |
| The caller's devices | /me/devices |
List entries are { library_id, path } book references (seam 1) that carry an
optional list-shape book. The server filters every list by the caller's current
access, and an id that isn't the caller's is a 404.
The shapes and status codes are in Up next, collections and ratings. The security rules are in Auth & security.
Bookmark labels, edits and the lists across books
These are gated by annotations (seam 8).
- Every bookmark carries
label, whichPOST .../bookmarksaccepts. - The owner edits a row with
PATCH /bookmarks/{id}andPATCH /notes/{id}. Each answers the bare row. GET /me/bookmarks({ bookmarks, next_cursor? }) and/me/notes({ notes, next_cursor? }) list the caller's rows across books./me/historypages on the samecursor, with abook?per row.
The shapes, bounds and status codes are in
PATCH /bookmarks/{id}. The
security rules are in
Auth & security, and
the client side is in
State & data.
3. Media auth rides in the URL (?token=)
What couples: browsers can't set an Authorization header on <img> or
<audio>. So cover and stream GETs accept the session token as a ?token= query
param on every platform.
Server: internal/api/middleware.go. Only these routes call
bearerToken(r, true), all through mediaAuth:
- The cover and stream routes (
requireBookMediaAuth, which also takes a cast token). - The two community cover routes,
/meta/coverand/me/upcoming/cover(requireMediaAuth). - The calendar feed,
/me/upcoming/releases.ics(requireFeedAuth), which takes afeedtoken only.
All other routes are header-only (bearerToken(r, false)). Tokens never ride the
query string there, where they could leak into access logs or Referer headers.
Frontend:
client.tsmediaTokenQuery()feedscoverUrl()andstreamUrl().- Native sends the header as well as the query parameter.
- The web player relies on the query parameter alone. A web cover is a plain
<img>(CoverImage), which can't send a header.
A change requires: do not "tighten" the server to reject query-param tokens on media routes without first removing the web player's dependency on them. The web player cannot authenticate media any other way, and neither can a Cast receiver.
Cover thumbnails
Thumbnails use the same auth. GET /libraries/{id}/cover?size=160|320|640
(capability cover_sizes) is a JPEG of the same art.
- Its
ETagcombines the size, the server's thumbnail revision (media.ThumbnailRev) and the art version:"thumb-<size>-<rev>-<hash>". - Its
Cache-Controlisprivate, no-cachefor a custom cover andprivate, max-age=86400for file art. - Any other
sizeis a400. - A thumbnail the server can't make is a
404. The client answers it by falling back to the full-art URL.
Clients add the book's cover_version as v=, purely as a cache buster:
coverUrl(lib, path, { size, version }).
From 2.5.1 the server takes size=96 too. The sizes are shared with
POST /admin/covers, where the admin console asks for 96 for its table rows. The
player never sends it, because an older server answers it 400. See
GET /libraries/{id}/cover.
Community covers
Community covers (capability meta_covers) are the other media-auth image route.
GET /libraries/{id}/meta/cover?path=&url=&size=160|320|640 (96 too from 2.5.1)
is a JPEG thumbnail of a community cover_url that the server fetched. The web
player's CSP stays img-src 'self' data: blob:, so the web player never contacts a
cover host. See seam 13.
Upcoming covers
GET /me/upcoming/cover?work=&size= (capability upcoming_covers) is the same kind
of thumbnail for an upcoming release. The server serves it only when the caller's own
upcoming list hands out that work's cover (see
GET /me/upcoming/cover).
The player builds it with client.upcomingCoverUrl, with the media token like every
cover.
The calendar feed token
The calendar feed (capability upcoming_calendar) is the one ?token= that is not
a session. GET /me/upcoming/releases.ics?token=<secret> takes a token of kind
feed and no other kind. Every other route refuses a feed token. So the link a
listener pastes into a calendar app reads their upcoming releases and nothing more.
- The server answers
POST /me/upcoming/calendarwith a server-relativepath. - The player puts that path on the server's away address, or else on the address in use.
- The player keeps the full URL in its secure store, like a session token.
See The calendar feed and Calendar feed tokens.
Cast tokens on media URLs
Cast tokens (capability cast) ride the same ?token=, on a book's cover and stream
only. A player casting to a Chromecast mints one for the book (POST /cast/token)
and builds the receiver's URLs with it, never with its session token. See
seam 14.
The ebook takes no URL token
GET /libraries/{id}/ebook (capability ebooks) takes header auth only
(requireAuth). It never reads ?token=, it refuses a cast token, and there is no
URL token kind for it. The web player fetches the bytes with the Authorization
header (client.ebook) and posts them to the reader frame. A read-along map
(GET /libraries/{id}/readalong, capability readalong) follows the same rule. See
seam 18.
4. Audio Content-Type must be real (byte-sniffed)
What couples: iOS AVPlayer rejects audio served as application/octet-stream
under nosniff (error -12847). So the served MIME type must be genuinely correct.
Server: internal/media/media.go ServeFile sniffs the magic bytes and falls
back to the extension:
| Magic bytes | Type |
|---|---|
ftyp | audio/mp4 |
| ID3 or MPEG sync | audio/mpeg |
| ADTS | audio/aac |
fLaC, OggS, RIFF, WAVE | their own audio types |
Frontend: assumes a correct Content-Type. There is deliberately no client
workaround.
A change requires: any change to how audio is served (new container support, a proxy in front) must preserve true audio MIME types. Validate it on an iOS device.
5. Transcode negotiation: direct_playable and ?transcode=1
What couples: whether a file needs server-side transcoding to play in a given client.
Server:
- The scanner records each book's
codec(ffprobecodec_name, migration0008_book_codec). itemandchaptersexposedirect_playable. It means plays in browsers as the server serves it:Book.PlaysInBrowsers(media.DirectPlayable; from 2.4.0 the container counts too) or, while ffmpeg is configured,Book.PlaysRemuxed.- With ffmpeg, an MP3-in-Matroska
.mkais served at the same stream URL as MP3. It is a cached lossless remux, and?original=1serves the stored bytes (see Remuxing Matroska). It streams in every client, shipped native builds included, so it istrue. - An
.mkaholding another codec staysfalse, so the web player transcodes it. The iOS app can't play it, and the Android app plays it only where Media3 decodes the codec inside. GET .../stream?path=…&transcode=1pipes through ffmpeg to MP3 (media.Transcode).&t=<seconds>starts mid-file, because transcoded output isn't byte-seekable.- The
--ffmpegflag gates all of this, and thetranscodecapability (seam 8) reflects it. The remux has no flag of its own.
Frontend:
Book.direct_playableandcodec, andChaptersResponse.direct_playableandcodec, are mirrored intypes.ts.client.tsstreamUrl(…, { transcode, t })builds a transcoded stream URL.- The web player negotiates it (
src/playback/transcode.ts). On web, a book whosedirect_playableis explicitlyfalsestreams with?transcode=1when its server'stranscodecapability istrue. - The web engine re-requests with a new
&t=on every seek and keeps positions track-absolute. - For such a book, the book page says the audio is converted for this browser, and downloading it is off in the browser.
- Native engines never transcode. They rely on the platform's own decoders, and a downloaded file is never transcoded.
- An
.mkafile is saved offline as<i>.mp3on iOS only (see Native engine in Offline).
See Web transcode negotiation in frontend playback.
A change requires: changing direct_playable's meaning or the transcode query
params changes both sides. The negotiation degrades by design. These all stream the
file directly, exactly as before negotiation existed:
- An absent
direct_playable, from an older server or any list response (onlyitemandchapterscarry it), reads as playable. - A codec never probed is sent as
true. - An unknown or
falsetranscodecapability.
Watch renditions
The same stream route serves a different kind of copy for a watch.
?rendition=watch answers with a cached AAC copy of the audio file: AAC-LC mono
48 kb/s in MP4.
- The copy has the same duration as the source, so whole-book positions, chapters and file places hold.
- The same
media.FileCachethe remux uses makes it once, into the data directory. - It is served with Range and a strong ETag, so a watch resumes a download.
- A cold encode answers
503 rendition_pendingwithRetry-After, never a live transcode. POSTandGET /libraries/{id}/renditionsprepare a whole book and report each file.
The renditions capability (seam 8) gates it. The
Apple Watch and Wear OS apps are its clients. The player only mirrors
the flag and ServerInfo.renditions. See
Watch renditions in
the server's media page and Watch renditions
in the API reference.
6. The chapter and whole-book timeline model
What couples: the subtlest shared model. Single-file m4b chapters and multi-file mp3 "parts" are normalized to one shape, so a player renders both identically.
Server:
metadata.Chaptercarriesfile_path(the library-relative file to stream), in-filestartandend, andbook_offset(its start on the whole-book timeline).GET .../chaptersreturns{ chapters, files, duration }.
Frontend:
src/playback/book-queue.tsbuilds the track queue fromfiles. Without them it uses the distinct chapterfile_paths, and without those the single-file path.src/playback/store.tsmaps between(trackIndex, position)and the whole-book position through cumulativeoffsets, and overlays chapters bybook_offset.
A change requires: any change to chapter normalization changes the queue builder
and the timeline math with it. And always stream the file, not the book. A folder
path in a track URL is the MediaToolbox -12864 bug class (see
Invariants §3).
Community and generated chapters
The chapters may come from somewhere other than the files:
- A community recording's list fitted onto the audio (Community chapters).
- The chapters a Bridge found in the narration of a book with none of its own (Generated chapters).
They keep this exact shape, so a player needs nothing to play them. The only
additive marker is chapters_source: "community" or "generated" on /chapters and
the /item book. The player mirrors it as Book.chapters_source and
ChaptersResponse.chapters_source (the ChaptersSource union).
- The player's book page captions such chapters. An older app shows them as ordinary chapters.
- A downloaded book replaces its saved chapters with a newer server answer while its
audio files are unchanged: same paths, order and sizes (
startChapterRefresh). - The same files in a new order (the server's natural file order, 2.4.0) are taken
only by a start that loads them that way (
adoptStartedChapters), never from a background answer. - From 2.4.1 the download hot-swap can take them too. A copy that lands while a start's new order streams takes that start's chapters, only when they give the order the engine plays, so only the files' urls change (see Offline).
Podcast episode chapters
Podcasts also return id3_chap from the server's CHAP attachments. The player
(2.2.0 and later, first released in 2.4.0) captions it "Chapters from the episode
file". See seam 17.
7. Pairing and connect deep links
What couples: the handshake from an auth code to a session. It spans both repos and two URL carriers.
Server:
internal/api/qr.gobuildPairingemits two links:web_url(<base>/web/connect?token=…) is encoded in the QR. The app's own scanner reads it. A phone's camera app opens it at the web player, since the store apps claim no domain (only a build named inapp_linksdoes).uri(audiosilo://connect?server=<base>&token=<pairing_token>) uses the custom scheme.
- The flow is
POST /auth/redeem(a code for a pairing payload), thenPOST /auth/exchange(a pairing token for a device-scoped session). - The redeem payload adds two optional fields for the connect page:
admin(true when the code pairs an admin's account) andlinks.apkbesidelinks.iosandlinks.android(the store links). The player reads neither.
Frontend:
- The
audiosiloscheme inapp.json. - The connect and pairing parser and the
/web/connectroute consume both carriers. client.tsredeemCode()andexchange().- The app's scanner and the web player's paste field also read the console's invite
link (
inviteURL,<base>/connect#code=…;parseInviteScanfrom player 2.4.0). They sign in with its code through the same redeem and exchange. A change to that link's shape changes both repos.
A change requires: these are a contract:
- The scheme.
- The query keys:
server,token, and the optionalhome,away,sid, anduid,book,path. - The
/web/connectroute path.
Change the server emitter, the parser and app.json together, or pairing breaks.
Home and away addresses
Every pairing payload (/auth/redeem, /auth/pair, the demo session's pairing)
may carry addresses: { home?, away? } (the addresses capability).
awayispublic_url.homeislan_url, or else the request's ownHostwhen that is a home-network host (config.Addresses). The host rule is in Configuration.- The server drops a home equal to the away.
Both links carry them as extra query params after the existing ones, and only
when set (…&away=…&home=…). Read them by name, because the encoder sorts them. Old
clients ignore them.
The /auth/exchange, /auth/login and /demo/session answers carry addresses
too. GET /addresses lets a paired device learn one set later. The shapes are in
Home and away addresses.
On the frontend:
parsePairingScanreturnsaddresses.- The connect flow stores them on the connection (
Connection.addresses).mergeAddresseskeeps a knownhomethat an answer lacks. serverUrlis never rewritten.- The native app picks the address per request. It uses home only after a tokenless
GET <home>/api/v1/serveranswered with this connection'sserver_id, else away, elseserverUrl. - Never send a token to a home address before its
server_idmatched. - The web player never switches, because it is served same-origin.
See Connect and home/away addresses.
Listen in player
The admin console's book page mints a token for its own user (POST /auth/pair). It
then opens the same-origin
/web/connect?token=…&sid=…&uid=…&book=<library_id>&path=<rel_path> in a new tab.
- The player skips the exchange when it is already signed in as
uidonsid. - The player ends the flow on that book. It rebuilds the route with
bookHref, never from a URL in the link.
Handing a pairing to a watch
The phone mints a token for its own user (POST /auth/pair {"ttl_seconds": 600}).
It sends the token to the watch in a pair message, not a link: over WatchConnectivity
to an Apple Watch, as a Data Layer item to a Wear OS watch. The watch exchanges it with
POST /auth/exchange under the same address rules, and identifies as
audiosilo-watch/<ver> (watchos) or (wearos). The messages between phone and watch are a
contract inside the frontend repo, not the server's (see
The message contract and
The Data Layer contract).
8. Capability flags: GET /api/v1/server
What couples: feature negotiation, so any app build can talk to any server version.
Server: handleServerInfo advertises the capability flags and the server
version (api.Version, stamped from the release tag through ldflags). The full flag
table, with what each one gates and when it is true, is owned by
API conventions.
By what decides their value, the flags are:
- Server setup:
web_player(whether/webis populated),transcode(ffmpeg availability) andrenditions(true with ffmpeg and a data directory, elsefalse; the kinds are a top-levelrenditionslist, seam 5). - Always true:
admin_ui,api_keys(the server accepts user-minted API keys) andexport. - The runtime metadata switch:
metadata,meta_bundleandmeta_covers. - Always true on a server that has them:
browse_people,cover_sizes,next_bookand every flag added after it, includinghouseholdandcast(seam 14),household_members(seam 15),podcast_opml,ebooksandreadalong(the reader and read-along maps, seam 18), andlistening_sync(listening as events, seam 19). - The Bridge's heartbeat:
bridge(seam 16). Thebridge: {version, features}block besidecapabilitiesgoes to a signed-in caller only (a session or API key of any role, not a demo account). Thebridgeflag itself stays public. - A managed module: one flag per managed module,
contributions,podcastsandaudible. A module flag istruewhen the module's toggle and its Bridge requirement are met, and is sentfalseotherwise. The console readscontributionsto offer contributing.podcastsneeds thepodcastBridge feature and gates actions, while reads stay core and players older than 2.2.0 are excluded. Only the console readsaudible(seam 16). - Podcast protocol flags:
podcast_fetch:trueis an independent protocol flag, sent even when the Bridge is offline or the module is off. Older servers omit it (seam 17).podcast_discoveryis true when the boot-onlypodcasts.discovery.enabledand the module toggle are on, independent of the Bridge, and is sentfalseotherwise. - Upcoming releases:
upcomingis true while community metadata is on andupcoming.enabledis set. It gatesGET /me/upcoming, the upcoming releases in the caller's series.upcoming_calendarandupcoming_coversare exactlyupcoming. They gate the calendar feed and/me/upcoming/cover. - Reserved:
uploadandwebsocketare reserved for planned phases.POST /uploadsand WebSocket sync are not shipped.
Frontend:
- The
ServerInfotype. Feature gating and the "connected server version" display key off it. - Every flag from
meta_bundleon is optional onCapabilities. The player reads it through the exported, tri-stateuseCapability(flag, connectionId?)inhooks.ts. It isundefineduntil/serveranswers, thentrueorfalse. - The gated hooks give a server without the flag no query function (
skipToken), so the player never asks it. - The gated mutations (
useCapabilityMutation) reject withCapabilityErrorwithout sending anything while the flag is false or/serverhasn't answered.CapabilityErroris not anApiError, so it shows no reconnect banner.
A change requires: adding a capability is a two-repo change. Flip the flag as the
feature lands on the server, and gate the new UI on it in the client. Never assume a
capability is present. The one exception is listening_sync while unknown (see
The gate in seam 19). A module's flag is its id. It is
mirrored in Capabilities (types.ts) like any other flag and named in the player's
ModuleFlag too, except audible, which the player doesn't mirror (see
Player modules).
9. The web player is served by the server at /web
What couples: the frontend's web build is not vendored in the server repo. The
server serves it at runtime from web_dir. Docker bakes a pinned build in, and
native binaries embed one through -tags embedplayer.
Frontend: app.json sets experiments.baseUrl: "/web" and
web.output: "static". The export must be built with baseUrl=/web so asset URLs
resolve under the subpath.
Server: internal/web/web.go serves web_dir at /web.
- It falls back to
index.htmlfor SPA routes. - It answers 404 for missing assets.
- It sends a per-response scoped CSP that hashes each document's inline scripts.
- The one exception is the player's ebook reader document,
reader.html, served at/web/reader.htmlunder its own policy (seam 18).
A change requires: watch the CSP coupling. The server computes the hash per document at serve time, so it usually tracks automatically. A change in how Expo inlines bootstrap scripts can still break strict CSP. Otherwise the two are compatible by construction, because the server image pins a specific web build.
10. Demo mode (demo.audiosilo.app)
What couples: the public throwaway-account demo flow.
Server:
config.demo.*(enabled, library, max_users, idle_ttl).POST /demo/sessionmints a throwaway account that the server reaps later (migration0005_user_is_demo).- In demo mode the site root
/redirects toconst webDemoPath = "/web/demo"inapi.go. This constant is explicitly the single point of coupling with the player's router.
Frontend: client.ts demoSession(), the DemoSession type, and the
/web/demo route and screen in the Expo router.
A change requires: when you rename the player's demo route, update
webDemoPath in the server in the same change.
11. Build and release coupling (order matters)
What couples: the deployable server image contains a pinned web player, so the web image must exist before the server image builds.
Frontend: .github/workflows/web.yml exports the web build and pushes
ghcr.io/<owner>/audiosilo-web.
Server:
DockerfiledoesFROM ${WEB_IMAGE} AS web, thenCOPY --from=web /web /app/web..github/workflows/image.ymlbuilds and pushesghcr.io/<owner>/audiosilo-server..github/workflows/release.ymlandscripts/fetch-web-player.shembed the same pinned build into native binaries.
A change requires: publish the web image before the server image on every
release. The served /web identifies as its app.json version, and the server's
podcast gate keys on it (2.2.0 and later see podcasts). So a server release that
bakes in an older web image serves a player that shows no podcasts. The full detail
is in Release pipeline, and the operator runbook is
Releasing.
12. CORS for web development
What couples: during development the web player (expo start --web on :8081)
and the API (:8080) are different origins.
Server: set cors_origins to include the web origin (for example
http://localhost:8081). "*" disables the check. Empty means no cross-origin
headers, and native and same-origin clients still work.
Frontend: npm run web serves on :8081 by default.
A change requires: nothing structural. Self-signed TLS (the server default) needs
trusting in the browser, or use AUDIOSILO_TLS_MODE=off for plain-HTTP local dev.
The identity header and cross-origin calls
The player's X-AudioSilo-Client identity header is a custom header, so it would
force a preflight on every cross-origin call. The server's
Access-Control-Allow-Headers lists it, but servers released before it don't.
- So the web player sends it only same-origin (
shouldIdentifyinsrc/lib/client-id.ts). Native always sends it. - Keep that rule when adding any custom request header (see Client identification).
- The server shows podcasts only to an identified 2.2.0 or later player, so the
cross-origin dev loop shows none. A development build can opt in with
EXPO_PUBLIC_IDENTIFY_CROSS_ORIGIN=1, never a production build.
For an allowed origin the server also allows Range, and exposes Retry-After and
the OPML export's X-OPML-* counts. What an older server means for the player is in
CORS.
Media for a player on another site
The web player loads another origin's covers, artwork and audio in CORS mode
(corsModeFor, applied by CoverImage and the <audio> element; see
the web engine). So the server's
cors_origins governs them. An older player loads them no-cors and relies on
Cross-Origin-Resource-Policy instead (CORS). Media
takes only an explicit token, never a cookie, which is why both are safe (see
Transport hardening).
13. Community metadata: a three-repo seam
What couples: enriched book metadata (description, characters, recaps, "more in
this series") originates in audiosilo-meta and flows through the server to the
player.
audiosilo-meta (upstream): metaserve serves the community metadata read-only:
GET /lookup, /works/{id}, /series/{id}, and
/works/{id}/recordings/{rid}/chapters for
community chapters. See the
metadata database developer pages.
Server:
internal/metaresolves a book's ASIN or ISBN againstmetaserveand composes an enrichment envelope. A book with neither, or onemetaservedoes not know, is resolved by its recorded match instead (see Matches by work id).GET /libraries/{id}/metareturns it, behind an admin off-switch and a bounded cache.GET /meta/work?id=…serves one work document straight through, from the same cache, for the earlier books of a series the caller does not own. It is authed but not library-scoped, since a work id names nothing on this server.- The
metadatacapability onGET /serverreflects whether either lookup is live. - The admin console's match dialog (
GET /admin/libraries/{id}/book/match) asksmetaserve's structuredGET /works/match(see Community metadata matching). - Community chapters (
internal/chaptercheck), upcoming releases (internal/upcoming) and the unknown-ASIN report (internal/coverage) also read frommetaserve, and the report writes to it. Each has its own sub-section below.
Frontend:
- The
BookMetaenvelope is hand-mirrored insrc/api/types.tsand fetched byclient.bookMeta. - The book screen renders it, capability-gated, on its Recaps, Characters and Series tabs.
- The series page, Home (the Now card and Previously on), Search, the end credits and the player's companion and reveal toast read it too.
client.metaWorkanduseMetaWorklazily fetch a previous book's work when the reader opens its row.
A change requires: the server consumes metaserve's response shapes. So a
change to those shapes ripples from audiosilo-meta to the server's internal/meta,
and on to the player only if the server's outward /meta envelope changes. Keep it
additive, same as every other seam here. A change to the exported API of pkg/query
or pkg/release reaches the server through its go.mod.
Community metadata matching
The match dialog sends GET /works/match the book's tag and path facts:
- Its
languageand repeatablenarrator. An oldermetaserveignores them. - Its hints, as
asinandisbn, when it has neither of its own: an Audiobookshelf import's and its tags' (matchrun.QueryFor, in this order).
Against a metaserve that predates the route, the server falls back to lookup and
works/search. The candidates pass metaserve's score and reasons through to
the console's hand-mirrored types (admin-ui/src/api/types.ts), not to the player.
The server, not the console, decides which ASIN a match attaches:
- Each recording's ASINs keep their
region(asin_refs). - The server orders
asinsby its preferred marketplace (metadata.region). - Bulk match runs reuse the same match per book.
- The server decides what is confident (
matchrun.Classify, theReviewFloor).
Where the scoring comes from:
- A mirror-mode server gets
metaserve's scoring with theaudiosilo-metaversion ingo.mod(v0.24.0). - An online server gets the scoring of the service it asks. meta.audiosilo.app has
run v0.24.0 since 2026-11-05, so the position and runtime factors are live for
every online server that asks
works/match. languageandnarratorcount only from a server that sends them (2.6.0).- In 2.6.0 the identifier rule, the floor, title tidying and tag hints are server-side.
A run in match mode also sends expand=work, so one
answer per book carries the best work and its series facts. The dialog, a refresh and
a repick never send it. An old metaserve ignores it, and then the server fetches the
work and series itself (see
What a run asks of the community service).
Dramatizations
A dramatization is a recording of its book. So a plain book matches the work but never takes that recording.
works/matchnames none by length.- The server's
meta.DefaultRecordingkeeps the same rule by hand. A recording counts as a dramatization by its publisher or full-cast credit, or by the work's label only when the recording states no publisher. So a server asking an oldermetaserveis safe too.
From 2.6.0 the server alone goes further. None of these needs a metaserve change:
- A book's author credit or genre can state a dramatization.
- A work holding only dramatizations gives a plain book the one its length names.
- A match run trades an off-length recording (mostly a tag hint's) for one of the
same work that fits (
meta.FitRecording).
The rules are in
GET /admin/libraries/{id}/book/match.
Otherwise a run holds for review a plain book whose work holds only dramatizations
(item detail: "dramatization_only", no identifier to attach). The candidate's
additive dramatization_only makes the console's dialog preselect no recording.
Which statement speaks for which recording:
- A work's label speaks only for a recording that states no publisher.
- A statement that stays on the title (
(Dramatized), a split part's label) speaks for every recording. - When no recording of a work says so of itself, the title's statement covers them all.
Matches by work id
From 2.6.0 the server records each match by its work id (book_matches). So a store
listing that metaserve holds with no ASIN or ISBN counts as matched. A refresh
looks such a book up by works/{id}, following a retired slug's 301. The rule a
run applies to such a book is in
Bulk community matching.
From 2.7.0 the server's /meta answers such a book from its recorded match. It
tries the book's identifiers first, then its book_matches work_id and
recording_id when they find nothing. The envelope is the same, so the wire does
not change.
A book added from LibriVox gets its row from the server's
fold of the done librivox.fetch, with the work and recording the server resolved
at queue time (how accepted). Where a LibriVox recording is on the server is
read from these rows alone, by their exact, non-empty recording_id, never from
/meta's first-recording answer for a work.
The player's book page asks /meta for every loaded book that is not an episode
(bookPageMetaEnabled), so a book matched with neither identifier shows its
community content there. Most other surfaces keep the identifier gate
(metaEnabledFor). Enriched book metadata
lists which surface uses which gate.
Community chapters from the meta side
Community chapters (internal/chaptercheck) fetch the chapter list of the lookup's
exact recording_id only, never the first recording a work lists. The server fits
the list onto the book's audio. The chapters reach the player as ordinary chapters
with the additive chapters_source: "community" (see
seam 6). The console's panel reads the
admin book page's community_chapters.
Upcoming releases from the meta side
Upcoming releases (2.6.0, internal/upcoming) read from metaserve once a day:
- The watch feed (
watch/feed.json?s=), 50 series a request. The work id is taken from each item's tag or URL. GET /series/{id}for a series never read, one with news, or one read over a week ago.
The series rails' work cards carry release_date through to /meta (additive).
The player (2.6.0) reads
GET /me/upcoming as
UpcomingResponse in types.ts (client.upcoming(), useUpcomingAll()). It asks
only a server that sends upcoming, and never for a kid.
- Home's Coming up in your series, the
/upcomingpage and the You hub's Upcoming section show it. - An item opens its series page by its
localview. - The series page dates its not-yet-released ghosts from the rails'
release_date("Out Nov 12, 2026"). - The player also shows each item's real cover (
upcoming_covers) and offers a calendar feed (upcoming_calendar, afeedtoken). Both carry their token in the URL (see seam 3).
See Upcoming releases.
The unknown-ASIN report
The server's one write to metaserve is the opt-in unknown-ASIN report (2.6.0,
internal/coverage, metadata.report_unknown, off by default). Once a day the
server sends
POST /api/v1/intake/unknown-asins
with exactly {"asins":[...]}, holding only the ASINs a lookup answered 404 for. The
transport rules (https only, no identifying headers, no redirects), and how the
server handles an older metaserve or intake turned off, are in
Configuration.
Reading-order families
Reading-order families (metaserve schema_version 7 ordering, ordering_of and
orderings) are collapsed server-side into one rail per family, with the other
orders as additive alternates. So a shipped player that ignores them still sees one
rail in the primary order. The current player adds a per-family order toggle that its
"previous books" list follows (see
Reading-order families).
The /meta bundle fields
These are all additive:
work.community_description, the CC BY-SA description, kept apart from the CC0description.work.attribution, present iff the work carries CC BY-SA content. The server writes the legal text. A client must render it beside that content and never compose it.recording.chapter_count.recording.librivox{id, url?, zip_url?}, only on a LibriVox recording (seeGET /libraries/{id}/meta). The server checks each URL, and the player checksurlagain before About links it.local{library_id, path}on each rail entry the caller owns, in the main view and the orderings. The server resolveslocalper request, after the shared cache, on a copy, and never stores it there. A failure placing the caller's books sends the envelope withoutlocalrather than failing it.
Two query params sit behind the meta_bundle capability:
?include=previousaddsprevious[]: the main view's earlier works, nearest first, at most 5.?spoilers=hidegates the envelope by the caller's saved server progress.
Spoiler gating still stays on the device by default. The player gates against its live position and does not send the param.
reveal and through are numbers a gate compares with the listener's chapter. When
the book has more chapters than the work's position_scale states, the server
answers them on this book's chapters.
It does so per request, never cached, and always errs later. Only the server can do
that for a shipped app.
What /next answers
GET /libraries/{id}/next follows every series the book is in: main first, then its
other series.
- It takes the community rails in that order, with rails named like none of its series last. The first rail with a next work that doesn't step back decides.
- That rail answers when it places that work on one of the caller's books, possibly in another library. A later rail never answers in its place.
- Otherwise the local series answer (each in turn), then the folder, then none.
- When one of those answers, an unplaced community next work rides along as
workwithoutlocal.
The wire shape is the same as for one series.
The persistent metadata cache
The server also keeps answers in a persistent meta_cache table, so a restart is
warm and an outage serves the last known answer.
- It has no config key and follows
metadata.enabled. - It holds community data only.
- It keeps the newest 20,000 rows, of which works fetched by id are at most 2,000.
See /meta and
Configuration.
Community covers through the server
meta_covers tracks metadata.
GET /libraries/{id}/meta/cover?path=&url=&size=160|320|640 uses media auth
(?token=) and answers image/jpeg, a thumbnail of url. size is optional, with a
default of 320, and takes 96 too from 2.5.1.
- The server answers only when that exact URL is a
cover_urlthat the/metaenvelope for the same(library_id, path)hands out: the recording's, or any rail entry's in a main view or an ordering. - Anything else is
404 no such coverbefore any fetch. It is never an open proxy. - Scope and errors are as for
/meta: 403 or 404 for the path, 404 with metadata off, and 502 metadata unavailable. 502 cover_unavailablemeans the fetch failed. The server does not fetch that URL again for a minute, and concurrent misses share one fetch.404 no covermeans the file is not an image or is over the pixel bound.- Only a re-encoded JPEG is served, with
ETag"community-<size>-<rev>-<hash>"andprivate, max-age=86400.
On the frontend:
Capabilities.meta_covers.client.communityCoverUrl(lib, path, cover_url, { size }).communityCoverSourceanduseCommunityCoveruse the proxy when the flag is on. Without it (an older server, or/serverunreachable), native loadscover_urldirectly, and the web player shows the placeholder because its CSP blocks third-party images by design.
With metadata on, the server, not listeners' devices, contacts the cover hosts. See
/meta/cover.
Mirror mode
Mirror mode (metadata.mode: mirror, opt-in; remote is the default) makes the seam
tighter.
- Once a day the server downloads audiosilo-meta's data release artifact
(
meta.sqlite.gz) into<data>/meta-mirror/. The file holds the CC0 core and the CC BY-SA community layer. - It fetches the artifact with audiosilo-meta's public
pkg/release. - It answers the same
/api/v1requests in-process with audiosilo-meta's publicpkg/query(Open,NewHandler), which is metaserve's own handler, byte for byte. internal/meta's client is unchanged. The local copy answers while one is ready, andbase_urlotherwise (see the full fallback ladder).- The player is untouched. There is no wire change and no capability change.
Two couplings follow, both part of this seam:
- The handler's responses are a public API. A mirror-mode server decodes the
local answers with the same structs it uses against meta.audiosilo.app. So
pkg/query's responses change the way metaserve's always have: additively. - The artifact schema is a direct coupling. A mirror-mode server reads whatever
the newest data release is, routinely an artifact newer than its own code. So
artifact schema changes must stay additive: new tables and columns, never a
renamed or dropped one, and never a changed meaning for an existing column. A newer
artifact still opens and answers. How the versions are gated and bumped is in
the meta data model.
A server picks up
pkg/querychanges only when itsgo.modmoves to a new audiosilo-meta tag.
Contributing back
The server never writes to the community repositories. What an admin shares or corrects is a Bridge job (seam 16). The Bridge files it as a GitHub issue under the admin's own account:
- When the Bridge uses its Accounts sign-in, it files through the AudioSilo Contributions App.
- With a token from its environment or
gh, it files a plain issue.route.ymlleaves that issue to the routing labels the Bridge set, when they stuck, or to a maintainer.
Two meta-side pieces make that a seam:
- The correction-batch form:
[corrections]issues with a fenced, versionedchangesblock, which the Bridge writes and meta'scorrectBatchreads. route.yml, which routes an App-filed issue by its title prefix ([work],[corrections];[characters]and[recaps]on the community repository) or holds it (intake:held).
The title prefixes and the batch's v: 1 block are the contract between the Bridge
and meta. Two more couplings ride on the corrections:
- How meta's form reads a value (
issueformformValue, with character references decoded) is mirrored by the server'smeta.correctchecks and by the console's dialog. A change there moves both (see Corrections:meta.correct). - The server's bounds on a batch (128 KiB of params, the value and evidence lengths) must stay within what the Bridge reads (64 KiB as stored, an unknown field refused) and what meta takes. Raising one starts with meta and the Bridge.
14. Listening in the house and casting
What couples: two additive features, each behind its own flag (seam 8).
Server: the routes below. Every privacy rule for Listening in the house is the server's alone.
Frontend: mirrored in types.ts (HouseholdSettings, HouseListeningRow,
HouseListening, CastToken), client.ts and hooks.ts.
A change requires:
- The household rules stay in the server's catalog. A player must never filter someone in.
- Any widening of where a cast token is accepted is a security-critical change, with allowed and denied tests on the server.
Listening in the house: the wire
Capability household:
GETandPUT /me/household({ share_listening }).GET /household/listening({ sharing, listening: [Row] }).
The shapes are in Listening in the house. Every privacy rule is the server's (see what other people see). The player only decides what to show of the answer and how often to ask. The client side is in Listening in the house.
Casting: the wire
Capability cast. POST /cast/token { library_id, path } answers
201 { token, expires_at }. The token is a credential for one book, accepted only as
the ?token= of that book's cover and stream (see
Cast tokens as media auth).
Its lifetime and policy are in Cast tokens.
- The web player's CSP admits the Cast Web Sender's three scripts on
https://www.gstatic.comby path inscript-src, and nothing else. - The receiver never sees the session token.
- No flag means no Cast button.
The client side, including which address the receiver's URLs use, is in Chromecast.
15. Household members (profiles and kids mode stay client-side)
What couples: household members are server-side, while profiles and kids mode are client-side.
- A profile (a person on this device), its PIN and its kids mode never reach a server (see Profiles).
- A household member (capability
household_members) is a real account that an owner creates from the player. Everything about what it may do is the server's.
The wire. All of it is additive. The shapes are in Household members.
/me/household/members(list, create) and/{id}(PATCH,DELETE),/{id}/session,/{id}/pair,/{id}/devicesand/{id}/devices/{device_id}. They are mirrored intypes.ts,client.tsandhooks.ts(see State and data).- The
userobject (/meand every session envelope) gainsmanaged_by({ id, username }ornull) andmember_kind("kid","grownup"ornull). - For a member only, the
userobject also gainsdisplay_name, the name the owner typed. The player names a new profile for the account after it. - The member's access is not on the
userobject. The player can't see it and never needs it. /me/devicesrows gainmember_sign_ins, andDELETE /me/devices/{id}?also_members=1answers{ current, members_signed_out }.- The admin console's fields and settings stay inside the server repo (see Admin: users).
Server rules. Each has an allowed and a denied test (see Auth & security).
- What a member may listen to: the owner's access, or the collections the owner shares, plus their own grants.
- One level, and never an admin.
- The kid refusals (
403 kid_account). - Kids are kept out of share targets and Listening in the house.
- Someone else's member is a
404, like no such account. - A disabled member's session and pairing code are
409 member_disabled.
Client rules (see Household page and members):
addMemberHereplaces a member that the owner signs in on this device from the Household page. The owner stays the person listening, and the default connection doesn't move.member_kind === 'kid'turns kids mode on by default and allows it with no grown-up on the device.managed_bypicks the kids shelf's collections.- The player never filters a kid's access itself.
- The player reads refusals by
code, never by the server's message (see Error mapping). A newcodeneeds a case inAPI_ERROR_CODESand its words in the player, or the player reads it by its status.
A change requires:
- Any change to the member rule is security-critical, with allowed and denied tests on the server.
- A new member field is the usual wire change in both repos, gated on the flag.
Household members and old clients
- A shipped player ignores the new
userfields and sees a member as an ordinary account. - A kid signed in to an old player is still limited by the server.
- An older server has no flag. The player then offers no "Add someone to your household", and profiles and kids mode work as before.
16. The Bridge
What couples: the Bridge (audiosilo-bridge, public,
AGPL-3.0) is a companion client of the server's wire format, alongside the player.
The server defines the contract, and the Bridge mirrors it by hand in
internal/link. A change to the server's internal/bridge or handlers_bridge.go
is a change in both repos, with a test on each side.
Server: defines the contract in internal/bridge and handlers_bridge.go. The
sub-sections below cover the routes, the bridge token kind, the bridge_jobs
queue and the .audiosilo/ files the server reads.
Bridge: internal/link mirrors the routes and shapes by hand. Its
internal/link/audiblesync.go mirrors position sync. The Bridge sends
X-AudioSilo-Client: AudioSilo Bridge/<version> (<os>) on every call.
Frontend: the player's admin-only generate action and the mirrored types (see The player's Bridge surface).
A change requires:
- The server handler and its test.
- The mirrored type in the Bridge's
internal/linkand its test. - For the generate action, also the console's
admin-uiand the player'stypes.ts,client.tsand hooks, with their tests. - For a new optional heartbeat block, both
acceptslists. - CROSS-REPO.md §23.
The token's rules are security-critical, so they need allowed and denied tests on the server.
The Bridge pulls
The server never connects to the Bridge. The server calls no route on the Bridge and
dials no Bridge address. The console links to the Bridge's page (ui_url from the
heartbeat, http(s) only) for the browser to open, and nothing more.
Pairing and the bridge token
Pairing:
- The console's
POST /admin/bridge/pair(a session only) mints a 15-minute code. The console's pairing code comes with the server's home address when there is one. - The Bridge's public
POST /bridge/pairtrades the code for a token of kindbridge. - One Bridge per server. Pairing another revokes the first.
- The Bridge keeps the token sealed, never in its config.
The token kind:
- The server accepts it only on
/api/v1/bridge/*, from the header only, and only while its admin is an enabled admin. - Every other route refuses it, media
?token=included. - Heartbeats and claims check it again inside their own write.
- A
401means the server refuses the token. The Bridge stops calling in, says why, and retries every 5 minutes. A demoted admin's Bridge comes back when they are re-promoted. An unpair or a new pairing revoked it for good. - A failure to check the token is a
503, which the Bridge retries.
The routes the Bridge calls
The shapes are in The Bridge in the API reference.
| Route | What it does |
|---|---|
PUT /bridge/status | The heartbeat, every 30 s. The answer lists the libraries and accepts. |
GET /bridge/jobs/next?wait=&kind= | A long-poll claim, at most 25 s, narrowed to the kinds with a free lane. 204 for none. |
PATCH /bridge/jobs/{id} | Takes {attempt, state?, progress?, result?, error?}. The answer's cancel_requested asks the Bridge to stop. 409 not_held when its claim no longer holds the job, a job that no longer exists included. The server cleans free text and never refuses it. |
GET /bridge/libraries/{id}/book[?path=] | An indexed book's files with the sizes the server sees now. A single-file book is its own file. With no path, the first whole book among the library's first five by title. 404 no_book. With a path, an additive book of the book's facts: title, credits split, series and position, ASIN, ISBN, duration and the cached work_id. |
POST /bridge/rescan {library_id, path, mode?, from?} | full (optionally carrying a Bridge-made move's durable state from from), or attachments (only the book's .audiosilo/ files). |
The heartbeat carries an additive estimates block. The server reads it leniently:
it ignores an unknown key and drops a bad block, never the heartbeat. A setup
block is read the same way. The Bridge sends setup only once a reply's accepts
lists setup, because an older server refuses an unknown heartbeat field.
The claim floor
accepts may also list claim_min_priority. It permits an optional signed int32
min_priority on the claim.
- The server filters it inside the claim transaction.
- A malformed value is
400 invalid_request. - Omitting it keeps the ordinary behavior.
A change in the Bridge's eligible kinds, pairing or capacity cancels its outstanding claim and reissues it at once. The server releases a lease whose reply was never delivered without spending an attempt. See claims and leases.
The bridge capability
capabilities.bridgeis true only while the last heartbeat is under two minutes old.bridge: {version, features}sits besidecapabilities.featuresis a list, nevernull.- The server reuses a heartbeat it read for up to 5 s.
- The
bridgeblock goes to signed-in callers only. The flag stays public. - Clients never assume it. A feature the Bridge doesn't advertise is off even when the flag is on.
featuresmay also holdcontribute(the Bridge can file contributions; see the heartbeat), which thecontributionsmodule's flag needs (seam 8).featuresmay also holdmeta, when the Bridge files corrections.
The job model
- The server's
bridge_jobsrow is the intent and the lease. It is path-keyed, moves with its book, and goes with its library. - The Bridge's
server_jobsrow is the execution truth. - Leases last 2 minutes, and every report renews them.
- A job gets three attempts.
- Kinds are allow-listed twice. The server must know the kind and the Bridge must
have advertised it, else
409 kind_unavailable.
See Pairing and jobs.
Library mappings are checked
The Bridge never assumes a mapping. Before it calls a books library mapped, it compares a known indexed book's file sizes through its mount with the server's. Then it probes writing. A podcast library instead uses a shared root marker that the Bridge reads back through the server, even when the library is empty.
The Bridge checks after pairing, when the server's libraries change, every
mapping.check_minutes (default 10), and on demand. Each heartbeat carries the last
results. See Library mappings.
Hidden folders
No content route reaches a path through a dot-folder by name
(library.ContentPath). Such a path is never indexed, listed, streamed or served,
and is a 404. So the Bridge's .audiosilo/ inside a book can't become a phantom
book or a streamable file.
The .audiosilo/ file contract
The Bridge writes characters.json and recaps.json (audiosilo-meta's
model.Characters and model.Recaps) and bridge.json into a book's folder. For a
single-file book, each name has <stem>. before it.
The server indexes them: checked, size-capped, regular files only. It lays them into
/meta per request:
- Community content wins per dimension.
work.generatednames what came from the files.- An unmatched book answers
matched: falsewith awork. - The CC BY-SA
attributionnever covers them.
A change to the formats touches audiosilo-meta's model, the Bridge's writer and the server's reader. See The .audiosilo folder.
The job kinds
| Kind | What it does |
|---|---|
sidecars.generate | Writes characters, recaps and a description into the book's .audiosilo/ folder. The first job that writes. |
sidecars.describe | Writes a description alone, from the notes a run kept. |
chapters.detect | Finds a chapterless book's chapters, without the agent. |
readalong.align | Writes a read-along map, with no agent. |
sidecars.contribute | Files generated content on GitHub. Writes nothing. |
meta.correct | Files an admin's corrections on GitHub. |
audible.poll | Polls one Audible account. |
audible.import | Backs up one Audible title. |
audible.sync | Syncs listening positions with one Audible account. |
librivox.fetch | Downloads one LibriVox recording into a books library. |
Each kind's rules follow.
sidecars.generate
- The server checks the params
{source, contribute, work_id}strictly. - It refuses
409 library_not_writablefor a library that the heartbeat doesn't report mapped and writable. - It refuses
409 cannot_contributeforcontribute: issueunless the heartbeat says the Bridge can. - The progress (
step,stage,eta_seconds,cost_usd,source,park {code, hint, retry_at}) and result shapes are the Bridge's. The server stores them opaquely, and the console and the player read them by hand. GET /admin/bridge/estimatecosts a run from the heartbeat's figures.- The server announces
bridge_job_finishedandbridge_job_faileditself, on transitions.
Own-words descriptions
A sidecars.generate run also writes description.json (audiosilo-meta's
model.Description, 200-1500 characters).
- The Bridge writes it from the opening chapters' fact notes only, so it is spoiler-free.
- It is local only and is never contributed.
sidecars.describe writes one alone from the notes a run kept. Its params are {}.
It meets 409 generate_open while a generate job is open for the book, and a
generate job cancels an open describe job.
/meta serves it in its own additive field, work.generated_description: {text}:
- Never in
community_description. The shipped 2.0 app shows that one first and credits the community, so a generated text there would replace real blurbs on old apps. - Only when the community work has no
community_description. - Marked by
work.generated.description. The three booleans are always sent. - Kept under
spoilers=hide.
The player orders About itself: the community description, the book's own, the work's core one, then the generated one with its mark.
The estimate gains can_describe and has.*.description. The admin book detail
gains generated_description, and the attachments rescan answer gains
description. See The description step.
Chapterless books
The Bridge transcribes a single file with no usable chapter markers in windows and
finds its chapters in the narration. It writes them as .audiosilo/chapters.json:
- Version 1.
filesnamed relative to the book's folder, with their sizes.chapterson the whole-book timeline, contiguous, 2 to 2000, with entry k being logical position k.
The server stores the file checked (book_attachments.chapters, migration 0051).
It uses it as a chapter source between the community's and the files'
(chapters_source: "generated"). It does so only with no admin choice, and only on
the files the chapters were found on.
chapters.detect (feature chapters, params {}) does this without the agent, for
Health's no_chapters. 409 generate_open, detect_open, describe_open and
chapters_chosen keep it apart from the other kinds.
The rescan answer gains attachments.chapters, the admin rows chapter_choice, and
the admin book detail generated_chapters_fit. A change to the file touches the
Bridge's writer and the server's reader. See Chapterless books.
Read-along maps
readalong.align (feature readalong, params {}) writes, with no agent.
- It lines the book's ebook up with its narration. The ebook is the server's pick,
the book route's new
book.ebook {path, size}. - It writes
.audiosilo/readalong.json(audiosilo-readalong/v1), only when at least half the text lined up. - It is timed on the server's file lengths, the book route's new
files[].duration. 409 no_ebook,generate_openanddetect_openrefuse it.- A new generate or detect job meets
409 readalong_open. A generate job cancels a parked one.
The estimate gains kind=readalong.align, and the rescan answer gains
attachments.readalong. A change to the map touches the Bridge's writer, the
server's head checks and the player's reader. See Read-along.
Setup and contributions
The heartbeat's setup block is the Bridge's onboarding checklist: paired,
libraries, asr, agent, budget, github, plus can_contribute and
github_login. agent and github are optional. It reaches the console only, as
setup on GET /admin/bridge (see
the checklist).
Two kinds belong to the contributions module. While the module is off they answer
404 module_off and are held in the queue (see
jobs of a managed module).
sidecars.contributetakes params{work_id?, dimensions?}and writes nothing. It meets409 generate_open. A new generate job meets409 contribute_openwhile one is queued or working, and cancels a parked or held one.meta.correcttakes params{work_id, recording_id?, asin?, isbn?, region?, changes, evidence, source?}. The server refuses400 not_editedfor a change that isn't the book's admin edit.
The Bridge files both on GitHub (seam 13). Its GitHub sign-in, and the agent credential it hands to the agent container, live on its own page and never cross to the server. See Contributions.
Audible accounts
The Bridge signs in to Audible (a paste-back PKCE sign-in on its Accounts tab) and keeps every credential in its vault. The server never talks to Audible.
These Bridge-token routes are not behind the module's toggle:
| Route | What it does |
|---|---|
GET /bridge/users | Who an account may be mapped to: enabled adults and admins, never a kid, demo or disabled account. |
PUT and DELETE /bridge/audible/accounts/{account} | The mapping. The delete answers 204 whether the account is known or not. |
PUT .../titles | Up to 500 a call under one poll_id. The last call is final, which marks the rest gone. |
GET /bridge/libraries/{id}/books | A books library's books by path, 500 a page, for matching. |
PUT /bridge/libraries/{id}/enrichment?path= | An unambiguous match's ASIN. 409 asin_set never goes under one. |
audible.poll (feature audible, params {account}, no library) is internal, quiet
and deduped per account. The audible module's scheduler queues it every
poll_hours, and so does the console's Check now.
The module (modules.audible.enabled, off by default):
- Gates the console's
/admin/audible/*routes (404 module_off). The adminDELETE /admin/audible/accounts/{account}works either way. - Holds the polls.
A title's match is path-keyed with no FK, and a book's move carries it. The player
doesn't mirror the audible flag, because it has no Audible surface yet. See
Audible.
Audible backups
audible.import (feature audible) is internal and writing, not quiet, and deduped
per account and title.
- The server builds its params:
{account, asin, library_id, dest_path, template}. The Bridge reads them leniently and ignores unknown fields. - The Bridge reports progress steps
license|download|decrypt|place|rescan|enrich. - The result is
{library_id, path, files, bytes, enriched}, wherepathis the book path thatPOST /bridge/rescananswered. - A failure is
{code, message}with one of eight codes. - It uses no new route.
- The server folds a done result into the title's match (
how: "import") only for the title's own book.
Only new console routes were added (admin, behind the toggle). The Bridge's account
PUT now sets policy on create only. See
Backing up a title.
Audible planned destinations
- Each title of the titles PUT gains
planned({library_id, path, file, kind, siblings, state, conflict, template}or null). It is where a backup would go, planned by the Bridge during the poll. - Each title also gains
contested_path(omitempty), the book a contested title tied for. GET /admin/audible/titlesgainsplannedandcontested({library_id, path, with: [{asin, title}]}or null).audible.poll's params gaintemplate(omitempty).- Server migration
0065, Bridge migration0010(the device profile).
Server 2.6.0 and its accompanying new Bridge release must be deployed together for Audible. Server 2.5.1 and Bridge 0.5.0 predate this wire format.
- An older Bridge's strict decoder refuses a poll with a placement template.
templateis omitted only when"". - An older server's strict decoder refuses the new title fields.
- Older servers also lack the progress feed that position sync needs.
Pairing or a heartbeat alone does not prove compatibility, and there is no separate version gate on these payloads. Use the actual paired release tags from the release notes. See Planned destinations.
Audible position sync
audible.sync (feature audible) is internal, quiet, deduped per account, and
promoting.
- The server builds its params:
{account, titles: [{asin, library_id, path, runtime_s}]}, strict on both sides, at most 1 MiB. - The server queues it every 15 minutes for each account whose sync is on, and at once on a console change.
- Its failure codes are
audible_refusedandaudible_sign_in(transient). Onaudible_refusedthe server setsrefused_atand stops queueing until a Bridge PUT or a complete poll.
Two new Bridge-token routes act for the account's person:
POST /bridge/audible/accounts/{account}/listeningtakes{sent_at, events}, the Sync v2 event shapes. The server applies them furthest only, under deviceaudible:<account>namedAudible (<label>), with token 0 and no sessions or stats. Event ids are stable per place, so a resend is aduplicate.GET /bridge/progress?account=&since=&limit=answers{rows, next, more}. It is keyset onprogress.change_seqand leaves out the account's own rows.
Both answer 403 sync_off, 403 user_unavailable and 404 unknown_account. The
feed also answers 400 invalid_request.
- A
preordertitle takes no part in either direction. It is not in the params, it isnot_syncedon the listening route, and it is not in the feed. Its release offers its book's place again (server0071). - The account's
syncis the console's now. The Bridge's PUT sends it and the server ignores it. - The
audible:device id is reserved./me/listening/syncrefuses it, and a progress PUT saves it as none. - Server migration
0066, Bridge0011. The heartbeat gains no block. - The Bridge mirrors it in
internal/link/audiblesync.go.
See Position sync.
LibriVox fetches
librivox.fetch (feature librivox) is internal and writing, for a books library,
not quiet, deduped per library and project, and in no module.
- Three admin routes queue and support it:
GET /admin/librivox/search,GET /admin/librivox/workandPOST /admin/librivox/fetch. All are404 metadata_offwhile community metadata is off. There is no/serverflag: a client gates on thelibrivoxfeature. - The server builds its params,
{librivox_id, work_id, recording_id, library_id, title}, with the work, recording and title from metaserve'slookup?librivox_id=at queue time.titleis optional and the Bridge ignores it. The Bridge reads the params leniently and ignores unknown fields. - The Bridge reads the manifest from its own metadata service and downloads only
from
httpsarchive.org hosts, checking every redirect. - It reports progress steps
manifest|download|cover|place|rescan. The result is{library_id, path, librivox_id, files, bytes, adopted}, wherepathis the book path thatPOST /bridge/rescananswered.adoptedisfalseonly when this run placed the folder. The Bridge always sends it, and the server reads it as optional for an older Bridge. - A failure is
{code, message}, withnot_writable,exists,disk_full,not_found,archive_unavailableormetadata_off. - It uses no new Bridge route.
- The Bridge writes
.audiosilo/librivox.json({version, librivox_id, work_id, recording_id, archive_id}) into the book's folder. The server does not read it yet, and its scanner skips the name. - The server folds a done result into the book's
book_matchesrow (Matches by work id), byadopted: a placed book replaces the path's match and drops its leftover ASIN, ISBN and match hints, an adopted one keeps a match already there. Thebridge_job_finishedevent carrieslibrivox_match(matched,kept,unmatched).
See Add from LibriVox.
The player's Bridge surface
The player's first admin-only UI is gated on all of these:
user.role === 'admin', never a demo account, a household member or a kid's account.capabilities.bridge.bridge.featuresholdingsidecars.capabilities.metadata.
Its confirmation holds Generate back while the estimate's open_job is a
chapters.detect or sidecars.contribute job. Book.generated makes it ask /meta
for a book with no ASIN or ISBN. Its types.ts mirrors the job, the estimate,
BookMetaWork.generated and BookMetaWork.generated_description by hand.
Find on LibriVox is gated the same way with librivox in place of sidecars, so
it shows only while the Bridge is online. Its types.ts and client.ts mirror the
three LibriVox routes by hand.
Bridge seam and old clients
Everything is additive.
- An older server has no Bridge routes and no
bridgeflag. The Bridge's pairing gets a404and says the server is too old. Shipped players never see either. - A shipped player ignores a
workbesidematched: false. It shows a matched book's merged work without thegeneratedmark (the owner kept that as built), and it ignoresgenerated_descriptionentirely.
Setup and contributions:
- An older server sends no
accepts, so a newer Bridge sends it nosetup. - An older Bridge doesn't advertise the new kinds.
- A signed-in player still gets the
bridgeblock.
Read-along:
- An older Bridge ignores the book route's
durationandebook, and never advertisesreadalong.align. - A newer Bridge reads an older server's missing fields as none.
Audible accounts:
- An older Bridge never advertises
audible.poll. - An older console ignores the
audiblesetup item and module. - A shipped player ignores the
audibleflag.
Audible backups:
- An older Bridge never advertises
audible.import, so the server queues nothing for it (kind_unavailable, and a poll's policy pass queues nothing). - Its account PUT's
policystill creates an account as before.
LibriVox fetches:
- An older server doesn't know
librivox.fetch, so itsfeaturesnever listlibrivox, and neither the console nor the player offers the feature. - An older Bridge never advertises it, so the fetch route answers
kind_unavailable. - A shipped player never sees Find on LibriVox. A book added this way is an ordinary book with a recorded match.
Audible position sync:
- An older Bridge never advertises
audible.sync, so the server queues none and the feed is never read. Its account PUT'ssyncis ignored. - A shipped player sees Audible's place as any other device's (by its
device_name). One that echoes the row'saudible:device id in a progressPUTsaves under none. - A Bridge with position sync, against an older server, gets no sync queued and a
404from the feed. Its push loop asks again each minute. Deploy the Bridge with the server.
17. Podcast shows, feeds and client visibility
What couples: podcast libraries extend the seam between the server and the Bridge with episodes on disk, retention, and on-demand fetching with status and Retry. The player shows podcasts to the players the gate admits (2.2.0 and later; first released in the 2.4.0 player), including discovery, follow by URL, OPML and Check for new episodes.
Server: the library kind, the client gate (auth.SeesPodcasts), the podcast
jobs and their scheduling, the storage and retention routes, the on-demand stream
branch, discovery and OPML.
Bridge: runs the podcast job kinds, owns feed.json, and keeps the show
credentials in its vault.
Frontend: mirrors the types, client and hooks by hand (see Player views).
A change requires: the shared fixtures wire-2a.json (the jobs),
wire-2b.json (the fetch status) and publish-v1.json (the journal and list) fix
the contract, so a change to one of those shapes changes both repos. A breaking
feed.json change bumps its version. See
Podcasts and
Podcasts in the API reference for exact fields.
Podcast library kind
- Every library JSON and heartbeat reply includes
kind: books|podcasts. - Create defaults to books. Unknown values give
400 invalid_kind. - PATCH cannot change it (
400 kind_immutable). - Podcast roots cannot overlap any library (
409 root_overlaps), including when a books root is created or moved.
Podcast capability flags
capabilities.podcastsis always sent. It is false unlessmodules.podcasts.enabledis set and an online Bridge offerspodcastfrom its podcast kinds.- The Bridge advertises subscribe, poll, fetch and retain in
kinds, with no new heartbeat request field. - The independent, optional core flag
capabilities.podcast_fetch:trueadvertises on-demand and status support, even when the Bridge is offline or the module is off. Older servers omit it. - With the module off, the server gates actions, stops the scheduler and holds queued jobs. Reads stay core.
Which clients see podcasts
auth.SeesPodcasts(Credential.Client) feeds catalog.Caller.Podcasts.
AudioSilo Adminqualifies.AudioSiloversiondevqualifies.- A released player qualifies from
auth.PodcastPlayerMinVersion="2.2.0". The version is numericX.Y.Z, and a pre-release counts as its base version. - Players 2.1.x and older, unidentified clients, and API tools without a recognized client do not qualify, admins included.
The served /web identifies as its app.json version, so release the 2.2.0 web
image before the server that bakes it in.
Other visibility rules:
- A whole-list replace (
PUT /me/queue,PUT /me/collections/{id}/items) from a caller the gate hides podcasts from keeps the stored episodes that only the gate hides (catalog.GatedScopes). So an old player can't delete them. - Media tokens inherit the stored client when no valid request header exists.
- Private-show scopes apply after grants, only for followers matching the show's id. The admin catalog remains separate.
- An authorized library with no visible shows returns empty lists.
- Cast tokens do not expose podcasts.
Books stay separate
Podcast libraries are excluded from cross-library book search and recent, author and
series aggregates, book stats and admin book tools. Podcast /fs reads titles from
the index, not from loose audio on disk.
Podcast actions
- Adults may follow by URL (a whole-library grant) or by visible path, unfollow, and check. These sit behind the module's user wrapper, so no kids and no demo.
- The server creates the podcast jobs. The admin enqueue route refuses these kinds.
- A private URL's normalized current or previous hash proves the follow.
- A user may have five open subscribe requests.
- Manual checks are limited to one per 60 seconds when no poll is already open.
Podcast storage API
Retention GET, PUT and DELETE and episode fetch are
dedicated routes, for an admin
and never a demo account.
- The policy defaults to
{mode:"keep",keep:3,days:7}. It is keyed by library, show path and show id, and is durable across index rebuilds. PUTrequires all fields: modekeep|on_demand|archive, keep 1..1000, days 0..365.- Writes need the module, the exact kind from an online Bridge, and a writable
mapping.
GETstays core. - A valid fetch of a present file returns 200 before the storage checks.
- A new fetch is 202 with an existing or open job state, at priority 20. It forces no pin replacement.
Podcast retention
- Keep downloads the newest N listed episodes.
- Archive downloads all listed episodes, oldest first, and never deletes.
- On demand fetches on play, prepares one ahead in the queue, and runs no feed-wide scheduled downloads.
No followers means no policy-scheduled work. Authorized play, Retry and queue preparation still work. Deletion eligibility (followers finished plus grace, protections, Keep's window) is in retention. Keep is a window, not a disk quota.
Bridge-only policy and aggregate routes expose all
aggregate fields, with no
names, ids or positions. A mismatched generation, hash or size is
409 identity_changed. Immediately before a journaled move to owned trash,
tombstone and rescan, the Bridge verifies the local bytes and a five-second request
age on its own clock.
Podcast fetch and retain jobs
- Fetch binds
{show_id,episode_id}and an indexed episode path. Its dedupe key ispodcast.fetch|<library_id>|<path>, with no URL or credentials. - Retain binds the show path and id, at priority -20.
- All triggers share one fetch intent, at these priorities: play, Range, download, transcode and Retry 100; admin 20; prefetch 10; scheduled -10.
- Promotion takes the maximum and preserves the requester, attempts and progress. Running jobs keep their pool.
- Fetch has one ordinary download slot and one reserved interactive slot. Retain is light (2). Both are internal, writing and quiet.
- Replacements settle old work.
- Fetch byte progress is int64 with a nullable total. Successful results include Fetch, chapters and reused.
The job shapes and the shared
wire-2a.json fix the contract.
Podcast polling and scheduling
- The server schedules. The Bridge returns an adaptive
next_poll_seconds, which the server clamps to 15 minutes through 24 hours. - HTTP validators and status live on the server.
- The server folds ended jobs idempotently, with a completion wake and a 60-second recovery tick.
gonestops automatic checks. Other failures back off.- Default admin job lists omit polls unless filtered explicitly. Fetch and retain are listed.
- Podcast jobs never notify.
- New poll params and results, and feed status, bind the show id. Late or unbound results cannot recreate replacement status.
- Fetch backoff is separate from feed state: 30 minutes, doubling to 24 hours. The per-pass limits are in polling.
- Fetch and retain never change poll validators or secret flags.
Podcast files
- The Bridge-owned
feed.jsonv1 is the shared format. The server indexes episodes even without audio. - Paths are assigned once.
fetchrecords a committed generation or a tombstone, with an optional codec. - Readers ignore unknown fields. Breaking format changes bump the version.
- At the cap, removed never-fetched episodes may be dropped, oldest-removed first.
- Manually renaming a show folder breaks paths.
Podcast publication and media
The commit order of journal, audio, CHAP and Fetch keeps new bytes behind
<stem>.publish.json until they are complete. Recovery reuses the same generation.
- The first fetch is g1. An identical refetch keeps g. A changed hash increments it
and latches
timeline_changed. - Episode and
Book.episodeexpose the optional generation, hash and flag, even after deletion. Progress and bookmarks stay unchanged. - One validated descriptor serves each request.
- The direct ETag is
"podcast-g<generation>-<sha256>", with private revalidation and byte ranges. Transformed output has no source ETag. - Missing media triggers shared work, never publisher pass-through or partial playback.
On-demand fetch, status and Retry
After the auth, client and private scope, and exact-path checks, the stream branch for an absent file has at most two seconds for the DB, the enqueue, promotion and the wait.
- When the file is ready, the server serves normal media.
- Otherwise it answers
503withRetry-After: 2,Cache-Control: no-storeand{error,code,fetch:Status}. The codes arepodcast_fetching,podcast_fetch_unavailableandpodcast_fetch_failed. - A disconnect or timeout never cancels shared work.
The routes:
- Core, authenticated
GET /libraries/{id}/podcasts/fetch?path=only observes. POST{path}(never a demo account) is an explicit Retry. It answers 202 with the same{fetch:Status}, or 200 if ready.- Kids with read scope qualify.
Rules for new work:
- New work needs the module, the exact kind from an online Bridge, and a writable mapping. Ready files work with the Bridge offline or the module off.
- Existing intents may still promote.
- Ordinary streams honor the failure cooldown.
POSTbypasses it, but never dedupe or a present pin. - Identity races remain
409 identity_changed.
The fetch status on episode lists
Episode lists and Book.episode add an optional fetch:Status, batched per page.
Every key inside a Status is required, nullable ones included. It carries no
requester, params or raw progress text. The
exact shapes and the
shared wire-2b.json fix the protocol.
The listener's fetch cap
A non-admin creator may have five open fetch intents. A refused stream creation is
503 podcast_fetch_unavailable with fetch.error.code:"fetch_limit". A POST Retry
is 409 fetch_limit. See the
rules.
Podcast prefetch
- The server prefetches at most one next visible podcast from the actual caller's queue, per user and current item, skipping books.
- If the current item is absent from the queue, it takes the first visible podcast other than the current one.
- A play-fetch or a ready stream starts a bounded background look. Successful queue changes can reconsider during the current stream or fetch.
- Global dedupe shares work, and a removal never cancels it.
- A new prefetch is priority 10. A later play promotes the same row to 100.
Podcast claims and capacity
Once the claim floor is negotiated, the Bridge's reserved
interactive slot claims kind=podcast.fetch&min_priority=100&wait=25 and never takes
background work. The ordinary download lane drops to 1, which keeps two enclosure
downloads in total. An older server gets two ordinary download slots and no reserved
claim.
Podcast player views
The player mirrors the types, client and hooks by hand. It ships the shows, show and episode pages, following, Home's New episodes, and on-demand play through one controller:
- It probes
/streamonce. - It polls only queued or fetching episodes, every
max(2, Retry-After)seconds. - It offers an explicit Retry for a terminal failure.
- At ready, it refreshes the item and chapters, and marks the episode lists stale.
It preserves generation warnings without rescaling state, and recognizes
chapters_source:id3_chap. There are no cast or kids-mode podcasts, and no server
route changed. From 2.7.0 an episode also downloads to the device, with no wire change
(Downloading episodes). See Frontend podcasts.
Podcast discovery and OPML
This touches the server and docs only. The Bridge and the player are unchanged.
GET /podcasts/discovery/providers|search|artwork has the adult, non-demo, module
and client gates. Artwork takes a media ?token=.
- It searches iTunes by default, or Podcast Index when picked and configured. Podcast Index takes a server env key pair only, with no fallback and no merged ranking.
- Results are public data with relative, proxied artwork.
- A result is followed through the existing
POST /podcasts/follow {library_id, feed_url}.
POST /podcasts/opml/import?library_id= takes the raw XML.
- It validates the whole document before any effect, and answers per-row decisions.
- It makes at most five new subscribe requests per upload, within the five-open cap.
The rest are
limited, for a re-upload wave.
GET /podcasts/opml/export is core, so it works with the module off too.
- It writes only the caller's followed public shows, never a private one.
- It counts omissions in
X-OPML-Exported,X-OPML-Omitted-PrivateandX-OPML-Omitted-Unavailable.
An open subscribe is reused only at the same privacy (409 privacy_conflict). The
optional flags are podcast_discovery and podcast_opml. The exact shapes and the
player's rules (search on submit, artwork through the proxy only, the import waves,
export with the Authorization header) are in
Player: discovery and OPML
and Podcast discovery. For an allowed
origin, CORS exposes Retry-After and the X-OPML-* counts and allows Range
(CORS).
Podcast CHAP chapters
The sidecar is a version-1,
stem-prefixed chapters.json using from:id3_chap, with a Fetch timestamp and one
matching basename, size and duration.
- There is no ASR, agent or generated flag.
- The server consumes valid times directly and returns the optional
chapters_source:id3_chap. - Invalid lists, and lists of 0 or 1 chapters, install the audio without CHAP.
- The shared
publish-v1.jsonfixes the journal and list.
Podcast mapping and rescan
.audiosilo/library.jsonv1 holds{library_id,nonce}.GET /bridge/libraries/{id}/markerreturns the matching nonce. It reads a regular file only, at most 4 KiB, with no marker-directory symlink.- A podcast sample-book lookup without a path is
404 no_book. - A podcast
POST /bridge/rescanindexes a show, or the show of an indexed episode. It refuses attachments mode andfrom.
Podcast secrets and HTTP
Feed URLs stay out of private files, persisted job params, listings and logs.
- The server holds the pending credential in memory and injects it into the claim
only. A restart before the claim is
request_expired. - The Bridge seals it before local claim storage, and keeps the show credential in its vault.
feed_secret_missingis repaired by following the URL again.
The HTTP rules for feed, artwork and audio:
- The Bridge checks all DNS results and every dial and redirect, with no proxy.
- It strips source credentials from cross-origin requests.
- Private networks require an explicit Bridge
podcasts.allow_networks. - Audio uses an identity GET and no HEAD, with an independent 1024 MiB cap (1..4096) and at most three attempts.
- A proven resume requires an exact 206 range, total and strong ETag. A 200 restarts from zero.
- Checkpoints omit URLs and seal validators.
- Private enclosure resolution is a fresh, bounded feed read, with no merge. URLs stay in memory.
18. The ebook reader
What couples: the reader (capability ebooks) reads a book's EPUB, keeps a
reading place, and loads a reader document the server serves. It is all additive. An
older client ignores the field, and an older server has no flag, so the player
offers no Read. The apps and the ebook in downloads needed no server change.
Read-along maps (capability readalong) build on it, with the player following the
narration in the text (server and player 2.6.0).
Server:
- Every
Bookcarriesebook: {path, size}when the scan found one (library.BookEbook). GET /libraries/{id}/ebookserves the bytes, with header auth only.GETandPUT /libraries/{id}/reading-progresskeep the place./web/reader.htmlis served underreaderCSP.Book.readalongon/itemandGET /libraries/{id}/readalongserve a read-along map.
Frontend:
Book.ebook?: BookEbookintypes.ts.client.ebook(),readingProgress()andsaveReadingProgress(), read throughuseReadingPlace.public/reader.htmlfor the web, andreader-html.native.tsin the apps.
A change requires:
- A new field on the place, or a change to the conflict rule, changes both repos. It is the same rule listening sync uses (seam 19).
- The reader document's policy and the player's route names are coupled.
/web/reader*is the document, so the player never has a/readerroute. - Any URL credential for the ebook is a security-critical change, with allowed and denied tests on the server.
The book's ebook
Every Book the server returns carries ebook: {path, size} when the scan found one
readable .epub beside its audio, and omits it otherwise. The rule is the Bridge's
own, library.BookEbook:
- One
.epubin a folder book's folder. Several count as none. <stem>.epubbeside a single-file book.
See the scan rule. The player mirrors it as
Book.ebook?: BookEbook in types.ts. Its path is for display only.
The ebook bytes
GET /libraries/{id}/ebook?path=<the book's path> takes the Authorization header
only. It never takes ?token= or a URL token of any kind (see
GET /libraries/{id}/ebook).
- The web player's
client.ebook()fetches it and posts the bytes to the reader. Nothing ever holds a URL that carries a credential. - The apps download it with the header into a local file, and post it to the reader in base64 chunks (see the app host).
The reading place
GET and PUT /libraries/{id}/reading-progress?path= are keyed on the book's
path, under Sync v2's rule:
- A save carries
base_version. - A diverged save keeps the further place and answers the other as
other_place(see the conflict rule). device_nameis the server's, taken from the saving token.
The player mirrors it as ReadingProgress, ReadingProgressInput,
ReadingProgressSaved, OtherReadingPlace and the client's readingProgress() and
saveReadingProgress(), read through useReadingPlace. The player's queue sends the
last version it acknowledged (see
the reading place).
Listening sync reuses the rule for listening progress, and persists its other place on the row (seam 19).
The reader document
- The frontend builds
public/reader.html(vendored foliate-js, one inline script). - The web export ships it as
reader.html. - The server serves it at
/web/reader.htmlunderreaderCSP, not the player'shtmlCSP.readerCSPholds the hash of that script, computed at serve time,connect-src 'none',frame-ancestors 'self'andX-Frame-Options: SAMEORIGIN. - The apps' variant of the document is bundled in the app (
reader-html.native.ts, the same CSP) and never fetched from the server. - The host-reader
postMessageprotocol is the frontend's alone (see the protocol). - The player's service worker (
public/sw.js) must never answer the document with the app shell. That would boot the whole player inside the reader's frame.
Read-along maps
A Bridge's readalong.align job (seam 16) writes the book's
.audiosilo/readalong.json (see
the format). It holds:
- Sentence spans as EPUB CFIs that match foliate's
epubcfi.js(UTF-16 offsets), with whole-bookstartandendseconds. - The ebook's sha256.
- The audio files it was made from, book-relative.
The server:
- Stores only the checked head (migration
0058). - Hashes the ebook only for a book with a map.
- Answers
Book.readalongon/item, never on a list row:{state: "ready", coverage, generated_at}or{state: "stale", reason}(ebook_changed,audio_changed). - Serves
GET /libraries/{id}/readalong?path=with header auth only, as/ebook. It re-checks the file against the book on every request, and answers404 no_readalongor409 readalong_stale(seeGET /libraries/{id}/readalong).
The player shows its Text panel only for a ready map of a book it can read.
19. Listening sync (Sync v2)
What couples: with capability listening_sync, a player records listening as
events: a span of playing, a finish, an unfinish. Each event has its own id and
waits in a durable per-connection outbox. The player posts them to
POST /me/listening/sync.
The server derives progress, listening sessions and history from them, and answers
a status per event. All of it is additive. The workspace's CROSS-REPO.md §25 is the
full seam.
Server:
internal/api/handlers_listening_sync.go.internal/catalog/listening_sync.go(SyncListening).- Migration
0061(listening_events, the progress columns). PruneListeningEventsin the launcher's retention.- The legacy
PUTis logged as aputevent (SaveProgressFrom).
Frontend (see Listening sync: the outbox):
src/playback/listening-outbox.ts, the outbox and sending.listening-sync.ts, the triggers.- The spans in
store.ts. ack_versioninprogress-sync.ts.offerOfinplace-reconcile.ts.src/components/book/listening-offer.tsx.src/components/account/listening-sync-card.tsx.
A change requires:
- A new event field or status changes both repos. The server decodes events
strictly, so an unknown field makes that event
rejectedinvalid. Gate a new field on a flag. - A change to the cadence, the ack rule, the backstop or the offer thresholds is a change to the conflict model on both sides.
Listening sync: the wire
- The request carries
device_id,sent_atorclock_offset, and 0-500 events. - The per-event statuses are
applied,duplicate,diverged(withkeptandother_place),not_applied(witholderorresume_behind), andrejected(with its reason). - The answer carries
progressrows. - The player mirrors these by hand in
types.ts(ListeningSyncRequest,ListeningEvent,ListeningSyncResult,ListeningSyncResponse) and inclient.syncListening(body, {keepalive}). - Every progress response gains
device_nameandother_place(Progress,ListeningOtherPlace), both omitempty.
The ack rule
Each span carries base_version, the progress version the device last acknowledged
for the book. The device keeps it in its local mirror as ack_version, and reads it
when the span closes.
The server resolves the place by
the conflict rule. An event
made without seeing the stored row is diverged. The server keeps the further place
and offers the other.
When the player acknowledges a row is listed in the mirror's Sync v2 fields. Changing when the player acknowledges changes what the server keeps, so both sides change together.
The backstop
A resume or continue span that ends more than 60 s before an unfinished stored
place is resume_behind, so a cold start never pulls a book back. The player's
start reasons (moved_back, jump, restart for a deliberate move) are what let
a move back apply.
The offered place
The server persists a diverged event's lost place on the row (other_place). So a
device that never saw that answer still offers it.
- The player's
offerOfdecides when to show it: another device's place, more than 30 s away, not answered yet. - The player shows it as a Jump or Stay toast, and as the book page's "Play from there".
- Answering acknowledges the row.
- The server clears the offer on the first span of that device that it applies on a
version at or after the offer's (
other_version). When the server kept the stored row, that is its next version, so the span after the answer's first one.
File places
Each end of a span may carry its audio file and the seconds into it. The server places it on the book's current file order. So listening on a download with an older order lands in the same file (seam 6).
Sessions and history
On this path the player stops its periodic PUT loop and its POST /history. The
server builds both from the spans, at their clock-corrected device times.
The player's online cut keeps live sessions and Listening in the house working. It
sends continue spans every 15 s, or every 60 s with the app in the background or
the book downloaded (see
the pace). The server reads them
against its livePlaying and SessionGap (see
Listening sessions
in the data model). At the 60 s pace a session can read as paused for a moment.
Rollback to the legacy save
A 404 or 405 on the route, confirmed by the server's own /server (no flag),
turns the outbox into one legacy PUT per book. The connection goes back on the
PUT path.
The gate
The player takes the events path on the flag that the connection's server last
answered, kept across launches. It takes it while the flag is unknown too, for an
offline cold launch on a connection whose answer it never kept. The rollback covers
an older server. Only false takes the PUT path.
Batch size
A body over 1 MiB is 413 code too_large. Older servers answered it with a 400.
The player halves its chunk on a 413 and resends one event at a time on a 400, so
both reach it.
Listening sync and old clients
- A shipped player keeps
PUTandPOST /history, which behave exactly as before. The server only logs thePUT, and its answer gains the two omitempty fields. - A new player on an older server sees no flag and does the same (see The gate).
The wire-change checklist
Every change to the wire format follows the same shape. The worked example in CROSS-REPO.md is the listening-history feature. The step-by-step checklist, with that example file by file, is in Landing a cross-repo change.