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) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-23 23:22:52 +02:00
parent 11b2a1bd38
commit f4309aa327
15 changed files with 1853 additions and 4 deletions

15
Cargo.lock generated
View File

@ -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",

View File

@ -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" }

View File

@ -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

17
absdy/Cargo.toml Normal file
View File

@ -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"] }

54
absdy/README.md Normal file
View File

@ -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 = "<your audiobookshelf 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
└── <library> one node per "book" library
├── search create a search term with `%`
│ └── <term> books matching the term
│ └── <book> the book's audio files as tracks
└── <book> an audiobook; its files are tracks
└── <file> 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.

365
absdy/src/api.rs Normal file
View File

@ -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 <token>`. 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<u32>,
}
/// 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<AudioTrack>,
}
/// The audiobookshelf operations the provider needs. Behind `Box<dyn Abs>` 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<Vec<Library>, FetchError>;
/// A library's book items (at most `limit`), title-sorted.
async fn library_items(&self, library_id: &str, limit: usize) -> Result<Vec<Book>, 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<Vec<Book>, FetchError>;
/// One book's detail, including its audio files as ordered tracks.
async fn item_detail(&self, item_id: &str) -> Result<BookDetail, FetchError>;
/// 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", &"<redacted>")
.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<Self, FetchError> {
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<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, FetchError> {
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<Vec<Library>, 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<Vec<Book>, 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<Vec<Book>, 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<BookDetail, FetchError> {
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<LibraryDto>,
}
#[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<Library> {
(!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<ItemDto>,
}
#[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<TrackDto>,
}
#[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<Book> {
(!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<AudioTrack> {
(!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<SearchHit>,
}
#[derive(Debug, Deserialize)]
struct SearchHit {
#[serde(rename = "libraryItem")]
library_item: ItemDto,
}

651
absdy/src/lib.rs Normal file
View File

@ -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<String>,
/// The API key (bearer token). Required — without it the provider is
/// disabled. **Secret**: redacted from `Debug`, never logged.
pub api_key: Option<String>,
/// Items listed per library. Default [`DEFAULT_ITEMS_PER_LIBRARY`].
pub items_per_library: Option<usize>,
/// Books listed per search term. Default [`DEFAULT_SEARCH_RESULTS`].
pub search_results: Option<usize>,
/// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`].
pub call_timeout_secs: Option<u64>,
}
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(|_| "<redacted>"))
.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<AbsPath<'_>, 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<dyn Abs>,
settings: Settings,
/// Search terms created under `/abs/<lib>/search`, keyed by library id, in
/// creation order, deduplicated. In-memory only. Never held across awaits.
search_terms: RwLock<HashMap<String, Vec<String>>>,
}
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<dyn Abs>, 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<LibraryNodeChild>,
) -> 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<LibraryNode, ProviderError> {
let detail = self
.api
.item_detail(item_id)
.await
.map_err(|err| fetch_err("item detail", err))?;
let tracks: Vec<Track> = 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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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/<lib>/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<String> {
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<Self, ProviderError> {
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<Vec<String>, 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<Track, ProviderError> {
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<LibraryNode, ProviderError> {
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/<lib>/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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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");
}

380
absdy/src/tests.rs Normal file
View File

@ -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>,
/// library id → books.
items: HashMap<String, Vec<Book>>,
/// (library id, term) → books.
searches: HashMap<(String, String), Vec<Book>>,
/// item id → detail.
details: HashMap<String, BookDetail>,
base_url: String,
token: String,
}
#[async_trait]
impl Abs for FakeApi {
async fn libraries(&self) -> Result<Vec<Library>, FetchError> {
Ok(self.libraries.clone())
}
async fn library_items(&self, library_id: &str, limit: usize) -> Result<Vec<Book>, 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<Vec<Book>, 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<BookDetail, FetchError> {
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<u32>) -> 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("<redacted>"));
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());
}

73
absdy/tests/live.rs Normal file
View File

@ -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=<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());
}

View File

@ -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

View File

@ -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<Arc<fyyd::Client>>,
/// 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<Arc<absdy::Client>>,
}
/// 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)
}

View File

@ -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");

View File

@ -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.*

View File

@ -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 (T1T11) 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
G1G3.
- **Playback needs no API call.** A track's stream URL is fully derivable
from its path (`/api/items/<item>/file/<ino>?token=<key>`), 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<lib, Vec<String>>`), 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`.

View File

@ -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` = `"<item>:<ino>"`. *(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.