crabidy/architecture/audiobookshelf-provider.md

12 KiB

audiobookshelf provider (audiobooks)

Context and problem statement

A new library provider mounted at /abs that lets a user browse, search, and play the audiobooks on a self-hosted audiobookshelf (ABS) server.

  • Unlike fyyd/tidal/youtube, ABS is the user's own private server, reached over an authenticated HTTP API with an API key (a bearer token). So — like tidal — the provider needs credentials, and — unlike tidal — a missing or incomplete config disables it non-fatally (it is one optional server, not the whole app).
  • ABS organizes content as libraries → items → audio files. An audiobook item is usually split into many audio files (e.g. Neuromancer-01.opus-30.opus); each file is one track. So the tree carries the same "one extra level" as fyyd — library → book → tracks — plus a per-library search subtree that mirrors tidal/youtube search-term semantics.
  • Playing a track means streaming the file's content endpoint. ABS accepts the API key as a ?token= query parameter on that endpoint and honors HTTP range requests, so the URL is exactly the shape the audio player already handles — and the whole URL is derivable from the path, so playback needs no extra API call, no sidecar, no byte-proxying (contrast /youtube).
  • Captures (W, download) come for free: any node that serves tracks and raises is_downloadable gets w/W with no wire or TUI work.

The audiobookshelf API (grounding — live-validated against the test server)

Base https://<host>/api/. Every call carries Authorization: Bearer <key>, except the file endpoint which also accepts ?token=<key>. The endpoints we use (all verified against the provided test server):

Purpose Endpoint
List libraries GET /api/libraries
List a library's items GET /api/libraries/<lib>/items?limit=<n>&sort=…
Search within a library GET /api/libraries/<lib>/search?q=<t>&limit=<n>
Item detail (tracks) GET /api/items/<item>?expanded=1
Stream a file GET /api/items/<item>/file/<ino>?token=<key>

Objects (fields we read):

  • library: id, name, mediaType (we handle book).
  • item summary (in the items list): id, mediaType, media.metadata (title, authorName), media.numAudioFiles, media.duration.
  • item detail: media.metadata.{title,authorName} and media.tracks[], each track: index, title (the filename, e.g. Neuromancer-01.opus), duration (seconds, float), ino (stable file id, an integer string), contentUrl (/api/items/<item>/file/<ino> — same components as the path).
  • search response: { "book": [ { "libraryItem": <item> }, … ], … }.

Verified facts that shape the design: the file endpoint returns 200 with ?token= and 401 without; a Range request returns 206; ebook-only items report numAudioFiles == 0.

Assumptions (decided here)

  • The captures/creatable/editable/deletable TUI flows are provider-agnostic (confirmed by /youtube and /fyyd): mirroring tidal's search-term semantics costs no TUI or wire change. No proto change, no new ProviderCommand.
  • ABS needs credentials, so — unlike fyyd — a usable abs.toml must carry a base_url and an api_key. A missing file, or one lacking either field, disables the provider non-fatally (like fyyd/youtube on a failed probe): it only costs the /abs subtree, never startup.
  • The audio player streams the ?token= file URL directly (its windowed-HTTP path): ABS serves the raw file with range support, so there is no /youtube-style URL-lifetime or ~1 MiB-cap problem. Audiobook files are ordinary media (opus/mp3/m4a/flac) the existing decoder already handles.
  • Items and files are addressed by their ABS ids: the library id and item id are UUIDs, and the file ino is an integer string — all already URL-safe. Only user-typed search terms are percent-encoded.
  • The stream URL embeds a secret (?token=), so it must never be logged; and api_key must be redacted from Debug/config dumps (hard rule: redact secrets from logs and error reports).

Decisions

D1 — Crate absdy, mounted at /abs, non-fatal init

New workspace crate absdy implementing ProviderClient, shaped on fyyd (the closest analog: remote, search-driven, one extra container level, plain streamable URLs). Wired into ProviderOrchestrator with an abs_client: Option<Arc<absdy::Client>> field, abs_owns()/abs_provider() helpers, a build() block that reads abs.toml (non-fatal), a get_lib_root child gated on self.abs_client.is_some(), and one routing arm in each dispatch method. crabidy-server's settings gain abs in ALL_PROVIDERS (now 7), in ProviderToggles, in all(), and in provider_toggles().

D2 — HTTP behind a trait, faked in tests

All network access goes through one seam — an Abs trait (libraries, library_items, search_items, item_detail) behind Box<dyn Abs> — with a reqwest-based AbsApi (bearer auth) for production and a FakeApi in tests, exactly as fyyd hides reqwest behind Fyyd. Provider logic (tree shaping, path parsing, term store, stream-URL building) is then unit-tested with zero network. The trait's error type maps to ProviderError::FetchError at the boundary; malformed paths are MalformedPath; empty create/rename input is InvalidInput; missing credentials at init are Config.

