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:
5 files changed
+522
-1
No files matched your search
@@ -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,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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user