feat(qbzd): add discover — Discover rails, no recommendations (CONSOLE)

GET /api/discover?section= exposes the editorial Discover rails so a third
party can replicate the QBZ Discover page WITHOUT the personalized
recommendation engine (per-user reco/play-count stores the daemon never opens):
  index · most-streamed (Qobuz "most played") · new-releases · press-awards ·
  qobuzissims · album-of-the-week · ideal-discography · playlists[?tag] · tags ·
  release-watch[?release_type=artists|labels|awards] · featured[?type]. Optional
  ?genre=ID,ID scopes a section by genre.
- api/discover.rs: one route over get_discover_index / get_discover_albums /
  get_discover_playlists / get_playlist_tags / get_release_watch /
  get_featured_albums; typed serde shapes verbatim; auth-gated.
- cli/discover.rs: `qbzd discover [SECTION] [--genre --tag --release-type
  --type --limit --ids --json]`.
- api/mod.rs: P1_ROUTES 22->23; route + test. main.rs: Discover subcommand.

Verified: cargo test -p qbzd --bin qbzd -> 274 passed, 0 failed (with the
playlist-CRUD commit below it).

cc
This commit is contained in:
vicrodh committed 2026-07-19 16:56:49 -06:00
1 parent df4f29a50e
commit 1a50f4c1b1
5 files changed
+240 -1

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
// crates/qbzd/src/api/discover.rs — GET /api/discover (02 §2.3 CONSOLE). The
// editorial Discover rails, so a third-party client can replicate the QBZ
// Discover page WITHOUT the personalized recommendation engine (which needs
// per-user artist-vector / play-count stores the daemon deliberately does not
// open — qbz-app/src/settings/reco_store.rs, never installed here).
//
// One route, a `section` selector over the Qobuz-backed discover family:
// index -> get_discover_index (the composed home index)
// most-streamed -> /discover/mostStreamed (Qobuz "most played")
// new-releases -> /discover/newReleases
// press-awards -> /discover/pressAward
// qobuzissims -> /discover/qobuzissims
// album-of-the-week -> /discover/albumOfTheWeek
// ideal-discography -> /discover/idealDiscography
// playlists [?tag=] -> get_discover_playlists
// tags -> get_playlist_tags
// release-watch [?release_type=artists|labels|awards] -> get_release_watch
// featured ?type=<t> -> get_featured_albums (raw Qobuz featured_type)
//
// All auth-gated; all return the core's typed serde shapes verbatim (a stable
// --json contract); optional ?genre=ID,ID scopes the section by genre.
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;
pub fn discover(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let p = pairs(query);
let section = get(&p, "section").unwrap_or("index").to_string();
let genre = parse_genre(get(&p, "genre"));
let (limit, offset) = limit_offset(&p);
let core = state.runtime.core();
let data: Result<Value, Response<Cursor<Vec<u8>>>> = match section.as_str() {
"index" => serialize(state.rt.block_on(core.get_discover_index(genre))),
"most-streamed" => serialize(state.rt.block_on(core.get_discover_albums("/discover/mostStreamed", genre, offset, limit))),
"new-releases" => serialize(state.rt.block_on(core.get_discover_albums("/discover/newReleases", genre, offset, limit))),
"press-awards" => serialize(state.rt.block_on(core.get_discover_albums("/discover/pressAward", genre, offset, limit))),
"qobuzissims" => serialize(state.rt.block_on(core.get_discover_albums("/discover/qobuzissims", genre, offset, limit))),
"album-of-the-week" => serialize(state.rt.block_on(core.get_discover_albums("/discover/albumOfTheWeek", genre, offset, limit))),
"ideal-discography" => serialize(state.rt.block_on(core.get_discover_albums("/discover/idealDiscography", genre, offset, limit))),
"playlists" => {
let tag = get(&p, "tag").map(|s| s.to_string());
serialize(state.rt.block_on(core.get_discover_playlists(tag, genre, Some(limit), Some(offset))))
}
"tags" => serialize(state.rt.block_on(core.get_playlist_tags())),
"release-watch" => {
let rt = get(&p, "release_type").unwrap_or("artists");
serialize(state.rt.block_on(core.get_release_watch(rt, limit, offset)))
}
"featured" => match get(&p, "type") {
Some(ft) => {
let g1 = genre.as_ref().and_then(|v| v.first().copied());
serialize(state.rt.block_on(core.get_featured_albums(ft, limit, offset, g1)))
}
None => return err_json(400, "bad_request", "featured requires ?type=", "or use a named section, e.g. section=most-streamed"),
},
other => {
return err_json(
400,
"bad_request",
&format!("unknown discover section '{other}'"),
"section: index | most-streamed | new-releases | press-awards | qobuzissims | album-of-the-week | ideal-discography | playlists | tags | release-watch",
)
}
};
match data {
Ok(v) => json(200, serde_json::json!({"section": section, "data": v})),
Err(resp) => resp,
}
}
// ============================ internals ============================
/// Serialize an already-awaited core result, or map an upstream error to 502.
fn serialize<T: serde::Serialize>(r: Result<T, qbz_core::CoreError>) -> Result<Value, Response<Cursor<Vec<u8>>>> {
match r {
Ok(v) => Ok(serde_json::to_value(v).unwrap_or(Value::Null)),
Err(_) => Err(err_json(502, "discover_failed", "discover request to Qobuz failed", "try again in a moment")),
}
}
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 pairs(query: &str) -> Vec<(String, String)> {
query
.split('&')
.filter(|p| !p.is_empty())
.map(|p| {
let mut kv = p.splitn(2, '=');
let k = kv.next().unwrap_or("").to_string();
let raw = kv.next().unwrap_or("");
let v = urlencoding::decode(raw).map(|c| c.into_owned()).unwrap_or_else(|_| raw.to_string());
(k, v)
})
.collect()
}
fn get<'a>(p: &'a [(String, String)], key: &str) -> Option<&'a str> {
p.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()).filter(|v| !v.is_empty())
}
fn parse_genre(v: Option<&str>) -> Option<Vec<u64>> {
let ids: Vec<u64> = v?.split(',').filter_map(|s| s.trim().parse::<u64>().ok()).collect();
if ids.is_empty() {
None
} else {
Some(ids)
}
}
fn limit_offset(p: &[(String, String)]) -> (u32, u32) {
let limit = get(p, "limit").and_then(|v| v.parse::<u32>().ok()).map(|n| n.clamp(1, MAX_LIMIT)).unwrap_or(DEFAULT_LIMIT);
let offset = get(p, "offset").and_then(|v| v.parse::<u32>().ok()).unwrap_or(0);
(limit, offset)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_genre_reads_csv_or_none() {
assert_eq!(parse_genre(Some("1,2,3")), Some(vec![1, 2, 3]));
assert_eq!(parse_genre(Some("64")), Some(vec![64]));
assert_eq!(parse_genre(Some("")), None);
assert_eq!(parse_genre(None), None);
assert_eq!(parse_genre(Some("x,y")), None);
}
#[test]
fn get_and_limit_offset_defaults() {
let p = pairs("section=most-streamed&limit=500&genre=64");
assert_eq!(get(&p, "section"), Some("most-streamed"));
assert_eq!(get(&p, "missing"), None);
assert_eq!(limit_offset(&p), (MAX_LIMIT, 0));
}
}
+5 -1
View File
@@ -15,6 +15,7 @@
// 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 discover;
pub mod fav;
pub mod play;
pub mod playback;
@@ -79,6 +80,7 @@ pub const P1_ROUTES: &[(&str, &str)] = &[
("GET", "/api/artist"),
("GET", "/api/similar"),
("GET", "/api/suggest"),
("GET", "/api/discover"),
("POST", "/api/radio"),
("POST", "/api/playback/shuffle"),
("POST", "/api/playback/repeat"),
@@ -313,6 +315,7 @@ 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),
("GET", "/api/discover") => discover::discover(state, &query),
("POST", "/api/radio") => {
let body = read_json_body(req);
radio::radio(state, &body)
@@ -527,7 +530,8 @@ 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(), 22);
assert_eq!(P1_ROUTES.len(), 23);
assert!(P1_ROUTES.contains(&("GET", "/api/discover")));
assert!(P1_ROUTES.contains(&("GET", "/api/favorites")));
assert!(P1_ROUTES.contains(&("POST", "/api/favorites/add")));
assert!(P1_ROUTES.contains(&("POST", "/api/favorites/remove")));
+57
View File
@@ -0,0 +1,57 @@
// crates/qbzd/src/cli/discover.rs — `qbzd discover [SECTION] [--genre IDS]
// [--tag TAG] [--release-type RT] [--type FT] [--limit N] [--ids] [--json]`.
// Exposes the Discover rails so a client can replicate the Discover page. The
// human/--ids views walk the payload's items arrays generically; --json is the
// exact contract.
use crate::cli::browse::{collect_ids, render};
use crate::cli::client::ApiClient;
use crate::paths::ProfileRoots;
#[allow(clippy::too_many_arguments)]
pub async fn discover(
host: Option<String>,
section: Option<String>,
genre: Option<String>,
tag: Option<String>,
release_type: Option<String>,
kind: Option<String>,
limit: u32,
ids: bool,
json: bool,
roots: &ProfileRoots,
) -> i32 {
let sec = section.unwrap_or_else(|| "index".to_string());
let mut path = format!("/api/discover?section={}&limit={}", urlencoding::encode(&sec), limit);
if let Some(g) = genre.filter(|s| !s.is_empty()) {
path.push_str(&format!("&genre={}", urlencoding::encode(&g)));
}
if let Some(t) = tag.filter(|s| !s.is_empty()) {
path.push_str(&format!("&tag={}", urlencoding::encode(&t)));
}
if let Some(r) = release_type.filter(|s| !s.is_empty()) {
path.push_str(&format!("&release_type={}", urlencoding::encode(&r)));
}
if let Some(k) = kind.filter(|s| !s.is_empty()) {
path.push_str(&format!("&type={}", urlencoding::encode(&k)));
}
let client = ApiClient::new(host, roots);
match client.get(&path).await {
Ok(v) => {
if json {
println!("{}", serde_json::to_string(&v).unwrap_or_default());
} else if ids {
for id in collect_ids(&v) {
println!("{id}");
}
} else {
print!("{}", render(&v));
}
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
+1
View File
@@ -7,6 +7,7 @@ pub mod art;
pub mod browse;
pub mod client;
pub mod copy;
pub mod discover;
pub mod fav;
pub mod mode;
pub mod play;
+17
View File
@@ -84,6 +84,19 @@ enum Cmd {
#[arg(long)] ids: bool,
#[arg(long)] json: bool,
},
/// Discover rails: index | most-streamed | new-releases | press-awards |
/// qobuzissims | album-of-the-week | ideal-discography | playlists | tags |
/// release-watch (replicate Discover; no recommendations)
Discover {
section: Option<String>,
#[arg(long)] genre: Option<String>,
#[arg(long)] tag: Option<String>,
#[arg(long = "release-type")] release_type: Option<String>,
#[arg(long = "type")] kind: Option<String>,
#[arg(long, default_value_t = 20)] limit: u32,
#[arg(long)] ids: bool,
#[arg(long)] json: bool,
},
/// Favorites: list | add | remove
Fav { #[command(subcommand)] cmd: FavCmd },
/// Playlists: list | show
@@ -308,6 +321,10 @@ async fn main() {
let roots = paths::ProfileRoots::resolve(None, None);
cli::browse::suggest(cli.host, seed, limit, ids, json, &roots).await
}
Cmd::Discover { section, genre, tag, release_type, kind, limit, ids, json } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::discover::discover(cli.host, section, genre, tag, release_type, kind, limit, ids, json, &roots).await
}
Cmd::Fav { cmd } => {
let roots = paths::ProfileRoots::resolve(None, None);
match cmd {