feat(qbzd): add radio (seed-and-go) + resolve (CONSOLE)

- api/radio.rs: POST /api/radio {artist_id|track_id|album_id} — qobuz mode
  (get_radio_artist/track/album, zero local state). Reuses the new shared
  play::start_resolved for the queue-replace + cold-start ritual (protected
  audio untouched). Smart local-pool mode deferred (needs RadioDb::open_at
  plumbing through QbzCore — a shared-core change).
- api/play.rs: extract pub(crate) start_resolved (shared by play + radio).
- cli/radio.rs: `qbzd radio artist:ID | track:ID | album:ID`.
- cli/resolve.rs: `qbzd resolve <URL>` -> kind:ID token; pure client, no
  daemon and no network (qbz_qobuz::link_resolver).
- api/mod.rs: P1_ROUTES 6->7 (+POST /api/radio); route + test. main.rs:
  Radio + Resolve subcommands.

Verified: cargo test -p qbzd --bin qbzd -> 264 passed, 0 failed.

cc
This commit is contained in:
vicrodh committed 2026-07-19 15:06:38 -06:00
1 parent ba2f626208
commit d819d94ab5
7 files changed
+238 -12

No files matched your search

+8 -1
View File
@@ -18,6 +18,7 @@ pub mod browse;
pub mod play;
pub mod playback;
pub mod queue;
pub mod radio;
pub mod search;
pub mod settings;
pub mod status;
@@ -76,6 +77,7 @@ pub const P1_ROUTES: &[(&str, &str)] = &[
("GET", "/api/artist"),
("GET", "/api/similar"),
("GET", "/api/suggest"),
("POST", "/api/radio"),
];
/// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server
@@ -286,6 +288,10 @@ fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
("GET", "/api/artist") => browse::artist(state, &query),
("GET", "/api/similar") => browse::similar(state, &query),
("GET", "/api/suggest") => browse::suggest(state, &query),
("POST", "/api/radio") => {
let body = read_json_body(req);
radio::radio(state, &body)
}
("GET", "/api/queue") => queue::list(state, &query),
("POST", "/api/queue/add") => {
let body = read_json_body(req);
@@ -453,13 +459,14 @@ mod tests {
// §3.1.4 HARD RULE, applied to the content-verb door). Row 19:
// GET /api/search — caller: `qbzd search`. Count is pinned so a route
// with no caller cannot creep in; P1 must never overlap P0.
assert_eq!(P1_ROUTES.len(), 6);
assert_eq!(P1_ROUTES.len(), 7);
assert!(P1_ROUTES.contains(&("GET", "/api/search")));
assert!(P1_ROUTES.contains(&("POST", "/api/play")));
assert!(P1_ROUTES.contains(&("GET", "/api/album")));
assert!(P1_ROUTES.contains(&("GET", "/api/artist")));
assert!(P1_ROUTES.contains(&("GET", "/api/similar")));
assert!(P1_ROUTES.contains(&("GET", "/api/suggest")));
assert!(P1_ROUTES.contains(&("POST", "/api/radio")));
for r in P1_ROUTES {
assert!(!P0_ROUTES.contains(r), "{r:?} is duplicated across P0 and P1");
}
+18 -11
View File
@@ -49,17 +49,26 @@ pub fn play(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
Ok(v) => v,
Err(resp) => return resp,
};
start_resolved(state, tracks, context, body.get("index").and_then(|v| v.as_u64()))
}
/// Materialize resolved catalog `tracks` into the queue and cold-start the
/// chosen one through the SHIPPED ritual (set_queue → play_track_resolved →
/// save_session_now) — never a bare cursor move (control-surface §2.2). The
/// protected qbz-player/qbz-audio crates are untouched. `context` stamps
/// "playing from" provenance (context_kind ∈ album|artist|playlist —
/// qbz-models/src/playback.rs:60-71); `start_index` is the 0-based start
/// position. Shared by `play` (selector-resolved) and `radio` (seed-generated).
pub(crate) fn start_resolved(
state: &ApiState,
tracks: Vec<Track>,
context: Option<(&'static str, String)>,
start_index: Option<u64>,
) -> Response<Cursor<Vec<u8>>> {
if tracks.is_empty() {
return err_json(
404,
"not_found",
"nothing to play for that selector",
"check the id: qbzd search <QUERY>",
);
return err_json(404, "not_found", "nothing to play", "check the id: qbzd search <QUERY>");
}
// Map to QueueTracks, stamping "playing from" provenance for container plays
// (context_kind ∈ album|artist|playlist — qbz-models/src/playback.rs:60-71).
let mut queue_tracks: Vec<QueueTrack> = tracks.iter().map(track_to_queue_track).collect();
if let Some((kind, id)) = &context {
for qt in &mut queue_tracks {
@@ -69,12 +78,10 @@ pub fn play(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
}
let total = queue_tracks.len();
let start = clamp_index(body.get("index").and_then(|v| v.as_u64()), total);
let start = clamp_index(start_index, total);
let start_track_id = queue_tracks[start].id;
let start_summary = summary(&queue_tracks[start]);
// Replace the queue and cold-start the chosen track through the shipped
// ritual: set_queue → play_track_resolved → save_session_now.
state
.rt
.block_on(state.runtime.core().set_queue(queue_tracks, Some(start)));
+77
View File
@@ -0,0 +1,77 @@
// crates/qbzd/src/api/radio.rs — POST /api/radio (02-cli-and-api.md §3.4 row
// 25, P1). Seed-and-go: from an artist, track, or album seed, generate a Qobuz
// radio track list and start playing it.
//
// qobuz mode only in this slice (get_radio_artist/track/album — zero local
// state, ships first). The smart local-pool engine (create_smart_artist_radio)
// is gated on threading the daemon's data root into RadioDb::open_at (a shared-
// core change, deferred — memo D2). Reuses play::start_resolved for the
// queue-replace + cold-start ritual, so the protected audio path is untouched
// exactly as `play` leaves it.
use std::io::Cursor;
use serde_json::Value;
use tiny_http::Response;
use qbz_models::Track;
use crate::state::AuthState;
use super::{err_json, ApiState};
/// `POST /api/radio`. Body: one of `{"artist_id": N}` | `{"track_id": N}` |
/// `{"album_id": "..."}`. Errors: 409 needs_auth, 400 bad_request (no seed),
/// 502 radio_failed (upstream), 404/503 from `start_resolved` (empty / start).
pub fn radio(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let tracks = match fetch_radio(state, body) {
Ok(t) => t,
Err(resp) => return resp,
};
// Radio has no container-provenance kind in the album|artist|playlist set,
// so no context stamp; always starts from the top of the generated list.
super::play::start_resolved(state, tracks, None, None)
}
// ============================ internals ============================
fn fetch_radio(state: &ApiState, body: &Value) -> Result<Vec<Track>, Response<Cursor<Vec<u8>>>> {
let radio = if let Some(id) = body.get("artist_id").and_then(|v| v.as_u64()) {
state.rt.block_on(state.runtime.core().get_radio_artist(&id.to_string()))
} else if let Some(id) = body.get("track_id").and_then(|v| v.as_u64()) {
state.rt.block_on(state.runtime.core().get_radio_track(&id.to_string()))
} else if let Some(id) = body.get("album_id").and_then(|v| v.as_str()) {
state.rt.block_on(state.runtime.core().get_radio_album(id))
} else {
return Err(err_json(
400,
"bad_request",
"radio requires a seed",
"body: {\"artist_id\":N} | {\"track_id\":N} | {\"album_id\":\"...\"}",
));
};
match radio {
Ok(r) => Ok(r.tracks.items),
Err(_) => Err(err_json(
502,
"radio_failed",
"radio request to Qobuz failed",
"try again in a moment; check: qbzd status",
)),
}
}
fn auth_gate(state: &ApiState) -> Option<Response<Cursor<Vec<u8>>>> {
let needs_auth = state
.shared
.lock()
.map(|s| s.auth == AuthState::NeedsAuth)
.unwrap_or(false);
if needs_auth {
Some(err_json(409, "needs_auth", "not logged in to Qobuz", "run: qbzd login"))
} else {
None
}
}
+2
View File
@@ -8,6 +8,8 @@ pub mod client;
pub mod copy;
pub mod play;
pub mod queue;
pub mod radio;
pub mod resolve;
pub mod search;
pub mod settings;
pub mod status;
+81
View File
@@ -0,0 +1,81 @@
// crates/qbzd/src/cli/radio.rs — the `qbzd radio` verb (02 §2.3). Seed-and-go:
// `qbzd radio artist:<ID> | track:<ID> | album:<ID>` generates a Qobuz radio
// from the seed, replaces the queue with it, and starts playing — all in one
// POST /api/radio.
use serde_json::Value;
use crate::cli::client::ApiClient;
use crate::paths::ProfileRoots;
pub async fn radio(host: Option<String>, seed: String, roots: &ProfileRoots) -> i32 {
let body = match to_body(&seed) {
Ok(b) => b,
Err(msg) => {
eprintln!("error: {msg}");
eprintln!(" → usage: qbzd radio artist:<ID> | track:<ID> | album:<ID>");
return 2;
}
};
let client = ApiClient::new(host, roots);
match client.post("/api/radio", body).await {
Ok(v) => {
println!("{}", render(&v));
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
// ============================ internals ============================
fn to_body(seed: &str) -> Result<Value, String> {
let s = seed.trim();
if let Some(id) = s.strip_prefix("artist:") {
return num(id).map(|n| serde_json::json!({ "artist_id": n }));
}
if let Some(id) = s.strip_prefix("track:") {
return num(id).map(|n| serde_json::json!({ "track_id": n }));
}
if let Some(id) = s.strip_prefix("album:") {
if id.is_empty() {
return Err("album id is empty".into());
}
return Ok(serde_json::json!({ "album_id": id }));
}
Err(format!("unrecognized seed '{s}'"))
}
fn num(s: &str) -> Result<u64, String> {
s.parse::<u64>().map_err(|_| format!("'{s}' is not a numeric id"))
}
fn render(v: &Value) -> String {
let queued = v.get("queued").and_then(|x| x.as_u64()).unwrap_or(0);
let track = v.get("track");
let title = track.and_then(|t| t.get("title")).and_then(|x| x.as_str()).unwrap_or("");
let artist = track.and_then(|t| t.get("artist")).and_then(|x| x.as_str()).unwrap_or("");
if title.is_empty() {
format!("radio queued {queued} · playing")
} else if artist.is_empty() {
format!("radio queued {queued} · playing \"{title}\"")
} else {
format!("radio queued {queued} · playing \"{title}\" — {artist}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_body_maps_each_seed_kind() {
assert_eq!(to_body("artist:44042").unwrap(), serde_json::json!({"artist_id": 44042}));
assert_eq!(to_body("track:9").unwrap(), serde_json::json!({"track_id": 9}));
assert_eq!(to_body("album:abc").unwrap(), serde_json::json!({"album_id": "abc"}));
assert!(to_body("artist:xx").is_err());
assert!(to_body("nope").is_err());
}
}
+43
View File
@@ -0,0 +1,43 @@
// crates/qbzd/src/cli/resolve.rs — the `qbzd resolve <URL>` verb (02 §2.3).
// Pure, client-side, no daemon and no network: turn a Qobuz share URL into the
// `kind:ID` token the other verbs accept (`qbzd resolve <url>` →
// `album:c9vd8vvvrbpkc`, pipeable into `qbzd play`/`album`/`artist`). Uses the
// pure qbz_qobuz::link_resolver (already a qbzd dependency). Exit 0 on a
// recognized link, 2 on an unrecognized one.
use qbz_qobuz::link_resolver::{resolve_link, ResolvedLink};
pub fn resolve(url: String) -> i32 {
match resolve_link(&url) {
Ok(link) => {
println!("{}", token(&link));
0
}
Err(_) => {
eprintln!("error: unrecognized Qobuz URL");
eprintln!(" → expected an open.qobuz.com album/track/artist/playlist link");
2
}
}
}
fn token(link: &ResolvedLink) -> String {
match link {
ResolvedLink::OpenAlbum(id) => format!("album:{id}"),
ResolvedLink::OpenTrack(id) => format!("track:{id}"),
ResolvedLink::OpenArtist(id) => format!("artist:{id}"),
ResolvedLink::OpenPlaylist(id) => format!("playlist:{id}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_formats_each_kind() {
assert_eq!(token(&ResolvedLink::OpenAlbum("abc".into())), "album:abc");
assert_eq!(token(&ResolvedLink::OpenTrack(42)), "track:42");
assert_eq!(token(&ResolvedLink::OpenArtist(9)), "artist:9");
assert_eq!(token(&ResolvedLink::OpenPlaylist(7)), "playlist:7");
}
}
+9
View File
@@ -84,6 +84,10 @@ enum Cmd {
#[arg(long)] ids: bool,
#[arg(long)] json: bool,
},
/// Seed-and-go radio: artist:ID | track:ID | album:ID
Radio { seed: String },
/// Resolve a Qobuz URL to a kind:ID token (pure, no daemon)
Resolve { url: String },
/// Resume (bare) or play content: album:ID | track:ID | artist:ID | playlist:ID | URL
Play { content: Option<String> },
Pause, Toggle, Stop, Next, Prev,
@@ -255,6 +259,11 @@ async fn main() {
let roots = paths::ProfileRoots::resolve(None, None);
cli::browse::suggest(cli.host, seed, limit, ids, json, &roots).await
}
Cmd::Radio { seed } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::radio::radio(cli.host, seed, &roots).await
}
Cmd::Resolve { url } => cli::resolve::resolve(url),
Cmd::Play { content } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::play::play(cli.host, content, &roots).await