diff --git a/Cargo.lock b/Cargo.lock index 8cc5034..32f0645 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1169,6 +1169,7 @@ dependencies = [ "fyyd", "http", "include_dir", + "jamendody", "rand 0.10.2", "realfft", "reqwest 0.13.1", @@ -2438,6 +2439,21 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jamendody" +version = "0.1.0" +dependencies = [ + "async-trait", + "crabidy-core", + "reqwest 0.13.1", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "toml", + "tracing", +] + [[package]] name = "jni" version = "0.21.1" diff --git a/Cargo.toml b/Cargo.toml index 1bba1af..cf41763 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crabidy-server", "fsdy", "fyyd", + "jamendody", "soundclouddy", "tidaldy", "ytdy", @@ -109,6 +110,7 @@ crabidy-core = { path = "crabidy-core" } crabidy-server = { path = "crabidy-server" } fsdy = { path = "fsdy" } fyyd = { path = "fyyd" } +jamendody = { path = "jamendody" } soundclouddy = { path = "soundclouddy" } tidaldy = { path = "tidaldy" } ytdy = { path = "ytdy" } diff --git a/architecture/jamendo-provider.md b/architecture/jamendo-provider.md new file mode 100644 index 0000000..5037bc1 --- /dev/null +++ b/architecture/jamendo-provider.md @@ -0,0 +1,260 @@ +# jamendo provider (free / Creative-Commons music) + +## Context and problem statement + +A new library provider mounted at `/jamendo` that lets a user **search and +play** the Jamendo catalogue — hundreds of thousands of Creative-Commons +tracks — and browse the album a track belongs to, with **capture/download** +coming for free. + +Jamendo is a **remote, search-driven** streaming service like +tidal/soundcloud/fyyd, but it is the *easy* one, and the design leans on that: + +- **It has a real, stable, official API** (`api.jamendo.com/v3.0`). Unlike + SoundCloud there is **no `client_id` scraping and no rotation**: the developer + registers a `client_id` once at `devportal.jamendo.com` and drops it in + `jamendo.toml`. Every request just carries `client_id` + `format=json`. +- **The public catalogue needs no login.** Browsing and streaming are anonymous + with only a `client_id`; OAuth 2.0 exists solely for a user's own account + (favourites, personal playlists) and is **out of scope for v1**. So there is + no token lifecycle, no optional-login branch — the whole provider is one flat + public surface. +- **Tracks stream as a plain MP3 URL.** Each track object carries an `audio` + field that is a direct, range-streamable MP3 (`mp3d.jamendo.com/...`). crabidy + streams a single byte source over its existing windowed-HTTP path, so — + unlike SoundCloud's HLS work — **there is no `audio-player` change at all**. + This is the `absdy` shape: nodes serve tracks, `get_urls_for_track` returns a + URL, the existing MP3 (symphonia) decoder handles the rest. +- **Duration is already in seconds**, which is exactly the unit + `Track.duration` carries (soundclouddy divides its ms by 1000 at + `lib.rs:436`; absdy passes seconds straight through). No conversion, no + repeat of the web ms/seconds bug. +- **Captures** (`W`, download) come for free once nodes serve tracks and raise + `is_downloadable`: Jamendo tracks carry an `audiodownload` URL and the + content is CC-licensed and explicitly downloadable. No wire or TUI work. + +## The Jamendo API (grounding) + +Base `https://api.jamendo.com/v3.0`. **Every** request carries +`client_id=&format=json` (omitted from the cells below). List calls add +`&limit=&offset=`; `limit` max is **200** (default 10). Endpoints we use: + +| Purpose | Endpoint | +| --- | --- | +| Search tracks | `GET /tracks?search=` (or `namesearch`, `tags`) | +| Track detail (stream URL) | `GET /tracks?id=` | +| Search albums | `GET /albums?namesearch=` | +| Album's tracks | `GET /albums/tracks?id=` | + +Objects (fields we read): + +- **track**: `id` (numeric string), `name` (→ title), `artist_name`, + `album_name`, `duration` (**seconds**), `audio` (direct streaming MP3 URL), + `audiodownload` (download URL), `license_ccurl`. `audioformat=mp32` requests + the higher-bitrate stream (default is a low-bitrate `mp31`). +- **album**: `id`, `name`, `artist_name`; `/albums/tracks` returns the album + wrapping a `tracks[]` array of the same track shape. + +Search parameters: `search` (free text across track/album/artist/tags), +`namesearch` (name match), `tags` (AND) / `fuzzytags` (fuzzy OR), +`order` (relevance, popularity, downloads, listens, releasedate, …). v1 uses +`search` with the default relevance order. + +## Assumptions (decided here) + +- The captures / creatable / editable / deletable TUI + wire flows are + provider-agnostic (proven by `/youtube`, `/fyyd`, `/abs`, `/soundcloud`): + a search-term provider costs **no proto, wire, TUI, or `ProviderCommand` + change**. It is a pure path-prefix subtree. +- The Jamendo `audio` URL is a real streaming MP3 the existing windowed-HTTP + source plays unmodified — **no HLS, no new `audio-player` component**. (Risk + R1 gates this with a live play.) +- A missing / malformed / rejected `client_id` must **never crash startup or a + browse**. With no `client_id` the `/jamendo` subtree is simply **not + mounted** (like `/abs` with missing config); a `client_id` that the API later + rejects surfaces a typed error and degrades the subtree, never the app. +- `client_id` is a semi-secret account key: **redact it from `Debug`, config + dumps, and logs**, and never log a built stream URL (they can embed a signed + `from` token). (Hard rule: redact secrets.) +- Jamendo ids are numeric and URL-safe; only user-typed **search terms** are + percent-encoded into a path segment. + +## Decisions + +### D1 — Crate `jamendody`, mounted at `/jamendo`, non-fatal init + +New workspace crate `jamendody` implementing `ProviderClient`, shaped on +`absdy`/`soundclouddy` (remote, search-driven, plain leaf tracks with direct +URLs). Wired into `ProviderOrchestrator` with a +`jamendo_client: Option>` field, `jamendo_owns()` / +`jamendo_provider()` helpers, a `build()` block that reads `jamendo.toml` +(non-fatal — absent or `client_id`-less ⇒ `None`), a `get_lib_root` child gated +on `self.jamendo_client.is_some()`, and one routing arm in each dispatch method. +`crabidy-server` settings gain `jamendo` in `ALL_PROVIDERS` (8 → 9), in +`ProviderToggles`, in the defaults, and in `provider_toggles()`. No +`cli.rs` / `main.rs` change. + +### D2 — HTTP behind a trait, faked in tests + +All network access goes through one seam — a `Jam` trait +(`search_tracks`, `search_albums`, `album_tracks`, `track_detail`) behind +`Box` — with a `reqwest`-based `JamApi` for production and a `FakeApi` +in tests (as `absdy` hides `reqwest` behind `Abs`, `soundclouddy` behind `Sc`). +Provider logic (tree shaping, path parsing, term store) is unit-tested with zero +network. Errors map to `ProviderError::FetchError` at the boundary; malformed +paths → `MalformedPath`; empty create/rename → `InvalidInput`. Every call is +bounded by `call_timeout_secs` (D5, hard rule: timeouts on external calls). + +### D3 — Tree shape (search → tracks + albums; canonical leaves) + +Track and album ids are both numeric, so canonical paths are **type-tagged** to +disambiguate: `track/` (leaf) and `album/` (container). Browse nodes +point their children at these canonical paths, so playback and album expansion +never depend on the branch they were reached through. + +- `/jamendo` — child: `search` (creatable). Not itself queueable. +- `/jamendo/search` — `is_creatable`; children are the in-memory search terms + (`RwLock>`, dedup), each `is_editable` + `is_deletable`, exactly + like the tidal/youtube/soundcloud search stores. +- `/jamendo/search/` — the results: matching **tracks** as queueable + leaves (pointing at `/jamendo/track/`) and matching **albums** as + queueable containers (pointing at `/jamendo/album/`). +- `/jamendo/album/` — the album's tracks (queueable, downloadable); the + canonical container path. +- `/jamendo/track/` — the canonical **track leaf**. A track id alone + resolves a stream, so every branch's track children point here and playback + needs no browse context. + +Terms are percent-encoded into one segment (`encode_segment` / `decode_segment`, +shared helpers already used by the other search providers); numeric ids are +already URL-safe. + +### D4 — Playback: direct MP3, no player change + +`get_urls_for_track(/jamendo/track/)`: `GET /tracks?id=&audioformat=…`, +read `audio`, and **return it as `urls[0]`** (the player consumes only the +first). One API round-trip because the URL can embed a signed token; a track +with no `audio` (unstreamable) surfaces `NotStreamable`/`NotFound` and is +skipped, never a crash. The URL is a normal HTTP MP3 → the existing +`WindowedHttpStream` + symphonia MP3 decoder play it unchanged; `open_source` +routing is untouched. Track fields: `title` = `name`, `artist` = `artist_name`, +`album` = `Album { title: album_name }` when present else `None`, +`duration` = `duration` seconds → `Option` (filtered `> 0`), +`provider_item_id` = `"track:"` (keys the capture store), and nodes/tracks +raise `is_downloadable` (backed by `audiodownload`). + +### D5 — Auth and bounds + +- `Settings`: `client_id: String` (**required** — without it the provider does + not mount), `audioformat: Option` (default `mp32`), + `search_results: usize` (default 50, capped at the API's 200), + `album_tracks_limit: usize` (default 200), `call_timeout_secs: u64` + (default 30). Hand-written `Debug` redacts `client_id`. +- No scraping, no OAuth, no token refresh in v1 — the `client_id` is read once + from config and used on every call. An API `401`/`403` (revoked/invalid key) + maps to a typed `FetchError`; the subtree degrades, the app survives. +- Caps are `log`-ged when they truncate a listing, so truncation is visible, + not silent (hard rule: no silent caps). Listings are fetched fresh per call + (no cross-call cache), like the other remote providers; only search terms are + held in memory. + +### D6 — Out of scope (explicitly) + +- **OAuth user features**: personal favourites, a user's own playlists, and + writing to a Jamendo account. Additive later behind an optional token, + mirroring the soundcloud "login optional" branch. +- **Tag / genre / popular / radio browse** and **artist browse**: v1 is + search-driven (`search` → tracks + albums). Tag and popularity browse nodes + are a clean phase-2 add (same DTOs, new root children). +- **Pagination past the configured caps** (one page per listing). +- **Download-format negotiation** beyond the single `audioformat` setting. + +## Structure + +```d2 +direction: right + +server: crabidy-server { + orch: ProviderOrchestrator +} + +jam: "jamendody (crate)" { + client: "Client\n(ProviderClient)" + terms: "search terms\n(in-memory RwLock)" + api: "JamApi\n(reqwest seam: Jam trait)" + client -> terms + client -> api +} + +player: "audio-player" { + http: "WindowedHttpStream\n(existing, unchanged)" + dec: "rodio / symphonia (mp3)" + http -> dec: "mp3 bytes" +} + +japi: "Jamendo api.jamendo.com/v3.0" { shape: cloud } +cdn: "mp3d.jamendo.com (MP3)" { shape: cloud } + +server.orch -> jam.client: "/jamendo/..." +jam.api -> japi: "search / tracks / albums (JSON, client_id, timeout)" +server.orch -> player.http: "audio MP3 URL" +player.http -> cdn: "GET mp3 (range)" +``` + +## Key flow: search and play a track + +```d2 +shape: sequence_diagram +tui: TUI +orch: Orchestrator +j: jamendody +api: "Jamendo api-v3" +http: "WindowedHttpStream" +cdn: "mp3d.jamendo.com" + +tui -> orch: "open /jamendo/search" +tui -> orch: "create term \"lofi piano\"" +orch -> j: "create_lib_node(search, term)" +j -> tui: "term stored" +tui -> orch: "open /jamendo/search/" +orch -> j: "get_lib_node" +j -> api: "GET /tracks?search=…&client_id=" +api -> j: "tracks (id, name, artist, album, duration s, audio)" +j -> tui: "tracks as leaves (/jamendo/track/) + albums" +tui -> orch: "queue + play a track" +orch -> j: "get_urls_for_track(/jamendo/track/)" +j -> api: "GET /tracks?id= → read audio URL" +j -> orch: "urls = [ audio ]" +orch -> http: "player.play(audio)" +http -> cdn: "GET mp3 (range) → symphonia decodes" +``` + +## Boundaries / interfaces + +- **Inbound**: `ProviderClient` (crabidy-core) — the orchestrator dispatches + `/jamendo/...` paths here. No new trait methods; search-term semantics reuse + `create_lib_node` / `rename_lib_node` / `delete_lib_node`. +- **Outbound**: the `Jam` trait (network seam) — the only place `reqwest` and + the `client_id` live. Everything above it is pure and unit-tested. +- **Config**: `jamendo.toml` (`client_id`, optional bounds), round-tripped via + `settings()`; wired through `crabidy-server` settings like every provider. + +## Risks and open questions + +- **R1 — `audio` URL plays on the windowed-HTTP path.** The whole + "no player change" claim rests on the `audio` MP3 streaming cleanly (range + requests, clean EOS, correct duration from metadata). **Live-test gate**: play + a Jamendo track end-to-end and confirm no panic, correct seek bar, clean EOS. + If a signed URL turns out non-range or short-lived, the fallback is the same + as `/youtube`: resolve-just-before-play and treat a stale URL as a skipped + track. +- **R2 — `audioformat` availability.** `mp32` may not exist for every track; + decode defensively and fall back to whatever `audio` the listing returned + (the `audio` field already reflects the requested format or the default). +- **R3 — field / envelope drift.** DTOs decode defensively + (`#[serde(default)]`, ids as strings); a renamed field is a local fix in + `JamApi`. Live validation is a task-plan gate. +- **R4 — `client_id` validity at startup.** We do not verify the key at init + (no blocking network in `build()`); the first browse reveals a bad key as a + typed `FetchError`. Acceptable — matches how the other remote providers fail + lazily rather than at boot. diff --git a/crabidy-server/Cargo.toml b/crabidy-server/Cargo.toml index 497f2da..fadc020 100644 --- a/crabidy-server/Cargo.toml +++ b/crabidy-server/Cargo.toml @@ -36,6 +36,7 @@ flume.workspace = true absdy.workspace = true fsdy.workspace = true fyyd.workspace = true +jamendody.workspace = true futures.workspace = true rand.workspace = true reqwest.workspace = true diff --git a/crabidy-server/src/provider.rs b/crabidy-server/src/provider.rs index 968ff8d..11acda3 100644 --- a/crabidy-server/src/provider.rs +++ b/crabidy-server/src/provider.rs @@ -48,6 +48,10 @@ pub struct ProviderOrchestrator { /// obtained — non-fatal, it only costs the `/soundcloud` subtree /// (architecture/soundcloud-provider.md D1). sc_client: Option>, + /// The Jamendo provider; `None` when disabled or no `client_id` is + /// configured — non-fatal, it only costs the `/jamendo` subtree + /// (architecture/jamendo-provider.md D1). + jamendo_client: Option>, } /// Whether a path belongs to the filesystem provider. @@ -86,6 +90,11 @@ fn sc_owns(path: &str) -> bool { path == soundclouddy::PROVIDER_ROOT || path.starts_with("/soundcloud/") } +/// Whether a path belongs to the Jamendo provider. +fn jamendo_owns(path: &str) -> bool { + path == jamendody::PROVIDER_ROOT || path.starts_with("/jamendo/") +} + impl ProviderOrchestrator { /// The tidal client, or `MalformedPath` (with a warning) when the /// provider is disabled — a `/tidal` path then has no owner. @@ -163,6 +172,15 @@ impl ProviderOrchestrator { ProviderError::MalformedPath }) } + + /// The Jamendo client, or `MalformedPath` (with a warning) when the + /// provider is disabled — a `/jamendo` path then has no owner. + fn jamendo_provider(&self) -> Result<&jamendody::Client, ProviderError> { + self.jamendo_client.as_deref().ok_or_else(|| { + warn!("jamendo provider is disabled"); + ProviderError::MalformedPath + }) + } pub fn run(self) { tokio::spawn(async move { // Behind an Arc so long-running resolves can be spawned onto @@ -500,6 +518,30 @@ impl ProviderOrchestrator { } else { None }; + // Jamendo: non-fatal. Needs a registered `client_id`; a missing or + // empty `jamendo.toml` disables the provider — it only costs the + // `/jamendo` subtree (architecture/jamendo-provider.md D1). + let jamendo_client = if enabled.jamendo { + let jamendo_config_file = config_dir.join("jamendo.toml"); + debug!(config_file = %jamendo_config_file.display(), "loading jamendo config"); + let raw_jamendo_settings = fs::read_to_string(&jamendo_config_file).unwrap_or_default(); + match jamendody::Client::init(&raw_jamendo_settings).await { + Ok(client) => { + if let Err(err) = + tokio::fs::write(&jamendo_config_file, client.settings()).await + { + error!("failed to write jamendo config file: {err}"); + } + Some(Arc::new(client)) + } + Err(err) => { + warn!("jamendo provider disabled: {err}"); + None + } + } + } else { + None + }; let (provider_tx, provider_rx) = flume::bounded(100); Ok(Self { provider_rx, @@ -513,6 +555,7 @@ impl ProviderOrchestrator { fyyd_client, abs_client, sc_client, + jamendo_client, }) } } @@ -579,6 +622,12 @@ impl ProviderClient for ProviderOrchestrator { .as_ref() .is_some_and(|sc| sc.is_track_path(path)); } + if jamendo_owns(path) { + return self + .jamendo_client + .as_ref() + .is_some_and(|jamendo| jamendo.is_track_path(path)); + } false } @@ -617,6 +666,12 @@ impl ProviderClient for ProviderOrchestrator { if sc_owns(track_path) { return self.sc_provider()?.get_urls_for_track(track_path).await; } + if jamendo_owns(track_path) { + return self + .jamendo_provider()? + .get_urls_for_track(track_path) + .await; + } warn!(path = track_path, "no provider owns this track path"); Err(ProviderError::MalformedPath) } @@ -665,6 +720,12 @@ impl ProviderClient for ProviderOrchestrator { if sc_owns(track_path) { return self.sc_provider()?.get_metadata_for_track(track_path).await; } + if jamendo_owns(track_path) { + return self + .jamendo_provider()? + .get_metadata_for_track(track_path) + .await; + } warn!(path = track_path, "no provider owns this track path"); Err(ProviderError::MalformedPath) } @@ -720,6 +781,14 @@ impl ProviderClient for ProviderOrchestrator { ); root_node.children.push(child); } + if self.jamendo_client.is_some() { + let child = LibraryNodeChild::new( + jamendody::PROVIDER_ROOT.to_owned(), + "jamendo".to_owned(), + false, + ); + root_node.children.push(child); + } root_node } @@ -745,6 +814,8 @@ impl ProviderClient for ProviderOrchestrator { self.abs_provider()?.get_lib_node(path).await? } else if sc_owns(path) { self.sc_provider()?.get_lib_node(path).await? + } else if jamendo_owns(path) { + self.jamendo_provider()?.get_lib_node(path).await? } else { warn!(path, "no provider owns this path"); return Err(ProviderError::MalformedPath); @@ -813,6 +884,12 @@ impl ProviderClient for ProviderOrchestrator { .create_lib_node(parent_path, title) .await; } + if jamendo_owns(parent_path) { + return self + .jamendo_provider()? + .create_lib_node(parent_path, title) + .await; + } warn!(parent_path, "no provider supports creating nodes here"); Err(ProviderError::NotSupported) } @@ -861,6 +938,12 @@ impl ProviderClient for ProviderOrchestrator { if sc_owns(path) { return self.sc_provider()?.rename_lib_node(path, new_title).await; } + if jamendo_owns(path) { + return self + .jamendo_provider()? + .rename_lib_node(path, new_title) + .await; + } warn!(path, "no provider supports renaming this node"); Err(ProviderError::NotSupported) } @@ -921,6 +1004,12 @@ impl ProviderClient for ProviderOrchestrator { .resolve_tracks_into(path, chunk_tx) .await; } + if jamendo_owns(path) { + return self + .jamendo_provider()? + .resolve_tracks_into(path, chunk_tx) + .await; + } warn!(path, "no provider owns this path"); Err(ProviderError::MalformedPath) } @@ -953,6 +1042,9 @@ impl ProviderClient for ProviderOrchestrator { if sc_owns(path) { return self.sc_provider()?.delete_lib_node(path).await; } + if jamendo_owns(path) { + return self.jamendo_provider()?.delete_lib_node(path).await; + } warn!(path, "no provider supports deleting this node"); Err(ProviderError::NotSupported) } diff --git a/crabidy-server/src/settings.rs b/crabidy-server/src/settings.rs index 6d13d1f..d877d86 100644 --- a/crabidy-server/src/settings.rs +++ b/crabidy-server/src/settings.rs @@ -19,12 +19,13 @@ pub const SETTINGS_FILE: &str = "crabidy-server.toml"; /// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/abs`, /// `/soundcloud`, `/fs`, `/crabidy`, `/orphans`). `orphans` is a view over the /// store, so it needs `crabidy`. -pub const ALL_PROVIDERS: [&str; 8] = [ +pub const ALL_PROVIDERS: [&str; 9] = [ "tidal", "youtube", "fyyd", "abs", "soundcloud", + "jamendo", "fs", "crabidy", "orphans", @@ -78,6 +79,7 @@ pub struct ProviderToggles { pub fyyd: bool, pub abs: bool, pub soundcloud: bool, + pub jamendo: bool, pub fs: bool, pub crabidy: bool, pub orphans: bool, @@ -93,6 +95,7 @@ impl ProviderToggles { fyyd: true, abs: true, soundcloud: true, + jamendo: true, fs: true, crabidy: true, orphans: true, @@ -201,6 +204,7 @@ impl ServerSettings { fyyd: self.provider_enabled("fyyd"), abs: self.provider_enabled("abs"), soundcloud: self.provider_enabled("soundcloud"), + jamendo: self.provider_enabled("jamendo"), fs: self.provider_enabled("fs"), crabidy: self.provider_enabled("crabidy"), orphans: self.provider_enabled("orphans"), diff --git a/jamendody/Cargo.toml b/jamendody/Cargo.toml new file mode 100644 index 0000000..a2c13c5 --- /dev/null +++ b/jamendody/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "jamendody" +version.workspace = true +edition.workspace = true + +[dependencies] +async-trait.workspace = true +crabidy-core = { path = "../crabidy-core" } +reqwest.workspace = true +serde.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["time", "sync"] } +toml.workspace = true +tracing.workspace = true + +[dev-dependencies] +serde_json.workspace = true +tokio = { workspace = true, features = ["full"] } diff --git a/jamendody/src/api.rs b/jamendody/src/api.rs new file mode 100644 index 0000000..a58fce6 --- /dev/null +++ b/jamendody/src/api.rs @@ -0,0 +1,414 @@ +//! The Jamendo HTTP seam. +//! +//! All network access goes through the [`Jam`] trait so the provider's +//! tree/path logic is unit-tested with a fake and no network +//! (architecture/jamendo-provider.md D2). [`JamApi`] is the production +//! `reqwest` implementation; tests supply their own [`Jam`]. +//! +//! One semi-secret lives here: the `client_id` (the registered app key). It is +//! redacted from `Debug` and never logged, and a resolved `audio` URL (which +//! can embed a signed token) is likewise never logged. Unlike SoundCloud there +//! is no scraping or rotation — the `client_id` comes from config and is used +//! verbatim on every call. + +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::warn; + +/// The Jamendo API v3.0 base. Every request also carries `client_id`+`format`. +const API_BASE: &str = "https://api.jamendo.com/v3.0"; +/// Jamendo's hard cap on `limit` for a list request. +const MAX_LIMIT: usize = 200; + +/// A typed Jamendo request failure. Carries only non-secret context — never the +/// `client_id` or a signed `audio` URL. +#[derive(Debug, Error)] +pub enum FetchError { + /// The HTTP call failed (transport, timeout, or non-success status), or the + /// API envelope reported a non-`success` status. + #[error("jamendo request failed: {0}")] + Http(String), + /// The response body did not decode into the expected shape. + #[error("jamendo returned malformed data: {0}")] + Decode(String), + /// The resource does not exist (empty result set or HTTP 404). + #[error("jamendo resource not found")] + NotFound, + /// Auth was rejected — a revoked/invalid `client_id`. + #[error("jamendo authentication failed")] + Unauthorized, + /// The track exposes no playable `audio` URL. + #[error("jamendo track is not streamable")] + NotStreamable, +} + +/// A Jamendo track. `id` is a numeric string (URL-safe). `duration_secs` is the +/// track length **in seconds** — already the unit [`crabidy_core`]'s wire +/// `Track.duration` carries, so the provider passes it through unchanged. The +/// domain type never carries the stream URL (resolved separately via +/// [`Jam::track_stream`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JamTrack { + pub id: String, + pub title: String, + /// The performing artist (`artist_name`). + pub artist: String, + /// Album title (`album_name`), when the object carries one. + pub album: Option, + /// Track length in seconds. + pub duration_secs: Option, +} + +/// A Jamendo album. `tracks` is populated by [`Jam::album_tracks`] and empty for +/// an album returned from a search listing (id/title/artist only). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JamAlbum { + pub id: String, + pub title: String, + pub artist: String, + pub tracks: Vec, +} + +/// The Jamendo operations the provider needs. Behind `Box` so tests +/// fake it (architecture/jamendo-provider.md D2). Every call is bounded by the +/// [`JamApi`] timeout; `limit` is clamped to the API maximum. +#[async_trait] +pub trait Jam: Debug + Send + Sync { + /// Tracks matching a free-text query (at most `limit`). + async fn search_tracks(&self, query: &str, limit: usize) -> Result, FetchError>; + + /// Albums matching a free-text query (at most `limit`); their `tracks` are + /// left empty (the container is expanded lazily via [`Self::album_tracks`]). + async fn search_albums(&self, query: &str, limit: usize) -> Result, FetchError>; + + /// One album's tracks by id (at most `limit` tracks), in album order. + async fn album_tracks(&self, id: &str, limit: usize) -> Result; + + /// One track's metadata by id. [`FetchError::NotFound`] when absent. + async fn track_detail(&self, id: &str) -> Result; + + /// Resolves a track id to its playable `audio` URL. The URL is ephemeral and + /// possibly signed — never logged. [`FetchError::NotStreamable`] when the + /// track has no `audio`. + async fn track_stream(&self, id: &str) -> Result; +} + +/// Production `reqwest` client for `api.jamendo.com/v3.0`. `Debug` redacts the +/// `client_id` (hard rule: redact secrets from logs/reports). +pub struct JamApi { + http: reqwest::Client, + /// The registered app key. **Secret** — redacted from `Debug`, never logged. + client_id: String, + /// Requested audio format (e.g. `mp32`); selects the bitrate of `audio`. + audioformat: String, +} + +impl Debug for JamApi { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("JamApi") + .field("client_id", &"") + .field("audioformat", &self.audioformat) + .finish_non_exhaustive() + } +} + +impl JamApi { + /// Builds the client. `timeout` bounds every call (hard rule: timeouts on + /// external calls). `audioformat` defaults are applied by the caller. + pub fn new( + client_id: String, + audioformat: String, + timeout: Duration, + ) -> Result { + let http = reqwest::Client::builder() + .timeout(timeout) + .build() + .map_err(|err| FetchError::Http(err.to_string()))?; + Ok(Self { + http, + client_id, + audioformat, + }) + } + + /// Signed GET of `path` under the API base with the given query pairs, + /// decoding the Jamendo `{headers, results}` envelope. A non-`success` + /// envelope status or a 401/403/404 maps to a typed [`FetchError`]. + async fn get( + &self, + path: &str, + query: &[(&str, String)], + ) -> Result, FetchError> { + let mut req = self + .http + .get(format!("{API_BASE}/{path}")) + .query(&[("client_id", self.client_id.as_str()), ("format", "json")]); + if !query.is_empty() { + req = req.query(query); + } + let resp = req + .send() + .await + .map_err(|err| FetchError::Http(err.without_url().to_string()))?; + match resp.status() { + s if s.is_success() => {} + reqwest::StatusCode::NOT_FOUND => return Err(FetchError::NotFound), + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => { + return Err(FetchError::Unauthorized) + } + status => return Err(FetchError::Http(format!("status {status}"))), + } + let envelope: Envelope = resp + .json() + .await + .map_err(|err| FetchError::Decode(err.to_string()))?; + if !envelope.headers.status.eq_ignore_ascii_case("success") { + // Jamendo returns HTTP 200 with the error in the envelope; a bad + // client_id lands here. Never carries the id itself. + let msg = envelope.headers.error_message; + if msg.to_ascii_lowercase().contains("author") + || msg.to_ascii_lowercase().contains("client") + { + return Err(FetchError::Unauthorized); + } + return Err(FetchError::Http(if msg.is_empty() { + "non-success response".to_string() + } else { + msg + })); + } + Ok(envelope.results) + } + + /// Clamps a caller limit to Jamendo's maximum (logs when it truncates). + fn clamp_limit(limit: usize) -> usize { + if limit > MAX_LIMIT { + warn!( + limit, + cap = MAX_LIMIT, + "jamendo limit clamped to API maximum" + ); + MAX_LIMIT + } else { + limit + } + } +} + +#[async_trait] +impl Jam for JamApi { + async fn search_tracks(&self, query: &str, limit: usize) -> Result, FetchError> { + let dtos: Vec = self + .get( + "tracks", + &[ + ("search", query.to_string()), + ("limit", Self::clamp_limit(limit).to_string()), + ("audioformat", self.audioformat.clone()), + ], + ) + .await?; + Ok(dtos.into_iter().filter_map(TrackDto::into_track).collect()) + } + + async fn search_albums(&self, query: &str, limit: usize) -> Result, FetchError> { + let dtos: Vec = self + .get( + "albums", + &[ + ("namesearch", query.to_string()), + ("limit", Self::clamp_limit(limit).to_string()), + ], + ) + .await?; + Ok(dtos.into_iter().filter_map(AlbumDto::into_album).collect()) + } + + async fn album_tracks(&self, id: &str, limit: usize) -> Result { + let dtos: Vec = self + .get( + "albums/tracks", + &[ + ("id", id.to_string()), + ("limit", "1".to_string()), + ("audioformat", self.audioformat.clone()), + ], + ) + .await?; + let mut album = dtos + .into_iter() + .find_map(AlbumDto::into_album) + .ok_or(FetchError::NotFound)?; + album.tracks.truncate(Self::clamp_limit(limit)); + Ok(album) + } + + async fn track_detail(&self, id: &str) -> Result { + let dtos: Vec = self.get("tracks", &[("id", id.to_string())]).await?; + dtos.into_iter() + .find_map(TrackDto::into_track) + .ok_or(FetchError::NotFound) + } + + async fn track_stream(&self, id: &str) -> Result { + let dtos: Vec = self + .get( + "tracks", + &[ + ("id", id.to_string()), + ("audioformat", self.audioformat.clone()), + ], + ) + .await?; + let audio = dtos + .into_iter() + .next() + .and_then(|t| t.audio) + .filter(|u| !u.is_empty()) + .ok_or(FetchError::NotStreamable)?; + Ok(audio) + } +} + +// --- Wire DTOs: decode defensively, missing fields degrade, never panic. ----- + +/// The Jamendo response envelope: `{ headers: {...}, results: [...] }`. +#[derive(Deserialize)] +struct Envelope { + #[serde(default)] + headers: HeadersDto, + #[serde(default = "Vec::new")] + results: Vec, +} + +#[derive(Default, Deserialize)] +struct HeadersDto { + #[serde(default)] + status: String, + #[serde(default)] + error_message: String, +} + +/// Jamendo ids arrive as JSON strings; `duration` as an integer number of +/// seconds. Everything is optional and defaults so a shape change degrades. +#[derive(Default, Deserialize)] +struct TrackDto { + #[serde(default)] + id: Option, + #[serde(default)] + name: String, + #[serde(default)] + artist_name: String, + #[serde(default)] + album_name: Option, + #[serde(default)] + duration: Option, + /// The direct streaming MP3 URL (present on catalogue listings and detail). + #[serde(default)] + audio: Option, +} + +impl TrackDto { + fn into_track(self) -> Option { + let id = self.id.filter(|s| !s.is_empty())?; + Some(JamTrack { + id, + title: self.name, + artist: self.artist_name, + album: self.album_name.filter(|s| !s.is_empty()), + duration_secs: self.duration.filter(|d| *d > 0), + }) + } +} + +#[derive(Default, Deserialize)] +struct AlbumDto { + #[serde(default)] + id: Option, + #[serde(default)] + name: String, + #[serde(default)] + artist_name: String, + /// Present only on `albums/tracks`; empty for a plain album listing. + #[serde(default)] + tracks: Vec, +} + +impl AlbumDto { + fn into_album(self) -> Option { + let id = self.id.filter(|s| !s.is_empty())?; + let artist = self.artist_name; + let tracks = self + .tracks + .into_iter() + .filter_map(|mut t| { + // Album-track objects omit per-track artist/album; inherit them. + if t.artist_name.is_empty() { + t.artist_name = artist.clone(); + } + if t.album_name.is_none() && !self.name.is_empty() { + t.album_name = Some(self.name.clone()); + } + t.into_track() + }) + .collect(); + Some(JamAlbum { + id, + title: self.name, + artist, + tracks, + }) + } +} + +#[cfg(test)] +mod api_tests { + use super::*; + + #[test] + fn track_dto_decodes_and_maps() { + let json = r#"{ + "id": "1234", + "name": "Lofi Piano", + "artist_name": "Some Artist", + "album_name": "Chill", + "duration": 210, + "audio": "https://mp3d.jamendo.com/?trackid=1234&format=mp32" + }"#; + let dto: TrackDto = serde_json::from_str(json).unwrap(); + let track = dto.into_track().unwrap(); + assert_eq!(track.id, "1234"); + assert_eq!(track.title, "Lofi Piano"); + assert_eq!(track.artist, "Some Artist"); + assert_eq!(track.album.as_deref(), Some("Chill")); + // Duration stays in seconds (guards the ms/seconds regression). + assert_eq!(track.duration_secs, Some(210)); + } + + #[test] + fn album_tracks_inherit_artist_and_album() { + let json = r#"{ + "id": "77", + "name": "The Album", + "artist_name": "Band", + "tracks": [{ "id": "10", "name": "One", "duration": 100 }] + }"#; + let dto: AlbumDto = serde_json::from_str(json).unwrap(); + let album = dto.into_album().unwrap(); + assert_eq!(album.id, "77"); + assert_eq!(album.tracks.len(), 1); + assert_eq!(album.tracks[0].artist, "Band"); + assert_eq!(album.tracks[0].album.as_deref(), Some("The Album")); + } + + #[test] + fn defensive_decode_drops_idless_rows() { + let dto: TrackDto = serde_json::from_str(r#"{"name":"no id"}"#).unwrap(); + assert!(dto.into_track().is_none()); + } +} diff --git a/jamendody/src/lib.rs b/jamendody/src/lib.rs new file mode 100644 index 0000000..27c6981 --- /dev/null +++ b/jamendody/src/lib.rs @@ -0,0 +1,521 @@ +//! Jamendo provider: **search and play** the Jamendo catalogue of +//! Creative-Commons music, and browse the album a track belongs to. Mounted at +//! [`PROVIDER_ROOT`]. +//! +//! Shaped on the abs/soundcloud providers (remote, search-driven), but the +//! *simple* case (architecture/jamendo-provider.md): +//! 1. **No login** — the public catalogue needs only a registered +//! `client_id`; there is no OAuth or token lifecycle in v1. +//! 2. **Direct MP3 playback** — `get_urls_for_track` returns the track's +//! `audio` URL, which the audio-player streams on its existing +//! windowed-HTTP path. No HLS, no new player component. +//! +//! The `client_id` is a semi-secret app key, redacted from `Debug` and never +//! logged; resolved `audio` URLs (which can carry a signed token) are never +//! logged either. + +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::warn; + +pub mod api; +use api::{Jam, JamAlbum, JamApi, JamTrack}; + +/// First path segment owned by this provider. +pub const PROVIDER_ROOT: &str = "/jamendo"; + +/// Reserved second-level literals (numeric ids never collide with these). +const SEARCH_SEGMENT: &str = "search"; +/// Canonical type tags disambiguating numeric track vs album ids. +const TRACK_SEGMENT: &str = "track"; +const ALBUM_SEGMENT: &str = "album"; + +/// Default results per search term (Jamendo caps a request at 200). +pub const DEFAULT_SEARCH_RESULTS: usize = 50; +/// Default tracks listed per album. +pub const DEFAULT_ALBUM_TRACKS: usize = 200; +/// Default per-request timeout in seconds. +pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30; +/// Default requested audio format (`mp32` = higher-bitrate MP3). +pub const DEFAULT_AUDIOFORMAT: &str = "mp32"; + +/// Provider settings, persisted as `jamendo.toml`. `client_id` is **required**: +/// without it the provider does not mount (D5). The rest have defaults. +#[derive(Clone, Default, Deserialize, Serialize)] +pub struct Settings { + /// The registered Jamendo app key from `devportal.jamendo.com`. Required. + /// **Secret**: redacted from `Debug`. + pub client_id: Option, + /// Requested audio format. Default [`DEFAULT_AUDIOFORMAT`]. + pub audioformat: Option, + /// Results per search term (clamped to Jamendo's 200). Default + /// [`DEFAULT_SEARCH_RESULTS`]. + pub search_results: Option, + /// Tracks listed per album. Default [`DEFAULT_ALBUM_TRACKS`]. + pub album_tracks: Option, + /// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`]. + pub call_timeout_secs: Option, +} + +impl fmt::Debug for Settings { + /// Redacts `client_id` (hard rule: secrets never logged). + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Settings") + .field("client_id", &self.client_id.as_ref().map(|_| "")) + .field("audioformat", &self.audioformat) + .field("search_results", &self.search_results) + .field("album_tracks", &self.album_tracks) + .field("call_timeout_secs", &self.call_timeout_secs) + .finish() + } +} + +/// A parsed `/jamendo/...` path. Browse nodes point their children at the +/// **canonical** `track/` and `album/` shapes, so a leaf carries the id +/// alone — no browse context (D3/G7). +#[derive(Debug, PartialEq, Eq)] +enum JamPath<'a> { + Root, + /// The search-terms parent (creatable). + Search, + /// A single search term (percent-encoded segment). + SearchTerm { + term: &'a str, + }, + /// An album container by id (canonical). + AlbumLeaf { + id: &'a str, + }, + /// A track leaf by id (canonical). + TrackLeaf { + id: &'a str, + }, +} + +/// Splits a `/jamendo/...` path into its recognized shape. Unknown shapes are +/// [`ProviderError::MalformedPath`]. +fn parse_path(path: &str) -> Result, ProviderError> { + if path == PROVIDER_ROOT { + return Ok(JamPath::Root); + } + let rest = path + .strip_prefix("/jamendo/") + .ok_or(ProviderError::MalformedPath)?; + let segments: Vec<&str> = rest.split('/').collect(); + if segments.iter().any(|segment| segment.is_empty()) { + return Err(ProviderError::MalformedPath); + } + match segments.as_slice() { + [s] if *s == SEARCH_SEGMENT => Ok(JamPath::Search), + [s, term] if *s == SEARCH_SEGMENT => Ok(JamPath::SearchTerm { term }), + [s, id] if *s == ALBUM_SEGMENT => Ok(JamPath::AlbumLeaf { id }), + [s, id] if *s == TRACK_SEGMENT => Ok(JamPath::TrackLeaf { id }), + _ => 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, "jamendo fetch failed: {err}"); + ProviderError::FetchError +} + +/// Snapshot of the in-memory term store (never held across awaits). +fn store_snapshot(store: &RwLock>) -> Vec { + store.read().map(|v| v.clone()).unwrap_or_default() +} + +/// Adds a term if absent (dedup, creation order preserved). +fn store_add(store: &RwLock>, item: &str) { + if let Ok(mut list) = store.write() { + if !list.iter().any(|existing| existing == item) { + list.push(item.to_string()); + } + } +} + +/// Removes a term; `true` when it existed. +fn store_remove(store: &RwLock>, item: &str) -> bool { + match store.write() { + Ok(mut list) => { + let before = list.len(); + list.retain(|existing| existing != item); + list.len() != before + } + Err(_) => false, + } +} + +/// The Jamendo provider client. +pub struct Client { + api: Box, + settings: Settings, + /// Search terms created under `/jamendo/search`, in creation order, + /// deduplicated. In-memory only, never held across awaits. + search_terms: RwLock>, +} + +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 [`Jam`] backend — the seam the tests use. + fn with_api(api: Box, settings: Settings) -> Self { + Self { + api, + settings, + search_terms: RwLock::new(Vec::new()), + } + } + + fn search_limit(&self) -> usize { + self.settings + .search_results + .unwrap_or(DEFAULT_SEARCH_RESULTS) + } + + fn album_limit(&self) -> usize { + self.settings.album_tracks.unwrap_or(DEFAULT_ALBUM_TRACKS) + } + + // --- Node builders --- + + /// The `/jamendo` root: a single `search` child. Sync — fixed children (D3). + fn root_node(&self) -> LibraryNode { + LibraryNode { + path: PROVIDER_ROOT.to_string(), + title: "jamendo".to_string(), + parent: Some(crabidy_core::ROOT_PATH.to_string()), + tracks: Vec::new(), + children: vec![creatable_child( + join(PROVIDER_ROOT, SEARCH_SEGMENT), + SEARCH_SEGMENT, + )], + is_queable: false, + is_creatable: false, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + } + } + + /// The `/jamendo/search` node listing the stored terms, each editable + + /// deletable. Entries link to `search/`. + fn store_node(&self, path: &str) -> LibraryNode { + let children = store_snapshot(&self.search_terms) + .iter() + .map(|entry| LibraryNodeChild { + is_editable: true, + is_deletable: true, + ..LibraryNodeChild::new( + join(path, &crabidy_core::encode_segment(entry)), + entry.clone(), + false, + ) + }) + .collect(); + LibraryNode { + path: path.to_string(), + title: SEARCH_SEGMENT.to_string(), + parent: Some(parent_of(path)), + tracks: Vec::new(), + children, + is_queable: false, + is_creatable: true, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + } + } + + /// A `/jamendo/search/` node: matching tracks as directly-playable + /// rows (canonical paths) and matching albums as queueable container + /// children (canonical paths). + async fn search_term_node(&self, path: &str, term: &str) -> Result { + let tracks = self + .api + .search_tracks(term, self.search_limit()) + .await + .map_err(|err| fetch_err("search tracks", err))?; + let albums = self + .api + .search_albums(term, self.search_limit()) + .await + .map_err(|err| fetch_err("search albums", err))?; + Ok(self.list_node(path, term.to_string(), &tracks, &albums)) + } + + /// A `/jamendo/album/` node: the album's tracks as canonical rows. + async fn album_node(&self, path: &str, id: &str) -> Result { + let album = self + .api + .album_tracks(id, self.album_limit()) + .await + .map_err(|err| fetch_err("album tracks", err))?; + Ok(self.list_node(path, album.title.clone(), &album.tracks, &[])) + } + + /// Builds a node holding `tracks` as directly-playable rows (canonical + /// paths) and `albums` as queueable container children (canonical paths). + /// Queueable when it carries either. + fn list_node( + &self, + path: &str, + title: String, + tracks: &[JamTrack], + albums: &[JamAlbum], + ) -> LibraryNode { + let track_rows: Vec = tracks.iter().map(jam_track).collect(); + let children: Vec = albums + .iter() + .map(|a| { + LibraryNodeChild::new( + join(PROVIDER_ROOT, &format!("{ALBUM_SEGMENT}/{}", a.id)), + a.title.clone(), + true, + ) + }) + .collect(); + let is_queable = !track_rows.is_empty() || children.iter().any(|c| c.is_queable); + LibraryNode { + path: path.to_string(), + title, + parent: Some(parent_of(path)), + tracks: track_rows, + children, + is_queable, + is_creatable: false, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + } + } + + /// The track id addressed by a canonical track path, or `MalformedPath`. + fn track_id<'a>(&self, path: &'a str) -> Result<&'a str, ProviderError> { + match parse_path(path)? { + JamPath::TrackLeaf { id } => Ok(id), + _ => Err(ProviderError::MalformedPath), + } + } +} + +/// Builds the wire [`Track`] for a Jamendo track at its canonical path. +/// `duration_secs` is already seconds; `provider_item_id` is `"track:"`. +fn jam_track(track: &JamTrack) -> Track { + Track { + path: join(PROVIDER_ROOT, &format!("{TRACK_SEGMENT}/{}", track.id)), + artist: track.artist.clone(), + title: track.title.clone(), + duration: track.duration_secs, + album: track.album.as_ref().map(|title| Album { + title: title.clone(), + release_date: None, + }), + is_skipped: false, + provider_item_id: format!("track:{}", track.id), + is_captured: false, + } +} + +/// A creatable browse child (the search parent). +fn creatable_child(path: String, title: &str) -> LibraryNodeChild { + LibraryNodeChild { + is_creatable: true, + ..LibraryNodeChild::new(path, title.to_string(), false) + } +} + +fn join(base: &str, segment: &str) -> String { + crabidy_core::join_path(base, segment) +} + +fn parent_of(path: &str) -> String { + crabidy_core::parent_path(path) + .unwrap_or(crabidy_core::ROOT_PATH) + .to_string() +} + +#[async_trait] +impl ProviderClient for Client { + /// Parses `jamendo.toml` and builds the `reqwest`-backed [`api::JamApi`]. A + /// missing/empty `client_id` (or unparseable config) is a typed + /// [`ProviderError::Config`]: the orchestrator maps it to `None` so only the + /// `/jamendo` subtree is lost, never the server (D5/G6). No blocking network. + async fn init(raw_toml_settings: &str) -> Result { + let settings: Settings = toml::from_str(raw_toml_settings).map_err(|err| { + warn!("could not parse jamendo.toml: {err}"); + ProviderError::Config("jamendo.toml is not valid TOML".to_string()) + })?; + let client_id = settings + .client_id + .clone() + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| { + warn!("jamendo provider disabled: no client_id configured"); + ProviderError::Config("jamendo client_id is required".to_string()) + })?; + let audioformat = settings + .audioformat + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_AUDIOFORMAT.to_string()); + let timeout = Duration::from_secs( + settings + .call_timeout_secs + .unwrap_or(DEFAULT_CALL_TIMEOUT_SECS), + ); + let api = JamApi::new(client_id, audioformat, timeout).map_err(|err| { + warn!("cannot build the jamendo client: {err}"); + ProviderError::Config(err.to_string()) + })?; + Ok(Self::with_api(Box::new(api), settings)) + } + + /// Serializes settings back to TOML (round-trips `client_id` and bounds). + 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(JamPath::TrackLeaf { .. })) + } + + /// Resolves the track id to its direct `audio` URL (one API round trip). The + /// URL is ephemeral and possibly signed — never logged (D4). + async fn get_urls_for_track(&self, track_path: &str) -> Result, ProviderError> { + let id = self.track_id(track_path)?; + let url = self + .api + .track_stream(id) + .await + .map_err(|err| fetch_err("stream url", err))?; + Ok(vec![url]) + } + + async fn get_metadata_for_track(&self, track_path: &str) -> Result { + let id = self.track_id(track_path)?; + let track = self + .api + .track_detail(id) + .await + .map_err(|err| fetch_err("track metadata", err))?; + let mut wire = jam_track(&track); + wire.path = track_path.to_string(); + Ok(wire) + } + + /// The root is served synchronously (its children are fixed). + fn get_lib_root(&self) -> LibraryNode { + self.root_node() + } + + async fn get_lib_node(&self, path: &str) -> Result { + let mut node = match parse_path(path)? { + JamPath::Root => self.root_node(), + JamPath::Search => self.store_node(path), + JamPath::SearchTerm { term } => { + let decoded = crabidy_core::decode_segment(term); + // Unknown terms (stale client cache) are recreated implicitly. + store_add(&self.search_terms, &decoded); + self.search_term_node(path, &decoded).await? + } + JamPath::AlbumLeaf { id } => self.album_node(path, id).await?, + JamPath::TrackLeaf { .. } => { + warn!(path, "get_lib_node called with a track path"); + return Err(ProviderError::MalformedPath); + } + }; + // Download blessing (same rule as abs/soundcloud): a node serving tracks + // and each queueable child allows `W`. + node.is_downloadable = node.is_queable || !node.tracks.is_empty(); + for child in &mut node.children { + child.is_downloadable = child.is_queable; + } + Ok(node) + } + + /// `/jamendo/search` is creatable: register a term and return its node + /// (implicit recreation on stale paths, like the other providers). + async fn create_lib_node( + &self, + parent_path: &str, + title: &str, + ) -> Result { + let entry = title.trim(); + if entry.is_empty() { + return Err(ProviderError::InvalidInput); + } + match parse_path(parent_path)? { + JamPath::Search => {} + _ => { + warn!(parent_path, "node creation not supported here"); + return Err(ProviderError::NotSupported); + } + } + store_add(&self.search_terms, entry); + let entry_path = join( + &join(PROVIDER_ROOT, SEARCH_SEGMENT), + &crabidy_core::encode_segment(entry), + ); + self.get_lib_node(&entry_path).await + } + + async fn rename_lib_node( + &self, + path: &str, + new_title: &str, + ) -> Result { + let old_encoded = match parse_path(path)? { + JamPath::SearchTerm { term } => term, + _ => { + warn!(path, "only search terms are renamable"); + return Err(ProviderError::NotSupported); + } + }; + let new_entry = new_title.trim(); + if new_entry.is_empty() { + return Err(ProviderError::InvalidInput); + } + store_remove( + &self.search_terms, + &crabidy_core::decode_segment(old_encoded), + ); + store_add(&self.search_terms, new_entry); + let new_path = join( + &join(PROVIDER_ROOT, SEARCH_SEGMENT), + &crabidy_core::encode_segment(new_entry), + ); + self.get_lib_node(&new_path).await + } + + async fn delete_lib_node(&self, path: &str) -> Result { + let encoded = match parse_path(path)? { + JamPath::SearchTerm { term } => term, + _ => { + warn!(path, "only search terms are deletable"); + return Err(ProviderError::NotSupported); + } + }; + store_remove(&self.search_terms, &crabidy_core::decode_segment(encoded)); + self.get_lib_node(&join(PROVIDER_ROOT, SEARCH_SEGMENT)) + .await + } +} + +#[cfg(test)] +mod tests { + include!("tests.rs"); +} diff --git a/jamendody/src/tests.rs b/jamendody/src/tests.rs new file mode 100644 index 0000000..b53b961 --- /dev/null +++ b/jamendody/src/tests.rs @@ -0,0 +1,298 @@ +// Unit tests over a fixture-driven `FakeJam` backend (no network). They define +// the provider's contract (quality/jamendo.md) and cover path parsing, tree +// shaping, field mapping, secret redaction, and typed-error propagation. + +use std::collections::HashMap; + +use super::*; +use api::{JamAlbum, JamTrack}; + +// --- Path parsing (T2) --------------------------------------------------- + +#[test] +fn root_and_search_parse() { + assert_eq!(parse_path("/jamendo"), Ok(JamPath::Root)); + assert_eq!(parse_path("/jamendo/search"), Ok(JamPath::Search)); +} + +#[test] +fn canonical_leaves_parse() { + assert_eq!( + parse_path("/jamendo/track/12345"), + Ok(JamPath::TrackLeaf { id: "12345" }) + ); + assert_eq!( + parse_path("/jamendo/album/999"), + Ok(JamPath::AlbumLeaf { id: "999" }) + ); +} + +#[test] +fn term_segment_parses() { + assert_eq!( + parse_path("/jamendo/search/lofi%20piano"), + Ok(JamPath::SearchTerm { + term: "lofi%20piano" + }) + ); +} + +#[test] +fn foreign_and_malformed_paths_reject() { + assert_eq!(parse_path("/tidal/x"), Err(ProviderError::MalformedPath)); + assert_eq!(parse_path("/jamendo/"), Err(ProviderError::MalformedPath)); + assert_eq!(parse_path("/jamendo/track"), Err(ProviderError::MalformedPath)); + assert_eq!( + parse_path("/jamendo/track/1/2"), + Err(ProviderError::MalformedPath) + ); + assert_eq!(parse_path("/jamendo//x"), Err(ProviderError::MalformedPath)); + assert_eq!(parse_path("/jamendo/bogus/1"), Err(ProviderError::MalformedPath)); +} + +#[test] +fn is_track_path_only_for_canonical_track() { + let client = Client::with_api(Box::new(FakeJam::default()), settings_with_id()); + assert!(client.is_track_path("/jamendo/track/1")); + assert!(!client.is_track_path("/jamendo/album/1")); + assert!(!client.is_track_path("/jamendo/search/x")); +} + +// --- Settings redaction (T10) -------------------------------------------- + +#[test] +fn settings_debug_redacts_client_id() { + let s = Settings { + client_id: Some("SECRET_KEY".into()), + ..Settings::default() + }; + let dumped = format!("{s:?}"); + assert!(!dumped.contains("SECRET_KEY")); + assert!(dumped.contains("")); +} + +// --- Tree shaping and mapping (T1, T4, T5, T7, T11) ----------------------- + +#[tokio::test] +async fn root_has_only_search() { + let client = Client::with_api(Box::new(FakeJam::default()), settings_with_id()); + let root = client.get_lib_root(); + assert!(!root.is_queable); + assert_eq!(root.children.len(), 1); + assert_eq!(root.children[0].path, "/jamendo/search"); + assert!(root.children[0].is_creatable); +} + +#[tokio::test] +async fn search_term_lists_tracks_and_albums() { + let fake = FakeJam { + search_tracks: vec![track("10", "Kaini Industries")], + search_albums: vec![album("77", "BoC set", &[])], + ..FakeJam::default() + }; + let client = Client::with_api(Box::new(fake), settings_with_id()); + let node = client.get_lib_node("/jamendo/search/boc").await.unwrap(); + // Tracks are directly-playable rows at canonical paths. + assert!(node.tracks.iter().any(|t| t.path == "/jamendo/track/10")); + // Albums are queueable container children at canonical paths. + let child = node.children.iter().find(|c| c.path == "/jamendo/album/77"); + assert!(child.is_some(), "album child missing"); + assert!(child.unwrap().is_queable); + assert!(node.is_queable); + assert!(node.is_downloadable, "download blessing"); +} + +#[tokio::test] +async fn album_node_lists_tracks() { + let fake = FakeJam { + albums: HashMap::from([( + "77".to_string(), + album("77", "The Album", &["10", "11"]), + )]), + ..FakeJam::default() + }; + let client = Client::with_api(Box::new(fake), settings_with_id()); + let node = client.get_lib_node("/jamendo/album/77").await.unwrap(); + assert_eq!(node.tracks.len(), 2); + assert!(node.tracks.iter().all(|t| t.path.starts_with("/jamendo/track/"))); + assert!(node.is_queable && node.is_downloadable); +} + +#[tokio::test] +async fn track_metadata_maps_fields() { + let fake = FakeJam { + tracks: HashMap::from([("10".to_string(), track("10", "One"))]), + ..FakeJam::default() + }; + let client = Client::with_api(Box::new(fake), settings_with_id()); + let meta = client + .get_metadata_for_track("/jamendo/track/10") + .await + .unwrap(); + assert_eq!(meta.title, "One"); + assert_eq!(meta.artist, "Some Artist"); + assert_eq!(meta.album.as_ref().map(|a| a.title.as_str()), Some("Chill")); + assert_eq!(meta.provider_item_id, "track:10"); + // Duration passes through in seconds (guards ms/seconds regression, T11). + assert_eq!(meta.duration, Some(210)); +} + +// --- Playback (T6) -------------------------------------------------------- + +#[tokio::test] +async fn get_urls_for_track_returns_audio() { + let fake = FakeJam { + streams: HashMap::from([( + "10".to_string(), + "https://mp3d.jamendo.com/?trackid=10&format=mp32".to_string(), + )]), + ..FakeJam::default() + }; + let client = Client::with_api(Box::new(fake), settings_with_id()); + let urls = client + .get_urls_for_track("/jamendo/track/10") + .await + .unwrap(); + assert_eq!( + urls, + vec!["https://mp3d.jamendo.com/?trackid=10&format=mp32".to_string()] + ); + // A non-track path is rejected, not streamed. + assert_eq!( + client.get_urls_for_track("/jamendo/album/77").await, + Err(ProviderError::MalformedPath) + ); +} + +// --- Search-term CRUD (T8) ------------------------------------------------ + +#[tokio::test] +async fn create_rename_delete_search_term() { + let client = Client::with_api(Box::new(FakeJam::default()), settings_with_id()); + client.create_lib_node("/jamendo/search", "aphex").await.unwrap(); + let search = client.get_lib_node("/jamendo/search").await.unwrap(); + assert!(search + .children + .iter() + .any(|c| c.title == "aphex" && c.is_deletable && c.is_editable)); + // Empty title is rejected. + assert_eq!( + client.create_lib_node("/jamendo/search", " ").await, + Err(ProviderError::InvalidInput) + ); + // Rename moves the term. + let enc = crabidy_core::encode_segment("aphex"); + client + .rename_lib_node(&format!("/jamendo/search/{enc}"), "squarepusher") + .await + .unwrap(); + let search = client.get_lib_node("/jamendo/search").await.unwrap(); + assert!(search.children.iter().any(|c| c.title == "squarepusher")); + assert!(!search.children.iter().any(|c| c.title == "aphex")); + // Delete removes it. + let enc = crabidy_core::encode_segment("squarepusher"); + client + .delete_lib_node(&format!("/jamendo/search/{enc}")) + .await + .unwrap(); + let search = client.get_lib_node("/jamendo/search").await.unwrap(); + assert!(search.children.is_empty()); +} + +// --- Typed errors, no panics (T9) ----------------------------------------- + +#[tokio::test] +async fn backend_failures_are_typed() { + let client = Client::with_api(Box::new(FailingJam), settings_with_id()); + assert_eq!( + client.get_lib_node("/jamendo/search/x").await, + Err(ProviderError::FetchError) + ); + assert_eq!( + client.get_urls_for_track("/jamendo/track/1").await, + Err(ProviderError::FetchError) + ); +} + +// --- Fakes and fixtures --------------------------------------------------- + +fn settings_with_id() -> Settings { + Settings { + client_id: Some("test-key".into()), + ..Settings::default() + } +} + +fn track(id: &str, title: &str) -> JamTrack { + JamTrack { + id: id.into(), + title: title.into(), + artist: "Some Artist".into(), + album: Some("Chill".into()), + duration_secs: Some(210), + } +} + +fn album(id: &str, title: &str, track_ids: &[&str]) -> JamAlbum { + JamAlbum { + id: id.into(), + title: title.into(), + artist: "Band".into(), + tracks: track_ids.iter().map(|t| track(t, "Song")).collect(), + } +} + +/// A fixture-driven fake: every method reads a field, defaulting to empty. +#[derive(Debug, Default)] +struct FakeJam { + tracks: HashMap, + albums: HashMap, + search_tracks: Vec, + search_albums: Vec, + streams: HashMap, +} + +#[async_trait] +impl api::Jam for FakeJam { + async fn search_tracks(&self, _q: &str, _l: usize) -> Result, api::FetchError> { + Ok(self.search_tracks.clone()) + } + async fn search_albums(&self, _q: &str, _l: usize) -> Result, api::FetchError> { + Ok(self.search_albums.clone()) + } + async fn album_tracks(&self, id: &str, _l: usize) -> Result { + self.albums.get(id).cloned().ok_or(api::FetchError::NotFound) + } + async fn track_detail(&self, id: &str) -> Result { + self.tracks.get(id).cloned().ok_or(api::FetchError::NotFound) + } + async fn track_stream(&self, id: &str) -> Result { + self.streams + .get(id) + .cloned() + .ok_or(api::FetchError::NotStreamable) + } +} + +/// A backend whose every call fails — for the no-panic / typed-error gate. +#[derive(Debug)] +struct FailingJam; + +#[async_trait] +impl api::Jam for FailingJam { + async fn search_tracks(&self, _q: &str, _l: usize) -> Result, api::FetchError> { + Err(api::FetchError::Http("boom".into())) + } + async fn search_albums(&self, _q: &str, _l: usize) -> Result, api::FetchError> { + Err(api::FetchError::Http("boom".into())) + } + async fn album_tracks(&self, _id: &str, _l: usize) -> Result { + Err(api::FetchError::Http("boom".into())) + } + async fn track_detail(&self, _id: &str) -> Result { + Err(api::FetchError::Http("boom".into())) + } + async fn track_stream(&self, _id: &str) -> Result { + Err(api::FetchError::Http("boom".into())) + } +} diff --git a/plan/jamendo.md b/plan/jamendo.md new file mode 100644 index 0000000..8fb5d63 --- /dev/null +++ b/plan/jamendo.md @@ -0,0 +1,73 @@ +# Task plan — jamendo provider + +Ordered, independently verifiable tasks for `/jamendo` +(architecture/jamendo-provider.md, quality/jamendo.md). Each cites the gate/test +it satisfies. + +## Crate scaffold + +- [x] **P1** — Add `jamendody` to `Cargo.toml` workspace `members`; create + `jamendody/Cargo.toml` mirroring `soundclouddy` (deps: `async-trait`, + `crabidy-core`, `reqwest`, `serde`, `thiserror`, `tokio`, `toml`, `tracing`; + dev-dep `tokio` full). *Verify:* `cargo metadata` lists the crate. + +## Network seam (api.rs) + +- [x] **P2** — Domain types `JamTrack` (id, title, artist, album, + duration_secs) and `JamAlbum` (id, title, artist, tracks); `FetchError` + (`Http`, `Decode`, `NotFound`, `Unauthorized`, `NotStreamable`). *Verify:* + compiles. (G2) +- [x] **P3** — `Jam` trait: `search_tracks`, `search_albums`, `album_tracks`, + `track_detail`, `track_stream`. Doc each with error behavior. *Verify:* + compiles. (G2/G10) +- [x] **P4** — `JamApi` (reqwest): `new(client_id, audioformat, timeout)` with a + timeout-bearing client and redacting `Debug`; `get_json` helper adding + `client_id` + `format=json`, decoding the `{headers, results}` envelope, + mapping non-success/404/401 to `FetchError`. `limit.min(200)` cap. *Verify:* + `cargo build`. (G3/G4/G5/G9) +- [x] **P5** — Defensive DTOs (`#[serde(default)]`) for track/album/envelope and + `into_track`/`into_album`. *Verify:* build + a decode unit test on a JSON + fixture. (G9) + +## Provider logic (lib.rs) + +- [x] **P6** — `PROVIDER_ROOT`, segment consts, `JamPath` enum + `parse_path`. + *Verify:* T2, `foreign_and_malformed_paths_reject`. (D3) +- [x] **P7** — `Settings` (`client_id: Option` + bounds) with redacting + `Debug`. *Verify:* T10 `settings_debug_redacts_client_id`. (G3) +- [x] **P8** — `Client` + `with_api`, search-term store helpers + (`store_add/remove/snapshot`), `search_limit`/`album_limit`. *Verify:* + compiles. (D3) +- [x] **P9** — Node builders: `root_node` (search only), `store_node`, + `search_term_node` (tracks + albums), `album_node`, `list_node`, `jam_track`. + *Verify:* T1, T4, T5, T7, T11. (D3/D4) +- [x] **P10** — `ProviderClient` impl: `init` (require non-empty `client_id`, + else `Config`), `settings`, `is_track_path`, `get_urls_for_track` (→ + `track_stream`), `get_metadata_for_track`, `get_lib_root`, `get_lib_node` + (download blessing), `create/rename/delete_lib_node`. *Verify:* T3, T6, T8, + T9. (D4/G1/G2/G6) + +## Tests (tests.rs) + +- [x] **P11** — `FakeJam` + `FailingJam` fixtures and all T1–T11. *Verify:* + `cargo test -p jamendody`. (all tests) + +## Orchestrator + settings wiring (crabidy-server) + +- [x] **P12** — `settings.rs`: `jamendo` in `ALL_PROVIDERS` (8→9), + `ProviderToggles.jamendo`, defaults, `provider_toggles()`. *Verify:* build + + existing settings tests. (G6) +- [x] **P13** — `provider.rs`: `jamendo_client` field, `jamendo_owns`, + `jamendo_provider`, non-fatal `build()` block (reads `jamendo.toml`), + `is_track_path` arm, `get_lib_root` child (gated on `is_some`), `get_lib_node` + arm, `get_urls_for_track`/`get_metadata_for_track` arms, create/rename/delete + arms. *Verify:* `cargo build -p crabidy-server`. (D1/G6/G8) + +## Verification gate + +- [x] **P14** — `cargo fmt --check`, `cargo clippy -- -D warnings`, + `cargo test` green across the workspace. Re-read G1–G10 against the diff. +- [ ] **P15 (live gate, R1)** — with a real `client_id` in `jamendo.toml`, browse + `/jamendo/search/`, queue and **play** a track: confirm audio plays via + the existing windowed-HTTP path, seek bar correct (duration from metadata), + clean EOS, no panic. Deferred to a run with network + a key. diff --git a/plan/summary.md b/plan/summary.md index e37e43b..7443dc2 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -1122,3 +1122,44 @@ crabidy-server` clean. **Needs live SoundCloud to confirm (no creds/audio in sandbox):** the actual `client_id` scrape, live API JSON shapes, and mp3-HLS play-to-EOS — all exercised by `tests/live.rs` and the `#[ignore]` gates (quality G7/G8/G14/G19). Run those on a real machine. + +## jamendo provider — `/jamendo` (2026-07-24) + +Ran the full dev-flow pipeline autonomously for a `/jamendo` provider (free / +Creative-Commons music via the official `api.jamendo.com/v3.0`). Artifacts: +`architecture/jamendo-provider.md` (D1–D6, 2 diagrams), `quality/jamendo.md` +(T1–T11 + G1–G10), `plan/jamendo.md`. New crate **`jamendody`**, shaped on +`soundclouddy` but the *simple* case — Jamendo has a stable official API, so +there is **no `client_id` scraping, no OAuth, and no HLS**. + +- **Tree**: `/jamendo` → `search` (creatable) → `/jamendo/search/` listing + tracks (canonical `/jamendo/track/` rows) + albums (canonical queueable + `/jamendo/album/` containers) → `/jamendo/album/` tracks. Same + search-term store + download-blessing pattern as the other providers. +- **Playback with no player change**: `get_urls_for_track` returns the track's + direct `audio` MP3 URL, played on the existing windowed-HTTP + symphonia path. +- **`Jam` reqwest seam** (`api.rs`, faked in tests): `search_tracks`, + `search_albums`, `album_tracks`, `track_detail`, `track_stream`. Per-call + timeout, `limit` clamped to Jamendo's 200, `client_id` redacted from `Debug`, + defensive `#[serde(default)]` DTOs over the `{headers, results}` envelope + (HTTP-200 error envelopes mapped to typed `FetchError`). +- **Duration stays in seconds** — Jamendo's native unit *is* `Track.duration`'s, + so it passes through unchanged (a test guards against a ms/seconds regression). +- **Server wiring** (the standard pattern): `jamendo` in `ALL_PROVIDERS` (8→9), + `ProviderToggles.jamendo`; `jamendo_client` field, `jamendo_owns`, + `jamendo_provider`, a non-fatal `build()` block (missing/empty `client_id` + disables `/jamendo` only), a root child, and dispatch arms in all methods + (`is_track_path`, `get_lib_node`, `get_urls_for_track`, + `get_metadata_for_track`, `resolve_tracks_into`, create/rename/delete). + +Deviations: stream URL is a dedicated `track_stream` seam method (not a field on +`JamTrack`), matching soundcloud's metadata/URL separation; `client_id` required +with **no init-time network** (a bad key surfaces lazily as `FetchError`); +`audioformat` defaults to `mp32` (higher bitrate) over Jamendo's `mp31`. + +**Verified offline:** `jamendody` 16 tests (T1–T11 + 3 DTO-decode) green, +`crabidy-server` tests green (settings iterate `ALL_PROVIDERS`, no edit needed), +`cargo build -p jamendody -p crabidy-server` clean, clippy `-D warnings` + fmt +clean. **Deferred live gate (R1):** with a real `client_id` in `jamendo.toml`, +browse → queue → play a track and confirm the `audio` MP3 streams with a correct +seek bar and clean EOS. Needs network + a registered key; not runnable here. diff --git a/quality/jamendo.md b/quality/jamendo.md new file mode 100644 index 0000000..2228dd3 --- /dev/null +++ b/quality/jamendo.md @@ -0,0 +1,77 @@ +# Quality gates — jamendo provider + +Gates for the `/jamendo` provider (architecture/jamendo-provider.md). Automatic +tests live inline in `jamendody/src/tests.rs` (unit, over a `FakeJam`) and are +named in each gate. LLM gates are read-and-reason checks an implementing agent +verifies. + +## Automatic tests (jamendody/src/tests.rs) + +- **T1 `root_has_only_search`** — `get_lib_root` exposes exactly one child, + `/jamendo/search` (creatable), and the root is not queueable. (D3) +- **T2 path parsing** (`*_parse`, `foreign_and_malformed_paths_reject`) — + `/jamendo`, `/jamendo/search`, `/jamendo/search/`, `/jamendo/track/`, + `/jamendo/album/` parse to their variants; foreign roots, empty segments, + wrong arity, and unknown tags are `MalformedPath`. (D3) +- **T3 `is_track_path_only_for_canonical_track`** — true only for + `/jamendo/track/`; false for albums, search nodes, root. (D3/D4) +- **T4 `search_term_lists_tracks_and_albums`** — a search node carries matching + tracks as rows at canonical `/jamendo/track/` paths and matching albums as + **queueable** container children at `/jamendo/album/`; the node is + queueable and `is_downloadable`. (D3/D4) +- **T5 `album_node_lists_tracks`** — `/jamendo/album/` lists the album's + tracks as canonical rows; node is queueable + downloadable. (D3) +- **T6 `get_urls_for_track_returns_audio`** — `get_urls_for_track` on a track + path returns exactly the backend `audio` URL; a non-track path is + `MalformedPath`, not a stream. (D4) +- **T7 `track_metadata_maps_fields`** — `get_metadata_for_track` maps + `name→title`, `artist_name→artist`, `album_name→Album.title`, + `duration(secs)→Track.duration` unchanged, `provider_item_id == "track:"`. + (D4) +- **T8 `create_rename_delete_search_term`** — creating a term adds an editable + + deletable child under `/jamendo/search`; empty/whitespace title → + `InvalidInput`; rename moves the term; delete removes it. (D3) +- **T9 `backend_failures_are_typed`** — a backend whose every call errors makes + `get_lib_node` return `ProviderError::FetchError`, never panics. (D2) +- **T10 `settings_debug_redacts_client_id`** — `format!("{:?}", settings)` with + a `client_id` set does not contain the id and shows ``. (D5) +- **T11 `duration_is_seconds_not_ms`** — a track whose backend `duration_secs` + is 210 yields `Track.duration == Some(210)` (guards against a ms/seconds + regression like the web bug). (D1/D4) + +## LLM quality gates (read-and-reason) + +- **G1 — no panics on external conditions.** No `unwrap`/`expect`/`panic!` on + network, decode, config, or path input in `jamendody`. Lock poisoning + degrades to empty (`unwrap_or_default`), never panics. (Hard rule) +- **G2 — typed error boundary.** Every `JamApi` failure is a `FetchError` + variant; the provider maps it to `ProviderError` (`FetchError` / + `MalformedPath` / `NotSupported` / `InvalidInput`) and logs the typed cause. + No `color-eyre`/`anyhow` leaks across the trait. (Hard rule) +- **G3 — client_id is redacted everywhere.** `Settings` and `JamApi` have + hand-written `Debug` that redact `client_id`; it never appears in a log line, + and a resolved `audio` URL (may carry a signed token) is never logged. (Hard + rule: redact secrets) +- **G4 — timeouts on every external call.** The `reqwest` client is built with a + timeout from `call_timeout_secs`; no unbounded HTTP call exists. (Hard rule) +- **G5 — bounded results, no silent caps.** `search_results` (≤ API max 200) and + `album_tracks` bound listings; when a cap truncates, it is `log`-ged + (`debug`/`warn`), not silent. (Hard rule: no silent caps) +- **G6 — non-fatal init.** A missing/empty `client_id` (or unparseable + `jamendo.toml`) makes `init` return a typed `Config` error; the orchestrator + maps it to `None` so only `/jamendo` is lost, never the server. No blocking + network in `build()`. (D1/D5) +- **G7 — canonical leaf independence.** Track rows and album children always use + `/jamendo/track/` and `/jamendo/album/` regardless of the branch they + were reached through, so playback/expansion never depends on browse context. + (D3) +- **G8 — no proto/wire/TUI/player change.** The feature is a pure path-prefix + subtree: no edit to `crabidy.proto`, `ProviderCommand`, the TUI, or + `audio-player`. Search-term flows reuse the existing create/rename/delete RPCs. + (Assumptions) +- **G9 — defensive DTOs.** Wire structs use `#[serde(default)]`; a missing field + degrades (empty/`None`), a renamed field is a local `JamApi` fix, never a + crash. Ids decode whether numeric or string. (R3) +- **G10 — docs.** Public items (`Client`, `Settings`, `PROVIDER_ROOT`, the `Jam` + trait and its methods, domain types) carry doc comments stating intent and + error behavior. (Preference)