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:
parent
11b2a1bd38
commit
f4309aa327
|
|
@ -2,6 +2,20 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
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]]
|
[[package]]
|
||||||
name = "adler2"
|
name = "adler2"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
|
|
@ -1135,6 +1149,7 @@ dependencies = [
|
||||||
name = "crabidy-server"
|
name = "crabidy-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"absdy",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
|
"absdy",
|
||||||
"audio-player",
|
"audio-player",
|
||||||
"cbd",
|
"cbd",
|
||||||
"cbd-cli",
|
"cbd-cli",
|
||||||
|
|
@ -94,6 +95,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
|
|
||||||
# Local crates
|
# Local crates
|
||||||
|
absdy = { path = "absdy" }
|
||||||
audio-player = { path = "audio-player" }
|
audio-player = { path = "audio-player" }
|
||||||
cbd-cli = { path = "cbd-cli" }
|
cbd-cli = { path = "cbd-cli" }
|
||||||
cbd-tui = { path = "cbd-tui" }
|
cbd-tui = { path = "cbd-tui" }
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ each mounted as a subtree of one library:
|
||||||
├── tidal Tidal streaming (see tidaldy/README.md)
|
├── tidal Tidal streaming (see tidaldy/README.md)
|
||||||
├── youtube YouTube search & playlists (see ytdy/README.md)
|
├── youtube YouTube search & playlists (see ytdy/README.md)
|
||||||
├── fyyd podcast search (see fyyd/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)
|
├── fs a local music folder (see fsdy/README.md)
|
||||||
├── crabidy your saves: queues, bookmarks (`w`), and captures (`W`),
|
├── crabidy your saves: queues, bookmarks (`w`), and captures (`W`),
|
||||||
│ managed by the server (see architecture/crabidy-store.md)
|
│ managed by the server (see architecture/crabidy-store.md)
|
||||||
|
|
@ -56,6 +57,7 @@ filled in.
|
||||||
| `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) |
|
| `tidaly.toml` | Tidal | [tidaldy/README.md](tidaldy/README.md) |
|
||||||
| `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) |
|
| `ytdy.toml` | YouTube | [ytdy/README.md](ytdy/README.md) |
|
||||||
| `fyyd.toml` | podcasts | [fyyd/README.md](fyyd/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) |
|
| `fsdy.toml` | local fs | [fsdy/README.md](fsdy/README.md) |
|
||||||
| `cbd-tui.toml` | `cbd-tui` | below |
|
| `cbd-tui.toml` | `cbd-tui` | below |
|
||||||
| `cbd.toml` | `cbd` | below (same options as `cbd-tui.toml`) |
|
| `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:
|
On first start the server writes this file with every provider enabled:
|
||||||
|
|
||||||
```toml
|
```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
|
**Remove a name to disable that provider** — it no longer mounts and does
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ cbd-cli = { workspace = true, features = ["client"] }
|
||||||
crabidy-core.workspace = true
|
crabidy-core.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
|
absdy.workspace = true
|
||||||
fsdy.workspace = true
|
fsdy.workspace = true
|
||||||
fyyd.workspace = true
|
fyyd.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ pub struct ProviderOrchestrator {
|
||||||
/// The fyyd podcast provider; `None` when disabled or its client failed
|
/// The fyyd podcast provider; `None` when disabled or its client failed
|
||||||
/// to build (non-fatal, architecture/fyyd-provider.md D1).
|
/// to build (non-fatal, architecture/fyyd-provider.md D1).
|
||||||
fyyd_client: Option<Arc<fyyd::Client>>,
|
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.
|
/// 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/")
|
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 {
|
impl ProviderOrchestrator {
|
||||||
/// The tidal client, or `MalformedPath` (with a warning) when the
|
/// The tidal client, or `MalformedPath` (with a warning) when the
|
||||||
/// provider is disabled — a `/tidal` path then has no owner.
|
/// provider is disabled — a `/tidal` path then has no owner.
|
||||||
|
|
@ -127,6 +136,15 @@ impl ProviderOrchestrator {
|
||||||
ProviderError::MalformedPath
|
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) {
|
pub fn run(self) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Behind an Arc so long-running resolves can be spawned onto
|
// Behind an Arc so long-running resolves can be spawned onto
|
||||||
|
|
@ -418,6 +436,29 @@ impl ProviderOrchestrator {
|
||||||
} else {
|
} else {
|
||||||
None
|
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);
|
let (provider_tx, provider_rx) = flume::bounded(100);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
provider_rx,
|
provider_rx,
|
||||||
|
|
@ -429,6 +470,7 @@ impl ProviderOrchestrator {
|
||||||
orphans_client,
|
orphans_client,
|
||||||
youtube_client,
|
youtube_client,
|
||||||
fyyd_client,
|
fyyd_client,
|
||||||
|
abs_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -483,6 +525,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|fyyd| fyyd.is_track_path(path));
|
.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
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -515,6 +563,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if fyyd_owns(track_path) {
|
if fyyd_owns(track_path) {
|
||||||
return self.fyyd_provider()?.get_urls_for_track(track_path).await;
|
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");
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -554,6 +605,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.get_metadata_for_track(track_path)
|
.get_metadata_for_track(track_path)
|
||||||
.await;
|
.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");
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -596,6 +653,11 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
LibraryNodeChild::new(fyyd::PROVIDER_ROOT.to_owned(), "fyyd".to_owned(), false);
|
LibraryNodeChild::new(fyyd::PROVIDER_ROOT.to_owned(), "fyyd".to_owned(), false);
|
||||||
root_node.children.push(child);
|
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
|
root_node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -617,6 +679,8 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
self.orphans_provider()?.get_lib_node(path).await?
|
self.orphans_provider()?.get_lib_node(path).await?
|
||||||
} else if fyyd_owns(path) {
|
} else if fyyd_owns(path) {
|
||||||
self.fyyd_provider()?.get_lib_node(path).await?
|
self.fyyd_provider()?.get_lib_node(path).await?
|
||||||
|
} else if abs_owns(path) {
|
||||||
|
self.abs_provider()?.get_lib_node(path).await?
|
||||||
} else {
|
} else {
|
||||||
warn!(path, "no provider owns this path");
|
warn!(path, "no provider owns this path");
|
||||||
return Err(ProviderError::MalformedPath);
|
return Err(ProviderError::MalformedPath);
|
||||||
|
|
@ -673,6 +737,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.create_lib_node(parent_path, title)
|
.create_lib_node(parent_path, title)
|
||||||
.await;
|
.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");
|
warn!(parent_path, "no provider supports creating nodes here");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
@ -715,6 +785,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if fyyd_owns(path) {
|
if fyyd_owns(path) {
|
||||||
return self.fyyd_provider()?.rename_lib_node(path, new_title).await;
|
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");
|
warn!(path, "no provider supports renaming this node");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
@ -763,6 +836,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.resolve_tracks_into(path, chunk_tx)
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
if abs_owns(path) {
|
||||||
|
return self
|
||||||
|
.abs_provider()?
|
||||||
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path, "no provider owns this path");
|
warn!(path, "no provider owns this path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -789,6 +868,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if fyyd_owns(path) {
|
if fyyd_owns(path) {
|
||||||
return self.fyyd_provider()?.delete_lib_node(path).await;
|
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");
|
warn!(path, "no provider supports deleting this node");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,12 @@ use serde::{Deserialize, Serialize};
|
||||||
pub const SETTINGS_FILE: &str = "crabidy-server.toml";
|
pub const SETTINGS_FILE: &str = "crabidy-server.toml";
|
||||||
|
|
||||||
/// Every built-in provider, in the order the default config lists them. Each
|
/// Every built-in provider, in the order the default config lists them. Each
|
||||||
/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/fs`, `/crabidy`,
|
/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/abs`, `/fs`,
|
||||||
/// `/orphans`). `orphans` is a view over the store, so it needs `crabidy`.
|
/// `/crabidy`, `/orphans`). `orphans` is a view over the store, so it needs
|
||||||
pub const ALL_PROVIDERS: [&str; 6] = ["tidal", "youtube", "fyyd", "fs", "crabidy", "orphans"];
|
/// `crabidy`.
|
||||||
|
pub const ALL_PROVIDERS: [&str; 7] = [
|
||||||
|
"tidal", "youtube", "fyyd", "abs", "fs", "crabidy", "orphans",
|
||||||
|
];
|
||||||
|
|
||||||
/// Contents of `crabidy-server.toml`.
|
/// Contents of `crabidy-server.toml`.
|
||||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
|
|
@ -66,6 +69,7 @@ pub struct ProviderToggles {
|
||||||
pub tidal: bool,
|
pub tidal: bool,
|
||||||
pub youtube: bool,
|
pub youtube: bool,
|
||||||
pub fyyd: bool,
|
pub fyyd: bool,
|
||||||
|
pub abs: bool,
|
||||||
pub fs: bool,
|
pub fs: bool,
|
||||||
pub crabidy: bool,
|
pub crabidy: bool,
|
||||||
pub orphans: bool,
|
pub orphans: bool,
|
||||||
|
|
@ -79,6 +83,7 @@ impl ProviderToggles {
|
||||||
tidal: true,
|
tidal: true,
|
||||||
youtube: true,
|
youtube: true,
|
||||||
fyyd: true,
|
fyyd: true,
|
||||||
|
abs: true,
|
||||||
fs: true,
|
fs: true,
|
||||||
crabidy: true,
|
crabidy: true,
|
||||||
orphans: true,
|
orphans: true,
|
||||||
|
|
@ -185,6 +190,7 @@ impl ServerSettings {
|
||||||
tidal: self.provider_enabled("tidal"),
|
tidal: self.provider_enabled("tidal"),
|
||||||
youtube: self.provider_enabled("youtube"),
|
youtube: self.provider_enabled("youtube"),
|
||||||
fyyd: self.provider_enabled("fyyd"),
|
fyyd: self.provider_enabled("fyyd"),
|
||||||
|
abs: self.provider_enabled("abs"),
|
||||||
fs: self.provider_enabled("fs"),
|
fs: self.provider_enabled("fs"),
|
||||||
crabidy: self.provider_enabled("crabidy"),
|
crabidy: self.provider_enabled("crabidy"),
|
||||||
orphans: self.provider_enabled("orphans"),
|
orphans: self.provider_enabled("orphans"),
|
||||||
|
|
@ -422,6 +428,24 @@ mod tests {
|
||||||
assert_eq!(reloaded.auth.owner.as_deref(), Some("$argon2id$x"));
|
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]
|
#[test]
|
||||||
fn a_broken_file_is_a_startup_error_not_an_open_server() {
|
fn a_broken_file_is_a_startup_error_not_an_open_server() {
|
||||||
let dir = TempDir::new().expect("tempdir");
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
|
|
||||||
|
|
@ -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.*
|
||||||
|
|
@ -999,3 +999,59 @@ the resolve path and the capture reqwest client follow).
|
||||||
|
|
||||||
Verification: `fyyd` 10 tests + `crabidy-server` 74 lib + 4 integration
|
Verification: `fyyd` 10 tests + `crabidy-server` 74 lib + 4 integration
|
||||||
green; clippy and rustfmt clean on both crates.
|
green; clippy and rustfmt clean on both crates.
|
||||||
|
|
||||||
|
## audiobookshelf provider — `/abs` (2026-07-23)
|
||||||
|
|
||||||
|
New `absdy` crate mounted at `/abs`, browsing/searching/playing audiobooks
|
||||||
|
from a self-hosted audiobookshelf (ABS) server. Built end-to-end from the
|
||||||
|
architecture doc; the design was grounded live against the test server
|
||||||
|
before any code. All plan/audiobookshelf-provider.md tasks (T1–T11) done.
|
||||||
|
|
||||||
|
**Followed the fyyd shape closely** — an `Abs` reqwest seam (`api.rs`)
|
||||||
|
faked in tests so all 14 provider unit tests run with no network, an
|
||||||
|
in-memory search-term store, a `library → book → tracks` tree with a
|
||||||
|
per-library `search` subtree, and the same central "download blessing".
|
||||||
|
|
||||||
|
**Deviations / ABS-specific decisions (vs the fyyd template):**
|
||||||
|
|
||||||
|
- **Credentials, and a secret.** Unlike fyyd, ABS needs a `base_url` +
|
||||||
|
`api_key`; a missing/incomplete `abs.toml` disables `/abs` non-fatally
|
||||||
|
(like fyyd/youtube on a failed probe), it does *not* stay fatal like
|
||||||
|
tidal. The `api_key` and the `?token=` stream URL are secrets:
|
||||||
|
`Settings` and `AbsApi` have **manual redacting `Debug`**, and the token
|
||||||
|
is built only in `Abs::stream_url` — never logged, never handed to a
|
||||||
|
`reqwest` call inside `absdy` (browse auth is a bearer header). Gates
|
||||||
|
G1–G3.
|
||||||
|
|
||||||
|
- **Playback needs no API call.** A track's stream URL is fully derivable
|
||||||
|
from its path (`/api/items/<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`.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
Loading…
Reference in New Issue