//! 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, }