crabidy/absdy/src/api.rs

366 lines
11 KiB
Rust

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