feat(qbzd): add browse reads — album/artist/similar/suggest (CONSOLE P2)

qbzd can now BROWSE the catalog from the CLI: open an album, an artist page /
top-tracks / releases grid, find similar artists or albums, and pull For-You
suggestions — reads that feed the composition pipeline (`--ids | queue add -`).

- api/browse.rs: GET /api/album (get_album + optional get_album_suggest);
  GET /api/artist?view=page|top|albums (get_artist_page / get_artist_tracks /
  get_releases_grid); GET /api/similar?artist=|album= (get_similar_artists /
  get_album_suggest); GET /api/suggest?seed= (get_dynamic_suggest — seeds from
  the current queue when omitted). Typed serde shapes verbatim; auth-gated;
  blacklist fail-open.
- cli/browse.rs: `album`, `artist`, `similar`, `suggest`; a generic items/tracks
  walk drives the human list and --ids; `--seed -` reads ids from stdin.
- api/mod.rs: P1_ROUTES 2->6 (+album/artist/similar/suggest); routes + test.
  main.rs: four subcommands.

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

cc
This commit is contained in:
vicrodh committed 2026-07-19 15:02:19 -06:00
1 parent f2f65b0fbf
commit ba2f626208
5 files changed
+522 -1

No files matched your search

+242
View File
@@ -0,0 +1,242 @@
// crates/qbzd/src/api/browse.rs — the catalog READ verbs (02 §2.3):
// GET /api/album, /api/artist, /api/similar, /api/suggest.
//
// All auth-gated (they call the Qobuz client → NotInitialized without a
// session), all return the core's typed serde shapes verbatim (a stable
// `--json` contract, §3.1.4 — no raw catalog_search leakage), all blacklist
// fail-open by design (the daemon opens no blacklist store; a documented
// GUI-parity delta). None mutate playback — they are pure reads that feed the
// composition pipeline (`--ids` on the CLI side pipes into `queue add -`).
use std::collections::HashMap;
use std::io::Cursor;
use serde_json::Value;
use tiny_http::Response;
use crate::state::AuthState;
use super::{err_json, json, ApiState};
const DEFAULT_LIMIT: u32 = 20;
const MAX_LIMIT: u32 = 100;
/// Seed cap when `suggest` falls back to the current queue.
const QUEUE_SEED_CAP: usize = 20;
/// `GET /api/album?id=<ALBUM_ID>&suggest=<0|1>`. The full album envelope
/// (tracklist, ImageSet artwork, description, awards) via `core.get_album`;
/// `suggest=1` also includes similar albums (`get_album_suggest`).
pub fn album(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let p = parse(query);
let id = match p.get("id") {
Some(v) if !v.is_empty() => v.clone(),
_ => return err_json(400, "bad_request", "album requires an id", "usage: qbzd album <ALBUM_ID>"),
};
let album = match state.rt.block_on(state.runtime.core().get_album(&id)) {
Ok(a) => serde_json::to_value(a).unwrap_or(Value::Null),
Err(_) => return not_found("album", &id),
};
let similar = if wants(&p, "suggest") {
match state.rt.block_on(state.runtime.core().get_album_suggest(&id)) {
Ok(s) => serde_json::to_value(s).unwrap_or(Value::Null),
Err(_) => Value::Null,
}
} else {
Value::Null
};
json(200, serde_json::json!({"album": album, "similar": similar}))
}
/// `GET /api/artist?id=<ID>&view=page|top|albums&limit=&offset=&release_type=`.
/// `page` (default) = the artist page (bio, top tracks, similar); `top` = the
/// full top-tracks list; `albums` = the paged releases grid.
pub fn artist(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let p = parse(query);
let id = match p.get("id").and_then(|v| v.parse::<u64>().ok()) {
Some(id) => id,
None => return err_json(400, "bad_request", "artist requires a numeric id", "usage: qbzd artist <ARTIST_ID>"),
};
let (limit, offset) = limit_offset(&p);
let view = p.get("view").map(String::as_str).unwrap_or("page");
let core = state.runtime.core();
match view {
"page" => match state.rt.block_on(core.get_artist_page(id, None)) {
Ok(r) => json(200, serde_json::json!({"view": "page", "page": serde_json::to_value(r).unwrap_or(Value::Null)})),
Err(_) => not_found("artist", &id.to_string()),
},
"top" => match state.rt.block_on(core.get_artist_tracks(id, limit, offset)) {
Ok(tc) => json(200, serde_json::json!({"view": "top", "tracks": serde_json::to_value(tc).unwrap_or(Value::Null)})),
Err(_) => not_found("artist", &id.to_string()),
},
"albums" => {
let release_type = p.get("release_type").map(String::as_str).unwrap_or("album");
match state.rt.block_on(core.get_releases_grid(id, release_type, limit, offset, None)) {
Ok(r) => json(200, serde_json::json!({"view": "albums", "releases": serde_json::to_value(r).unwrap_or(Value::Null)})),
Err(_) => not_found("artist", &id.to_string()),
}
}
other => err_json(400, "bad_request", &format!("unknown view '{other}'"), "view: page | top | albums"),
}
}
/// `GET /api/similar?artist=<ID>` (similar artists) or `?album=<ID>` (similar
/// albums), `&limit=&offset=`. Exactly one selector.
pub fn similar(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let p = parse(query);
let (limit, offset) = limit_offset(&p);
if let Some(artist_id) = p.get("artist").and_then(|v| v.parse::<u64>().ok()) {
return match state.rt.block_on(state.runtime.core().get_similar_artists(artist_id, limit, offset)) {
Ok(page) => json(200, serde_json::json!({"artists": serde_json::to_value(page).unwrap_or(Value::Null)})),
Err(_) => not_found("artist", &artist_id.to_string()),
};
}
if let Some(album_id) = p.get("album").filter(|v| !v.is_empty()) {
return match state.rt.block_on(state.runtime.core().get_album_suggest(album_id)) {
Ok(sug) => json(200, serde_json::json!({"albums": serde_json::to_value(sug).unwrap_or(Value::Null)})),
Err(_) => not_found("album", album_id),
};
}
err_json(400, "bad_request", "similar requires artist=<ID> or album=<ID>", "usage: qbzd similar artist:<ID> | album:<ID>")
}
/// `GET /api/suggest?seed=<ID,ID,...>&limit=`. Dynamic For-You suggestions
/// (`get_dynamic_suggest`). Seeds are explicit (`seed=`) or, when omitted,
/// the current queue's track ids (current + upcoming, capped) — so the daemon
/// tracks no listening history, honoring the UNIX-honest seeding the CONSOLE
/// brief specifies.
pub fn suggest(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let p = parse(query);
let (limit, _offset) = limit_offset(&p);
let seeds: Vec<u64> = match p.get("seed") {
Some(s) if !s.is_empty() => s.split(',').filter_map(|x| x.trim().parse::<u64>().ok()).collect(),
_ => {
let q = state.rt.block_on(state.runtime.core().get_queue_state());
let mut ids: Vec<u64> = Vec::new();
if let Some(t) = &q.current_track {
ids.push(t.id);
}
for t in q.upcoming.iter().take(QUEUE_SEED_CAP.saturating_sub(1)) {
ids.push(t.id);
}
ids
}
};
if seeds.is_empty() {
return err_json(
400,
"bad_request",
"suggest needs seed track ids",
"play something first, or: qbzd suggest --seed <ID,ID>",
);
}
match state.rt.block_on(state.runtime.core().get_dynamic_suggest(&seeds, limit)) {
Ok(tracks) => json(200, serde_json::json!({"tracks": serde_json::to_value(tracks).unwrap_or(Value::Null)})),
Err(_) => err_json(502, "suggest_failed", "suggestion request to Qobuz failed", "try again in a moment"),
}
}
// ============================ internals ============================
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
}
}
fn not_found(kind: &str, id: &str) -> Response<Cursor<Vec<u8>>> {
err_json(404, "not_found", &format!("{kind} {id} not found"), "check the id: qbzd search <QUERY>")
}
/// Percent-decoded query-string map (values only; keys are plain ascii).
fn parse(query: &str) -> HashMap<String, String> {
let mut m = HashMap::new();
for pair in query.split('&') {
if pair.is_empty() {
continue;
}
let mut kv = pair.splitn(2, '=');
let key = kv.next().unwrap_or("").to_string();
let raw = kv.next().unwrap_or("");
let val = urlencoding::decode(raw)
.map(|c| c.into_owned())
.unwrap_or_else(|_| raw.to_string());
m.insert(key, val);
}
m
}
/// `limit` (clamped 1..=MAX, default 20) + `offset` (default 0).
fn limit_offset(p: &HashMap<String, String>) -> (u32, u32) {
let limit = p
.get("limit")
.and_then(|v| v.parse::<u32>().ok())
.map(|n| n.clamp(1, MAX_LIMIT))
.unwrap_or(DEFAULT_LIMIT);
let offset = p.get("offset").and_then(|v| v.parse::<u32>().ok()).unwrap_or(0);
(limit, offset)
}
/// A boolean-ish query flag (`?suggest=1` / `?suggest=true`).
fn wants(p: &HashMap<String, String>, key: &str) -> bool {
matches!(p.get(key).map(String::as_str), Some("1") | Some("true"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_decodes_values_and_splits_pairs() {
let m = parse("id=abc&view=top&q=a%20b");
assert_eq!(m.get("id").unwrap(), "abc");
assert_eq!(m.get("view").unwrap(), "top");
assert_eq!(m.get("q").unwrap(), "a b");
}
#[test]
fn limit_offset_clamps_and_defaults() {
let mut m = HashMap::new();
assert_eq!(limit_offset(&m), (DEFAULT_LIMIT, 0));
m.insert("limit".into(), "500".into());
m.insert("offset".into(), "5".into());
assert_eq!(limit_offset(&m), (MAX_LIMIT, 5));
m.insert("limit".into(), "0".into());
assert_eq!(limit_offset(&m).0, 1);
}
#[test]
fn wants_reads_truthy_flags() {
let mut m = HashMap::new();
assert!(!wants(&m, "suggest"));
m.insert("suggest".into(), "1".into());
assert!(wants(&m, "suggest"));
m.insert("suggest".into(), "true".into());
assert!(wants(&m, "suggest"));
m.insert("suggest".into(), "0".into());
assert!(!wants(&m, "suggest"));
}
}
+14 -1
View File
@@ -14,6 +14,7 @@
// The two-call split — `bind` at boot step 5 (stateless, so the foreign-occupant
// diagnosis runs BEFORE the stores/runtime exist), `serve` at boot step 11 — is
// what keeps the 01-architecture.md §8.1 boot order honest.
pub mod browse;
pub mod play;
pub mod playback;
pub mod queue;
@@ -71,6 +72,10 @@ pub const P0_ROUTES: &[(&str, &str)] = &[
pub const P1_ROUTES: &[(&str, &str)] = &[
("GET", "/api/search"),
("POST", "/api/play"),
("GET", "/api/album"),
("GET", "/api/artist"),
("GET", "/api/similar"),
("GET", "/api/suggest"),
];
/// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server
@@ -277,6 +282,10 @@ fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
let body = read_json_body(req);
play::play(state, &body)
}
("GET", "/api/album") => browse::album(state, &query),
("GET", "/api/artist") => browse::artist(state, &query),
("GET", "/api/similar") => browse::similar(state, &query),
("GET", "/api/suggest") => browse::suggest(state, &query),
("GET", "/api/queue") => queue::list(state, &query),
("POST", "/api/queue/add") => {
let body = read_json_body(req);
@@ -444,9 +453,13 @@ 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(), 2);
assert_eq!(P1_ROUTES.len(), 6);
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")));
for r in P1_ROUTES {
assert!(!P0_ROUTES.contains(r), "{r:?} is duplicated across P0 and P1");
}
+219
View File
@@ -0,0 +1,219 @@
// crates/qbzd/src/cli/browse.rs — the catalog READ verbs (02 §2.3):
// `qbzd album`, `qbzd artist`, `qbzd similar`, `qbzd suggest`. Each is a
// stateless renderer over one GET request. Three modes on every verb: default
// human list, `--ids` (ids one-per-line — the composition currency), `--json`
// (the raw payload). The human/`--ids` views walk the payload generically
// (items/tracks arrays); `--json` is the exact, complete contract.
use std::io::Read;
use serde_json::Value;
use crate::cli::client::ApiClient;
use crate::paths::ProfileRoots;
/// `qbzd album <ALBUM_ID> [--suggest] [--ids] [--json]`.
pub async fn album(host: Option<String>, id: String, suggest: bool, ids: bool, json: bool, roots: &ProfileRoots) -> i32 {
let path = format!("/api/album?id={}&suggest={}", urlencoding::encode(&id), if suggest { 1 } else { 0 });
get_and_render(host, roots, &path, ids, json).await
}
/// `qbzd artist <ARTIST_ID> [--top|--albums] [--limit N] [--ids] [--json]`.
#[allow(clippy::too_many_arguments)]
pub async fn artist(host: Option<String>, id: u64, top: bool, albums: bool, limit: u32, ids: bool, json: bool, roots: &ProfileRoots) -> i32 {
let view = if albums { "albums" } else if top { "top" } else { "page" };
let path = format!("/api/artist?id={id}&view={view}&limit={limit}");
get_and_render(host, roots, &path, ids, json).await
}
/// `qbzd similar <artist:ID | album:ID> [--limit N] [--ids] [--json]`.
pub async fn similar(host: Option<String>, selector: String, limit: u32, ids: bool, json: bool, roots: &ProfileRoots) -> i32 {
let path = match to_similar_query(&selector, limit) {
Ok(p) => p,
Err(msg) => {
eprintln!("error: {msg}");
eprintln!(" → usage: qbzd similar artist:<ID> | album:<ID>");
return 2;
}
};
get_and_render(host, roots, &path, ids, json).await
}
/// `qbzd suggest [--seed <ID,ID> | --seed -] [--limit N] [--ids] [--json]`.
/// No `--seed` = the daemon seeds from the current queue. `--seed -` reads ids
/// one-per-line from stdin.
pub async fn suggest(host: Option<String>, seed: Option<String>, limit: u32, ids: bool, json: bool, roots: &ProfileRoots) -> i32 {
let seed_param = match seed.as_deref() {
Some("-") => Some(read_stdin_ids()),
Some(s) => Some(s.to_string()),
None => None,
};
let mut path = format!("/api/suggest?limit={limit}");
if let Some(s) = seed_param.filter(|s| !s.is_empty()) {
path.push_str(&format!("&seed={}", urlencoding::encode(&s)));
}
get_and_render(host, roots, &path, ids, json).await
}
// ============================ shared ============================
async fn get_and_render(host: Option<String>, roots: &ProfileRoots, path: &str, ids: bool, json: bool) -> i32 {
let client = ApiClient::new(host, roots);
let payload = match client.get(path).await {
Ok(v) => v,
Err(e) => {
eprintln!("{e}");
return e.exit_code();
}
};
if json {
println!("{}", serde_json::to_string(&payload).unwrap_or_default());
} else if ids {
for id in collect_ids(&payload) {
println!("{id}");
}
} else {
print!("{}", render(&payload));
}
0
}
fn to_similar_query(selector: &str, limit: u32) -> Result<String, String> {
let s = selector.trim();
if let Some(id) = s.strip_prefix("artist:") {
let id: u64 = id.parse().map_err(|_| format!("'{id}' is not a numeric artist id"))?;
return Ok(format!("/api/similar?artist={id}&limit={limit}"));
}
if let Some(id) = s.strip_prefix("album:") {
if id.is_empty() {
return Err("album id is empty".into());
}
return Ok(format!("/api/similar?album={}&limit={limit}", urlencoding::encode(id)));
}
Err(format!("unrecognized selector '{s}'"))
}
fn read_stdin_ids() -> String {
let mut buf = String::new();
let _ = std::io::stdin().read_to_string(&mut buf);
buf.split_whitespace().collect::<Vec<_>>().join(",")
}
/// Generic human list: every object carried in an `items`/`tracks` array,
/// rendered as `id Artist — Title`. Robust across the album/artist/similar/
/// suggest payload shapes (the `--json` output is the exact contract).
fn render(p: &Value) -> String {
let mut items: Vec<&Value> = Vec::new();
walk(p, &mut items);
if items.is_empty() {
return "no results\n".to_string();
}
let mut out = String::new();
for it in items {
let id = id_str(it.get("id"));
if id.is_empty() {
continue;
}
let title = it
.get("title")
.or_else(|| it.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("(untitled)");
match secondary(it) {
Some(s) => out.push_str(&format!("{id} {s} — {title}\n")),
None => out.push_str(&format!("{id} {title}\n")),
}
}
if out.is_empty() {
out.push_str("no results\n");
}
out
}
fn collect_ids(p: &Value) -> Vec<String> {
let mut items: Vec<&Value> = Vec::new();
walk(p, &mut items);
items
.iter()
.map(|it| id_str(it.get("id")))
.filter(|s| !s.is_empty())
.collect()
}
/// Collect objects held in `items`/`tracks` arrays anywhere in the payload.
/// Nested reference objects (a track's `artist`/`album`) are NOT under those
/// keys, so they are not over-collected.
fn walk<'a>(v: &'a Value, out: &mut Vec<&'a Value>) {
match v {
Value::Object(map) => {
for (k, val) in map {
if (k == "items" || k == "tracks") && val.is_array() {
if let Value::Array(arr) = val {
for e in arr {
if e.is_object() && e.get("id").is_some() {
out.push(e);
}
}
}
}
walk(val, out);
}
}
Value::Array(arr) => {
for e in arr {
walk(e, out);
}
}
_ => {}
}
}
fn secondary(it: &Value) -> Option<&str> {
it.get("artist")
.and_then(|a| a.get("name"))
.and_then(|v| v.as_str())
.or_else(|| it.get("performer").and_then(|a| a.get("name")).and_then(|v| v.as_str()))
}
fn id_str(v: Option<&Value>) -> String {
match v {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_similar_query_builds_artist_and_album_paths() {
assert_eq!(to_similar_query("artist:123", 10).unwrap(), "/api/similar?artist=123&limit=10");
assert_eq!(to_similar_query("album:abc", 5).unwrap(), "/api/similar?album=abc&limit=5");
assert!(to_similar_query("artist:xy", 10).is_err());
assert!(to_similar_query("nope", 10).is_err());
}
#[test]
fn walk_collects_items_and_top_level_tracks_only() {
// album shape: album.tracks.items ; plus a nested track.album (must NOT
// be collected — it is not under an items/tracks array key).
let album = serde_json::json!({
"album": {"id": "A", "title": "Al",
"tracks": {"items": [
{"id": 1, "title": "T1", "album": {"id": "A", "title": "Al"}},
{"id": 2, "title": "T2"}
]}}
});
assert_eq!(collect_ids(&album), vec!["1", "2"]);
// suggest shape: top-level tracks array of Track.
let suggest = serde_json::json!({"tracks": [{"id": 9, "title": "S"}]});
assert_eq!(collect_ids(&suggest), vec!["9"]);
}
#[test]
fn render_empty_says_no_results() {
assert_eq!(render(&serde_json::json!({"album": Value::Null})), "no results\n");
}
}
+1
View File
@@ -3,6 +3,7 @@
// The CLI is a stateless renderer (02-cli-and-api.md §1.1); the copy strings it
// prints are normative (§1.4 error voice, §2.2 per-verb output). Keeping them in
// one place lets the spec and the code diff cleanly.
pub mod browse;
pub mod client;
pub mod copy;
pub mod play;
+46
View File
@@ -54,6 +54,36 @@ enum Cmd {
#[arg(long)] ids: bool,
#[arg(long)] json: bool,
},
/// Album page — tracklist with ids
Album {
id: String,
#[arg(long)] suggest: bool,
#[arg(long)] ids: bool,
#[arg(long)] json: bool,
},
/// Artist page (default), --top tracks, or --albums grid
Artist {
id: u64,
#[arg(long)] top: bool,
#[arg(long)] albums: bool,
#[arg(long, default_value_t = 20)] limit: u32,
#[arg(long)] ids: bool,
#[arg(long)] json: bool,
},
/// Similar artists or albums: artist:ID | album:ID
Similar {
selector: String,
#[arg(long, default_value_t = 20)] limit: u32,
#[arg(long)] ids: bool,
#[arg(long)] json: bool,
},
/// For-You suggestions — seeds from the queue, or --seed <ID,ID>|-
Suggest {
#[arg(long)] seed: Option<String>,
#[arg(long, default_value_t = 20)] limit: u32,
#[arg(long)] ids: bool,
#[arg(long)] json: bool,
},
/// Resume (bare) or play content: album:ID | track:ID | artist:ID | playlist:ID | URL
Play { content: Option<String> },
Pause, Toggle, Stop, Next, Prev,
@@ -209,6 +239,22 @@ async fn main() {
let roots = paths::ProfileRoots::resolve(None, None);
cli::search::search(cli.host, query, kind, limit, offset, ids, json, &roots).await
}
Cmd::Album { id, suggest, ids, json } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::browse::album(cli.host, id, suggest, ids, json, &roots).await
}
Cmd::Artist { id, top, albums, limit, ids, json } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::browse::artist(cli.host, id, top, albums, limit, ids, json, &roots).await
}
Cmd::Similar { selector, limit, ids, json } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::browse::similar(cli.host, selector, limit, ids, json, &roots).await
}
Cmd::Suggest { seed, limit, ids, json } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::browse::suggest(cli.host, seed, limit, ids, json, &roots).await
}
Cmd::Play { content } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::play::play(cli.host, content, &roots).await