366 lines
11 KiB
Rust
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,
|
|
}
|