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 raisesis_downloadablegetsw/Wwith 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 handlebook). - item summary (in the items list):
id,mediaType,media.metadata(title,authorName),media.numAudioFiles,media.duration. - item detail:
media.metadata.{title,authorName}andmedia.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
/youtubeand/fyyd): mirroring tidal's search-term semantics costs no TUI or wire change. No proto change, no newProviderCommand. - ABS needs credentials, so — unlike fyyd — a usable
abs.tomlmust carry abase_urland anapi_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/abssubtree, 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
inois 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; andapi_keymust be redacted fromDebug/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.
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/<lib>— children: a reservedsearchchild (is_creatable) plus the library's items (bounded, see D5) as book children. A book child'sis_queableis set fromnumAudioFiles > 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>/search—is_creatable; children are the in-memory search terms (RwLock<Vec<String>>, dedup, recreated implicitly on stale paths), eachis_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: parseitem+inofrom 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 isMalformedPath.- Track fields (from the item detail's
tracks[]):title= track title (the file name),artist= the book'sauthorName,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_trackon a bare track path) fetches the same item detail and picks the matchingino. 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 readsmedia.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
401then surfaces as a typedFetchError(a skipped track / an unreadable node), never a crash. Re-issue the key inabs.tomlto 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_queablecomes from the summary'snumAudioFiles; 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 islog-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 inAbsApi. Live validation is a task-plan gate (already exercised during design against the test server).