# 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](https://www.audiobookshelf.org/) (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:///api/`. Every call carries `Authorization: Bearer `, **except** the file endpoint which also accepts `?token=`. 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//items?limit=&sort=…` | | Search within a library | `GET /api/libraries//search?q=&limit=` | | Item detail (tracks) | `GET /api/items/?expanded=1` | | **Stream a file** | `GET /api/items//file/?token=` | 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//file/` — same components as the path). - **search** response: `{ "book": [ { "libraryItem": }, … ], … }`. 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>` 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` — 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`. ### D3 — Tree shape (libraries, books, tracks, per-library search) 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/` — 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//` — lists that book's audio files as **tracks**; queueable and downloadable (homogeneous tracks). - `/abs///` — the track leaf. - `/abs//search` — `is_creatable`; children are the in-memory search terms (`RwLock>`, 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//search/` — lists matching **books** as children (same book shape as a direct library child). - `/abs//search//` and `...//` — 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 `/api/items//file/?token=`. **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`), `provider_item_id` = `":"` (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 ```d2 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 ```d2 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/)" a -> api: "GET /api/libraries//items" api -> a: "items (id, title, numAudioFiles)" a -> tui: "[search] + books as children" tui -> orch: "open a book" orch -> a: "get_lib_node(/abs//)" a -> api: "GET /api/items/?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(.../)" a -> a: "build /api/items//file/?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).