Item ids are UUIDs and file inos are integers, so neither can equal the reserved segment search; the parser uses that to split the two branches.

  • /abs — children: one node per library from /api/libraries (a fixed browse; the provider is useful with zero typing). Not itself queueable.
  • /abs/<lib> — children: a reserved search child (is_creatable) plus the library's items (bounded, see D5) as book children. A book child's is_queable is set from numAudioFiles > 0, so ebook-only items show but are not queueable/capturable.
  • /abs/<lib>/<item> — lists that book's audio files as tracks; queueable and downloadable (homogeneous tracks).
  • /abs/<lib>/<item>/<ino> — the track leaf.
  • /abs/<lib>/searchis_creatable; children are the in-memory search terms (RwLock<Vec<String>>, dedup, recreated implicitly on stale paths), each is_editable + is_deletable, like tidal/youtube/fyyd. Search terms are stored per library (keyed by library id).
  • /abs/<lib>/search/<term> — lists matching books as children (same book shape as a direct library child).
  • /abs/<lib>/search/<term>/<item> and .../<item>/<ino> — identical book node and track leaf as under the direct browse; the two branches share the book-node and track-leaf builders.

Terms are percent-encoded into one segment (encode_segment/ decode_segment); library ids, item ids, and inos are already URL-safe.

D4 — Streams, metadata, no extra call for playback

  • get_urls_for_track: parse item + ino from the path and build <base>/api/items/<item>/file/<ino>?token=<key>. No API call — the URL is fully derivable from the path; the token is appended last and never logged. An unparseable path is MalformedPath.
  • Track fields (from the item detail's tracks[]): title = track title (the file name), artist = the book's authorName, album = the book title, duration = track duration (seconds → Option<u32>), provider_item_id = "<item>:<ino>" (stable per file, keys the content store for captures).
  • Metadata source. Listing a book fetches the item detail once and builds every track from tracks[] (title/duration/author all present). A directly fetched track (get_metadata_for_track on a bare track path) fetches the same item detail and picks the matching ino. A missing field degrades to an empty string / None, never an error.

D5 — Bounds and freshness

  • items_per_library (default 200), search_results (default 50) cap every listing so a huge library cannot stall the tree or queue resolution; call_timeout_secs (default 30) bounds each HTTP call (hard rule: timeouts on external calls).
  • Listings are fetched fresh per call (no cross-call cache), like the other remote providers. Only the search terms are stored, in memory, per library.

D6 — Out of scope (explicitly)

  • Podcast libraries (mediaType: "podcast", media.episodes): the test server has none; the book-node builder reads media.tracks. A podcast library's items would show no tracks (non-queueable). Adding an episodes branch is a later, additive change — noted as the extension point.
  • ABS user accounts beyond the single API key, playback-progress sync back to ABS, series/authors/collections/genre browse, and tag filtering.
  • Transcoding / HLS sessions — we stream the raw file with range requests.
  • Cover art, chapters, and ebook reading.
  • Pagination past the configured caps (one page is fetched per listing).

Structure

direction: right

server: crabidy-server {
  orch: ProviderOrchestrator
}

absdy: "absdy (crate)" {
  client: "Client\n(ProviderClient)"
  terms: "search terms\n(in-memory, per library)"
  api: "AbsApi\n(reqwest seam: Abs trait,\nbearer auth)"
  client -> terms
  client -> api
}

abs: "audiobookshelf\n(/api, bearer auth)" { shape: cloud }
player: audio-player { shape: hexagon }

server.orch -> absdy.client: "/abs/..."
absdy.api -> abs: "libraries / items / search / detail (JSON, timeout)"
server.orch -> player: "file URL with ?token="
player -> abs: "windowed HTTP stream (Range → 206)"

Key flow: browse a library, play a track

shape: sequence_diagram
tui: TUI
orch: Orchestrator
a: absdy
api: audiobookshelf

tui -> orch: "open /abs"
orch -> a: "get_lib_root / get_lib_node"
a -> api: "GET /api/libraries"
api -> a: "libraries (id, name)"
a -> tui: "libraries as children"
tui -> orch: "open a library"
orch -> a: "get_lib_node(/abs/<lib>)"
a -> api: "GET /api/libraries/<lib>/items"
api -> a: "items (id, title, numAudioFiles)"
a -> tui: "[search] + books as children"
tui -> orch: "open a book"
orch -> a: "get_lib_node(/abs/<lib>/<item>)"
a -> api: "GET /api/items/<item>?expanded=1"
api -> a: "tracks (ino, title, duration)"
a -> tui: "book node: files as tracks (queueable)"
tui -> orch: "queue + play a track"
orch -> a: "get_urls_for_track(.../<ino>)"
a -> a: "build /api/items/<item>/file/<ino>?token= (no call)"
orch -> orch: "player streams the file"

Risks and open questions

  • API-key lifetime. ABS API keys are long-lived tokens, but if the configured value is a short-lived session JWT it will eventually expire; a 401 then surfaces as a typed FetchError (a skipped track / an unreadable node), never a crash. Re-issue the key in abs.toml to recover.
  • Token leakage. The stream URL embeds the key. It must never reach a log, trace, or error report — enforced by building the URL only at the boundary, a redacting Debug, and logging paths/context (never the built URL). This is a quality gate.
  • Summary vs detail drift. A book child's is_queable comes from the summary's numAudioFiles; the actual track count comes from the detail. A mismatch only means an optimistic flag — resolution of an empty book yields no tracks (skipped), never an error.
  • Large libraries. Capped by items_per_library; deep browsing past the cap needs pagination (out of scope). The cap is log-ged so truncation is visible, not silent.
  • Field / envelope drift across ABS versions. DTOs decode defensively (#[serde(default)], missing fields degrade); a renamed field is a local fix in AbsApi. Live validation is a task-plan gate (already exercised during design against the test server).