Add the Jamendo provider (/jamendo) for Creative-Commons music

New jamendody crate implementing ProviderClient, mounted at /jamendo:
search the Jamendo catalogue and play tracks, browse an album a track
belongs to, with captures/downloads for free.

Unlike the SoundCloud provider this is the simple case — Jamendo has a
stable official API (api.jamendo.com/v3.0), so there is no client_id
scraping, no OAuth, and no HLS: a track streams via its direct audio MP3
URL on the existing windowed-HTTP path, and duration is already in
seconds (matching Track.duration). A registered client_id in jamendo.toml
is required; missing it disables /jamendo only, non-fatally.

Ran the full dev-flow pipeline; artifacts under architecture/, quality/,
and plan/. 16 jamendody unit tests over a faked Jam network seam; server
wired with the standard owns/provider/build/dispatch pattern and a
jamendo toggle in ALL_PROVIDERS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-24 13:43:05 +02:00
parent c4001df74d
commit 06381949ab
13 changed files with 1818 additions and 1 deletions

16
Cargo.lock generated
View File

@ -1169,6 +1169,7 @@ dependencies = [
"fyyd", "fyyd",
"http", "http",
"include_dir", "include_dir",
"jamendody",
"rand 0.10.2", "rand 0.10.2",
"realfft", "realfft",
"reqwest 0.13.1", "reqwest 0.13.1",
@ -2438,6 +2439,21 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 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]] [[package]]
name = "jni" name = "jni"
version = "0.21.1" version = "0.21.1"

View File

@ -11,6 +11,7 @@ members = [
"crabidy-server", "crabidy-server",
"fsdy", "fsdy",
"fyyd", "fyyd",
"jamendody",
"soundclouddy", "soundclouddy",
"tidaldy", "tidaldy",
"ytdy", "ytdy",
@ -109,6 +110,7 @@ crabidy-core = { path = "crabidy-core" }
crabidy-server = { path = "crabidy-server" } crabidy-server = { path = "crabidy-server" }
fsdy = { path = "fsdy" } fsdy = { path = "fsdy" }
fyyd = { path = "fyyd" } fyyd = { path = "fyyd" }
jamendody = { path = "jamendody" }
soundclouddy = { path = "soundclouddy" } soundclouddy = { path = "soundclouddy" }
tidaldy = { path = "tidaldy" } tidaldy = { path = "tidaldy" }
ytdy = { path = "ytdy" } ytdy = { path = "ytdy" }

View File

@ -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=<id>&format=json` (omitted from the cells below). List calls add
`&limit=<n>&offset=<o>`; `limit` max is **200** (default 10). Endpoints we use:
| Purpose | Endpoint |
| --- | --- |
| Search tracks | `GET /tracks?search=<t>` (or `namesearch`, `tags`) |
| Track detail (stream URL) | `GET /tracks?id=<id>` |
| Search albums | `GET /albums?namesearch=<t>` |
| Album's tracks | `GET /albums/tracks?id=<album_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<Arc<jamendody::Client>>` 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<dyn Jam>` — 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/<id>` (leaf) and `album/<id>` (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<Vec<String>>`, dedup), each `is_editable` + `is_deletable`, exactly
like the tidal/youtube/soundcloud search stores.
- `/jamendo/search/<term>` — the results: matching **tracks** as queueable
leaves (pointing at `/jamendo/track/<id>`) and matching **albums** as
queueable containers (pointing at `/jamendo/album/<id>`).
- `/jamendo/album/<id>` — the album's tracks (queueable, downloadable); the
canonical container path.
- `/jamendo/track/<id>` — 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/<id>)`: `GET /tracks?id=<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<u32>` (filtered `> 0`),
`provider_item_id` = `"track:<id>"` (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<String>` (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<Vec>)"
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/<term>"
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/<id>) + albums"
tui -> orch: "queue + play a track"
orch -> j: "get_urls_for_track(/jamendo/track/<id>)"
j -> api: "GET /tracks?id=<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.

View File

@ -36,6 +36,7 @@ flume.workspace = true
absdy.workspace = true absdy.workspace = true
fsdy.workspace = true fsdy.workspace = true
fyyd.workspace = true fyyd.workspace = true
jamendody.workspace = true
futures.workspace = true futures.workspace = true
rand.workspace = true rand.workspace = true
reqwest.workspace = true reqwest.workspace = true

