From 11b2a1bd385bf730fd100875ef52af5be6b2eea2 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 23 Jul 2026 23:09:54 +0200 Subject: [PATCH] Architecture: audiobookshelf (`/abs`) provider design Design doc for a new `absdy` provider mounted at `/abs` that browses, searches, and plays audiobooks from a self-hosted audiobookshelf server. Shaped on the fyyd provider: an `Abs` reqwest seam faked in tests, an in-memory per-library search-term store, and a `library -> book -> tracks` tree with a per-library `search` subtree. Grounded live against the test server: bearer auth for browse, `?token=` query auth plus HTTP range (206) on the file endpoint, so a track's stream URL is fully derivable from its path with no extra call. The embedded token and the api_key are secrets, redacted from logs/Debug (hard rule). Also gitignores the abs-api-key file so the JWT never lands in a commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 6 + architecture/audiobookshelf-provider.md | 242 ++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 architecture/audiobookshelf-provider.md diff --git a/.gitignore b/.gitignore index 28faf1e..17f658f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,9 @@ opencode.json .opencode/ .explained/ *.kickstart-new + +# mdbook build output +docs/book/ + +# audiobookshelf API key (secret, do not commit) +abs-api-key diff --git a/architecture/audiobookshelf-provider.md b/architecture/audiobookshelf-provider.md new file mode 100644 index 0000000..c7d64dd --- /dev/null +++ b/architecture/audiobookshelf-provider.md @@ -0,0 +1,242 @@ +# 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).