crabidy/architecture/jamendo-provider.md

13 KiB

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.

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<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/searchis_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

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

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.