crabidy/architecture/jamendo-provider.md

261 lines
12 KiB
Markdown

# 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.