261 lines
12 KiB
Markdown
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.
|