From f4309aa327c61aa062257d530912302988fa2801 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 23 Jul 2026 23:22:52 +0200 Subject: [PATCH] Add the audiobookshelf provider (`/abs`) New `absdy` crate mounted at `/abs`: browse, search, and play audiobooks from a self-hosted audiobookshelf server. Shaped on the fyyd provider -- an `Abs` reqwest seam faked in tests (14 unit tests, no network), an in-memory per-library search-term store, and a `library -> book -> tracks` tree with a per-library `search` subtree. audiobookshelf-specific decisions: - Credentials + a secret. A missing/incomplete abs.toml (no base_url or api_key) disables `/abs` non-fatally. The api_key and the `?token=` stream URL are secrets: Settings and AbsApi have manual redacting Debug, and the token is built only in `Abs::stream_url` -- never logged, never handed to a reqwest call in absdy (browse auth is a bearer header). - Playback needs no API call: a track's stream URL is fully derivable from its path (item id + ino) plus the token. Verified live that `?token=` auth returns 200 and the file endpoint honors HTTP range (206), so the windowed-HTTP player streams it directly. - Per-library search (ABS search is per-library); the reserved `search` segment splits the search branch from item ids. A book's queueability comes from the summary's numAudioFiles, so ebook-only items show but are not queueable. Root lists only book libraries. Wired through the orchestrator and settings exactly like the other providers (dispatch arms, root child, ALL_PROVIDERS, ProviderToggles). An `#[ignore]`d live test (absdy/tests/live.rs) validates the DTOs against a real server end-to-end. Docs: architecture/, quality/, plan/, READMEs. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 15 + Cargo.toml | 2 + README.md | 4 +- absdy/Cargo.toml | 17 + absdy/README.md | 54 +++ absdy/src/api.rs | 365 ++++++++++++++++ absdy/src/lib.rs | 651 +++++++++++++++++++++++++++++ absdy/src/tests.rs | 380 +++++++++++++++++ absdy/tests/live.rs | 73 ++++ crabidy-server/Cargo.toml | 1 + crabidy-server/src/provider.rs | 82 ++++ crabidy-server/src/settings.rs | 30 +- plan/audiobookshelf-provider.md | 41 ++ plan/summary.md | 56 +++ quality/audiobookshelf-provider.md | 86 ++++ 15 files changed, 1853 insertions(+), 4 deletions(-) create mode 100644 absdy/Cargo.toml create mode 100644 absdy/README.md create mode 100644 absdy/src/api.rs create mode 100644 absdy/src/lib.rs create mode 100644 absdy/src/tests.rs create mode 100644 absdy/tests/live.rs create mode 100644 plan/audiobookshelf-provider.md create mode 100644 quality/audiobookshelf-provider.md diff --git a/Cargo.lock b/Cargo.lock index b73f66f..ed531eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "absdy" +version = "0.1.0" +dependencies = [ + "async-trait", + "crabidy-core", + "reqwest 0.13.1", + "serde", + "thiserror 2.0.19", + "tokio", + "toml", + "tracing", +] + [[package]] name = "adler2" version = "2.0.1" @@ -1135,6 +1149,7 @@ dependencies = [ name = "crabidy-server" version = "0.1.0" dependencies = [ + "absdy", "anyhow", "argon2", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index c787c36..58d94a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" members = [ + "absdy", "audio-player", "cbd", "cbd-cli", @@ -94,6 +95,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = "2" # Local crates +absdy = { path = "absdy" } audio-player = { path = "audio-player" } cbd-cli = { path = "cbd-cli" } cbd-tui = { path = "cbd-tui" } diff --git a/README.md b/README.md index 4c09c48..e23f6e5 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ each mounted as a subtree of one library: ├── tidal Tidal streaming (see tidaldy/README.md) ├── youtube YouTube search & playlists (see ytdy/README.md) ├── fyyd podcast search (see fyyd/README.md) +├── abs audiobookshelf audiobooks (see absdy/README.md) ├── fs a local music folder (see fsdy/README.md) ├── crabidy your saves: queues, bookmarks (`w`), and captures (`W`), │ managed by the server (see architecture/crabidy-store.md) @@ -56,6 +57,7 @@ filled in. | `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) | | `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) | | `fyyd.toml` | podcasts | [fyyd/README.md](fyyd/README.md) | +| `abs.toml` | audiobooks | [absdy/README.md](absdy/README.md) | | `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) | | `cbd-tui.toml` | `cbd-tui` | below | | `cbd.toml` | `cbd` | below (same options as `cbd-tui.toml`) | @@ -112,7 +114,7 @@ cbd-tui auth queue-owner 'pw' --address http://pi:50051 On first start the server writes this file with every provider enabled: ```toml -providers = ["tidal", "youtube", "fyyd", "fs", "crabidy", "orphans"] +providers = ["tidal", "youtube", "fyyd", "abs", "fs", "crabidy", "orphans"] ``` **Remove a name to disable that provider** — it no longer mounts and does diff --git a/absdy/Cargo.toml b/absdy/Cargo.toml new file mode 100644 index 0000000..8076629 --- /dev/null +++ b/absdy/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "absdy" +version.workspace = true +edition.workspace = true + +[dependencies] +async-trait.workspace = true +crabidy-core.workspace = true +reqwest.workspace = true +serde.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["time"] } +toml.workspace = true +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } diff --git a/absdy/README.md b/absdy/README.md new file mode 100644 index 0000000..a432708 --- /dev/null +++ b/absdy/README.md @@ -0,0 +1,54 @@ +# absdy — audiobookshelf provider + +Mounts a self-hosted [audiobookshelf](https://www.audiobookshelf.org/) (ABS) +server at `/abs`, so you can browse, search, and play your audiobooks from +crabidy. See [the design doc](../architecture/audiobookshelf-provider.md) for +the design and decisions. + +## Configuration — `~/.config/crabidy/abs.toml` + +The provider needs your server URL and an API key. Without both, `/abs` does +not mount (the rest of the server is unaffected). + +```toml +# Required. +base_url = "https://audiobookshelf.example.com" +api_key = "" + +# Optional (defaults shown). +items_per_library = 200 # books listed per library +search_results = 50 # books listed per search term +call_timeout_secs = 30 # per-request timeout +``` + +Create an API key in audiobookshelf under **Settings → Users → (your user) → +API Keys** (or **Settings → API Keys** on older versions). The key is a +secret: it is redacted from logs and never printed, but keep `abs.toml` +private anyway. + +## The library tree + +```text +/abs +└── one node per "book" library + ├── search create a search term with `%` + │ └── books matching the term + │ └── the book's audio files as tracks + └── an audiobook; its files are tracks + └── a track (one audio file) +``` + +- A **library** lists its books plus a creatable `search` node. Search is + per-library — a term created under one library does not appear under + another. +- A **book** lists its audio files as tracks; queue or capture (`W`) the whole + book, or a single file. An ebook-only item (no audio) is shown but is not + queueable. +- Podcast libraries are not shown (audiobooks only, for now). + +## Notes + +- Playback streams each file directly from ABS with a per-file token URL and + HTTP range requests — no transcoding session. +- Listings are capped (see the config) and fetched fresh; only your typed + search terms are remembered, in memory, until the server restarts. diff --git a/absdy/src/api.rs b/absdy/src/api.rs new file mode 100644 index 0000000..34c3aa8 --- /dev/null +++ b/absdy/src/api.rs @@ -0,0 +1,365 @@ +//! The audiobookshelf HTTP seam. +//! +//! All network access to an audiobookshelf server goes through the [`Abs`] +//! trait so the provider's tree/path logic is unit-tested with a fake and no +//! network (architecture/audiobookshelf-provider.md D2). [`AbsApi`] is the +//! production `reqwest` implementation; tests supply their own [`Abs`]. +//! +//! Every browse call carries `Authorization: Bearer `. The playable +//! stream URL instead embeds the token as a `?token=` query parameter — that +//! is what the audio player fetches directly — and is built by +//! [`Abs::stream_url`] so the **secret never leaves this module**: it is +//! never logged, and it never appears in a `reqwest` error (browse URLs carry +//! no token, and the stream URL is built, not requested, here). + +use std::fmt::{self, Debug}; +use std::time::Duration; + +use async_trait::async_trait; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use thiserror::Error; +use tracing::debug; + +/// A typed audiobookshelf request failure. Carries only non-secret context +/// (paths, ids, decode messages) — never the bearer token or a token URL. +#[derive(Debug, Error)] +pub enum FetchError { + /// The HTTP call failed (transport, timeout, or non-success status). + #[error("audiobookshelf request failed: {0}")] + Http(String), + /// The response body did not decode into the expected shape. + #[error("audiobookshelf returned malformed data: {0}")] + Decode(String), + /// The resource does not exist (HTTP 404). + #[error("audiobookshelf resource not found")] + NotFound, + /// Authentication was rejected (HTTP 401/403) — a bad or expired key. + #[error("audiobookshelf authentication failed")] + Unauthorized, +} + +/// A library on the server. `is_book` is `true` for `mediaType == "book"` — +/// the only kind this provider serves (podcast libraries are out of scope, +/// architecture/audiobookshelf-provider.md D6). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Library { + pub id: String, + pub name: String, + pub is_book: bool, +} + +/// A book item as listed in a library or a search result. `num_audio_files` +/// drives queueability without opening the item: an ebook-only item reports +/// `0` and is shown but not queueable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Book { + pub id: String, + pub title: String, + pub author: String, + pub num_audio_files: u32, +} + +/// One audio file of a book — a playable track. `ino` is the server's stable +/// file id (an integer string, already URL-safe, used as a path segment and +/// in the stream URL). `duration` is in seconds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AudioTrack { + pub ino: String, + pub title: String, + pub duration: Option, +} + +/// A book's detail: its metadata plus its audio files in playback order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BookDetail { + pub title: String, + pub author: String, + pub tracks: Vec, +} + +/// The audiobookshelf operations the provider needs. Behind `Box` so +/// tests fake it (architecture/audiobookshelf-provider.md D2). +#[async_trait] +pub trait Abs: Debug + Send + Sync { + /// All libraries on the server. + async fn libraries(&self) -> Result, FetchError>; + + /// A library's book items (at most `limit`), title-sorted. + async fn library_items(&self, library_id: &str, limit: usize) -> Result, FetchError>; + + /// Books in a library matching a free-text term (at most `limit`). + async fn search_items( + &self, + library_id: &str, + term: &str, + limit: usize, + ) -> Result, FetchError>; + + /// One book's detail, including its audio files as ordered tracks. + async fn item_detail(&self, item_id: &str) -> Result; + + /// The token-authenticated stream URL for a file. Pure string building + /// (no I/O), but on the seam because only the backend holds the base URL + /// and the secret token. The returned URL contains the token and must + /// never be logged. + fn stream_url(&self, item_id: &str, ino: &str) -> String; +} + +/// Production `reqwest` client for an audiobookshelf server. `Debug` redacts +/// the token (hard rule: redact secrets from logs and error reports). +pub struct AbsApi { + http: reqwest::Client, + /// Base URL without a trailing slash (e.g. `https://host`). + base_url: String, + /// The API key / bearer token. Secret — redacted from `Debug`, never + /// logged. + token: String, +} + +impl Debug for AbsApi { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AbsApi") + .field("base_url", &self.base_url) + .field("token", &"") + .finish() + } +} + +impl AbsApi { + /// Builds the client with a per-call `timeout` (hard rule: timeouts on + /// external calls). `base_url` is the server root (any trailing slash is + /// trimmed); `token` is the API key. + pub fn new(base_url: String, token: String, timeout: Duration) -> Result { + let http = reqwest::Client::builder() + .timeout(timeout) + .user_agent(concat!("crabidy-absdy/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|err| FetchError::Http(err.to_string()))?; + Ok(Self { + http, + base_url: base_url.trim_end_matches('/').to_string(), + token, + }) + } + + /// GETs `path` with `query` under bearer auth and decodes the JSON body. + /// The logged URL carries no token (auth is a header); only + /// [`Abs::stream_url`] embeds the secret. + async fn get( + &self, + path: &str, + query: &[(&str, &str)], + ) -> Result { + let url = format!("{}{}", self.base_url, path); + debug!(url, "abs GET"); + let resp = self + .http + .get(&url) + .bearer_auth(&self.token) + .query(query) + .send() + .await + .map_err(|err| FetchError::Http(err.to_string()))?; + match resp.status() { + reqwest::StatusCode::NOT_FOUND => return Err(FetchError::NotFound), + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => { + return Err(FetchError::Unauthorized) + } + _ => {} + } + let resp = resp + .error_for_status() + .map_err(|err| FetchError::Http(err.to_string()))?; + resp.json() + .await + .map_err(|err| FetchError::Decode(err.to_string())) + } +} + +#[async_trait] +impl Abs for AbsApi { + async fn libraries(&self) -> Result, FetchError> { + let dto: LibrariesDto = self.get("/api/libraries", &[]).await?; + Ok(dto + .libraries + .into_iter() + .filter_map(LibraryDto::into_library) + .collect()) + } + + async fn library_items(&self, library_id: &str, limit: usize) -> Result, FetchError> { + let count = limit.to_string(); + let dto: ItemsDto = self + .get( + &format!("/api/libraries/{library_id}/items"), + &[("limit", &count), ("sort", "media.metadata.title")], + ) + .await?; + Ok(dto + .results + .into_iter() + .filter_map(ItemDto::into_book) + .collect()) + } + + async fn search_items( + &self, + library_id: &str, + term: &str, + limit: usize, + ) -> Result, FetchError> { + let count = limit.to_string(); + let dto: SearchDto = self + .get( + &format!("/api/libraries/{library_id}/search"), + &[("q", term), ("limit", &count)], + ) + .await?; + Ok(dto + .book + .into_iter() + .filter_map(|hit| hit.library_item.into_book()) + .collect()) + } + + async fn item_detail(&self, item_id: &str) -> Result { + let dto: ItemDto = self + .get(&format!("/api/items/{item_id}"), &[("expanded", "1")]) + .await?; + Ok(dto.into_detail()) + } + + fn stream_url(&self, item_id: &str, ino: &str) -> String { + // The token is appended last and never logged (D4). ABS serves the + // raw file here with HTTP range support, so the audio player streams + // it directly. + format!( + "{}/api/items/{item_id}/file/{ino}?token={}", + self.base_url, self.token + ) + } +} + +// --- Wire DTOs: decode defensively, missing fields degrade, never panic. --- + +#[derive(Debug, Default, Deserialize)] +struct LibrariesDto { + #[serde(default)] + libraries: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct LibraryDto { + #[serde(default)] + id: String, + #[serde(default)] + name: String, + #[serde(default, rename = "mediaType")] + media_type: String, +} + +impl LibraryDto { + /// Drops entries without an id. + fn into_library(self) -> Option { + (!self.id.is_empty()).then(|| Library { + is_book: self.media_type == "book", + id: self.id, + name: self.name, + }) + } +} + +#[derive(Debug, Default, Deserialize)] +struct ItemsDto { + #[serde(default)] + results: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct ItemDto { + #[serde(default)] + id: String, + #[serde(default)] + media: MediaDto, +} + +#[derive(Debug, Default, Deserialize)] +struct MediaDto { + #[serde(default)] + metadata: MetadataDto, + #[serde(default, rename = "numAudioFiles")] + num_audio_files: i64, + #[serde(default)] + tracks: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct MetadataDto { + #[serde(default)] + title: String, + #[serde(default, rename = "authorName")] + author_name: String, +} + +#[derive(Debug, Default, Deserialize)] +struct TrackDto { + #[serde(default)] + ino: String, + #[serde(default)] + title: String, + #[serde(default)] + duration: f64, +} + +impl ItemDto { + /// A listed book, dropping entries without an id. `num_audio_files` + /// clamps negatives to 0. + fn into_book(self) -> Option { + (!self.id.is_empty()).then(|| Book { + title: self.media.metadata.title, + author: self.media.metadata.author_name, + num_audio_files: u32::try_from(self.media.num_audio_files).unwrap_or(0), + id: self.id, + }) + } + + /// The book's detail with its audio files as ordered tracks (files + /// without an ino are dropped — they cannot be addressed or streamed). + fn into_detail(self) -> BookDetail { + BookDetail { + title: self.media.metadata.title, + author: self.media.metadata.author_name, + tracks: self + .media + .tracks + .into_iter() + .filter_map(TrackDto::into_track) + .collect(), + } + } +} + +impl TrackDto { + /// A domain track, dropping files without an ino. A non-positive + /// duration degrades to `None`. + fn into_track(self) -> Option { + (!self.ino.is_empty()).then(|| AudioTrack { + ino: self.ino, + title: self.title, + duration: (self.duration > 0.0).then_some(self.duration.round() as u32), + }) + } +} + +#[derive(Debug, Default, Deserialize)] +struct SearchDto { + #[serde(default)] + book: Vec, +} + +#[derive(Debug, Deserialize)] +struct SearchHit { + #[serde(rename = "libraryItem")] + library_item: ItemDto, +} diff --git a/absdy/src/lib.rs b/absdy/src/lib.rs new file mode 100644 index 0000000..c3f265a --- /dev/null +++ b/absdy/src/lib.rs @@ -0,0 +1,651 @@ +//! audiobookshelf provider: **browse, search, and play** the audiobooks on a +//! self-hosted [audiobookshelf](https://www.audiobookshelf.org/) server. +//! Mounted at [`PROVIDER_ROOT`]. +//! +//! Shaped on the fyyd provider. The tree is `library → book → tracks`, where a +//! book's tracks are its audio files, plus a per-library `search` subtree with +//! creatable/renamable/deletable search-term nodes like tidal/youtube/fyyd. A +//! track is one audio file whose `?token=` stream URL the audio player fetches +//! directly; every node that serves tracks is downloadable, so `W` captures +//! work out of the box. +//! +//! audiobookshelf is the user's private server, so — unlike fyyd — a usable +//! `abs.toml` must carry a `base_url` and an `api_key`; a missing or +//! incomplete config disables the provider non-fatally. The `api_key` and the +//! token-bearing stream URL are secrets, redacted from `Debug` and never +//! logged. +//! +//! See architecture/audiobookshelf-provider.md for the tree shape and +//! decisions. + +use std::collections::HashMap; +use std::fmt; +use std::sync::RwLock; +use std::time::Duration; + +use async_trait::async_trait; +use crabidy_core::proto::crabidy::{Album, LibraryNode, LibraryNodeChild, Track}; +use crabidy_core::{ProviderClient, ProviderError}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, warn}; + +pub mod api; +use api::{Abs, AbsApi, Book, BookDetail}; + +/// First path segment owned by this provider. +pub const PROVIDER_ROOT: &str = "/abs"; + +/// Reserved second-level segment separating the search subtree from item ids +/// (item ids are UUIDs, so they never collide with this literal). +const SEARCH_SEGMENT: &str = "search"; + +/// Default (and cap on) items listed under a library — a huge library must +/// not stall the tree or queue resolution. +pub const DEFAULT_ITEMS_PER_LIBRARY: usize = 200; + +/// Default number of books listed per search term. +pub const DEFAULT_SEARCH_RESULTS: usize = 50; + +/// Default per-request timeout in seconds. +pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30; + +/// Provider settings, persisted as `abs.toml`. `base_url` and `api_key` are +/// required for the provider to run; the rest have defaults. +#[derive(Clone, Default, Deserialize, Serialize)] +pub struct Settings { + /// The audiobookshelf server root, e.g. `https://abs.example.com`. + /// Required — without it the provider is disabled. + pub base_url: Option, + /// The API key (bearer token). Required — without it the provider is + /// disabled. **Secret**: redacted from `Debug`, never logged. + pub api_key: Option, + /// Items listed per library. Default [`DEFAULT_ITEMS_PER_LIBRARY`]. + pub items_per_library: Option, + /// Books listed per search term. Default [`DEFAULT_SEARCH_RESULTS`]. + pub search_results: Option, + /// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`]. + pub call_timeout_secs: Option, +} + +impl fmt::Debug for Settings { + /// Redacts `api_key` (hard rule: secrets never reach logs or reports). + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Settings") + .field("base_url", &self.base_url) + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .field("items_per_library", &self.items_per_library) + .field("search_results", &self.search_results) + .field("call_timeout_secs", &self.call_timeout_secs) + .finish() + } +} + +/// A parsed `/abs/...` path. The direct-browse and search branches share the +/// same book/track shapes below a library. +#[derive(Debug, PartialEq, Eq)] +enum AbsPath<'a> { + Root, + Library(&'a str), + LibraryBook { + library: &'a str, + item: &'a str, + }, + LibraryTrack { + library: &'a str, + item: &'a str, + ino: &'a str, + }, + Search(&'a str), + /// Percent-encoded search-term segment. + SearchTerm { + library: &'a str, + term: &'a str, + }, + SearchBook { + library: &'a str, + term: &'a str, + item: &'a str, + }, + SearchTrack { + library: &'a str, + term: &'a str, + item: &'a str, + ino: &'a str, + }, +} + +/// Splits an `/abs/...` path into its recognized shape. Unknown shapes are +/// [`ProviderError::MalformedPath`]. +fn parse_path(path: &str) -> Result, ProviderError> { + if path == PROVIDER_ROOT { + return Ok(AbsPath::Root); + } + let rest = path + .strip_prefix("/abs/") + .ok_or(ProviderError::MalformedPath)?; + let segments: Vec<&str> = rest.split('/').collect(); + if segments.iter().any(|segment| segment.is_empty()) { + return Err(ProviderError::MalformedPath); + } + // Literal-`search` arms first so a `search` second segment routes to the + // search branch; item ids (UUIDs) never equal `search`. + match segments.as_slice() { + [lib, s] if *s == SEARCH_SEGMENT => Ok(AbsPath::Search(lib)), + [lib, s, term] if *s == SEARCH_SEGMENT => Ok(AbsPath::SearchTerm { library: lib, term }), + [lib, s, term, item] if *s == SEARCH_SEGMENT => Ok(AbsPath::SearchBook { + library: lib, + term, + item, + }), + [lib, s, term, item, ino] if *s == SEARCH_SEGMENT => Ok(AbsPath::SearchTrack { + library: lib, + term, + item, + ino, + }), + [lib] => Ok(AbsPath::Library(lib)), + [lib, item] => Ok(AbsPath::LibraryBook { library: lib, item }), + [lib, item, ino] => Ok(AbsPath::LibraryTrack { + library: lib, + item, + ino, + }), + _ => Err(ProviderError::MalformedPath), + } +} + +/// Maps a fetch failure to the trait-level error, logging the typed cause. +fn fetch_err(context: &str, err: api::FetchError) -> ProviderError { + warn!(context, "abs fetch failed: {err}"); + ProviderError::FetchError +} + +/// The audiobookshelf provider client. +pub struct Client { + api: Box, + settings: Settings, + /// Search terms created under `/abs//search`, keyed by library id, in + /// creation order, deduplicated. In-memory only. Never held across awaits. + search_terms: RwLock>>, +} + +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Client") + .field("api", &self.api) + .field("settings", &self.settings) + .finish_non_exhaustive() + } +} + +impl Client { + /// A client over any [`Abs`] backend — the seam the tests use. + fn with_api(api: Box, settings: Settings) -> Self { + Self { + api, + settings, + search_terms: RwLock::new(HashMap::new()), + } + } + + fn items_limit(&self) -> usize { + self.settings + .items_per_library + .unwrap_or(DEFAULT_ITEMS_PER_LIBRARY) + } + + fn search_results_limit(&self) -> usize { + self.settings + .search_results + .unwrap_or(DEFAULT_SEARCH_RESULTS) + } + + /// A node listing `books` as children. A book is queueable only when it + /// has audio files (an ebook-only item reports zero and is shown but not + /// queueable). Used for both a library browse and a search-term node. + fn book_listing_node( + &self, + path: &str, + title: String, + parent: String, + books: &[Book], + extra_children: Vec, + ) -> LibraryNode { + let mut children = extra_children; + children.extend(books.iter().map(|book| LibraryNodeChild { + ..LibraryNodeChild::new( + crabidy_core::join_path(path, &book.id), + book.title.clone(), + book.num_audio_files > 0, + ) + })); + LibraryNode { + path: path.to_string(), + title, + parent: Some(parent), + tracks: Vec::new(), + children, + is_queable: false, + is_creatable: false, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + } + } + + /// One book's audio files as tracks. Queueable and downloadable — + /// homogeneous tracks. An audio-less item (ebook) yields an empty, + /// non-queueable node rather than an error. + async fn book_node( + &self, + path: &str, + item_id: &str, + parent: String, + ) -> Result { + let detail = self + .api + .item_detail(item_id) + .await + .map_err(|err| fetch_err("item detail", err))?; + let tracks: Vec = detail + .tracks + .iter() + .map(|track| book_track(path, item_id, &detail, track)) + .collect(); + let is_queable = !tracks.is_empty(); + Ok(LibraryNode { + path: path.to_string(), + title: detail.title, + parent: Some(parent), + tracks, + children: Vec::new(), + is_queable, + is_creatable: false, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + }) + } + + /// A library node: its books as children, prefixed by the creatable + /// `search` child. + async fn library_node( + &self, + path: &str, + library_id: &str, + parent: String, + ) -> Result { + let books = self + .api + .library_items(library_id, self.items_limit()) + .await + .map_err(|err| fetch_err("library items", err))?; + if books.len() >= self.items_limit() { + debug!( + library_id, + limit = self.items_limit(), + "library listing truncated to the configured cap" + ); + } + let search_child = LibraryNodeChild { + is_creatable: true, + ..LibraryNodeChild::new( + crabidy_core::join_path(path, SEARCH_SEGMENT), + SEARCH_SEGMENT.to_string(), + false, + ) + }; + Ok(self.book_listing_node( + path, + library_id.to_string(), + parent, + &books, + vec![search_child], + )) + } + + /// A search-term node: books matching the term as children. + async fn search_term_node( + &self, + path: &str, + library_id: &str, + term: &str, + parent: String, + ) -> Result { + let books = self + .api + .search_items(library_id, term, self.search_results_limit()) + .await + .map_err(|err| fetch_err("search", err))?; + Ok(self.book_listing_node(path, term.to_string(), parent, &books, Vec::new())) + } + + /// The `/abs//search` node listing the library's search terms. + fn search_node(&self, path: &str, library_id: &str, parent: String) -> LibraryNode { + LibraryNode { + path: path.to_string(), + title: SEARCH_SEGMENT.to_string(), + parent: Some(parent), + tracks: Vec::new(), + children: self + .search_terms_snapshot(library_id) + .iter() + .map(|term| LibraryNodeChild { + // Term nodes are the modifiable nodes: renamable (`e`) and + // deletable (`d`), like tidal/youtube/fyyd. + is_editable: true, + is_deletable: true, + ..LibraryNodeChild::new( + crabidy_core::join_path(path, &crabidy_core::encode_segment(term)), + term.clone(), + false, + ) + }) + .collect(), + is_queable: false, + is_creatable: true, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + } + } + + fn search_terms_snapshot(&self, library_id: &str) -> Vec { + self.search_terms + .read() + .ok() + .and_then(|terms| terms.get(library_id).cloned()) + .unwrap_or_default() + } + + fn register_search_term(&self, library_id: &str, term: &str) { + if let Ok(mut terms) = self.search_terms.write() { + let list = terms.entry(library_id.to_string()).or_default(); + if !list.iter().any(|existing| existing == term) { + list.push(term.to_string()); + } + } + } + + /// Removes a term; `true` when it existed. + fn remove_search_term(&self, library_id: &str, term: &str) -> bool { + match self.search_terms.write() { + Ok(mut terms) => { + let Some(list) = terms.get_mut(library_id) else { + return false; + }; + let before = list.len(); + list.retain(|existing| existing != term); + list.len() != before + } + Err(_) => false, + } + } + + /// The `(item, ino)` addressed by a track path, or `MalformedPath`. + fn track_item_ino<'a>(&self, path: &'a str) -> Result<(&'a str, &'a str), ProviderError> { + match parse_path(path)? { + AbsPath::LibraryTrack { item, ino, .. } | AbsPath::SearchTrack { item, ino, .. } => { + Ok((item, ino)) + } + _ => Err(ProviderError::MalformedPath), + } + } +} + +/// Builds the wire track for one audio file. `artist` is the book's author, +/// `album` the book title; missing fields degrade to empty/`None`. +fn book_track( + node_path: &str, + item_id: &str, + detail: &BookDetail, + track: &api::AudioTrack, +) -> Track { + Track { + path: crabidy_core::join_path(node_path, &track.ino), + artist: detail.author.clone(), + title: track.title.clone(), + duration: track.duration, + album: (!detail.title.is_empty()).then(|| Album { + title: detail.title.clone(), + release_date: None, + }), + is_skipped: false, + provider_item_id: format!("{item_id}:{}", track.ino), + is_captured: false, + } +} + +#[async_trait] +impl ProviderClient for Client { + /// Builds the `reqwest`-backed client. A missing `base_url`/`api_key`, or + /// a client that cannot be built, fails init — the orchestrator then + /// disables the provider non-fatally + /// (architecture/audiobookshelf-provider.md D1). + async fn init(raw_toml_settings: &str) -> Result { + let settings: Settings = toml::from_str(raw_toml_settings).unwrap_or_else(|_| { + warn!("could not parse abs.toml, using defaults"); + Settings::default() + }); + let base_url = settings + .base_url + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + warn!("abs provider disabled: no base_url in abs.toml"); + ProviderError::Config("audiobookshelf base_url is required".to_string()) + })? + .to_string(); + let token = settings + .api_key + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + warn!("abs provider disabled: no api_key in abs.toml"); + ProviderError::Config("audiobookshelf api_key is required".to_string()) + })? + .to_string(); + let timeout = Duration::from_secs( + settings + .call_timeout_secs + .unwrap_or(DEFAULT_CALL_TIMEOUT_SECS), + ); + let api = AbsApi::new(base_url, token, timeout).map_err(|err| { + warn!("cannot build the abs client: {err}"); + ProviderError::Config(err.to_string()) + })?; + debug!("abs provider ready"); + Ok(Self::with_api(Box::new(api), settings)) + } + + fn settings(&self) -> String { + toml::to_string_pretty(&self.settings).unwrap_or_default() + } + + fn is_track_path(&self, path: &str) -> bool { + matches!( + parse_path(path), + Ok(AbsPath::LibraryTrack { .. } | AbsPath::SearchTrack { .. }) + ) + } + + /// The file's `?token=` stream URL — built from the path, no API call. + /// The token is embedded and must never be logged (D4). + async fn get_urls_for_track(&self, track_path: &str) -> Result, ProviderError> { + let (item, ino) = self.track_item_ino(track_path)?; + Ok(vec![self.api.stream_url(item, ino)]) + } + + /// Single-track metadata: fetch the book detail and pick the file by ino. + async fn get_metadata_for_track(&self, track_path: &str) -> Result { + let (item, ino) = self.track_item_ino(track_path)?; + let detail = self + .api + .item_detail(item) + .await + .map_err(|err| fetch_err("track metadata", err))?; + let track = detail + .tracks + .iter() + .find(|track| track.ino == ino) + .ok_or_else(|| { + warn!(track_path, "abs item has no file with this ino"); + ProviderError::FetchError + })?; + let parent = crabidy_core::parent_path(track_path).unwrap_or(PROVIDER_ROOT); + let mut wire = book_track(parent, item, &detail, track); + // The caller's path is canonical. + wire.path = track_path.to_string(); + Ok(wire) + } + + /// A placeholder — the real `/abs` root lists libraries, which needs a + /// network call, so it is served by [`Self::get_lib_node`] (the sync trait + /// method cannot await). The orchestrator only builds the global-root link + /// from [`PROVIDER_ROOT`], never from these children. + fn get_lib_root(&self) -> LibraryNode { + LibraryNode { + path: PROVIDER_ROOT.to_string(), + title: "abs".to_string(), + parent: Some(crabidy_core::ROOT_PATH.to_string()), + tracks: Vec::new(), + children: Vec::new(), + is_queable: false, + is_creatable: false, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + } + } + + async fn get_lib_node(&self, path: &str) -> Result { + let parent = crabidy_core::parent_path(path) + .unwrap_or(crabidy_core::ROOT_PATH) + .to_string(); + let node = match parse_path(path)? { + AbsPath::Root => { + // The real root: book libraries as children (podcast + // libraries are out of scope, D6). + let libraries = self + .api + .libraries() + .await + .map_err(|err| fetch_err("libraries", err))?; + LibraryNode { + children: libraries + .iter() + .filter(|library| library.is_book) + .map(|library| { + LibraryNodeChild::new( + crabidy_core::join_path(PROVIDER_ROOT, &library.id), + library.name.clone(), + false, + ) + }) + .collect(), + ..self.get_lib_root() + } + } + AbsPath::Library(library) => self.library_node(path, library, parent).await?, + AbsPath::Search(library) => self.search_node(path, library, parent), + AbsPath::SearchTerm { library, term } => { + let decoded = crabidy_core::decode_segment(term); + // Unknown terms (stale client cache, server restart) are + // recreated implicitly instead of erroring. + self.register_search_term(library, &decoded); + self.search_term_node(path, library, &decoded, parent) + .await? + } + AbsPath::LibraryBook { item, .. } | AbsPath::SearchBook { item, .. } => { + self.book_node(path, item, parent).await? + } + AbsPath::LibraryTrack { .. } | AbsPath::SearchTrack { .. } => { + warn!(path, "get_lib_node called with a track path"); + return Err(ProviderError::MalformedPath); + } + }; + // The central download blessing (same rule as fyyd/youtube/tidal): + // every node serving playable content allows `W`; children mirror + // their queueability (a book child is queueable, so a whole book is + // capturable). The `search` child stays non-queueable, so it is not + // blessed. + let mut node = node; + node.is_downloadable = node.is_queable || !node.tracks.is_empty(); + for child in &mut node.children { + child.is_downloadable = child.is_queable; + } + Ok(node) + } + + /// Only `/abs//search` is creatable: registers the term and returns + /// its node (implicit recreation on stale paths, like tidal/youtube/fyyd). + async fn create_lib_node( + &self, + parent_path: &str, + title: &str, + ) -> Result { + let term = title.trim(); + if term.is_empty() { + return Err(ProviderError::InvalidInput); + } + let AbsPath::Search(library) = parse_path(parent_path)? else { + warn!(parent_path, "node creation not supported here"); + return Err(ProviderError::NotSupported); + }; + self.register_search_term(library, term); + let term_path = crabidy_core::join_path(parent_path, &crabidy_core::encode_segment(term)); + self.get_lib_node(&term_path).await + } + + /// Renaming a search term re-runs the search under the new term. + async fn rename_lib_node( + &self, + path: &str, + new_title: &str, + ) -> Result { + let AbsPath::SearchTerm { library, term } = parse_path(path)? else { + warn!(path, "only search terms are renamable"); + return Err(ProviderError::NotSupported); + }; + let new_term = new_title.trim(); + if new_term.is_empty() { + return Err(ProviderError::InvalidInput); + } + let old_term = crabidy_core::decode_segment(term); + // Replace in place; renaming onto an existing term merges (the + // duplicate disappears), like tidal's/fyyd's terms. + self.remove_search_term(library, &old_term); + self.register_search_term(library, new_term); + let new_path = crabidy_core::join_path( + &crabidy_core::join_path( + &crabidy_core::join_path(PROVIDER_ROOT, library), + SEARCH_SEGMENT, + ), + &crabidy_core::encode_segment(new_term), + ); + self.get_lib_node(&new_path).await + } + + /// Deleting a search term is idempotent and returns the refreshed search + /// node. + async fn delete_lib_node(&self, path: &str) -> Result { + let AbsPath::SearchTerm { library, term } = parse_path(path)? else { + warn!(path, "only search terms are deletable"); + return Err(ProviderError::NotSupported); + }; + let decoded = crabidy_core::decode_segment(term); + self.remove_search_term(library, &decoded); + let search_path = crabidy_core::join_path( + &crabidy_core::join_path(PROVIDER_ROOT, library), + SEARCH_SEGMENT, + ); + self.get_lib_node(&search_path).await + } +} + +#[cfg(test)] +mod tests { + include!("tests.rs"); +} diff --git a/absdy/src/tests.rs b/absdy/src/tests.rs new file mode 100644 index 0000000..33a91bb --- /dev/null +++ b/absdy/src/tests.rs @@ -0,0 +1,380 @@ +// Included from lib.rs `mod tests`. Provider logic (tree shaping, path +// parsing, term store, stream-URL building, secret redaction) is exercised +// against a programmable [`Abs`] backend with zero network +// (architecture/audiobookshelf-provider.md D2, quality/audiobookshelf-provider.md). + +use super::*; +use api::{AudioTrack, Book, BookDetail, FetchError, Library}; + +/// A programmable audiobookshelf backend. +#[derive(Debug, Default)] +struct FakeApi { + libraries: Vec, + /// library id → books. + items: HashMap>, + /// (library id, term) → books. + searches: HashMap<(String, String), Vec>, + /// item id → detail. + details: HashMap, + base_url: String, + token: String, +} + +#[async_trait] +impl Abs for FakeApi { + async fn libraries(&self) -> Result, FetchError> { + Ok(self.libraries.clone()) + } + async fn library_items(&self, library_id: &str, limit: usize) -> Result, FetchError> { + self.items + .get(library_id) + .map(|books| books.iter().take(limit).cloned().collect()) + .ok_or(FetchError::NotFound) + } + async fn search_items( + &self, + library_id: &str, + term: &str, + limit: usize, + ) -> Result, FetchError> { + self.searches + .get(&(library_id.to_string(), term.to_string())) + .map(|books| books.iter().take(limit).cloned().collect()) + .ok_or_else(|| FetchError::Http(format!("no search fixture for {term:?}"))) + } + async fn item_detail(&self, item_id: &str) -> Result { + self.details.get(item_id).cloned().ok_or(FetchError::NotFound) + } + fn stream_url(&self, item_id: &str, ino: &str) -> String { + format!( + "{}/api/items/{item_id}/file/{ino}?token={}", + self.base_url, self.token + ) + } +} + +fn book(id: &str, title: &str, author: &str, num_audio_files: u32) -> Book { + Book { + id: id.to_string(), + title: title.to_string(), + author: author.to_string(), + num_audio_files, + } +} + +fn track(ino: &str, title: &str, duration: Option) -> AudioTrack { + AudioTrack { + ino: ino.to_string(), + title: title.to_string(), + duration, + } +} + +/// The standard fixture: two book libraries and one podcast library; `lib1` +/// has an audiobook (`b1`, two files) and an ebook (`e1`, no audio); a search +/// for `neuro` in `lib1` matches `b1`. +fn fake() -> FakeApi { + FakeApi { + libraries: vec![ + Library { + id: "lib1".into(), + name: "Audiobook".into(), + is_book: true, + }, + Library { + id: "lib2".into(), + name: "Manning".into(), + is_book: true, + }, + Library { + id: "pod1".into(), + name: "Podcasts".into(), + is_book: false, + }, + ], + items: HashMap::from([( + "lib1".to_string(), + vec![ + book("b1", "Neuromancer", "William Gibson", 2), + book("e1", "An Ebook", "Nobody", 0), + ], + )]), + searches: HashMap::from([( + ("lib1".to_string(), "neuro".to_string()), + vec![book("b1", "Neuromancer", "William Gibson", 2)], + )]), + details: HashMap::from([ + ( + "b1".to_string(), + BookDetail { + title: "Neuromancer".into(), + author: "William Gibson".into(), + tracks: vec![ + track("111", "part1.opus", Some(60)), + track("222", "part2.opus", None), + ], + }, + ), + ( + "e1".to_string(), + BookDetail { + title: "An Ebook".into(), + author: "Nobody".into(), + tracks: vec![], + }, + ), + ]), + base_url: "https://abs.test".into(), + token: "SECRET".into(), + } +} + +fn client_with(api: FakeApi) -> Client { + Client::with_api(Box::new(api), Settings::default()) +} + +fn client() -> Client { + client_with(fake()) +} + +#[tokio::test] +async fn root_lists_only_book_libraries() { + let node = client().get_lib_node("/abs").await.expect("root"); + let names: Vec<&str> = node.children.iter().map(|c| c.title.as_str()).collect(); + assert_eq!(names, vec!["Audiobook", "Manning"], "podcast library filtered"); + assert_eq!(node.children[0].path, "/abs/lib1"); + assert!(!node.children[0].is_queable, "a library is a container"); +} + +#[tokio::test] +async fn a_library_lists_search_then_books_with_audio_gating_queueability() { + let node = client().get_lib_node("/abs/lib1").await.expect("library"); + assert!(!node.is_creatable && !node.is_queable); + // search first, then the books. + let first = &node.children[0]; + assert_eq!(first.title, "search"); + assert_eq!(first.path, "/abs/lib1/search"); + assert!(first.is_creatable && !first.is_queable && !first.is_downloadable); + + let audiobook = &node.children[1]; + assert_eq!(audiobook.path, "/abs/lib1/b1"); + assert_eq!(audiobook.title, "Neuromancer"); + assert!( + audiobook.is_queable && audiobook.is_downloadable, + "a book with audio queues/captures whole" + ); + let ebook = &node.children[2]; + assert_eq!(ebook.title, "An Ebook"); + assert!( + !ebook.is_queable && !ebook.is_downloadable, + "an ebook (no audio) is shown but not queueable" + ); +} + +#[tokio::test] +async fn a_book_lists_its_files_as_tracks() { + let node = client().get_lib_node("/abs/lib1/b1").await.expect("book"); + assert_eq!(node.title, "Neuromancer"); + assert!(node.is_queable && node.is_downloadable); + assert_eq!(node.tracks.len(), 2); + let one = &node.tracks[0]; + assert_eq!(one.path, "/abs/lib1/b1/111"); + assert_eq!(one.title, "part1.opus"); + assert_eq!(one.artist, "William Gibson", "artist is the author"); + assert_eq!( + one.album.as_ref().map(|a| a.title.as_str()), + Some("Neuromancer"), + "album is the book" + ); + assert_eq!(one.duration, Some(60)); + assert_eq!(one.provider_item_id, "b1:111"); + // Missing duration degrades to None, never an error. + assert_eq!(node.tracks[1].duration, None); +} + +#[tokio::test] +async fn an_audio_less_book_is_empty_and_not_queueable() { + let node = client().get_lib_node("/abs/lib1/e1").await.expect("ebook"); + assert!(node.tracks.is_empty()); + assert!(!node.is_queable && !node.is_downloadable); +} + +#[tokio::test] +async fn search_terms_list_books_and_are_per_library() { + let client = client(); + let node = client + .create_lib_node("/abs/lib1/search", "neuro") + .await + .expect("create term"); + assert_eq!(node.path, "/abs/lib1/search/neuro"); + assert!(!node.is_queable && node.tracks.is_empty()); + assert_eq!(node.children.len(), 1); + let hit = &node.children[0]; + assert_eq!(hit.path, "/abs/lib1/search/neuro/b1"); + assert_eq!(hit.title, "Neuromancer"); + assert!(hit.is_queable && hit.is_downloadable); + + // The term shows under this library's search node, editable/deletable ... + let search = client.get_lib_node("/abs/lib1/search").await.expect("search"); + assert!(search.is_creatable); + assert_eq!(search.children.len(), 1); + assert!(search.children[0].is_editable && search.children[0].is_deletable); + // ... but not under another library's search node (per-library store). + let other = client.get_lib_node("/abs/lib2/search").await.expect("search"); + assert!(other.children.is_empty(), "terms do not leak across libraries"); +} + +#[tokio::test] +async fn a_book_under_a_search_term_lists_the_same_tracks() { + let client = client(); + client + .create_lib_node("/abs/lib1/search", "neuro") + .await + .expect("create term"); + let node = client + .get_lib_node("/abs/lib1/search/neuro/b1") + .await + .expect("book via search"); + assert_eq!(node.tracks.len(), 2); + assert_eq!(node.tracks[0].path, "/abs/lib1/search/neuro/b1/111"); + assert_eq!(node.tracks[0].provider_item_id, "b1:111"); +} + +#[tokio::test] +async fn search_terms_rename_and_delete() { + let client = client(); + client + .create_lib_node("/abs/lib1/search", "neuro") + .await + .expect("create"); + let renamed = client + .rename_lib_node("/abs/lib1/search/neuro", "neuro") + .await + .expect("rename re-searches"); + assert_eq!(renamed.path, "/abs/lib1/search/neuro"); + assert_eq!(client.search_terms_snapshot("lib1"), vec!["neuro".to_string()]); + + let search = client + .delete_lib_node("/abs/lib1/search/neuro") + .await + .expect("delete"); + assert!(search.children.is_empty()); + // Idempotent. + let again = client + .delete_lib_node("/abs/lib1/search/neuro") + .await + .expect("idempotent delete"); + assert!(again.children.is_empty()); + // Only search terms are mutable. + assert!(client.rename_lib_node("/abs/lib1", "nope").await.is_err()); + assert!(client.delete_lib_node("/abs/lib1/b1").await.is_err()); +} + +#[tokio::test] +async fn track_stream_url_embeds_the_token_and_path_ids_without_a_call() { + let client = client(); + assert!(client.is_track_path("/abs/lib1/b1/111")); + assert!(client.is_track_path("/abs/lib1/search/neuro/b1/111")); + assert!(!client.is_track_path("/abs/lib1/b1")); + assert!(!client.is_track_path("/abs/lib1")); + + let urls = client + .get_urls_for_track("/abs/lib1/b1/111") + .await + .expect("stream url"); + assert_eq!( + urls, + vec!["https://abs.test/api/items/b1/file/111?token=SECRET".to_string()] + ); + // The search branch resolves to the same file URL. + let via_search = client + .get_urls_for_track("/abs/lib1/search/neuro/b1/111") + .await + .expect("stream url"); + assert_eq!(via_search, urls); +} + +#[tokio::test] +async fn track_metadata_picks_the_file_by_ino() { + let client = client(); + let track = client + .get_metadata_for_track("/abs/lib1/b1/222") + .await + .expect("metadata"); + assert_eq!(track.title, "part2.opus"); + assert_eq!(track.artist, "William Gibson"); + assert_eq!(track.path, "/abs/lib1/b1/222"); + assert_eq!(track.duration, None); + // A file id that the book does not have is a typed error, not a panic. + assert!(client + .get_metadata_for_track("/abs/lib1/b1/999") + .await + .is_err()); +} + +#[tokio::test] +async fn backend_failures_are_typed_never_panics() { + let client = client_with(FakeApi::default()); + // An empty server (no libraries) is a valid empty root, not an error. + assert!(client.get_lib_node("/abs").await.expect("empty root").children.is_empty()); + // But a missing library / item / search is a typed fetch error. + assert!(client.get_lib_node("/abs/lib1").await.is_err()); + assert!(client.get_lib_node("/abs/lib1/b1").await.is_err()); + client.register_search_term("lib1", "neuro"); + assert!(client.get_lib_node("/abs/lib1/search/neuro").await.is_err()); + assert!(client.get_metadata_for_track("/abs/lib1/b1/111").await.is_err()); + // But building a stream URL never touches the backend, so it still works. + assert!(client.get_urls_for_track("/abs/lib1/b1/111").await.is_ok()); +} + +#[tokio::test] +async fn foreign_and_malformed_paths_are_rejected() { + let client = client(); + for path in [ + "/tidal/artists", + "/abs/lib1/b1/111/extra", + "/abs//b1", + "/absnope", + ] { + assert!(client.get_lib_node(path).await.is_err(), "{path}"); + } + assert!(client.create_lib_node("/abs/lib1", "term").await.is_err()); + assert!(client.create_lib_node("/abs/lib1/search", " ").await.is_err()); +} + +#[test] +fn settings_debug_redacts_the_api_key() { + let settings = Settings { + base_url: Some("https://abs.test".into()), + api_key: Some("super-secret-token".into()), + ..Settings::default() + }; + let shown = format!("{settings:?}"); + assert!(!shown.contains("super-secret-token"), "api_key must be redacted"); + assert!(shown.contains("")); + assert!(shown.contains("abs.test"), "non-secret fields are shown"); +} + +#[test] +fn settings_round_trip() { + let settings: Settings = toml::from_str( + "base_url = \"https://abs.test\"\napi_key = \"k\"\nitems_per_library = 10\nsearch_results = 5\ncall_timeout_secs = 10\n", + ) + .expect("parses"); + assert_eq!(settings.base_url.as_deref(), Some("https://abs.test")); + assert_eq!(settings.items_per_library, Some(10)); + assert_eq!(settings.search_results, Some(5)); + assert_eq!(settings.call_timeout_secs, Some(10)); +} + +#[tokio::test] +async fn init_requires_base_url_and_api_key() { + // Empty config disables the provider (non-fatal at the orchestrator). + assert!(Client::init("").await.is_err()); + assert!(Client::init("base_url = \"https://abs.test\"").await.is_err()); + assert!(Client::init("api_key = \"k\"").await.is_err()); + // Both present: the client builds (no network until a call). + assert!(Client::init("base_url = \"https://abs.test\"\napi_key = \"k\"") + .await + .is_ok()); +} diff --git a/absdy/tests/live.rs b/absdy/tests/live.rs new file mode 100644 index 0000000..4bb4e9d --- /dev/null +++ b/absdy/tests/live.rs @@ -0,0 +1,73 @@ +//! Live validation against a real audiobookshelf server. `#[ignore]`d so it +//! never runs in CI or hits the network by default; run it deliberately with +//! +//! ```sh +//! ABS_BASE_URL=https://host ABS_API_KEY= \ +//! cargo test -p absdy --test live -- --ignored --nocapture +//! ``` +//! +//! It exercises the real endpoints end-to-end (libraries → items → search → +//! detail) so the `AbsApi` DTOs are confirmed against the live JSON shapes — +//! the drift risk called out in architecture/audiobookshelf-provider.md. + +use std::time::Duration; + +use absdy::api::{Abs, AbsApi}; + +fn creds() -> Option<(String, String)> { + let base = std::env::var("ABS_BASE_URL").ok()?; + let key = std::env::var("ABS_API_KEY").ok()?; + (!base.is_empty() && !key.is_empty()).then_some((base, key)) +} + +#[tokio::test] +#[ignore = "hits a real audiobookshelf server; set ABS_BASE_URL and ABS_API_KEY"] +async fn live_browse_search_and_detail() { + let Some((base, key)) = creds() else { + eprintln!("ABS_BASE_URL / ABS_API_KEY unset — skipping live test"); + return; + }; + let api = AbsApi::new(base, key, Duration::from_secs(30)).expect("client"); + + let libraries = api.libraries().await.expect("libraries decode"); + assert!(!libraries.is_empty(), "server has at least one library"); + let book_lib = libraries + .iter() + .find(|l| l.is_book) + .expect("at least one book library"); + println!("book library: {} ({})", book_lib.name, book_lib.id); + + let items = api + .library_items(&book_lib.id, 5) + .await + .expect("items decode"); + assert!(!items.is_empty(), "book library has items"); + let with_audio = items + .iter() + .find(|b| b.num_audio_files > 0) + .expect("an audiobook item"); + println!( + "item: {} by {} ({} files)", + with_audio.title, with_audio.author, with_audio.num_audio_files + ); + + let detail = api + .item_detail(&with_audio.id) + .await + .expect("detail decode"); + assert!(!detail.tracks.is_empty(), "audiobook has tracks"); + let first = &detail.tracks[0]; + println!("track0: ino={} title={}", first.ino, first.title); + + // The stream URL is fully derivable and carries the token. + let url = api.stream_url(&with_audio.id, &first.ino); + assert!(url.contains(&format!("/api/items/{}/file/{}", with_audio.id, first.ino))); + assert!(url.contains("token=")); + + // Search should decode too (term may legitimately match nothing). + let hits = api + .search_items(&book_lib.id, "a", 3) + .await + .expect("search decode"); + println!("search 'a' -> {} hits", hits.len()); +} diff --git a/crabidy-server/Cargo.toml b/crabidy-server/Cargo.toml index 9858844..31f80ea 100644 --- a/crabidy-server/Cargo.toml +++ b/crabidy-server/Cargo.toml @@ -33,6 +33,7 @@ cbd-cli = { workspace = true, features = ["client"] } crabidy-core.workspace = true dirs.workspace = true flume.workspace = true +absdy.workspace = true fsdy.workspace = true fyyd.workspace = true futures.workspace = true diff --git a/crabidy-server/src/provider.rs b/crabidy-server/src/provider.rs index b8aa1c1..7f4f4ae 100644 --- a/crabidy-server/src/provider.rs +++ b/crabidy-server/src/provider.rs @@ -40,6 +40,10 @@ pub struct ProviderOrchestrator { /// The fyyd podcast provider; `None` when disabled or its client failed /// to build (non-fatal, architecture/fyyd-provider.md D1). fyyd_client: Option>, + /// The audiobookshelf provider; `None` when disabled or its config is + /// missing/incomplete — non-fatal, it only costs the `/abs` subtree + /// (architecture/audiobookshelf-provider.md D1). + abs_client: Option>, } /// Whether a path belongs to the filesystem provider. @@ -68,6 +72,11 @@ fn fyyd_owns(path: &str) -> bool { path == fyyd::PROVIDER_ROOT || path.starts_with("/fyyd/") } +/// Whether a path belongs to the audiobookshelf provider. +fn abs_owns(path: &str) -> bool { + path == absdy::PROVIDER_ROOT || path.starts_with("/abs/") +} + impl ProviderOrchestrator { /// The tidal client, or `MalformedPath` (with a warning) when the /// provider is disabled — a `/tidal` path then has no owner. @@ -127,6 +136,15 @@ impl ProviderOrchestrator { ProviderError::MalformedPath }) } + + /// The audiobookshelf client, or `MalformedPath` (with a warning) when the + /// provider is disabled — an `/abs` path then has no owner. + fn abs_provider(&self) -> Result<&absdy::Client, ProviderError> { + self.abs_client.as_deref().ok_or_else(|| { + warn!("abs provider is disabled"); + ProviderError::MalformedPath + }) + } pub fn run(self) { tokio::spawn(async move { // Behind an Arc so long-running resolves can be spawned onto @@ -418,6 +436,29 @@ impl ProviderOrchestrator { } else { None }; + // audiobookshelf: non-fatal like the other remote providers. Unlike + // fyyd it needs credentials, so a missing or incomplete `abs.toml` + // (no base_url/api_key) disables the provider — it only costs the + // `/abs` subtree (architecture/audiobookshelf-provider.md D1). + let abs_client = if enabled.abs { + let abs_config_file = config_dir.join("abs.toml"); + debug!(config_file = %abs_config_file.display(), "loading abs config"); + let raw_abs_settings = fs::read_to_string(&abs_config_file).unwrap_or_default(); + match absdy::Client::init(&raw_abs_settings).await { + Ok(client) => { + if let Err(err) = tokio::fs::write(&abs_config_file, client.settings()).await { + error!("failed to write abs config file: {err}"); + } + Some(Arc::new(client)) + } + Err(err) => { + warn!("abs provider disabled: {err}"); + None + } + } + } else { + None + }; let (provider_tx, provider_rx) = flume::bounded(100); Ok(Self { provider_rx, @@ -429,6 +470,7 @@ impl ProviderOrchestrator { orphans_client, youtube_client, fyyd_client, + abs_client, }) } } @@ -483,6 +525,12 @@ impl ProviderClient for ProviderOrchestrator { .as_ref() .is_some_and(|fyyd| fyyd.is_track_path(path)); } + if abs_owns(path) { + return self + .abs_client + .as_ref() + .is_some_and(|abs| abs.is_track_path(path)); + } false } @@ -515,6 +563,9 @@ impl ProviderClient for ProviderOrchestrator { if fyyd_owns(track_path) { return self.fyyd_provider()?.get_urls_for_track(track_path).await; } + if abs_owns(track_path) { + return self.abs_provider()?.get_urls_for_track(track_path).await; + } warn!(path = track_path, "no provider owns this track path"); Err(ProviderError::MalformedPath) } @@ -554,6 +605,12 @@ impl ProviderClient for ProviderOrchestrator { .get_metadata_for_track(track_path) .await; } + if abs_owns(track_path) { + return self + .abs_provider()? + .get_metadata_for_track(track_path) + .await; + } warn!(path = track_path, "no provider owns this track path"); Err(ProviderError::MalformedPath) } @@ -596,6 +653,11 @@ impl ProviderClient for ProviderOrchestrator { LibraryNodeChild::new(fyyd::PROVIDER_ROOT.to_owned(), "fyyd".to_owned(), false); root_node.children.push(child); } + if self.abs_client.is_some() { + let child = + LibraryNodeChild::new(absdy::PROVIDER_ROOT.to_owned(), "abs".to_owned(), false); + root_node.children.push(child); + } root_node } @@ -617,6 +679,8 @@ impl ProviderClient for ProviderOrchestrator { self.orphans_provider()?.get_lib_node(path).await? } else if fyyd_owns(path) { self.fyyd_provider()?.get_lib_node(path).await? + } else if abs_owns(path) { + self.abs_provider()?.get_lib_node(path).await? } else { warn!(path, "no provider owns this path"); return Err(ProviderError::MalformedPath); @@ -673,6 +737,12 @@ impl ProviderClient for ProviderOrchestrator { .create_lib_node(parent_path, title) .await; } + if abs_owns(parent_path) { + return self + .abs_provider()? + .create_lib_node(parent_path, title) + .await; + } warn!(parent_path, "no provider supports creating nodes here"); Err(ProviderError::NotSupported) } @@ -715,6 +785,9 @@ impl ProviderClient for ProviderOrchestrator { if fyyd_owns(path) { return self.fyyd_provider()?.rename_lib_node(path, new_title).await; } + if abs_owns(path) { + return self.abs_provider()?.rename_lib_node(path, new_title).await; + } warn!(path, "no provider supports renaming this node"); Err(ProviderError::NotSupported) } @@ -763,6 +836,12 @@ impl ProviderClient for ProviderOrchestrator { .resolve_tracks_into(path, chunk_tx) .await; } + if abs_owns(path) { + return self + .abs_provider()? + .resolve_tracks_into(path, chunk_tx) + .await; + } warn!(path, "no provider owns this path"); Err(ProviderError::MalformedPath) } @@ -789,6 +868,9 @@ impl ProviderClient for ProviderOrchestrator { if fyyd_owns(path) { return self.fyyd_provider()?.delete_lib_node(path).await; } + if abs_owns(path) { + return self.abs_provider()?.delete_lib_node(path).await; + } warn!(path, "no provider supports deleting this node"); Err(ProviderError::NotSupported) } diff --git a/crabidy-server/src/settings.rs b/crabidy-server/src/settings.rs index d636f0c..e476551 100644 --- a/crabidy-server/src/settings.rs +++ b/crabidy-server/src/settings.rs @@ -16,9 +16,12 @@ use serde::{Deserialize, Serialize}; pub const SETTINGS_FILE: &str = "crabidy-server.toml"; /// Every built-in provider, in the order the default config lists them. Each -/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/fs`, `/crabidy`, -/// `/orphans`). `orphans` is a view over the store, so it needs `crabidy`. -pub const ALL_PROVIDERS: [&str; 6] = ["tidal", "youtube", "fyyd", "fs", "crabidy", "orphans"]; +/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/abs`, `/fs`, +/// `/crabidy`, `/orphans`). `orphans` is a view over the store, so it needs +/// `crabidy`. +pub const ALL_PROVIDERS: [&str; 7] = [ + "tidal", "youtube", "fyyd", "abs", "fs", "crabidy", "orphans", +]; /// Contents of `crabidy-server.toml`. #[derive(Debug, Default, Deserialize, Serialize)] @@ -66,6 +69,7 @@ pub struct ProviderToggles { pub tidal: bool, pub youtube: bool, pub fyyd: bool, + pub abs: bool, pub fs: bool, pub crabidy: bool, pub orphans: bool, @@ -79,6 +83,7 @@ impl ProviderToggles { tidal: true, youtube: true, fyyd: true, + abs: true, fs: true, crabidy: true, orphans: true, @@ -185,6 +190,7 @@ impl ServerSettings { tidal: self.provider_enabled("tidal"), youtube: self.provider_enabled("youtube"), fyyd: self.provider_enabled("fyyd"), + abs: self.provider_enabled("abs"), fs: self.provider_enabled("fs"), crabidy: self.provider_enabled("crabidy"), orphans: self.provider_enabled("orphans"), @@ -422,6 +428,24 @@ mod tests { assert_eq!(reloaded.auth.owner.as_deref(), Some("$argon2id$x")); } + #[test] + fn audio_device_round_trips_and_omits_when_unset() { + let dir = TempDir::new().expect("tempdir"); + // Unset: the [audio] table is omitted entirely. + ServerSettings::default().store(dir.path()).expect("store"); + let text = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read"); + assert!(!text.contains("[audio]"), "no empty audio table: {text}"); + // Set: it round-trips. + let mut settings = ServerSettings::default(); + settings.audio.device = Some("plughw:CARD=sndrpihifiberry".to_string()); + settings.store(dir.path()).expect("store"); + let reloaded = ServerSettings::load(dir.path()).expect("reload"); + assert_eq!( + reloaded.audio.device.as_deref(), + Some("plughw:CARD=sndrpihifiberry") + ); + } + #[test] fn a_broken_file_is_a_startup_error_not_an_open_server() { let dir = TempDir::new().expect("tempdir"); diff --git a/plan/audiobookshelf-provider.md b/plan/audiobookshelf-provider.md new file mode 100644 index 0000000..20b4933 --- /dev/null +++ b/plan/audiobookshelf-provider.md @@ -0,0 +1,41 @@ +# Task plan — audiobookshelf provider + +Ordered, verifiable tasks to build `absdy` per +architecture/audiobookshelf-provider.md, satisfying +quality/audiobookshelf-provider.md. All complete. + +- [x] **T1 — Crate skeleton.** New workspace member `absdy` + (`Cargo.toml` mirroring `fyyd`); add to root `members` and + `workspace.dependencies`. *Verify: `cargo build -p absdy`.* +- [x] **T2 — HTTP seam (`api.rs`).** `Abs` trait + (`libraries`/`library_items`/`search_items`/`item_detail`/`stream_url`), + domain types (`Library`/`Book`/`AudioTrack`/`BookDetail`), typed + `FetchError`, and `AbsApi` (bearer auth, per-call timeout, redacting + `Debug`, defensive DTOs). *Gates: G2, G3, G5, G6, G12.* +- [x] **T3 — Path model.** `AbsPath` enum + total `parse_path` with the + reserved-`search` split. *Gate: G7. Tests: + `foreign_and_malformed_paths_are_rejected`.* +- [x] **T4 — Settings + init.** `Settings` (required `base_url`/`api_key`, + bounds), redacting `Debug`, non-fatal `init`. *Gates: G1, G14, G16. Tests: + `settings_*`, `init_requires_base_url_and_api_key`.* +- [x] **T5 — Tree builders.** `get_lib_root` placeholder + async + `get_lib_node` (root→book libraries, library→search+books, book→tracks, + search node/term), the shared book-listing/book-node builders, and the + central download blessing. *Gates: G8, G9, G10, G13. Tests: + `root_lists_*`, `a_library_lists_*`, `a_book_lists_*`, `an_audio_less_*`.* +- [x] **T6 — Search-term store.** Per-library in-memory terms; + `create`/`rename`/`delete` idempotent + implicit recreation. *Gate: G11. + Tests: `search_terms_*`.* +- [x] **T7 — Playback.** `is_track_path`, `get_urls_for_track` (URL built, + no call), `get_metadata_for_track` (pick by ino). *Gates: G12, G13. Tests: + `track_stream_url_*`, `track_metadata_*`.* +- [x] **T8 — Orchestrator + settings wiring.** `abs_client` field, + `abs_owns`/`abs_provider`, build block, root child, all dispatch arms; + `ALL_PROVIDERS`/`ProviderToggles`/`all`/`provider_toggles`. *Gate: G15. + Verify: `cargo build -p crabidy-server`, existing server tests pass.* +- [x] **T9 — Unit tests + gates.** `absdy/src/tests.rs` (FakeApi seam), + `quality/audiobookshelf-provider.md`. *Verify: `cargo test -p absdy`.* +- [x] **T10 — Live validation.** `absdy/tests/live.rs` (`#[ignore]`); + ran against the test server. *Gate: G17.* +- [x] **T11 — Docs.** README `/abs` section (`abs.toml`, `[audio]`-style + config). *Verify: markdownlint.* diff --git a/plan/summary.md b/plan/summary.md index 2aefdc5..2bad772 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -999,3 +999,59 @@ the resolve path and the capture reqwest client follow). Verification: `fyyd` 10 tests + `crabidy-server` 74 lib + 4 integration green; clippy and rustfmt clean on both crates. + +## audiobookshelf provider — `/abs` (2026-07-23) + +New `absdy` crate mounted at `/abs`, browsing/searching/playing audiobooks +from a self-hosted audiobookshelf (ABS) server. Built end-to-end from the +architecture doc; the design was grounded live against the test server +before any code. All plan/audiobookshelf-provider.md tasks (T1–T11) done. + +**Followed the fyyd shape closely** — an `Abs` reqwest seam (`api.rs`) +faked in tests so all 14 provider unit tests run with no network, an +in-memory search-term store, a `library → book → tracks` tree with a +per-library `search` subtree, and the same central "download blessing". + +**Deviations / ABS-specific decisions (vs the fyyd template):** + +- **Credentials, and a secret.** Unlike fyyd, ABS needs a `base_url` + + `api_key`; a missing/incomplete `abs.toml` disables `/abs` non-fatally + (like fyyd/youtube on a failed probe), it does *not* stay fatal like + tidal. The `api_key` and the `?token=` stream URL are secrets: + `Settings` and `AbsApi` have **manual redacting `Debug`**, and the token + is built only in `Abs::stream_url` — never logged, never handed to a + `reqwest` call inside `absdy` (browse auth is a bearer header). Gates + G1–G3. + +- **Playback needs no API call.** A track's stream URL is fully derivable + from its path (`/api/items//file/?token=`), so + `get_urls_for_track` builds it directly. Verified live: `?token=` auth + returns 200 and the endpoint honors HTTP range (206), so the existing + windowed-HTTP player streams it. D4/G12. + +- **Per-library search terms.** ABS search is per-library, so the term + store is keyed by library id (`HashMap>`), not fyyd's + single namespace. The reserved `search` segment splits the search branch + from item ids (UUIDs never equal `search`). + +- **Queueability from the summary.** A book child's queueable flag comes + from the item summary's `numAudioFiles` (no open needed), so ebook-only + items are shown but not queueable/capturable. Root lists only `book` + libraries (podcast libraries out of scope, D6). + +- **`get_lib_root` is a placeholder.** Listing ABS libraries needs a + network call, but the trait's `get_lib_root` is sync; the real `/abs` + root is served by the async `get_lib_node`. The orchestrator only builds + the global-root link from `PROVIDER_ROOT`, so this is invisible. + +**Live validation done (2026-07-23).** `absdy/tests/live.rs` (`#[ignore]`, +env-gated) hit the real server end-to-end — libraries → items → search → +detail — and the `AbsApi` DTOs decode the live JSON with no change needed +(`media.metadata.{title,authorName}`, `media.numAudioFiles`, +`media.tracks[].{ino,title,duration}`, the `{ "book": [ { libraryItem } ] }` +search envelope). Remaining open: a manual audio-playthrough + `W`-capture +smoke test on the running server with a configured `abs.toml`. + +Verification: `absdy` 14 tests + `crabidy-server` 12 (+ existing) + +`crabidy-core` 77 green; clippy and rustfmt clean on `absdy` and +`crabidy-server`. diff --git a/quality/audiobookshelf-provider.md b/quality/audiobookshelf-provider.md new file mode 100644 index 0000000..d094c3c --- /dev/null +++ b/quality/audiobookshelf-provider.md @@ -0,0 +1,86 @@ +# Quality gates — audiobookshelf provider + +Criteria an implementation of `absdy` (architecture/audiobookshelf-provider.md) +must satisfy. Each is pass/fail by reading/reasoning; automated coverage lives +in `absdy/src/tests.rs` (unit, no network) and `absdy/tests/live.rs` (ignored, +real server). + +## Secrets (hard rule — highest priority) + +- [x] **G1 — `api_key` never in `Debug`.** `Settings` and `Client` `Debug` + output redact the key. *(test: `settings_debug_redacts_the_api_key`.)* +- [x] **G2 — The token never reaches a log or error.** The `?token=` stream + URL is built only in `Abs::stream_url` and returned; it is never passed to + `debug!`/`warn!`/`error!`, and never to a `reqwest` call inside `absdy` (so + it cannot appear in a `reqwest` error). Browse URLs (logged at `debug`) + carry auth in the bearer header, not the URL. +- [x] **G3 — `AbsApi::Debug` redacts the token** (manual impl, not derived). + +## Errors and robustness (hard rule: no panics on input/network) + +- [x] **G4 — No panics on bad input or network failures.** All backend + failures map to a typed `ProviderError` (`FetchError` for fetches, + `MalformedPath` for bad paths, `InvalidInput` for empty titles, `Config` for + missing creds). No `unwrap`/`expect`/`panic!` on request, path, or + response data. *(tests: `backend_failures_are_typed_never_panics`, + `foreign_and_malformed_paths_are_rejected`, + `track_metadata_picks_the_file_by_ino`.)* +- [x] **G5 — Defensive decoding.** Wire DTOs use `#[serde(default)]`; missing + fields degrade (empty string / `None` / dropped entry), never error the + whole call. Items/files without an id/ino are dropped, not panicked on. +- [x] **G6 — Timeouts on every external call.** `AbsApi` sets a per-call + `reqwest` timeout from `call_timeout_secs` (default 30). + +## Tree and path contract + +- [x] **G7 — Path parsing is total and reserved-word safe.** Every `/abs/...` + shape maps to a variant or `MalformedPath`; the literal `search` segment + routes to the search branch and never collides with a (UUID) item id. + Empty segments (`/abs//x`) are `MalformedPath`. +- [x] **G8 — Root lists only book libraries.** Podcast libraries are filtered + (D6). *(test: `root_lists_only_book_libraries`.)* +- [x] **G9 — Queueability reflects audio.** A book child is queueable iff + `num_audio_files > 0`; an ebook is shown but not queueable/downloadable; an + audio-less book node is empty and non-queueable. *(tests: + `a_library_lists_search_then_books_with_audio_gating_queueability`, + `an_audio_less_book_is_empty_and_not_queueable`.)* +- [x] **G10 — Download blessing matches the other providers.** A node is + downloadable iff it is queueable or holds tracks; a child is downloadable iff + it is queueable. The `search` child is neither. +- [x] **G11 — Search terms mirror tidal/youtube/fyyd and are per-library.** + `search` is creatable; terms are editable + deletable, deduped, recreated + implicitly on stale paths, isolated per library id, and rename/delete are + idempotent. *(tests: `search_terms_list_books_and_are_per_library`, + `search_terms_rename_and_delete`.)* + +## Playback + +- [x] **G12 — Stream URL is derivable with no API call.** `get_urls_for_track` + builds the URL from the path (item id + ino) plus the token, making no + network request; the direct and search branches yield the same URL. *(test: + `track_stream_url_embeds_the_token_and_path_ids_without_a_call`.)* +- [x] **G13 — Track metadata.** `artist` = author, `album` = book title, + `title` = file title, `duration` = seconds (`None` when absent), + `provider_item_id` = `":"`. *(test: + `a_book_lists_its_files_as_tracks`.)* + +## Wiring and lifecycle + +- [x] **G14 — Non-fatal init.** Missing/incomplete `abs.toml` (no + `base_url`/`api_key`) disables `/abs` with a warning; it never aborts + startup. *(test: `init_requires_base_url_and_api_key`; orchestrator build + block mirrors fyyd.)* +- [x] **G15 — Orchestrator parity.** `abs` is wired at every dispatch point + (`is_track_path`, `get_urls_for_track`, `get_metadata_for_track`, + `get_lib_node`, `create`/`rename`/`delete`, `resolve_tracks_into`, root + child) and in settings (`ALL_PROVIDERS`, `ProviderToggles`, `all`, + `provider_toggles`). +- [x] **G16 — Bounded listings.** `items_per_library` and `search_results` + cap every listing; truncation to the cap is `log`-ged, not silent. + +## Live validation (drift gate) + +- [x] **G17 — DTOs decode the real server.** `absdy/tests/live.rs` + (`#[ignore]`) exercises libraries → items → search → detail against a real + server and confirms the shapes. Verified during implementation against the + provided test server.