Add the Jamendo provider (/jamendo) for Creative-Commons music
New jamendody crate implementing ProviderClient, mounted at /jamendo: search the Jamendo catalogue and play tracks, browse an album a track belongs to, with captures/downloads for free. Unlike the SoundCloud provider this is the simple case — Jamendo has a stable official API (api.jamendo.com/v3.0), so there is no client_id scraping, no OAuth, and no HLS: a track streams via its direct audio MP3 URL on the existing windowed-HTTP path, and duration is already in seconds (matching Track.duration). A registered client_id in jamendo.toml is required; missing it disables /jamendo only, non-fatally. Ran the full dev-flow pipeline; artifacts under architecture/, quality/, and plan/. 16 jamendody unit tests over a faked Jam network seam; server wired with the standard owns/provider/build/dispatch pattern and a jamendo toggle in ALL_PROVIDERS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c4001df74d
commit
06381949ab
|
|
@ -1169,6 +1169,7 @@ dependencies = [
|
||||||
"fyyd",
|
"fyyd",
|
||||||
"http",
|
"http",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
|
"jamendody",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"realfft",
|
"realfft",
|
||||||
"reqwest 0.13.1",
|
"reqwest 0.13.1",
|
||||||
|
|
@ -2438,6 +2439,21 @@ version = "1.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jamendody"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"crabidy-core",
|
||||||
|
"reqwest 0.13.1",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ members = [
|
||||||
"crabidy-server",
|
"crabidy-server",
|
||||||
"fsdy",
|
"fsdy",
|
||||||
"fyyd",
|
"fyyd",
|
||||||
|
"jamendody",
|
||||||
"soundclouddy",
|
"soundclouddy",
|
||||||
"tidaldy",
|
"tidaldy",
|
||||||
"ytdy",
|
"ytdy",
|
||||||
|
|
@ -109,6 +110,7 @@ crabidy-core = { path = "crabidy-core" }
|
||||||
crabidy-server = { path = "crabidy-server" }
|
crabidy-server = { path = "crabidy-server" }
|
||||||
fsdy = { path = "fsdy" }
|
fsdy = { path = "fsdy" }
|
||||||
fyyd = { path = "fyyd" }
|
fyyd = { path = "fyyd" }
|
||||||
|
jamendody = { path = "jamendody" }
|
||||||
soundclouddy = { path = "soundclouddy" }
|
soundclouddy = { path = "soundclouddy" }
|
||||||
tidaldy = { path = "tidaldy" }
|
tidaldy = { path = "tidaldy" }
|
||||||
ytdy = { path = "ytdy" }
|
ytdy = { path = "ytdy" }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -36,6 +36,7 @@ flume.workspace = true
|
||||||
absdy.workspace = true
|
absdy.workspace = true
|
||||||
fsdy.workspace = true
|
fsdy.workspace = true
|
||||||
fyyd.workspace = true
|
fyyd.workspace = true
|
||||||
|
jamendody.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ pub struct ProviderOrchestrator {
|
||||||
/// obtained — non-fatal, it only costs the `/soundcloud` subtree
|
/// obtained — non-fatal, it only costs the `/soundcloud` subtree
|
||||||
/// (architecture/soundcloud-provider.md D1).
|
/// (architecture/soundcloud-provider.md D1).
|
||||||
sc_client: Option<Arc<soundclouddy::Client>>,
|
sc_client: Option<Arc<soundclouddy::Client>>,
|
||||||
|
/// The Jamendo provider; `None` when disabled or no `client_id` is
|
||||||
|
/// configured — non-fatal, it only costs the `/jamendo` subtree
|
||||||
|
/// (architecture/jamendo-provider.md D1).
|
||||||
|
jamendo_client: Option<Arc<jamendody::Client>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a path belongs to the filesystem provider.
|
/// Whether a path belongs to the filesystem provider.
|
||||||
|
|
@ -86,6 +90,11 @@ fn sc_owns(path: &str) -> bool {
|
||||||
path == soundclouddy::PROVIDER_ROOT || path.starts_with("/soundcloud/")
|
path == soundclouddy::PROVIDER_ROOT || path.starts_with("/soundcloud/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a path belongs to the Jamendo provider.
|
||||||
|
fn jamendo_owns(path: &str) -> bool {
|
||||||
|
path == jamendody::PROVIDER_ROOT || path.starts_with("/jamendo/")
|
||||||
|
}
|
||||||
|
|
||||||
impl ProviderOrchestrator {
|
impl ProviderOrchestrator {
|
||||||
/// The tidal client, or `MalformedPath` (with a warning) when the
|
/// The tidal client, or `MalformedPath` (with a warning) when the
|
||||||
/// provider is disabled — a `/tidal` path then has no owner.
|
/// provider is disabled — a `/tidal` path then has no owner.
|
||||||
|
|
@ -163,6 +172,15 @@ impl ProviderOrchestrator {
|
||||||
ProviderError::MalformedPath
|
ProviderError::MalformedPath
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Jamendo client, or `MalformedPath` (with a warning) when the
|
||||||
|
/// provider is disabled — a `/jamendo` path then has no owner.
|
||||||
|
fn jamendo_provider(&self) -> Result<&jamendody::Client, ProviderError> {
|
||||||
|
self.jamendo_client.as_deref().ok_or_else(|| {
|
||||||
|
warn!("jamendo provider is disabled");
|
||||||
|
ProviderError::MalformedPath
|
||||||
|
})
|
||||||
|
}
|
||||||
pub fn run(self) {
|
pub fn run(self) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Behind an Arc so long-running resolves can be spawned onto
|
// Behind an Arc so long-running resolves can be spawned onto
|
||||||
|
|
@ -500,6 +518,30 @@ impl ProviderOrchestrator {
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
// Jamendo: non-fatal. Needs a registered `client_id`; a missing or
|
||||||
|
// empty `jamendo.toml` disables the provider — it only costs the
|
||||||
|
// `/jamendo` subtree (architecture/jamendo-provider.md D1).
|
||||||
|
let jamendo_client = if enabled.jamendo {
|
||||||
|
let jamendo_config_file = config_dir.join("jamendo.toml");
|
||||||
|
debug!(config_file = %jamendo_config_file.display(), "loading jamendo config");
|
||||||
|
let raw_jamendo_settings = fs::read_to_string(&jamendo_config_file).unwrap_or_default();
|
||||||
|
match jamendody::Client::init(&raw_jamendo_settings).await {
|
||||||
|
Ok(client) => {
|
||||||
|
if let Err(err) =
|
||||||
|
tokio::fs::write(&jamendo_config_file, client.settings()).await
|
||||||
|
{
|
||||||
|
error!("failed to write jamendo config file: {err}");
|
||||||
|
}
|
||||||
|
Some(Arc::new(client))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("jamendo provider disabled: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let (provider_tx, provider_rx) = flume::bounded(100);
|
let (provider_tx, provider_rx) = flume::bounded(100);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
provider_rx,
|
provider_rx,
|
||||||
|
|
@ -513,6 +555,7 @@ impl ProviderOrchestrator {
|
||||||
fyyd_client,
|
fyyd_client,
|
||||||
abs_client,
|
abs_client,
|
||||||
sc_client,
|
sc_client,
|
||||||
|
jamendo_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -579,6 +622,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|sc| sc.is_track_path(path));
|
.is_some_and(|sc| sc.is_track_path(path));
|
||||||
}
|
}
|
||||||
|
if jamendo_owns(path) {
|
||||||
|
return self
|
||||||
|
.jamendo_client
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|jamendo| jamendo.is_track_path(path));
|
||||||
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -617,6 +666,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if sc_owns(track_path) {
|
if sc_owns(track_path) {
|
||||||
return self.sc_provider()?.get_urls_for_track(track_path).await;
|
return self.sc_provider()?.get_urls_for_track(track_path).await;
|
||||||
}
|
}
|
||||||
|
if jamendo_owns(track_path) {
|
||||||
|
return self
|
||||||
|
.jamendo_provider()?
|
||||||
|
.get_urls_for_track(track_path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path = track_path, "no provider owns this track path");
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -665,6 +720,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if sc_owns(track_path) {
|
if sc_owns(track_path) {
|
||||||
return self.sc_provider()?.get_metadata_for_track(track_path).await;
|
return self.sc_provider()?.get_metadata_for_track(track_path).await;
|
||||||
}
|
}
|
||||||
|
if jamendo_owns(track_path) {
|
||||||
|
return self
|
||||||
|
.jamendo_provider()?
|
||||||
|
.get_metadata_for_track(track_path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path = track_path, "no provider owns this track path");
|
warn!(path = track_path, "no provider owns this track path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -720,6 +781,14 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
);
|
);
|
||||||
root_node.children.push(child);
|
root_node.children.push(child);
|
||||||
}
|
}
|
||||||
|
if self.jamendo_client.is_some() {
|
||||||
|
let child = LibraryNodeChild::new(
|
||||||
|
jamendody::PROVIDER_ROOT.to_owned(),
|
||||||
|
"jamendo".to_owned(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
root_node.children.push(child);
|
||||||
|
}
|
||||||
root_node
|
root_node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -745,6 +814,8 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
self.abs_provider()?.get_lib_node(path).await?
|
self.abs_provider()?.get_lib_node(path).await?
|
||||||
} else if sc_owns(path) {
|
} else if sc_owns(path) {
|
||||||
self.sc_provider()?.get_lib_node(path).await?
|
self.sc_provider()?.get_lib_node(path).await?
|
||||||
|
} else if jamendo_owns(path) {
|
||||||
|
self.jamendo_provider()?.get_lib_node(path).await?
|
||||||
} else {
|
} else {
|
||||||
warn!(path, "no provider owns this path");
|
warn!(path, "no provider owns this path");
|
||||||
return Err(ProviderError::MalformedPath);
|
return Err(ProviderError::MalformedPath);
|
||||||
|
|
@ -813,6 +884,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.create_lib_node(parent_path, title)
|
.create_lib_node(parent_path, title)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
if jamendo_owns(parent_path) {
|
||||||
|
return self
|
||||||
|
.jamendo_provider()?
|
||||||
|
.create_lib_node(parent_path, title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(parent_path, "no provider supports creating nodes here");
|
warn!(parent_path, "no provider supports creating nodes here");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
@ -861,6 +938,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if sc_owns(path) {
|
if sc_owns(path) {
|
||||||
return self.sc_provider()?.rename_lib_node(path, new_title).await;
|
return self.sc_provider()?.rename_lib_node(path, new_title).await;
|
||||||
}
|
}
|
||||||
|
if jamendo_owns(path) {
|
||||||
|
return self
|
||||||
|
.jamendo_provider()?
|
||||||
|
.rename_lib_node(path, new_title)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path, "no provider supports renaming this node");
|
warn!(path, "no provider supports renaming this node");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
@ -921,6 +1004,12 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
.resolve_tracks_into(path, chunk_tx)
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
if jamendo_owns(path) {
|
||||||
|
return self
|
||||||
|
.jamendo_provider()?
|
||||||
|
.resolve_tracks_into(path, chunk_tx)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
warn!(path, "no provider owns this path");
|
warn!(path, "no provider owns this path");
|
||||||
Err(ProviderError::MalformedPath)
|
Err(ProviderError::MalformedPath)
|
||||||
}
|
}
|
||||||
|
|
@ -953,6 +1042,9 @@ impl ProviderClient for ProviderOrchestrator {
|
||||||
if sc_owns(path) {
|
if sc_owns(path) {
|
||||||
return self.sc_provider()?.delete_lib_node(path).await;
|
return self.sc_provider()?.delete_lib_node(path).await;
|
||||||
}
|
}
|
||||||
|
if jamendo_owns(path) {
|
||||||
|
return self.jamendo_provider()?.delete_lib_node(path).await;
|
||||||
|
}
|
||||||
warn!(path, "no provider supports deleting this node");
|
warn!(path, "no provider supports deleting this node");
|
||||||
Err(ProviderError::NotSupported)
|
Err(ProviderError::NotSupported)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,13 @@ pub const SETTINGS_FILE: &str = "crabidy-server.toml";
|
||||||
/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/abs`,
|
/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/abs`,
|
||||||
/// `/soundcloud`, `/fs`, `/crabidy`, `/orphans`). `orphans` is a view over the
|
/// `/soundcloud`, `/fs`, `/crabidy`, `/orphans`). `orphans` is a view over the
|
||||||
/// store, so it needs `crabidy`.
|
/// store, so it needs `crabidy`.
|
||||||
pub const ALL_PROVIDERS: [&str; 8] = [
|
pub const ALL_PROVIDERS: [&str; 9] = [
|
||||||
"tidal",
|
"tidal",
|
||||||
"youtube",
|
"youtube",
|
||||||
"fyyd",
|
"fyyd",
|
||||||
"abs",
|
"abs",
|
||||||
"soundcloud",
|
"soundcloud",
|
||||||
|
"jamendo",
|
||||||
"fs",
|
"fs",
|
||||||
"crabidy",
|
"crabidy",
|
||||||
"orphans",
|
"orphans",
|
||||||
|
|
@ -78,6 +79,7 @@ pub struct ProviderToggles {
|
||||||
pub fyyd: bool,
|
pub fyyd: bool,
|
||||||
pub abs: bool,
|
pub abs: bool,
|
||||||
pub soundcloud: bool,
|
pub soundcloud: bool,
|
||||||
|
pub jamendo: bool,
|
||||||
pub fs: bool,
|
pub fs: bool,
|
||||||
pub crabidy: bool,
|
pub crabidy: bool,
|
||||||
pub orphans: bool,
|
pub orphans: bool,
|
||||||
|
|
@ -93,6 +95,7 @@ impl ProviderToggles {
|
||||||
fyyd: true,
|
fyyd: true,
|
||||||
abs: true,
|
abs: true,
|
||||||
soundcloud: true,
|
soundcloud: true,
|
||||||
|
jamendo: true,
|
||||||
fs: true,
|
fs: true,
|
||||||
crabidy: true,
|
crabidy: true,
|
||||||
orphans: true,
|
orphans: true,
|
||||||
|
|
@ -201,6 +204,7 @@ impl ServerSettings {
|
||||||
fyyd: self.provider_enabled("fyyd"),
|
fyyd: self.provider_enabled("fyyd"),
|
||||||
abs: self.provider_enabled("abs"),
|
abs: self.provider_enabled("abs"),
|
||||||
soundcloud: self.provider_enabled("soundcloud"),
|
soundcloud: self.provider_enabled("soundcloud"),
|
||||||
|
jamendo: self.provider_enabled("jamendo"),
|
||||||
fs: self.provider_enabled("fs"),
|
fs: self.provider_enabled("fs"),
|
||||||
crabidy: self.provider_enabled("crabidy"),
|
crabidy: self.provider_enabled("crabidy"),
|
||||||
orphans: self.provider_enabled("orphans"),
|
orphans: self.provider_enabled("orphans"),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "jamendody"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait.workspace = true
|
||||||
|
crabidy-core = { path = "../crabidy-core" }
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["time", "sync"] }
|
||||||
|
toml.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|
@ -0,0 +1,414 @@
|
||||||
|
//! The Jamendo HTTP seam.
|
||||||
|
//!
|
||||||
|
//! All network access goes through the [`Jam`] trait so the provider's
|
||||||
|
//! tree/path logic is unit-tested with a fake and no network
|
||||||
|
//! (architecture/jamendo-provider.md D2). [`JamApi`] is the production
|
||||||
|
//! `reqwest` implementation; tests supply their own [`Jam`].
|
||||||
|
//!
|
||||||
|
//! One semi-secret lives here: the `client_id` (the registered app key). It is
|
||||||
|
//! redacted from `Debug` and never logged, and a resolved `audio` URL (which
|
||||||
|
//! can embed a signed token) is likewise never logged. Unlike SoundCloud there
|
||||||
|
//! is no scraping or rotation — the `client_id` comes from config and is used
|
||||||
|
//! verbatim on every call.
|
||||||
|
|
||||||
|
use std::fmt::{self, Debug};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
/// The Jamendo API v3.0 base. Every request also carries `client_id`+`format`.
|
||||||
|
const API_BASE: &str = "https://api.jamendo.com/v3.0";
|
||||||
|
/// Jamendo's hard cap on `limit` for a list request.
|
||||||
|
const MAX_LIMIT: usize = 200;
|
||||||
|
|
||||||
|
/// A typed Jamendo request failure. Carries only non-secret context — never the
|
||||||
|
/// `client_id` or a signed `audio` URL.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum FetchError {
|
||||||
|
/// The HTTP call failed (transport, timeout, or non-success status), or the
|
||||||
|
/// API envelope reported a non-`success` status.
|
||||||
|
#[error("jamendo request failed: {0}")]
|
||||||
|
Http(String),
|
||||||
|
/// The response body did not decode into the expected shape.
|
||||||
|
#[error("jamendo returned malformed data: {0}")]
|
||||||
|
Decode(String),
|
||||||
|
/// The resource does not exist (empty result set or HTTP 404).
|
||||||
|
#[error("jamendo resource not found")]
|
||||||
|
NotFound,
|
||||||
|
/// Auth was rejected — a revoked/invalid `client_id`.
|
||||||
|
#[error("jamendo authentication failed")]
|
||||||
|
Unauthorized,
|
||||||
|
/// The track exposes no playable `audio` URL.
|
||||||
|
#[error("jamendo track is not streamable")]
|
||||||
|
NotStreamable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Jamendo track. `id` is a numeric string (URL-safe). `duration_secs` is the
|
||||||
|
/// track length **in seconds** — already the unit [`crabidy_core`]'s wire
|
||||||
|
/// `Track.duration` carries, so the provider passes it through unchanged. The
|
||||||
|
/// domain type never carries the stream URL (resolved separately via
|
||||||
|
/// [`Jam::track_stream`]).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct JamTrack {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
/// The performing artist (`artist_name`).
|
||||||
|
pub artist: String,
|
||||||
|
/// Album title (`album_name`), when the object carries one.
|
||||||
|
pub album: Option<String>,
|
||||||
|
/// Track length in seconds.
|
||||||
|
pub duration_secs: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Jamendo album. `tracks` is populated by [`Jam::album_tracks`] and empty for
|
||||||
|
/// an album returned from a search listing (id/title/artist only).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct JamAlbum {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub artist: String,
|
||||||
|
pub tracks: Vec<JamTrack>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Jamendo operations the provider needs. Behind `Box<dyn Jam>` so tests
|
||||||
|
/// fake it (architecture/jamendo-provider.md D2). Every call is bounded by the
|
||||||
|
/// [`JamApi`] timeout; `limit` is clamped to the API maximum.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Jam: Debug + Send + Sync {
|
||||||
|
/// Tracks matching a free-text query (at most `limit`).
|
||||||
|
async fn search_tracks(&self, query: &str, limit: usize) -> Result<Vec<JamTrack>, FetchError>;
|
||||||
|
|
||||||
|
/// Albums matching a free-text query (at most `limit`); their `tracks` are
|
||||||
|
/// left empty (the container is expanded lazily via [`Self::album_tracks`]).
|
||||||
|
async fn search_albums(&self, query: &str, limit: usize) -> Result<Vec<JamAlbum>, FetchError>;
|
||||||
|
|
||||||
|
/// One album's tracks by id (at most `limit` tracks), in album order.
|
||||||
|
async fn album_tracks(&self, id: &str, limit: usize) -> Result<JamAlbum, FetchError>;
|
||||||
|
|
||||||
|
/// One track's metadata by id. [`FetchError::NotFound`] when absent.
|
||||||
|
async fn track_detail(&self, id: &str) -> Result<JamTrack, FetchError>;
|
||||||
|
|
||||||
|
/// Resolves a track id to its playable `audio` URL. The URL is ephemeral and
|
||||||
|
/// possibly signed — never logged. [`FetchError::NotStreamable`] when the
|
||||||
|
/// track has no `audio`.
|
||||||
|
async fn track_stream(&self, id: &str) -> Result<String, FetchError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production `reqwest` client for `api.jamendo.com/v3.0`. `Debug` redacts the
|
||||||
|
/// `client_id` (hard rule: redact secrets from logs/reports).
|
||||||
|
pub struct JamApi {
|
||||||
|
http: reqwest::Client,
|
||||||
|
/// The registered app key. **Secret** — redacted from `Debug`, never logged.
|
||||||
|
client_id: String,
|
||||||
|
/// Requested audio format (e.g. `mp32`); selects the bitrate of `audio`.
|
||||||
|
audioformat: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for JamApi {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("JamApi")
|
||||||
|
.field("client_id", &"<redacted>")
|
||||||
|
.field("audioformat", &self.audioformat)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JamApi {
|
||||||
|
/// Builds the client. `timeout` bounds every call (hard rule: timeouts on
|
||||||
|
/// external calls). `audioformat` defaults are applied by the caller.
|
||||||
|
pub fn new(
|
||||||
|
client_id: String,
|
||||||
|
audioformat: String,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<Self, FetchError> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(timeout)
|
||||||
|
.build()
|
||||||
|
.map_err(|err| FetchError::Http(err.to_string()))?;
|
||||||
|
Ok(Self {
|
||||||
|
http,
|
||||||
|
client_id,
|
||||||
|
audioformat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signed GET of `path` under the API base with the given query pairs,
|
||||||
|
/// decoding the Jamendo `{headers, results}` envelope. A non-`success`
|
||||||
|
/// envelope status or a 401/403/404 maps to a typed [`FetchError`].
|
||||||
|
async fn get<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
query: &[(&str, String)],
|
||||||
|
) -> Result<Vec<T>, FetchError> {
|
||||||
|
let mut req = self
|
||||||
|
.http
|
||||||
|
.get(format!("{API_BASE}/{path}"))
|
||||||
|
.query(&[("client_id", self.client_id.as_str()), ("format", "json")]);
|
||||||
|
if !query.is_empty() {
|
||||||
|
req = req.query(query);
|
||||||
|
}
|
||||||
|
let resp = req
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| FetchError::Http(err.without_url().to_string()))?;
|
||||||
|
match resp.status() {
|
||||||
|
s if s.is_success() => {}
|
||||||
|
reqwest::StatusCode::NOT_FOUND => return Err(FetchError::NotFound),
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
|
||||||
|
return Err(FetchError::Unauthorized)
|
||||||
|
}
|
||||||
|
status => return Err(FetchError::Http(format!("status {status}"))),
|
||||||
|
}
|
||||||
|
let envelope: Envelope<T> = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|err| FetchError::Decode(err.to_string()))?;
|
||||||
|
if !envelope.headers.status.eq_ignore_ascii_case("success") {
|
||||||
|
// Jamendo returns HTTP 200 with the error in the envelope; a bad
|
||||||
|
// client_id lands here. Never carries the id itself.
|
||||||
|
let msg = envelope.headers.error_message;
|
||||||
|
if msg.to_ascii_lowercase().contains("author")
|
||||||
|
|| msg.to_ascii_lowercase().contains("client")
|
||||||
|
{
|
||||||
|
return Err(FetchError::Unauthorized);
|
||||||
|
}
|
||||||
|
return Err(FetchError::Http(if msg.is_empty() {
|
||||||
|
"non-success response".to_string()
|
||||||
|
} else {
|
||||||
|
msg
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(envelope.results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamps a caller limit to Jamendo's maximum (logs when it truncates).
|
||||||
|
fn clamp_limit(limit: usize) -> usize {
|
||||||
|
if limit > MAX_LIMIT {
|
||||||
|
warn!(
|
||||||
|
limit,
|
||||||
|
cap = MAX_LIMIT,
|
||||||
|
"jamendo limit clamped to API maximum"
|
||||||
|
);
|
||||||
|
MAX_LIMIT
|
||||||
|
} else {
|
||||||
|
limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Jam for JamApi {
|
||||||
|
async fn search_tracks(&self, query: &str, limit: usize) -> Result<Vec<JamTrack>, FetchError> {
|
||||||
|
let dtos: Vec<TrackDto> = self
|
||||||
|
.get(
|
||||||
|
"tracks",
|
||||||
|
&[
|
||||||
|
("search", query.to_string()),
|
||||||
|
("limit", Self::clamp_limit(limit).to_string()),
|
||||||
|
("audioformat", self.audioformat.clone()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(dtos.into_iter().filter_map(TrackDto::into_track).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_albums(&self, query: &str, limit: usize) -> Result<Vec<JamAlbum>, FetchError> {
|
||||||
|
let dtos: Vec<AlbumDto> = self
|
||||||
|
.get(
|
||||||
|
"albums",
|
||||||
|
&[
|
||||||
|
("namesearch", query.to_string()),
|
||||||
|
("limit", Self::clamp_limit(limit).to_string()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(dtos.into_iter().filter_map(AlbumDto::into_album).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn album_tracks(&self, id: &str, limit: usize) -> Result<JamAlbum, FetchError> {
|
||||||
|
let dtos: Vec<AlbumDto> = self
|
||||||
|
.get(
|
||||||
|
"albums/tracks",
|
||||||
|
&[
|
||||||
|
("id", id.to_string()),
|
||||||
|
("limit", "1".to_string()),
|
||||||
|
("audioformat", self.audioformat.clone()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut album = dtos
|
||||||
|
.into_iter()
|
||||||
|
.find_map(AlbumDto::into_album)
|
||||||
|
.ok_or(FetchError::NotFound)?;
|
||||||
|
album.tracks.truncate(Self::clamp_limit(limit));
|
||||||
|
Ok(album)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn track_detail(&self, id: &str) -> Result<JamTrack, FetchError> {
|
||||||
|
let dtos: Vec<TrackDto> = self.get("tracks", &[("id", id.to_string())]).await?;
|
||||||
|
dtos.into_iter()
|
||||||
|
.find_map(TrackDto::into_track)
|
||||||
|
.ok_or(FetchError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn track_stream(&self, id: &str) -> Result<String, FetchError> {
|
||||||
|
let dtos: Vec<TrackDto> = self
|
||||||
|
.get(
|
||||||
|
"tracks",
|
||||||
|
&[
|
||||||
|
("id", id.to_string()),
|
||||||
|
("audioformat", self.audioformat.clone()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let audio = dtos
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|t| t.audio)
|
||||||
|
.filter(|u| !u.is_empty())
|
||||||
|
.ok_or(FetchError::NotStreamable)?;
|
||||||
|
Ok(audio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wire DTOs: decode defensively, missing fields degrade, never panic. -----
|
||||||
|
|
||||||
|
/// The Jamendo response envelope: `{ headers: {...}, results: [...] }`.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Envelope<T> {
|
||||||
|
#[serde(default)]
|
||||||
|
headers: HeadersDto,
|
||||||
|
#[serde(default = "Vec::new")]
|
||||||
|
results: Vec<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
struct HeadersDto {
|
||||||
|
#[serde(default)]
|
||||||
|
status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
error_message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jamendo ids arrive as JSON strings; `duration` as an integer number of
|
||||||
|
/// seconds. Everything is optional and defaults so a shape change degrades.
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
struct TrackDto {
|
||||||
|
#[serde(default)]
|
||||||
|
id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
artist_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
album_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
duration: Option<u32>,
|
||||||
|
/// The direct streaming MP3 URL (present on catalogue listings and detail).
|
||||||
|
#[serde(default)]
|
||||||
|
audio: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackDto {
|
||||||
|
fn into_track(self) -> Option<JamTrack> {
|
||||||
|
let id = self.id.filter(|s| !s.is_empty())?;
|
||||||
|
Some(JamTrack {
|
||||||
|
id,
|
||||||
|
title: self.name,
|
||||||
|
artist: self.artist_name,
|
||||||
|
album: self.album_name.filter(|s| !s.is_empty()),
|
||||||
|
duration_secs: self.duration.filter(|d| *d > 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
struct AlbumDto {
|
||||||
|
#[serde(default)]
|
||||||
|
id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
artist_name: String,
|
||||||
|
/// Present only on `albums/tracks`; empty for a plain album listing.
|
||||||
|
#[serde(default)]
|
||||||
|
tracks: Vec<TrackDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumDto {
|
||||||
|
fn into_album(self) -> Option<JamAlbum> {
|
||||||
|
let id = self.id.filter(|s| !s.is_empty())?;
|
||||||
|
let artist = self.artist_name;
|
||||||
|
let tracks = self
|
||||||
|
.tracks
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|mut t| {
|
||||||
|
// Album-track objects omit per-track artist/album; inherit them.
|
||||||
|
if t.artist_name.is_empty() {
|
||||||
|
t.artist_name = artist.clone();
|
||||||
|
}
|
||||||
|
if t.album_name.is_none() && !self.name.is_empty() {
|
||||||
|
t.album_name = Some(self.name.clone());
|
||||||
|
}
|
||||||
|
t.into_track()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Some(JamAlbum {
|
||||||
|
id,
|
||||||
|
title: self.name,
|
||||||
|
artist,
|
||||||
|
tracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod api_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_dto_decodes_and_maps() {
|
||||||
|
let json = r#"{
|
||||||
|
"id": "1234",
|
||||||
|
"name": "Lofi Piano",
|
||||||
|
"artist_name": "Some Artist",
|
||||||
|
"album_name": "Chill",
|
||||||
|
"duration": 210,
|
||||||
|
"audio": "https://mp3d.jamendo.com/?trackid=1234&format=mp32"
|
||||||
|
}"#;
|
||||||
|
let dto: TrackDto = serde_json::from_str(json).unwrap();
|
||||||
|
let track = dto.into_track().unwrap();
|
||||||
|
assert_eq!(track.id, "1234");
|
||||||
|
assert_eq!(track.title, "Lofi Piano");
|
||||||
|
assert_eq!(track.artist, "Some Artist");
|
||||||
|
assert_eq!(track.album.as_deref(), Some("Chill"));
|
||||||
|
// Duration stays in seconds (guards the ms/seconds regression).
|
||||||
|
assert_eq!(track.duration_secs, Some(210));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_tracks_inherit_artist_and_album() {
|
||||||
|
let json = r#"{
|
||||||
|
"id": "77",
|
||||||
|
"name": "The Album",
|
||||||
|
"artist_name": "Band",
|
||||||
|
"tracks": [{ "id": "10", "name": "One", "duration": 100 }]
|
||||||
|
}"#;
|
||||||
|
let dto: AlbumDto = serde_json::from_str(json).unwrap();
|
||||||
|
let album = dto.into_album().unwrap();
|
||||||
|
assert_eq!(album.id, "77");
|
||||||
|
assert_eq!(album.tracks.len(), 1);
|
||||||
|
assert_eq!(album.tracks[0].artist, "Band");
|
||||||
|
assert_eq!(album.tracks[0].album.as_deref(), Some("The Album"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defensive_decode_drops_idless_rows() {
|
||||||
|
let dto: TrackDto = serde_json::from_str(r#"{"name":"no id"}"#).unwrap();
|
||||||
|
assert!(dto.into_track().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,521 @@
|
||||||
|
//! Jamendo provider: **search and play** the Jamendo catalogue of
|
||||||
|
//! Creative-Commons music, and browse the album a track belongs to. Mounted at
|
||||||
|
//! [`PROVIDER_ROOT`].
|
||||||
|
//!
|
||||||
|
//! Shaped on the abs/soundcloud providers (remote, search-driven), but the
|
||||||
|
//! *simple* case (architecture/jamendo-provider.md):
|
||||||
|
//! 1. **No login** — the public catalogue needs only a registered
|
||||||
|
//! `client_id`; there is no OAuth or token lifecycle in v1.
|
||||||
|
//! 2. **Direct MP3 playback** — `get_urls_for_track` returns the track's
|
||||||
|
//! `audio` URL, which the audio-player streams on its existing
|
||||||
|
//! windowed-HTTP path. No HLS, no new player component.
|
||||||
|
//!
|
||||||
|
//! The `client_id` is a semi-secret app key, redacted from `Debug` and never
|
||||||
|
//! logged; resolved `audio` URLs (which can carry a signed token) are never
|
||||||
|
//! logged either.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use crabidy_core::proto::crabidy::{Album, LibraryNode, LibraryNodeChild, Track};
|
||||||
|
use crabidy_core::{ProviderClient, ProviderError};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
|
use api::{Jam, JamAlbum, JamApi, JamTrack};
|
||||||
|
|
||||||
|
/// First path segment owned by this provider.
|
||||||
|
pub const PROVIDER_ROOT: &str = "/jamendo";
|
||||||
|
|
||||||
|
/// Reserved second-level literals (numeric ids never collide with these).
|
||||||
|
const SEARCH_SEGMENT: &str = "search";
|
||||||
|
/// Canonical type tags disambiguating numeric track vs album ids.
|
||||||
|
const TRACK_SEGMENT: &str = "track";
|
||||||
|
const ALBUM_SEGMENT: &str = "album";
|
||||||
|
|
||||||
|
/// Default results per search term (Jamendo caps a request at 200).
|
||||||
|
pub const DEFAULT_SEARCH_RESULTS: usize = 50;
|
||||||
|
/// Default tracks listed per album.
|
||||||
|
pub const DEFAULT_ALBUM_TRACKS: usize = 200;
|
||||||
|
/// Default per-request timeout in seconds.
|
||||||
|
pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30;
|
||||||
|
/// Default requested audio format (`mp32` = higher-bitrate MP3).
|
||||||
|
pub const DEFAULT_AUDIOFORMAT: &str = "mp32";
|
||||||
|
|
||||||
|
/// Provider settings, persisted as `jamendo.toml`. `client_id` is **required**:
|
||||||
|
/// without it the provider does not mount (D5). The rest have defaults.
|
||||||
|
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// The registered Jamendo app key from `devportal.jamendo.com`. Required.
|
||||||
|
/// **Secret**: redacted from `Debug`.
|
||||||
|
pub client_id: Option<String>,
|
||||||
|
/// Requested audio format. Default [`DEFAULT_AUDIOFORMAT`].
|
||||||
|
pub audioformat: Option<String>,
|
||||||
|
/// Results per search term (clamped to Jamendo's 200). Default
|
||||||
|
/// [`DEFAULT_SEARCH_RESULTS`].
|
||||||
|
pub search_results: Option<usize>,
|
||||||
|
/// Tracks listed per album. Default [`DEFAULT_ALBUM_TRACKS`].
|
||||||
|
pub album_tracks: Option<usize>,
|
||||||
|
/// Per-request timeout in seconds. Default [`DEFAULT_CALL_TIMEOUT_SECS`].
|
||||||
|
pub call_timeout_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Settings {
|
||||||
|
/// Redacts `client_id` (hard rule: secrets never logged).
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("Settings")
|
||||||
|
.field("client_id", &self.client_id.as_ref().map(|_| "<redacted>"))
|
||||||
|
.field("audioformat", &self.audioformat)
|
||||||
|
.field("search_results", &self.search_results)
|
||||||
|
.field("album_tracks", &self.album_tracks)
|
||||||
|
.field("call_timeout_secs", &self.call_timeout_secs)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed `/jamendo/...` path. Browse nodes point their children at the
|
||||||
|
/// **canonical** `track/<id>` and `album/<id>` shapes, so a leaf carries the id
|
||||||
|
/// alone — no browse context (D3/G7).
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum JamPath<'a> {
|
||||||
|
Root,
|
||||||
|
/// The search-terms parent (creatable).
|
||||||
|
Search,
|
||||||
|
/// A single search term (percent-encoded segment).
|
||||||
|
SearchTerm {
|
||||||
|
term: &'a str,
|
||||||
|
},
|
||||||
|
/// An album container by id (canonical).
|
||||||
|
AlbumLeaf {
|
||||||
|
id: &'a str,
|
||||||
|
},
|
||||||
|
/// A track leaf by id (canonical).
|
||||||
|
TrackLeaf {
|
||||||
|
id: &'a str,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a `/jamendo/...` path into its recognized shape. Unknown shapes are
|
||||||
|
/// [`ProviderError::MalformedPath`].
|
||||||
|
fn parse_path(path: &str) -> Result<JamPath<'_>, ProviderError> {
|
||||||
|
if path == PROVIDER_ROOT {
|
||||||
|
return Ok(JamPath::Root);
|
||||||
|
}
|
||||||
|
let rest = path
|
||||||
|
.strip_prefix("/jamendo/")
|
||||||
|
.ok_or(ProviderError::MalformedPath)?;
|
||||||
|
let segments: Vec<&str> = rest.split('/').collect();
|
||||||
|
if segments.iter().any(|segment| segment.is_empty()) {
|
||||||
|
return Err(ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
match segments.as_slice() {
|
||||||
|
[s] if *s == SEARCH_SEGMENT => Ok(JamPath::Search),
|
||||||
|
[s, term] if *s == SEARCH_SEGMENT => Ok(JamPath::SearchTerm { term }),
|
||||||
|
[s, id] if *s == ALBUM_SEGMENT => Ok(JamPath::AlbumLeaf { id }),
|
||||||
|
[s, id] if *s == TRACK_SEGMENT => Ok(JamPath::TrackLeaf { id }),
|
||||||
|
_ => Err(ProviderError::MalformedPath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a fetch failure to the trait-level error, logging the typed cause.
|
||||||
|
fn fetch_err(context: &str, err: api::FetchError) -> ProviderError {
|
||||||
|
warn!(context, "jamendo fetch failed: {err}");
|
||||||
|
ProviderError::FetchError
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of the in-memory term store (never held across awaits).
|
||||||
|
fn store_snapshot(store: &RwLock<Vec<String>>) -> Vec<String> {
|
||||||
|
store.read().map(|v| v.clone()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a term if absent (dedup, creation order preserved).
|
||||||
|
fn store_add(store: &RwLock<Vec<String>>, item: &str) {
|
||||||
|
if let Ok(mut list) = store.write() {
|
||||||
|
if !list.iter().any(|existing| existing == item) {
|
||||||
|
list.push(item.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a term; `true` when it existed.
|
||||||
|
fn store_remove(store: &RwLock<Vec<String>>, item: &str) -> bool {
|
||||||
|
match store.write() {
|
||||||
|
Ok(mut list) => {
|
||||||
|
let before = list.len();
|
||||||
|
list.retain(|existing| existing != item);
|
||||||
|
list.len() != before
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Jamendo provider client.
|
||||||
|
pub struct Client {
|
||||||
|
api: Box<dyn Jam>,
|
||||||
|
settings: Settings,
|
||||||
|
/// Search terms created under `/jamendo/search`, in creation order,
|
||||||
|
/// deduplicated. In-memory only, never held across awaits.
|
||||||
|
search_terms: RwLock<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Client {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("Client")
|
||||||
|
.field("api", &self.api)
|
||||||
|
.field("settings", &self.settings)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// A client over any [`Jam`] backend — the seam the tests use.
|
||||||
|
fn with_api(api: Box<dyn Jam>, settings: Settings) -> Self {
|
||||||
|
Self {
|
||||||
|
api,
|
||||||
|
settings,
|
||||||
|
search_terms: RwLock::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_limit(&self) -> usize {
|
||||||
|
self.settings
|
||||||
|
.search_results
|
||||||
|
.unwrap_or(DEFAULT_SEARCH_RESULTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_limit(&self) -> usize {
|
||||||
|
self.settings.album_tracks.unwrap_or(DEFAULT_ALBUM_TRACKS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Node builders ---
|
||||||
|
|
||||||
|
/// The `/jamendo` root: a single `search` child. Sync — fixed children (D3).
|
||||||
|
fn root_node(&self) -> LibraryNode {
|
||||||
|
LibraryNode {
|
||||||
|
path: PROVIDER_ROOT.to_string(),
|
||||||
|
title: "jamendo".to_string(),
|
||||||
|
parent: Some(crabidy_core::ROOT_PATH.to_string()),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
children: vec![creatable_child(
|
||||||
|
join(PROVIDER_ROOT, SEARCH_SEGMENT),
|
||||||
|
SEARCH_SEGMENT,
|
||||||
|
)],
|
||||||
|
is_queable: false,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `/jamendo/search` node listing the stored terms, each editable +
|
||||||
|
/// deletable. Entries link to `search/<encoded>`.
|
||||||
|
fn store_node(&self, path: &str) -> LibraryNode {
|
||||||
|
let children = store_snapshot(&self.search_terms)
|
||||||
|
.iter()
|
||||||
|
.map(|entry| LibraryNodeChild {
|
||||||
|
is_editable: true,
|
||||||
|
is_deletable: true,
|
||||||
|
..LibraryNodeChild::new(
|
||||||
|
join(path, &crabidy_core::encode_segment(entry)),
|
||||||
|
entry.clone(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
LibraryNode {
|
||||||
|
path: path.to_string(),
|
||||||
|
title: SEARCH_SEGMENT.to_string(),
|
||||||
|
parent: Some(parent_of(path)),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
children,
|
||||||
|
is_queable: false,
|
||||||
|
is_creatable: true,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `/jamendo/search/<term>` node: matching tracks as directly-playable
|
||||||
|
/// rows (canonical paths) and matching albums as queueable container
|
||||||
|
/// children (canonical paths).
|
||||||
|
async fn search_term_node(&self, path: &str, term: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let tracks = self
|
||||||
|
.api
|
||||||
|
.search_tracks(term, self.search_limit())
|
||||||
|
.await
|
||||||
|
.map_err(|err| fetch_err("search tracks", err))?;
|
||||||
|
let albums = self
|
||||||
|
.api
|
||||||
|
.search_albums(term, self.search_limit())
|
||||||
|
.await
|
||||||
|
.map_err(|err| fetch_err("search albums", err))?;
|
||||||
|
Ok(self.list_node(path, term.to_string(), &tracks, &albums))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `/jamendo/album/<id>` node: the album's tracks as canonical rows.
|
||||||
|
async fn album_node(&self, path: &str, id: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let album = self
|
||||||
|
.api
|
||||||
|
.album_tracks(id, self.album_limit())
|
||||||
|
.await
|
||||||
|
.map_err(|err| fetch_err("album tracks", err))?;
|
||||||
|
Ok(self.list_node(path, album.title.clone(), &album.tracks, &[]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a node holding `tracks` as directly-playable rows (canonical
|
||||||
|
/// paths) and `albums` as queueable container children (canonical paths).
|
||||||
|
/// Queueable when it carries either.
|
||||||
|
fn list_node(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
title: String,
|
||||||
|
tracks: &[JamTrack],
|
||||||
|
albums: &[JamAlbum],
|
||||||
|
) -> LibraryNode {
|
||||||
|
let track_rows: Vec<Track> = tracks.iter().map(jam_track).collect();
|
||||||
|
let children: Vec<LibraryNodeChild> = albums
|
||||||
|
.iter()
|
||||||
|
.map(|a| {
|
||||||
|
LibraryNodeChild::new(
|
||||||
|
join(PROVIDER_ROOT, &format!("{ALBUM_SEGMENT}/{}", a.id)),
|
||||||
|
a.title.clone(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let is_queable = !track_rows.is_empty() || children.iter().any(|c| c.is_queable);
|
||||||
|
LibraryNode {
|
||||||
|
path: path.to_string(),
|
||||||
|
title,
|
||||||
|
parent: Some(parent_of(path)),
|
||||||
|
tracks: track_rows,
|
||||||
|
children,
|
||||||
|
is_queable,
|
||||||
|
is_creatable: false,
|
||||||
|
is_downloadable: false,
|
||||||
|
tracks_deletable: false,
|
||||||
|
is_captured: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The track id addressed by a canonical track path, or `MalformedPath`.
|
||||||
|
fn track_id<'a>(&self, path: &'a str) -> Result<&'a str, ProviderError> {
|
||||||
|
match parse_path(path)? {
|
||||||
|
JamPath::TrackLeaf { id } => Ok(id),
|
||||||
|
_ => Err(ProviderError::MalformedPath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the wire [`Track`] for a Jamendo track at its canonical path.
|
||||||
|
/// `duration_secs` is already seconds; `provider_item_id` is `"track:<id>"`.
|
||||||
|
fn jam_track(track: &JamTrack) -> Track {
|
||||||
|
Track {
|
||||||
|
path: join(PROVIDER_ROOT, &format!("{TRACK_SEGMENT}/{}", track.id)),
|
||||||
|
artist: track.artist.clone(),
|
||||||
|
title: track.title.clone(),
|
||||||
|
duration: track.duration_secs,
|
||||||
|
album: track.album.as_ref().map(|title| Album {
|
||||||
|
title: title.clone(),
|
||||||
|
release_date: None,
|
||||||
|
}),
|
||||||
|
is_skipped: false,
|
||||||
|
provider_item_id: format!("track:{}", track.id),
|
||||||
|
is_captured: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A creatable browse child (the search parent).
|
||||||
|
fn creatable_child(path: String, title: &str) -> LibraryNodeChild {
|
||||||
|
LibraryNodeChild {
|
||||||
|
is_creatable: true,
|
||||||
|
..LibraryNodeChild::new(path, title.to_string(), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join(base: &str, segment: &str) -> String {
|
||||||
|
crabidy_core::join_path(base, segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parent_of(path: &str) -> String {
|
||||||
|
crabidy_core::parent_path(path)
|
||||||
|
.unwrap_or(crabidy_core::ROOT_PATH)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderClient for Client {
|
||||||
|
/// Parses `jamendo.toml` and builds the `reqwest`-backed [`api::JamApi`]. A
|
||||||
|
/// missing/empty `client_id` (or unparseable config) is a typed
|
||||||
|
/// [`ProviderError::Config`]: the orchestrator maps it to `None` so only the
|
||||||
|
/// `/jamendo` subtree is lost, never the server (D5/G6). No blocking network.
|
||||||
|
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
|
||||||
|
let settings: Settings = toml::from_str(raw_toml_settings).map_err(|err| {
|
||||||
|
warn!("could not parse jamendo.toml: {err}");
|
||||||
|
ProviderError::Config("jamendo.toml is not valid TOML".to_string())
|
||||||
|
})?;
|
||||||
|
let client_id = settings
|
||||||
|
.client_id
|
||||||
|
.clone()
|
||||||
|
.filter(|id| !id.trim().is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
warn!("jamendo provider disabled: no client_id configured");
|
||||||
|
ProviderError::Config("jamendo client_id is required".to_string())
|
||||||
|
})?;
|
||||||
|
let audioformat = settings
|
||||||
|
.audioformat
|
||||||
|
.clone()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_AUDIOFORMAT.to_string());
|
||||||
|
let timeout = Duration::from_secs(
|
||||||
|
settings
|
||||||
|
.call_timeout_secs
|
||||||
|
.unwrap_or(DEFAULT_CALL_TIMEOUT_SECS),
|
||||||
|
);
|
||||||
|
let api = JamApi::new(client_id, audioformat, timeout).map_err(|err| {
|
||||||
|
warn!("cannot build the jamendo client: {err}");
|
||||||
|
ProviderError::Config(err.to_string())
|
||||||
|
})?;
|
||||||
|
Ok(Self::with_api(Box::new(api), settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes settings back to TOML (round-trips `client_id` and bounds).
|
||||||
|
fn settings(&self) -> String {
|
||||||
|
toml::to_string_pretty(&self.settings).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_track_path(&self, path: &str) -> bool {
|
||||||
|
matches!(parse_path(path), Ok(JamPath::TrackLeaf { .. }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the track id to its direct `audio` URL (one API round trip). The
|
||||||
|
/// URL is ephemeral and possibly signed — never logged (D4).
|
||||||
|
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
||||||
|
let id = self.track_id(track_path)?;
|
||||||
|
let url = self
|
||||||
|
.api
|
||||||
|
.track_stream(id)
|
||||||
|
.await
|
||||||
|
.map_err(|err| fetch_err("stream url", err))?;
|
||||||
|
Ok(vec![url])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError> {
|
||||||
|
let id = self.track_id(track_path)?;
|
||||||
|
let track = self
|
||||||
|
.api
|
||||||
|
.track_detail(id)
|
||||||
|
.await
|
||||||
|
.map_err(|err| fetch_err("track metadata", err))?;
|
||||||
|
let mut wire = jam_track(&track);
|
||||||
|
wire.path = track_path.to_string();
|
||||||
|
Ok(wire)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The root is served synchronously (its children are fixed).
|
||||||
|
fn get_lib_root(&self) -> LibraryNode {
|
||||||
|
self.root_node()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let mut node = match parse_path(path)? {
|
||||||
|
JamPath::Root => self.root_node(),
|
||||||
|
JamPath::Search => self.store_node(path),
|
||||||
|
JamPath::SearchTerm { term } => {
|
||||||
|
let decoded = crabidy_core::decode_segment(term);
|
||||||
|
// Unknown terms (stale client cache) are recreated implicitly.
|
||||||
|
store_add(&self.search_terms, &decoded);
|
||||||
|
self.search_term_node(path, &decoded).await?
|
||||||
|
}
|
||||||
|
JamPath::AlbumLeaf { id } => self.album_node(path, id).await?,
|
||||||
|
JamPath::TrackLeaf { .. } => {
|
||||||
|
warn!(path, "get_lib_node called with a track path");
|
||||||
|
return Err(ProviderError::MalformedPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Download blessing (same rule as abs/soundcloud): a node serving tracks
|
||||||
|
// and each queueable child allows `W`.
|
||||||
|
node.is_downloadable = node.is_queable || !node.tracks.is_empty();
|
||||||
|
for child in &mut node.children {
|
||||||
|
child.is_downloadable = child.is_queable;
|
||||||
|
}
|
||||||
|
Ok(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/jamendo/search` is creatable: register a term and return its node
|
||||||
|
/// (implicit recreation on stale paths, like the other providers).
|
||||||
|
async fn create_lib_node(
|
||||||
|
&self,
|
||||||
|
parent_path: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let entry = title.trim();
|
||||||
|
if entry.is_empty() {
|
||||||
|
return Err(ProviderError::InvalidInput);
|
||||||
|
}
|
||||||
|
match parse_path(parent_path)? {
|
||||||
|
JamPath::Search => {}
|
||||||
|
_ => {
|
||||||
|
warn!(parent_path, "node creation not supported here");
|
||||||
|
return Err(ProviderError::NotSupported);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store_add(&self.search_terms, entry);
|
||||||
|
let entry_path = join(
|
||||||
|
&join(PROVIDER_ROOT, SEARCH_SEGMENT),
|
||||||
|
&crabidy_core::encode_segment(entry),
|
||||||
|
);
|
||||||
|
self.get_lib_node(&entry_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_lib_node(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
new_title: &str,
|
||||||
|
) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let old_encoded = match parse_path(path)? {
|
||||||
|
JamPath::SearchTerm { term } => term,
|
||||||
|
_ => {
|
||||||
|
warn!(path, "only search terms are renamable");
|
||||||
|
return Err(ProviderError::NotSupported);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let new_entry = new_title.trim();
|
||||||
|
if new_entry.is_empty() {
|
||||||
|
return Err(ProviderError::InvalidInput);
|
||||||
|
}
|
||||||
|
store_remove(
|
||||||
|
&self.search_terms,
|
||||||
|
&crabidy_core::decode_segment(old_encoded),
|
||||||
|
);
|
||||||
|
store_add(&self.search_terms, new_entry);
|
||||||
|
let new_path = join(
|
||||||
|
&join(PROVIDER_ROOT, SEARCH_SEGMENT),
|
||||||
|
&crabidy_core::encode_segment(new_entry),
|
||||||
|
);
|
||||||
|
self.get_lib_node(&new_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
||||||
|
let encoded = match parse_path(path)? {
|
||||||
|
JamPath::SearchTerm { term } => term,
|
||||||
|
_ => {
|
||||||
|
warn!(path, "only search terms are deletable");
|
||||||
|
return Err(ProviderError::NotSupported);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
store_remove(&self.search_terms, &crabidy_core::decode_segment(encoded));
|
||||||
|
self.get_lib_node(&join(PROVIDER_ROOT, SEARCH_SEGMENT))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
include!("tests.rs");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
// Unit tests over a fixture-driven `FakeJam` backend (no network). They define
|
||||||
|
// the provider's contract (quality/jamendo.md) and cover path parsing, tree
|
||||||
|
// shaping, field mapping, secret redaction, and typed-error propagation.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use api::{JamAlbum, JamTrack};
|
||||||
|
|
||||||
|
// --- Path parsing (T2) ---------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_and_search_parse() {
|
||||||
|
assert_eq!(parse_path("/jamendo"), Ok(JamPath::Root));
|
||||||
|
assert_eq!(parse_path("/jamendo/search"), Ok(JamPath::Search));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_leaves_parse() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_path("/jamendo/track/12345"),
|
||||||
|
Ok(JamPath::TrackLeaf { id: "12345" })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_path("/jamendo/album/999"),
|
||||||
|
Ok(JamPath::AlbumLeaf { id: "999" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn term_segment_parses() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_path("/jamendo/search/lofi%20piano"),
|
||||||
|
Ok(JamPath::SearchTerm {
|
||||||
|
term: "lofi%20piano"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_and_malformed_paths_reject() {
|
||||||
|
assert_eq!(parse_path("/tidal/x"), Err(ProviderError::MalformedPath));
|
||||||
|
assert_eq!(parse_path("/jamendo/"), Err(ProviderError::MalformedPath));
|
||||||
|
assert_eq!(parse_path("/jamendo/track"), Err(ProviderError::MalformedPath));
|
||||||
|
assert_eq!(
|
||||||
|
parse_path("/jamendo/track/1/2"),
|
||||||
|
Err(ProviderError::MalformedPath)
|
||||||
|
);
|
||||||
|
assert_eq!(parse_path("/jamendo//x"), Err(ProviderError::MalformedPath));
|
||||||
|
assert_eq!(parse_path("/jamendo/bogus/1"), Err(ProviderError::MalformedPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_track_path_only_for_canonical_track() {
|
||||||
|
let client = Client::with_api(Box::new(FakeJam::default()), settings_with_id());
|
||||||
|
assert!(client.is_track_path("/jamendo/track/1"));
|
||||||
|
assert!(!client.is_track_path("/jamendo/album/1"));
|
||||||
|
assert!(!client.is_track_path("/jamendo/search/x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Settings redaction (T10) --------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_debug_redacts_client_id() {
|
||||||
|
let s = Settings {
|
||||||
|
client_id: Some("SECRET_KEY".into()),
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
let dumped = format!("{s:?}");
|
||||||
|
assert!(!dumped.contains("SECRET_KEY"));
|
||||||
|
assert!(dumped.contains("<redacted>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tree shaping and mapping (T1, T4, T5, T7, T11) -----------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_has_only_search() {
|
||||||
|
let client = Client::with_api(Box::new(FakeJam::default()), settings_with_id());
|
||||||
|
let root = client.get_lib_root();
|
||||||
|
assert!(!root.is_queable);
|
||||||
|
assert_eq!(root.children.len(), 1);
|
||||||
|
assert_eq!(root.children[0].path, "/jamendo/search");
|
||||||
|
assert!(root.children[0].is_creatable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_term_lists_tracks_and_albums() {
|
||||||
|
let fake = FakeJam {
|
||||||
|
search_tracks: vec![track("10", "Kaini Industries")],
|
||||||
|
search_albums: vec![album("77", "BoC set", &[])],
|
||||||
|
..FakeJam::default()
|
||||||
|
};
|
||||||
|
let client = Client::with_api(Box::new(fake), settings_with_id());
|
||||||
|
let node = client.get_lib_node("/jamendo/search/boc").await.unwrap();
|
||||||
|
// Tracks are directly-playable rows at canonical paths.
|
||||||
|
assert!(node.tracks.iter().any(|t| t.path == "/jamendo/track/10"));
|
||||||
|
// Albums are queueable container children at canonical paths.
|
||||||
|
let child = node.children.iter().find(|c| c.path == "/jamendo/album/77");
|
||||||
|
assert!(child.is_some(), "album child missing");
|
||||||
|
assert!(child.unwrap().is_queable);
|
||||||
|
assert!(node.is_queable);
|
||||||
|
assert!(node.is_downloadable, "download blessing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn album_node_lists_tracks() {
|
||||||
|
let fake = FakeJam {
|
||||||
|
albums: HashMap::from([(
|
||||||
|
"77".to_string(),
|
||||||
|
album("77", "The Album", &["10", "11"]),
|
||||||
|
)]),
|
||||||
|
..FakeJam::default()
|
||||||
|
};
|
||||||
|
let client = Client::with_api(Box::new(fake), settings_with_id());
|
||||||
|
let node = client.get_lib_node("/jamendo/album/77").await.unwrap();
|
||||||
|
assert_eq!(node.tracks.len(), 2);
|
||||||
|
assert!(node.tracks.iter().all(|t| t.path.starts_with("/jamendo/track/")));
|
||||||
|
assert!(node.is_queable && node.is_downloadable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn track_metadata_maps_fields() {
|
||||||
|
let fake = FakeJam {
|
||||||
|
tracks: HashMap::from([("10".to_string(), track("10", "One"))]),
|
||||||
|
..FakeJam::default()
|
||||||
|
};
|
||||||
|
let client = Client::with_api(Box::new(fake), settings_with_id());
|
||||||
|
let meta = client
|
||||||
|
.get_metadata_for_track("/jamendo/track/10")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(meta.title, "One");
|
||||||
|
assert_eq!(meta.artist, "Some Artist");
|
||||||
|
assert_eq!(meta.album.as_ref().map(|a| a.title.as_str()), Some("Chill"));
|
||||||
|
assert_eq!(meta.provider_item_id, "track:10");
|
||||||
|
// Duration passes through in seconds (guards ms/seconds regression, T11).
|
||||||
|
assert_eq!(meta.duration, Some(210));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Playback (T6) --------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_urls_for_track_returns_audio() {
|
||||||
|
let fake = FakeJam {
|
||||||
|
streams: HashMap::from([(
|
||||||
|
"10".to_string(),
|
||||||
|
"https://mp3d.jamendo.com/?trackid=10&format=mp32".to_string(),
|
||||||
|
)]),
|
||||||
|
..FakeJam::default()
|
||||||
|
};
|
||||||
|
let client = Client::with_api(Box::new(fake), settings_with_id());
|
||||||
|
let urls = client
|
||||||
|
.get_urls_for_track("/jamendo/track/10")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
urls,
|
||||||
|
vec!["https://mp3d.jamendo.com/?trackid=10&format=mp32".to_string()]
|
||||||
|
);
|
||||||
|
// A non-track path is rejected, not streamed.
|
||||||
|
assert_eq!(
|
||||||
|
client.get_urls_for_track("/jamendo/album/77").await,
|
||||||
|
Err(ProviderError::MalformedPath)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search-term CRUD (T8) ------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_rename_delete_search_term() {
|
||||||
|
let client = Client::with_api(Box::new(FakeJam::default()), settings_with_id());
|
||||||
|
client.create_lib_node("/jamendo/search", "aphex").await.unwrap();
|
||||||
|
let search = client.get_lib_node("/jamendo/search").await.unwrap();
|
||||||
|
assert!(search
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.title == "aphex" && c.is_deletable && c.is_editable));
|
||||||
|
// Empty title is rejected.
|
||||||
|
assert_eq!(
|
||||||
|
client.create_lib_node("/jamendo/search", " ").await,
|
||||||
|
Err(ProviderError::InvalidInput)
|
||||||
|
);
|
||||||
|
// Rename moves the term.
|
||||||
|
let enc = crabidy_core::encode_segment("aphex");
|
||||||
|
client
|
||||||
|
.rename_lib_node(&format!("/jamendo/search/{enc}"), "squarepusher")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let search = client.get_lib_node("/jamendo/search").await.unwrap();
|
||||||
|
assert!(search.children.iter().any(|c| c.title == "squarepusher"));
|
||||||
|
assert!(!search.children.iter().any(|c| c.title == "aphex"));
|
||||||
|
// Delete removes it.
|
||||||
|
let enc = crabidy_core::encode_segment("squarepusher");
|
||||||
|
client
|
||||||
|
.delete_lib_node(&format!("/jamendo/search/{enc}"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let search = client.get_lib_node("/jamendo/search").await.unwrap();
|
||||||
|
assert!(search.children.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Typed errors, no panics (T9) -----------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn backend_failures_are_typed() {
|
||||||
|
let client = Client::with_api(Box::new(FailingJam), settings_with_id());
|
||||||
|
assert_eq!(
|
||||||
|
client.get_lib_node("/jamendo/search/x").await,
|
||||||
|
Err(ProviderError::FetchError)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.get_urls_for_track("/jamendo/track/1").await,
|
||||||
|
Err(ProviderError::FetchError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fakes and fixtures ---------------------------------------------------
|
||||||
|
|
||||||
|
fn settings_with_id() -> Settings {
|
||||||
|
Settings {
|
||||||
|
client_id: Some("test-key".into()),
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track(id: &str, title: &str) -> JamTrack {
|
||||||
|
JamTrack {
|
||||||
|
id: id.into(),
|
||||||
|
title: title.into(),
|
||||||
|
artist: "Some Artist".into(),
|
||||||
|
album: Some("Chill".into()),
|
||||||
|
duration_secs: Some(210),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album(id: &str, title: &str, track_ids: &[&str]) -> JamAlbum {
|
||||||
|
JamAlbum {
|
||||||
|
id: id.into(),
|
||||||
|
title: title.into(),
|
||||||
|
artist: "Band".into(),
|
||||||
|
tracks: track_ids.iter().map(|t| track(t, "Song")).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fixture-driven fake: every method reads a field, defaulting to empty.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct FakeJam {
|
||||||
|
tracks: HashMap<String, JamTrack>,
|
||||||
|
albums: HashMap<String, JamAlbum>,
|
||||||
|
search_tracks: Vec<JamTrack>,
|
||||||
|
search_albums: Vec<JamAlbum>,
|
||||||
|
streams: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl api::Jam for FakeJam {
|
||||||
|
async fn search_tracks(&self, _q: &str, _l: usize) -> Result<Vec<JamTrack>, api::FetchError> {
|
||||||
|
Ok(self.search_tracks.clone())
|
||||||
|
}
|
||||||
|
async fn search_albums(&self, _q: &str, _l: usize) -> Result<Vec<JamAlbum>, api::FetchError> {
|
||||||
|
Ok(self.search_albums.clone())
|
||||||
|
}
|
||||||
|
async fn album_tracks(&self, id: &str, _l: usize) -> Result<JamAlbum, api::FetchError> {
|
||||||
|
self.albums.get(id).cloned().ok_or(api::FetchError::NotFound)
|
||||||
|
}
|
||||||
|
async fn track_detail(&self, id: &str) -> Result<JamTrack, api::FetchError> {
|
||||||
|
self.tracks.get(id).cloned().ok_or(api::FetchError::NotFound)
|
||||||
|
}
|
||||||
|
async fn track_stream(&self, id: &str) -> Result<String, api::FetchError> {
|
||||||
|
self.streams
|
||||||
|
.get(id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(api::FetchError::NotStreamable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A backend whose every call fails — for the no-panic / typed-error gate.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct FailingJam;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl api::Jam for FailingJam {
|
||||||
|
async fn search_tracks(&self, _q: &str, _l: usize) -> Result<Vec<JamTrack>, api::FetchError> {
|
||||||
|
Err(api::FetchError::Http("boom".into()))
|
||||||
|
}
|
||||||
|
async fn search_albums(&self, _q: &str, _l: usize) -> Result<Vec<JamAlbum>, api::FetchError> {
|
||||||
|
Err(api::FetchError::Http("boom".into()))
|
||||||
|
}
|
||||||
|
async fn album_tracks(&self, _id: &str, _l: usize) -> Result<JamAlbum, api::FetchError> {
|
||||||
|
Err(api::FetchError::Http("boom".into()))
|
||||||
|
}
|
||||||
|
async fn track_detail(&self, _id: &str) -> Result<JamTrack, api::FetchError> {
|
||||||
|
Err(api::FetchError::Http("boom".into()))
|
||||||
|
}
|
||||||
|
async fn track_stream(&self, _id: &str) -> Result<String, api::FetchError> {
|
||||||
|
Err(api::FetchError::Http("boom".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Task plan — jamendo provider
|
||||||
|
|
||||||
|
Ordered, independently verifiable tasks for `/jamendo`
|
||||||
|
(architecture/jamendo-provider.md, quality/jamendo.md). Each cites the gate/test
|
||||||
|
it satisfies.
|
||||||
|
|
||||||
|
## Crate scaffold
|
||||||
|
|
||||||
|
- [x] **P1** — Add `jamendody` to `Cargo.toml` workspace `members`; create
|
||||||
|
`jamendody/Cargo.toml` mirroring `soundclouddy` (deps: `async-trait`,
|
||||||
|
`crabidy-core`, `reqwest`, `serde`, `thiserror`, `tokio`, `toml`, `tracing`;
|
||||||
|
dev-dep `tokio` full). *Verify:* `cargo metadata` lists the crate.
|
||||||
|
|
||||||
|
## Network seam (api.rs)
|
||||||
|
|
||||||
|
- [x] **P2** — Domain types `JamTrack` (id, title, artist, album,
|
||||||
|
duration_secs) and `JamAlbum` (id, title, artist, tracks); `FetchError`
|
||||||
|
(`Http`, `Decode`, `NotFound`, `Unauthorized`, `NotStreamable`). *Verify:*
|
||||||
|
compiles. (G2)
|
||||||
|
- [x] **P3** — `Jam` trait: `search_tracks`, `search_albums`, `album_tracks`,
|
||||||
|
`track_detail`, `track_stream`. Doc each with error behavior. *Verify:*
|
||||||
|
compiles. (G2/G10)
|
||||||
|
- [x] **P4** — `JamApi` (reqwest): `new(client_id, audioformat, timeout)` with a
|
||||||
|
timeout-bearing client and redacting `Debug`; `get_json` helper adding
|
||||||
|
`client_id` + `format=json`, decoding the `{headers, results}` envelope,
|
||||||
|
mapping non-success/404/401 to `FetchError`. `limit.min(200)` cap. *Verify:*
|
||||||
|
`cargo build`. (G3/G4/G5/G9)
|
||||||
|
- [x] **P5** — Defensive DTOs (`#[serde(default)]`) for track/album/envelope and
|
||||||
|
`into_track`/`into_album`. *Verify:* build + a decode unit test on a JSON
|
||||||
|
fixture. (G9)
|
||||||
|
|
||||||
|
## Provider logic (lib.rs)
|
||||||
|
|
||||||
|
- [x] **P6** — `PROVIDER_ROOT`, segment consts, `JamPath` enum + `parse_path`.
|
||||||
|
*Verify:* T2, `foreign_and_malformed_paths_reject`. (D3)
|
||||||
|
- [x] **P7** — `Settings` (`client_id: Option<String>` + bounds) with redacting
|
||||||
|
`Debug`. *Verify:* T10 `settings_debug_redacts_client_id`. (G3)
|
||||||
|
- [x] **P8** — `Client` + `with_api`, search-term store helpers
|
||||||
|
(`store_add/remove/snapshot`), `search_limit`/`album_limit`. *Verify:*
|
||||||
|
compiles. (D3)
|
||||||
|
- [x] **P9** — Node builders: `root_node` (search only), `store_node`,
|
||||||
|
`search_term_node` (tracks + albums), `album_node`, `list_node`, `jam_track`.
|
||||||
|
*Verify:* T1, T4, T5, T7, T11. (D3/D4)
|
||||||
|
- [x] **P10** — `ProviderClient` impl: `init` (require non-empty `client_id`,
|
||||||
|
else `Config`), `settings`, `is_track_path`, `get_urls_for_track` (→
|
||||||
|
`track_stream`), `get_metadata_for_track`, `get_lib_root`, `get_lib_node`
|
||||||
|
(download blessing), `create/rename/delete_lib_node`. *Verify:* T3, T6, T8,
|
||||||
|
T9. (D4/G1/G2/G6)
|
||||||
|
|
||||||
|
## Tests (tests.rs)
|
||||||
|
|
||||||
|
- [x] **P11** — `FakeJam` + `FailingJam` fixtures and all T1–T11. *Verify:*
|
||||||
|
`cargo test -p jamendody`. (all tests)
|
||||||
|
|
||||||
|
## Orchestrator + settings wiring (crabidy-server)
|
||||||
|
|
||||||
|
- [x] **P12** — `settings.rs`: `jamendo` in `ALL_PROVIDERS` (8→9),
|
||||||
|
`ProviderToggles.jamendo`, defaults, `provider_toggles()`. *Verify:* build +
|
||||||
|
existing settings tests. (G6)
|
||||||
|
- [x] **P13** — `provider.rs`: `jamendo_client` field, `jamendo_owns`,
|
||||||
|
`jamendo_provider`, non-fatal `build()` block (reads `jamendo.toml`),
|
||||||
|
`is_track_path` arm, `get_lib_root` child (gated on `is_some`), `get_lib_node`
|
||||||
|
arm, `get_urls_for_track`/`get_metadata_for_track` arms, create/rename/delete
|
||||||
|
arms. *Verify:* `cargo build -p crabidy-server`. (D1/G6/G8)
|
||||||
|
|
||||||
|
## Verification gate
|
||||||
|
|
||||||
|
- [x] **P14** — `cargo fmt --check`, `cargo clippy -- -D warnings`,
|
||||||
|
`cargo test` green across the workspace. Re-read G1–G10 against the diff.
|
||||||
|
- [ ] **P15 (live gate, R1)** — with a real `client_id` in `jamendo.toml`, browse
|
||||||
|
`/jamendo/search/<term>`, queue and **play** a track: confirm audio plays via
|
||||||
|
the existing windowed-HTTP path, seek bar correct (duration from metadata),
|
||||||
|
clean EOS, no panic. Deferred to a run with network + a key.
|
||||||
|
|
@ -1122,3 +1122,44 @@ crabidy-server` clean. **Needs live SoundCloud to confirm (no creds/audio in
|
||||||
sandbox):** the actual `client_id` scrape, live API JSON shapes, and mp3-HLS
|
sandbox):** the actual `client_id` scrape, live API JSON shapes, and mp3-HLS
|
||||||
play-to-EOS — all exercised by `tests/live.rs` and the `#[ignore]` gates
|
play-to-EOS — all exercised by `tests/live.rs` and the `#[ignore]` gates
|
||||||
(quality G7/G8/G14/G19). Run those on a real machine.
|
(quality G7/G8/G14/G19). Run those on a real machine.
|
||||||
|
|
||||||
|
## jamendo provider — `/jamendo` (2026-07-24)
|
||||||
|
|
||||||
|
Ran the full dev-flow pipeline autonomously for a `/jamendo` provider (free /
|
||||||
|
Creative-Commons music via the official `api.jamendo.com/v3.0`). Artifacts:
|
||||||
|
`architecture/jamendo-provider.md` (D1–D6, 2 diagrams), `quality/jamendo.md`
|
||||||
|
(T1–T11 + G1–G10), `plan/jamendo.md`. New crate **`jamendody`**, shaped on
|
||||||
|
`soundclouddy` but the *simple* case — Jamendo has a stable official API, so
|
||||||
|
there is **no `client_id` scraping, no OAuth, and no HLS**.
|
||||||
|
|
||||||
|
- **Tree**: `/jamendo` → `search` (creatable) → `/jamendo/search/<term>` listing
|
||||||
|
tracks (canonical `/jamendo/track/<id>` rows) + albums (canonical queueable
|
||||||
|
`/jamendo/album/<id>` containers) → `/jamendo/album/<id>` tracks. Same
|
||||||
|
search-term store + download-blessing pattern as the other providers.
|
||||||
|
- **Playback with no player change**: `get_urls_for_track` returns the track's
|
||||||
|
direct `audio` MP3 URL, played on the existing windowed-HTTP + symphonia path.
|
||||||
|
- **`Jam` reqwest seam** (`api.rs`, faked in tests): `search_tracks`,
|
||||||
|
`search_albums`, `album_tracks`, `track_detail`, `track_stream`. Per-call
|
||||||
|
timeout, `limit` clamped to Jamendo's 200, `client_id` redacted from `Debug`,
|
||||||
|
defensive `#[serde(default)]` DTOs over the `{headers, results}` envelope
|
||||||
|
(HTTP-200 error envelopes mapped to typed `FetchError`).
|
||||||
|
- **Duration stays in seconds** — Jamendo's native unit *is* `Track.duration`'s,
|
||||||
|
so it passes through unchanged (a test guards against a ms/seconds regression).
|
||||||
|
- **Server wiring** (the standard pattern): `jamendo` in `ALL_PROVIDERS` (8→9),
|
||||||
|
`ProviderToggles.jamendo`; `jamendo_client` field, `jamendo_owns`,
|
||||||
|
`jamendo_provider`, a non-fatal `build()` block (missing/empty `client_id`
|
||||||
|
disables `/jamendo` only), a root child, and dispatch arms in all methods
|
||||||
|
(`is_track_path`, `get_lib_node`, `get_urls_for_track`,
|
||||||
|
`get_metadata_for_track`, `resolve_tracks_into`, create/rename/delete).
|
||||||
|
|
||||||
|
Deviations: stream URL is a dedicated `track_stream` seam method (not a field on
|
||||||
|
`JamTrack`), matching soundcloud's metadata/URL separation; `client_id` required
|
||||||
|
with **no init-time network** (a bad key surfaces lazily as `FetchError`);
|
||||||
|
`audioformat` defaults to `mp32` (higher bitrate) over Jamendo's `mp31`.
|
||||||
|
|
||||||
|
**Verified offline:** `jamendody` 16 tests (T1–T11 + 3 DTO-decode) green,
|
||||||
|
`crabidy-server` tests green (settings iterate `ALL_PROVIDERS`, no edit needed),
|
||||||
|
`cargo build -p jamendody -p crabidy-server` clean, clippy `-D warnings` + fmt
|
||||||
|
clean. **Deferred live gate (R1):** with a real `client_id` in `jamendo.toml`,
|
||||||
|
browse → queue → play a track and confirm the `audio` MP3 streams with a correct
|
||||||
|
seek bar and clean EOS. Needs network + a registered key; not runnable here.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Quality gates — jamendo provider
|
||||||
|
|
||||||
|
Gates for the `/jamendo` provider (architecture/jamendo-provider.md). Automatic
|
||||||
|
tests live inline in `jamendody/src/tests.rs` (unit, over a `FakeJam`) and are
|
||||||
|
named in each gate. LLM gates are read-and-reason checks an implementing agent
|
||||||
|
verifies.
|
||||||
|
|
||||||
|
## Automatic tests (jamendody/src/tests.rs)
|
||||||
|
|
||||||
|
- **T1 `root_has_only_search`** — `get_lib_root` exposes exactly one child,
|
||||||
|
`/jamendo/search` (creatable), and the root is not queueable. (D3)
|
||||||
|
- **T2 path parsing** (`*_parse`, `foreign_and_malformed_paths_reject`) —
|
||||||
|
`/jamendo`, `/jamendo/search`, `/jamendo/search/<term>`, `/jamendo/track/<id>`,
|
||||||
|
`/jamendo/album/<id>` parse to their variants; foreign roots, empty segments,
|
||||||
|
wrong arity, and unknown tags are `MalformedPath`. (D3)
|
||||||
|
- **T3 `is_track_path_only_for_canonical_track`** — true only for
|
||||||
|
`/jamendo/track/<id>`; false for albums, search nodes, root. (D3/D4)
|
||||||
|
- **T4 `search_term_lists_tracks_and_albums`** — a search node carries matching
|
||||||
|
tracks as rows at canonical `/jamendo/track/<id>` paths and matching albums as
|
||||||
|
**queueable** container children at `/jamendo/album/<id>`; the node is
|
||||||
|
queueable and `is_downloadable`. (D3/D4)
|
||||||
|
- **T5 `album_node_lists_tracks`** — `/jamendo/album/<id>` lists the album's
|
||||||
|
tracks as canonical rows; node is queueable + downloadable. (D3)
|
||||||
|
- **T6 `get_urls_for_track_returns_audio`** — `get_urls_for_track` on a track
|
||||||
|
path returns exactly the backend `audio` URL; a non-track path is
|
||||||
|
`MalformedPath`, not a stream. (D4)
|
||||||
|
- **T7 `track_metadata_maps_fields`** — `get_metadata_for_track` maps
|
||||||
|
`name→title`, `artist_name→artist`, `album_name→Album.title`,
|
||||||
|
`duration(secs)→Track.duration` unchanged, `provider_item_id == "track:<id>"`.
|
||||||
|
(D4)
|
||||||
|
- **T8 `create_rename_delete_search_term`** — creating a term adds an editable +
|
||||||
|
deletable child under `/jamendo/search`; empty/whitespace title →
|
||||||
|
`InvalidInput`; rename moves the term; delete removes it. (D3)
|
||||||
|
- **T9 `backend_failures_are_typed`** — a backend whose every call errors makes
|
||||||
|
`get_lib_node` return `ProviderError::FetchError`, never panics. (D2)
|
||||||
|
- **T10 `settings_debug_redacts_client_id`** — `format!("{:?}", settings)` with
|
||||||
|
a `client_id` set does not contain the id and shows `<redacted>`. (D5)
|
||||||
|
- **T11 `duration_is_seconds_not_ms`** — a track whose backend `duration_secs`
|
||||||
|
is 210 yields `Track.duration == Some(210)` (guards against a ms/seconds
|
||||||
|
regression like the web bug). (D1/D4)
|
||||||
|
|
||||||
|
## LLM quality gates (read-and-reason)
|
||||||
|
|
||||||
|
- **G1 — no panics on external conditions.** No `unwrap`/`expect`/`panic!` on
|
||||||
|
network, decode, config, or path input in `jamendody`. Lock poisoning
|
||||||
|
degrades to empty (`unwrap_or_default`), never panics. (Hard rule)
|
||||||
|
- **G2 — typed error boundary.** Every `JamApi` failure is a `FetchError`
|
||||||
|
variant; the provider maps it to `ProviderError` (`FetchError` /
|
||||||
|
`MalformedPath` / `NotSupported` / `InvalidInput`) and logs the typed cause.
|
||||||
|
No `color-eyre`/`anyhow` leaks across the trait. (Hard rule)
|
||||||
|
- **G3 — client_id is redacted everywhere.** `Settings` and `JamApi` have
|
||||||
|
hand-written `Debug` that redact `client_id`; it never appears in a log line,
|
||||||
|
and a resolved `audio` URL (may carry a signed token) is never logged. (Hard
|
||||||
|
rule: redact secrets)
|
||||||
|
- **G4 — timeouts on every external call.** The `reqwest` client is built with a
|
||||||
|
timeout from `call_timeout_secs`; no unbounded HTTP call exists. (Hard rule)
|
||||||
|
- **G5 — bounded results, no silent caps.** `search_results` (≤ API max 200) and
|
||||||
|
`album_tracks` bound listings; when a cap truncates, it is `log`-ged
|
||||||
|
(`debug`/`warn`), not silent. (Hard rule: no silent caps)
|
||||||
|
- **G6 — non-fatal init.** A missing/empty `client_id` (or unparseable
|
||||||
|
`jamendo.toml`) makes `init` return a typed `Config` error; the orchestrator
|
||||||
|
maps it to `None` so only `/jamendo` is lost, never the server. No blocking
|
||||||
|
network in `build()`. (D1/D5)
|
||||||
|
- **G7 — canonical leaf independence.** Track rows and album children always use
|
||||||
|
`/jamendo/track/<id>` and `/jamendo/album/<id>` regardless of the branch they
|
||||||
|
were reached through, so playback/expansion never depends on browse context.
|
||||||
|
(D3)
|
||||||
|
- **G8 — no proto/wire/TUI/player change.** The feature is a pure path-prefix
|
||||||
|
subtree: no edit to `crabidy.proto`, `ProviderCommand`, the TUI, or
|
||||||
|
`audio-player`. Search-term flows reuse the existing create/rename/delete RPCs.
|
||||||
|
(Assumptions)
|
||||||
|
- **G9 — defensive DTOs.** Wire structs use `#[serde(default)]`; a missing field
|
||||||
|
degrades (empty/`None`), a renamed field is a local `JamApi` fix, never a
|
||||||
|
crash. Ids decode whether numeric or string. (R3)
|
||||||
|
- **G10 — docs.** Public items (`Client`, `Settings`, `PROVIDER_ROOT`, the `Jam`
|
||||||
|
trait and its methods, domain types) carry doc comments stating intent and
|
||||||
|
error behavior. (Preference)
|
||||||
Loading…
Reference in New Issue