View File

@ -48,6 +48,10 @@ pub struct ProviderOrchestrator {
/// obtained — non-fatal, it only costs the `/soundcloud` subtree /// obtained — non-fatal, it only costs the `/soundcloud` subtree
/// (architecture/soundcloud-provider.md D1). /// (architecture/soundcloud-provider.md D1).
sc_client: Option<Arc<soundclouddy::Client>>, sc_client: Option<Arc<soundclouddy::Client>>,
/// 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<Arc<jamendody::Client>>,
} }
/// Whether a path belongs to the filesystem provider. /// 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/") 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 { 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.
@ -163,6 +172,15 @@ impl ProviderOrchestrator {
ProviderError::MalformedPath 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) { 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
@ -500,6 +518,30 @@ impl ProviderOrchestrator {
} else { } else {
None 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); let (provider_tx, provider_rx) = flume::bounded(100);
Ok(Self { Ok(Self {
provider_rx, provider_rx,
@ -513,6 +555,7 @@ impl ProviderOrchestrator {
fyyd_client, fyyd_client,
abs_client, abs_client,
sc_client, sc_client,
jamendo_client,
}) })
} }
} }
@ -579,6 +622,12 @@ impl ProviderClient for ProviderOrchestrator {
.as_ref() .as_ref()
.is_some_and(|sc| sc.is_track_path(path)); .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 false
} }
@ -617,6 +666,12 @@ impl ProviderClient for ProviderOrchestrator {
if sc_owns(track_path) { if sc_owns(track_path) {
return self.sc_provider()?.get_urls_for_track(track_path).await; 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"); warn!(path = track_path, "no provider owns this track path");
Err(ProviderError::MalformedPath) Err(ProviderError::MalformedPath)
} }
@ -665,6 +720,12 @@ impl ProviderClient for ProviderOrchestrator {
if sc_owns(track_path) { if sc_owns(track_path) {
return self.sc_provider()?.get_metadata_for_track(track_path).await; 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"); warn!(path = track_path, "no provider owns this track path");
Err(ProviderError::MalformedPath) Err(ProviderError::MalformedPath)
} }
@ -720,6 +781,14 @@ impl ProviderClient for ProviderOrchestrator {
); );
root_node.children.push(child); 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 root_node
} }
@ -745,6 +814,8 @@ impl ProviderClient for ProviderOrchestrator {
self.abs_provider()?.get_lib_node(path).await? self.abs_provider()?.get_lib_node(path).await?
} else if sc_owns(path) { } else if sc_owns(path) {
self.sc_provider()?.get_lib_node(path).await? self.sc_provider()?.get_lib_node(path).await?
} else if jamendo_owns(path) {
self.jamendo_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);
@ -813,6 +884,12 @@ impl ProviderClient for ProviderOrchestrator {
.create_lib_node(parent_path, title) .create_lib_node(parent_path, title)
.await; .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"); warn!(parent_path, "no provider supports creating nodes here");
Err(ProviderError::NotSupported) Err(ProviderError::NotSupported)
} }
@ -861,6 +938,12 @@ impl ProviderClient for ProviderOrchestrator {
if sc_owns(path) { if sc_owns(path) {
return self.sc_provider()?.rename_lib_node(path, new_title).await; 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"); warn!(path, "no provider supports renaming this node");
Err(ProviderError::NotSupported) Err(ProviderError::NotSupported)
} }
@ -921,6 +1004,12 @@ impl ProviderClient for ProviderOrchestrator {
.resolve_tracks_into(path, chunk_tx) .resolve_tracks_into(path, chunk_tx)
.await; .await;
} }
if jamendo_owns(path) {
return self
.jamendo_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)
} }
@ -953,6 +1042,9 @@ impl ProviderClient for ProviderOrchestrator {
if sc_owns(path) { if sc_owns(path) {
return self.sc_provider()?.delete_lib_node(path).await; 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"); warn!(path, "no provider supports deleting this node");
Err(ProviderError::NotSupported) Err(ProviderError::NotSupported)
} }

View File

@ -19,12 +19,13 @@ pub const SETTINGS_FILE: &str = "crabidy-server.toml";
/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/abs`, /// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/abs`,
/// `/soundcloud`, `/fs`, `/crabidy`, `/orphans`). `orphans` is a view over the /// `/soundcloud`, `/fs`, `/crabidy`, `/orphans`). `orphans` is a view over the
/// store, so it needs `crabidy`. /// store, so it needs `crabidy`.
pub const ALL_PROVIDERS: [&str; 8] = [ pub const ALL_PROVIDERS: [&str; 9] = [
"tidal", "tidal",
"youtube", "youtube",
"fyyd", "fyyd",
"abs", "abs",
"soundcloud", "soundcloud",
"jamendo",
"fs", "fs",
"crabidy", "crabidy",
"orphans", "orphans",
@ -78,6 +79,7 @@ pub struct ProviderToggles {
pub fyyd: bool, pub fyyd: bool,
pub abs: bool, pub abs: bool,
pub soundcloud: bool, pub soundcloud: bool,
pub jamendo: bool,
pub fs: bool, pub fs: bool,
pub crabidy: bool, pub crabidy: bool,
pub orphans: bool, pub orphans: bool,
@ -93,6 +95,7 @@ impl ProviderToggles {
fyyd: true, fyyd: true,
abs: true, abs: true,
soundcloud: true, soundcloud: true,
jamendo: true,
fs: true, fs: true,
crabidy: true, crabidy: true,
orphans: true, orphans: true,
@ -201,6 +204,7 @@ impl ServerSettings {
fyyd: self.provider_enabled("fyyd"), fyyd: self.provider_enabled("fyyd"),
abs: self.provider_enabled("abs"), abs: self.provider_enabled("abs"),
soundcloud: self.provider_enabled("soundcloud"), soundcloud: self.provider_enabled("soundcloud"),
jamendo: self.provider_enabled("jamendo"),
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"),

18
jamendody/Cargo.toml Normal file
View File

@ -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"] }

414
jamendody/src/api.rs Normal file
View File

@ -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<String>,
/// Track length in seconds.
pub duration_secs: Option<u32>,
}
/// 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<JamTrack>,
}
/// The Jamendo operations the provider needs. Behind `Box<dyn Jam>` 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<Vec<JamTrack>, 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<Vec<JamAlbum>, FetchError>;
/// One album's tracks by id (at most `limit` tracks), in album order.
async fn album_tracks(&self, id: &str, limit: usize) -> Result<JamAlbum, FetchError>;
/// One track's metadata by id. [`FetchError::NotFound`] when absent.
async fn track_detail(&self, id: &str) -> Result<JamTrack, FetchError>;
/// 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<String, FetchError>;
}
/// 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", &"<redacted>")
.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<Self, FetchError> {
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<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, String)],
) -> Result<Vec<T>, 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<T> = 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<Vec<JamTrack>, FetchError> {
let dtos: Vec<TrackDto> = 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<Vec<JamAlbum>, FetchError> {
let dtos: Vec<AlbumDto> = 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<JamAlbum, FetchError> {
let dtos: Vec<AlbumDto> = 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<JamTrack, FetchError> {
let dtos: Vec<TrackDto> = 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<String, FetchError> {
let dtos: Vec<TrackDto> = 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<T> {
#[serde(default)]
headers: HeadersDto,
#[serde(default = "Vec::new")]
results: Vec<T>,
}
#[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<String>,
#[serde(default)]
name: String,
#[serde(default)]
artist_name: String,
#[serde(default)]
album_name: Option<String>,
#[serde(default)]
duration: Option<u32>,
/// The direct streaming MP3 URL (present on catalogue listings and detail).
#[serde(default)]
audio: Option<String>,
}
impl TrackDto {
fn into_track(self) -> Option<JamTrack> {
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<String>,
#[serde(default)]
name: String,
#[serde(default)]
artist_name: String,
/// Present only on `albums/tracks`; empty for a plain album listing.
#[serde(default)]
tracks: Vec<TrackDto>,
}
impl AlbumDto {
fn into_album(self) -> Option<JamAlbum> {
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());
}
}

521
jamendody/src/lib.rs Normal file
View File

@ -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<String>,
/// Requested audio format. Default [`DEFAULT_AUDIOFORMAT`].
pub audioformat: Option<String>,
/// Results per search term (clamped to Jamendo's 200). Default
/// [`DEFAULT_SEARCH_RESULTS`].
pub search_results: Option<usize>,
/// Tracks listed per album. Default [`DEFAULT_ALBUM_TRACKS`].
pub album_tracks: Option<usize>,
/// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`].
pub call_timeout_secs: Option<u64>,
}
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(|_| "<redacted>"))
.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/<id>` and `album/<id>` 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<JamPath<'_>, 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<String>>) -> Vec<String> {
store.read().map(|v| v.clone()).unwrap_or_default()
}
/// Adds a term if absent (dedup, creation order preserved).
fn store_add(store: &RwLock<Vec<String>>, 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<Vec<String>>, 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<dyn Jam>,
settings: Settings,
/// Search terms created under `/jamendo/search`, in creation order,
/// deduplicated. In-memory only, never held across awaits.
search_terms: RwLock<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 [`Jam`] backend — the seam the tests use.
fn with_api(api: Box<dyn Jam>, 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/<encoded>`.
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/<term>` 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<LibraryNode, ProviderError> {
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/<id>` node: the album's tracks as canonical rows.
async fn album_node(&self, path: &str, id: &str) -> Result<LibraryNode, ProviderError> {
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<Track> = tracks.iter().map(jam_track).collect();
let children: Vec<LibraryNodeChild> = 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:<id>"`.
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<Self, ProviderError> {
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<Vec<String>, 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<Track, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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<LibraryNode, ProviderError> {
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");
}

298
jamendody/src/tests.rs Normal file
View File

@ -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("<redacted>"));
}
// --- 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<String, JamTrack>,
albums: HashMap<String, JamAlbum>,
search_tracks: Vec<JamTrack>,
search_albums: Vec<JamAlbum>,
streams: HashMap<String, String>,
}
#[async_trait]
impl api::Jam for FakeJam {
async fn search_tracks(&self, _q: &str, _l: usize) -> Result<Vec<JamTrack>, api::FetchError> {
Ok(self.search_tracks.clone())
}
async fn search_albums(&self, _q: &str, _l: usize) -> Result<Vec<JamAlbum>, api::FetchError> {
Ok(self.search_albums.clone())
}
async fn album_tracks(&self, id: &str, _l: usize) -> Result<JamAlbum, api::FetchError> {
self.albums.get(id).cloned().ok_or(api::FetchError::NotFound)
}
async fn track_detail(&self, id: &str) -> Result<JamTrack, api::FetchError> {
self.tracks.get(id).cloned().ok_or(api::FetchError::NotFound)
}
async fn track_stream(&self, id: &str) -> Result<String, api::FetchError> {
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<Vec<JamTrack>, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
async fn search_albums(&self, _q: &str, _l: usize) -> Result<Vec<JamAlbum>, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
async fn album_tracks(&self, _id: &str, _l: usize) -> Result<JamAlbum, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
async fn track_detail(&self, _id: &str) -> Result<JamTrack, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
async fn track_stream(&self, _id: &str) -> Result<String, api::FetchError> {
Err(api::FetchError::Http("boom".into()))
}
}

73
plan/jamendo.md Normal file
View File

@ -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<String>` + 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 T1T11. *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 G1G10 against the diff.
- [ ] **P15 (live gate, R1)** — with a real `client_id` in `jamendo.toml`, browse
`/jamendo/search/<term>`, 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.

View File

@ -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 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 play-to-EOS — all exercised by `tests/live.rs` and the `#[ignore]` gates
(quality G7/G8/G14/G19). Run those on a real machine. (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` (D1D6, 2 diagrams), `quality/jamendo.md`
(T1T11 + G1G10), `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/<term>` listing
tracks (canonical `/jamendo/track/<id>` rows) + albums (canonical queueable
`/jamendo/album/<id>` containers) → `/jamendo/album/<id>` 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 (T1T11 + 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.

77
quality/jamendo.md Normal file
View File

@ -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/<term>`, `/jamendo/track/<id>`,
`/jamendo/album/<id>` 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/<id>`; 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/<id>` paths and matching albums as
**queueable** container children at `/jamendo/album/<id>`; the node is
queueable and `is_downloadable`. (D3/D4)
- **T5 `album_node_lists_tracks`**`/jamendo/album/<id>` 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:<id>"`.
(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 `<redacted>`. (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/<id>` and `/jamendo/album/<id>` 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)