# 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. **A `User-Agent` header is mandatory** (live-discovered 2026-07-24): Jamendo's API returns HTTP 200 `success` with an **empty** result set to any request that carries none — and `reqwest` sends none by default — so `JamApi` sets one. This is silent (no error), so a missing UA looks exactly like "no matches". 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.