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