12 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 noclient_idscraping and no rotation: the developer registers aclient_idonce atdevportal.jamendo.comand drops it injamendo.toml. Every request just carriesclient_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
audiofield 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 noaudio-playerchange at all. This is theabsdyshape: nodes serve tracks,get_urls_for_trackreturns a URL, the existing MP3 (symphonia) decoder handles the rest. - Duration is already in seconds, which is exactly the unit
Track.durationcarries (soundclouddy divides its ms by 1000 atlib.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 raiseis_downloadable: Jamendo tracks carry anaudiodownloadURL 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=mp32requests the higher-bitrate stream (default is a low-bitratemp31). - album:
id,name,artist_name;/albums/tracksreturns the album wrapping atracks[]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, orProviderCommandchange. It is a pure path-prefix subtree. - The Jamendo
audioURL is a real streaming MP3 the existing windowed-HTTP source plays unmodified — no HLS, no newaudio-playercomponent. (Risk R1 gates this with a live play.) - A missing / malformed / rejected
client_idmust never crash startup or a browse. With noclient_idthe/jamendosubtree is simply not mounted (like/abswith missing config); aclient_idthat the API later rejects surfaces a typed error and degrades the subtree, never the app. client_idis a semi-secret account key: redact it fromDebug, config dumps, and logs, and never log a built stream URL (they can embed a signedfromtoken). (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), eachis_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>(defaultmp32),search_results: usize(default 50, capped at the API's 200),album_tracks_limit: usize(default 200),call_timeout_secs: u64(default 30). Hand-writtenDebugredactsclient_id.- No scraping, no OAuth, no token refresh in v1 — the
client_idis read once from config and used on every call. An API401/403(revoked/invalid key) maps to a typedFetchError; 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
audioformatsetting.
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 reusecreate_lib_node/rename_lib_node/delete_lib_node. - Outbound: the
Jamtrait (network seam) — the only placereqwestand theclient_idlive. Everything above it is pure and unit-tested. - Config:
jamendo.toml(client_id, optional bounds), round-tripped viasettings(); wired throughcrabidy-serversettings like every provider.
Risks and open questions
- R1 —
audioURL plays on the windowed-HTTP path. The whole "no player change" claim rests on theaudioMP3 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 —
audioformatavailability.mp32may not exist for every track; decode defensively and fall back to whateveraudiothe listing returned (theaudiofield 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 inJamApi. Live validation is a task-plan gate. - R4 —
client_idvalidity at startup. We do not verify the key at init (no blocking network inbuild()); the first browse reveals a bad key as a typedFetchError. Acceptable — matches how the other remote providers fail lazily rather than at boot.