feat(qbzd): add fav (list/add/remove) + playlist (list/show) (CONSOLE P1)

- api/fav.rs: GET /api/favorites (paged read), POST /api/favorites/add|remove.
  The contract is SINGULAR (track|album|artist) everywhere; the server
  pluralizes for get_favorites, hiding the plural-read/singular-write trap.
  `add {current:true}` favorites the now-playing track.
- api/playlist.rs: GET /api/playlists (collection), GET /api/playlist?id=
  (one playlist, full track list). Reads only in this slice; CRUD deferred.
- cli/fav.rs + cli/playlist.rs reuse browse's generic render/collect_ids
  (made pub(crate)); playlist list has its own id/name/count renderer.
- api/mod.rs: P1_ROUTES 12->17 (+favorites x3, playlists, playlist); routes +
  test. main.rs: Fav/Playlist subcommand groups.

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

cc
This commit is contained in:
vicrodh committed 2026-07-19 16:28:03 -06:00
1 parent ecc47b6d23
commit 9ab41fbe8c
8 files changed
+514 -4

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
// crates/qbzd/src/api/fav.rs — favorites (02 §2.3, §3.4 rows 20-22): GET
// /api/favorites (paged read), POST /api/favorites/add|remove.
//
// The plural-read/singular-write fav_type trap (get_favorites wants "tracks",
// add_favorite wants "track" — qbz-core/src/core.rs:1072 vs :1088) is HIDDEN
// from clients: the contract is SINGULAR everywhere (track|album|artist) and
// the server pluralizes for the read. All auth-gated (favorites are per-user).
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 = 50;
const MAX_LIMIT: u32 = 500;
/// `GET /api/favorites?type=track|album|artist&limit=&offset=`. Returns the
/// raw favorites payload under `favorites` (its `items` arrays are the same
/// shape the CLI's generic renderer walks). `type` defaults to `track`.
pub fn list(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let mut ftype = FavType::Track;
let mut limit = DEFAULT_LIMIT;
let mut offset = 0u32;
for (k, v) in pairs(query) {
match k.as_str() {
"type" | "fav_type" => match FavType::parse(&v) {
Some(t) => ftype = t,
None => return bad_type(&v),
},
"limit" => {
if let Ok(n) = v.parse::<u32>() {
limit = n.clamp(1, MAX_LIMIT);
}
}
"offset" => {
if let Ok(n) = v.parse::<u32>() {
offset = n;
}
}
_ => {}
}
}
match state.rt.block_on(state.runtime.core().get_favorites(ftype.plural(), limit, offset)) {
Ok(v) => json(200, serde_json::json!({"type": ftype.singular(), "favorites": v})),
Err(_) => err_json(502, "favorites_failed", "favorites request to Qobuz failed", "try again in a moment"),
}
}
/// `POST /api/favorites/add`. Body `{"fav_type": "track|album|artist",
/// "item_id": "..."}` or `{"fav_type": "track", "current": true}` (favorite the
/// now-playing track). Idempotent from the caller's view (Qobuz de-dupes).
pub fn add(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
mutate(state, body, true)
}
/// `POST /api/favorites/remove`. Same body shape (minus `current`).
pub fn remove(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
mutate(state, body, false)
}
// ============================ internals ============================
fn mutate(state: &ApiState, body: &Value, adding: bool) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let ftype = match body.get("fav_type").and_then(|v| v.as_str()).and_then(FavType::parse) {
Some(t) => t,
None => return err_json(400, "bad_request", "requires fav_type: track|album|artist", "body: {\"fav_type\":\"track\",\"item_id\":\"...\"}"),
};
// `current: true` (add only) favorites the now-playing track.
let item_id = if adding && body.get("current").and_then(|v| v.as_bool()).unwrap_or(false) {
if ftype != FavType::Track {
return err_json(400, "bad_request", "current only applies to fav_type track", "use: fav add track --current");
}
match state.rt.block_on(state.runtime.core().get_queue_state()).current_track {
Some(t) => t.id.to_string(),
None => return err_json(404, "not_found", "nothing is playing", "queue a track first"),
}
} else {
match body.get("item_id").and_then(|v| v.as_str()) {
Some(id) if !id.is_empty() => id.to_string(),
_ => return err_json(400, "bad_request", "requires an item_id", "body: {\"fav_type\":\"track\",\"item_id\":\"176544871\"}"),
}
};
let result = if adding {
state.rt.block_on(state.runtime.core().add_favorite(ftype.singular(), &item_id))
} else {
state.rt.block_on(state.runtime.core().remove_favorite(ftype.singular(), &item_id))
};
match result {
Ok(()) => json(200, serde_json::json!({"ok": true, "type": ftype.singular(), "item_id": item_id})),
Err(_) => err_json(502, "favorites_failed", "favorites update failed", "try again in a moment"),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FavType {
Track,
Album,
Artist,
}
impl FavType {
/// Accepts singular or plural, case-insensitive (lenient input).
fn parse(s: &str) -> Option<FavType> {
match s.trim().to_ascii_lowercase().as_str() {
"track" | "tracks" => Some(FavType::Track),
"album" | "albums" => Some(FavType::Album),
"artist" | "artists" => Some(FavType::Artist),
_ => None,
}
}
/// Singular form for add_favorite/remove_favorite (core.rs:1088/1099).
fn singular(self) -> &'static str {
match self {
FavType::Track => "track",
FavType::Album => "album",
FavType::Artist => "artist",
}
}
/// Plural form for get_favorites (core.rs:1072).
fn plural(self) -> &'static str {
match self {
FavType::Track => "tracks",
FavType::Album => "albums",
FavType::Artist => "artists",
}
}
}
fn bad_type(got: &str) -> Response<Cursor<Vec<u8>>> {
err_json(400, "bad_request", &format!("unknown fav type '{got}'"), "type: track | album | artist")
}
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
}
}
/// Percent-decoded (key, value) pairs from a query string.
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()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fav_type_parses_singular_and_plural_and_maps_both_forms() {
for s in ["track", "tracks", "TRACK"] {
assert_eq!(FavType::parse(s), Some(FavType::Track));
}
assert_eq!(FavType::parse("albums"), Some(FavType::Album));
assert_eq!(FavType::parse("artist"), Some(FavType::Artist));
assert_eq!(FavType::parse("songs"), None);
// the trap, hidden: read uses plural, write uses singular.
assert_eq!(FavType::Track.plural(), "tracks");
assert_eq!(FavType::Track.singular(), "track");
}
#[test]
fn pairs_decodes_values() {
let p = pairs("type=track&limit=50");
assert_eq!(p, vec![("type".into(), "track".into()), ("limit".into(), "50".into())]);
}
}
+24 -1
View File
@@ -15,8 +15,10 @@
// 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 fav;
pub mod play;
pub mod playback;
pub mod playlist;
pub mod queue;
pub mod radio;
pub mod search;
@@ -83,6 +85,11 @@ pub const P1_ROUTES: &[(&str, &str)] = &[
("POST", "/api/queue/move"),
("POST", "/api/queue/jump"),
("POST", "/api/queue/stop-after"),
("GET", "/api/favorites"),
("POST", "/api/favorites/add"),
("POST", "/api/favorites/remove"),
("GET", "/api/playlists"),
("GET", "/api/playlist"),
];
/// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server
@@ -305,6 +312,17 @@ fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
let body = read_json_body(req);
radio::radio(state, &body)
}
("GET", "/api/favorites") => fav::list(state, &query),
("POST", "/api/favorites/add") => {
let body = read_json_body(req);
fav::add(state, &body)
}
("POST", "/api/favorites/remove") => {
let body = read_json_body(req);
fav::remove(state, &body)
}
("GET", "/api/playlists") => playlist::list(state),
("GET", "/api/playlist") => playlist::show(state, &query),
("GET", "/api/queue") => queue::list(state, &query),
("POST", "/api/queue/add") => {
let body = read_json_body(req);
@@ -484,7 +502,12 @@ 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(), 12);
assert_eq!(P1_ROUTES.len(), 17);
assert!(P1_ROUTES.contains(&("GET", "/api/favorites")));
assert!(P1_ROUTES.contains(&("POST", "/api/favorites/add")));
assert!(P1_ROUTES.contains(&("POST", "/api/favorites/remove")));
assert!(P1_ROUTES.contains(&("GET", "/api/playlists")));
assert!(P1_ROUTES.contains(&("GET", "/api/playlist")));
assert!(P1_ROUTES.contains(&("GET", "/api/search")));
assert!(P1_ROUTES.contains(&("POST", "/api/play")));
assert!(P1_ROUTES.contains(&("GET", "/api/album")));
+83
View File
@@ -0,0 +1,83 @@
// crates/qbzd/src/api/playlist.rs — playlists (02 §2.3, §3.4 row 24). GET
// /api/playlists (the user's collection) and GET /api/playlist?id= (one
// playlist with its COMPLETE track list — get_playlist auto-pages server-side).
// Reads only in this slice; playlist CRUD (create/update/delete/tracks) is a
// later batch. Auth-gated; typed serde shapes verbatim.
use std::io::Cursor;
use serde_json::Value;
use tiny_http::Response;
use crate::state::AuthState;
use super::{err_json, json, ApiState};
/// `GET /api/playlists` — the user's playlist collection.
pub fn list(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
match state.rt.block_on(state.runtime.core().get_user_playlists()) {
Ok(pls) => json(
200,
serde_json::json!({"playlists": serde_json::to_value(pls).unwrap_or(Value::Null)}),
),
Err(_) => err_json(502, "playlists_failed", "playlists request to Qobuz failed", "try again in a moment"),
}
}
/// `GET /api/playlist?id=<ID>` — one playlist with its full track list.
pub fn show(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let id = match id_param(query) {
Some(id) => id,
None => return err_json(400, "bad_request", "playlist requires a numeric id", "usage: qbzd playlist show <ID>"),
};
match state.rt.block_on(state.runtime.core().get_playlist(id)) {
Ok(pl) => json(
200,
serde_json::json!({"playlist": serde_json::to_value(pl).unwrap_or(Value::Null)}),
),
Err(_) => err_json(404, "not_found", &format!("playlist {id} not found"), "check: qbzd playlist list"),
}
}
// ============================ internals ============================
fn id_param(query: &str) -> Option<u64> {
for pair in query.split('&') {
let mut kv = pair.splitn(2, '=');
if kv.next() == Some("id") {
return kv.next().and_then(|v| v.parse::<u64>().ok());
}
}
None
}
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
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_param_reads_numeric_id() {
assert_eq!(id_param("id=987654"), Some(987654));
assert_eq!(id_param("foo=1&id=42"), Some(42));
assert_eq!(id_param("id=abc"), None);
assert_eq!(id_param(""), None);
}
}
+4 -3
View File
@@ -100,8 +100,9 @@ fn read_stdin_ids() -> String {
/// 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 {
/// suggest payload shapes (the `--json` output is the exact contract). Shared
/// with `cli::fav` (favorites payloads are the same items-array shape).
pub(crate) fn render(p: &Value) -> String {
let mut items: Vec<&Value> = Vec::new();
walk(p, &mut items);
if items.is_empty() {
@@ -129,7 +130,7 @@ fn render(p: &Value) -> String {
out
}
fn collect_ids(p: &Value) -> Vec<String> {
pub(crate) fn collect_ids(p: &Value) -> Vec<String> {
let mut items: Vec<&Value> = Vec::new();
walk(p, &mut items);
items
+75
View File
@@ -0,0 +1,75 @@
// crates/qbzd/src/cli/fav.rs — the `qbzd fav list|add|remove` verbs (02 §2.3).
// The fav_type contract is SINGULAR (track|album|artist); the server hides the
// plural-read trap. `fav add track --current` favorites the now-playing track.
use serde_json::Value;
use crate::cli::browse::{collect_ids, render};
use crate::cli::client::ApiClient;
use crate::paths::ProfileRoots;
/// `qbzd fav list [--type track|album|artist] [--ids] [--json]`.
pub async fn list(host: Option<String>, kind: Option<String>, ids: bool, json: bool, roots: &ProfileRoots) -> i32 {
let t = kind.unwrap_or_else(|| "track".to_string());
let path = format!("/api/favorites?type={}", urlencoding::encode(&t));
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()
}
}
}
/// `qbzd fav add <track|album|artist> <ID>` · `qbzd fav add track --current`.
pub async fn add(host: Option<String>, fav_type: String, id: Option<String>, current: bool, roots: &ProfileRoots) -> i32 {
let mut body = serde_json::json!({ "fav_type": fav_type });
if current {
body["current"] = Value::Bool(true);
} else {
match id {
Some(i) => body["item_id"] = Value::String(i),
None => {
eprintln!("error: fav add needs an id (or --current for a track)");
eprintln!(" → usage: qbzd fav add track <ID> | qbzd fav add track --current");
return 2;
}
}
}
post(host, roots, "/api/favorites/add", body, "favorited").await
}
/// `qbzd fav remove <track|album|artist> <ID>`.
pub async fn remove(host: Option<String>, fav_type: String, id: String, roots: &ProfileRoots) -> i32 {
let body = serde_json::json!({ "fav_type": fav_type, "item_id": id });
post(host, roots, "/api/favorites/remove", body, "unfavorited").await
}
// ============================ internals ============================
async fn post(host: Option<String>, roots: &ProfileRoots, path: &str, body: Value, verb: &str) -> i32 {
let client = ApiClient::new(host, roots);
match client.post(path, body).await {
Ok(v) => {
let t = v.get("type").and_then(|x| x.as_str()).unwrap_or("item");
let id = v.get("item_id").and_then(|x| x.as_str()).unwrap_or("");
println!("{verb} {t} {id}");
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
+2
View File
@@ -6,8 +6,10 @@
pub mod browse;
pub mod client;
pub mod copy;
pub mod fav;
pub mod mode;
pub mod play;
pub mod playlist;
pub mod queue;
pub mod radio;
pub mod resolve;
+91
View File
@@ -0,0 +1,91 @@
// crates/qbzd/src/cli/playlist.rs — the `qbzd playlist list|show` verbs
// (02 §2.3). Reads only in this slice. `playlist show --ids` emits the
// playlist's track ids (pipe into `queue add -`).
use serde_json::Value;
use crate::cli::browse::{collect_ids, render};
use crate::cli::client::ApiClient;
use crate::paths::ProfileRoots;
/// `qbzd playlist list [--json]`.
pub async fn list(host: Option<String>, json: bool, roots: &ProfileRoots) -> i32 {
let client = ApiClient::new(host, roots);
match client.get("/api/playlists").await {
Ok(v) => {
if json {
println!("{}", serde_json::to_string(&v).unwrap_or_default());
} else {
print!("{}", render_list(&v));
}
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
/// `qbzd playlist show <ID> [--ids] [--json]`.
pub async fn show(host: Option<String>, id: u64, ids: bool, json: bool, roots: &ProfileRoots) -> i32 {
let client = ApiClient::new(host, roots);
match client.get(&format!("/api/playlist?id={id}")).await {
Ok(v) => {
if json {
println!("{}", serde_json::to_string(&v).unwrap_or_default());
} else if ids {
for tid in collect_ids(&v) {
println!("{tid}");
}
} else {
print!("{}", render(&v));
}
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
// ============================ internals ============================
/// The collection view: `id Name (N tracks)` per playlist. The array is under
/// `playlists` (not an items/tracks key), so it has its own small renderer.
fn render_list(v: &Value) -> String {
match v.get("playlists").and_then(|p| p.as_array()) {
Some(a) if !a.is_empty() => {
let mut out = String::new();
for pl in a {
let id = pl.get("id").and_then(|x| x.as_u64()).map(|n| n.to_string()).unwrap_or_default();
let name = pl.get("name").and_then(|x| x.as_str()).unwrap_or("(untitled)");
let count = pl.get("tracks_count").and_then(|x| x.as_u64()).unwrap_or(0);
out.push_str(&format!("{id} {name} ({count} tracks)\n"));
}
out
}
_ => "no playlists\n".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_list_shows_id_name_count() {
let v = serde_json::json!({"playlists": [
{"id": 987, "name": "Fusion", "tracks_count": 42},
{"id": 12, "name": "Chill"}
]});
let out = render_list(&v);
assert!(out.contains("987 Fusion (42 tracks)"), "{out}");
assert!(out.contains("12 Chill (0 tracks)"), "{out}");
}
#[test]
fn render_list_empty() {
assert_eq!(render_list(&serde_json::json!({"playlists": []})), "no playlists\n");
}
}
+40
View File
@@ -84,6 +84,10 @@ enum Cmd {
#[arg(long)] ids: bool,
#[arg(long)] json: bool,
},
/// Favorites: list | add | remove
Fav { #[command(subcommand)] cmd: FavCmd },
/// Playlists: list | show
Playlist { #[command(subcommand)] cmd: PlaylistCmd },
/// Shuffle: on | off | toggle (bare = toggle)
Shuffle { mode: Option<String> },
/// Repeat: off | all | one
@@ -125,6 +129,23 @@ enum QueueCmd {
StopAfter { arg: Option<String> },
}
#[derive(Subcommand)]
enum FavCmd {
List {
#[arg(long = "type")] kind: Option<String>,
#[arg(long)] ids: bool,
#[arg(long)] json: bool,
},
Add { fav_type: String, id: Option<String>, #[arg(long)] current: bool },
Remove { fav_type: String, id: String },
}
#[derive(Subcommand)]
enum PlaylistCmd {
List { #[arg(long)] json: bool },
Show { id: u64, #[arg(long)] ids: bool, #[arg(long)] json: bool },
}
#[derive(Subcommand)]
enum SettingsCmd {
Export {
@@ -269,6 +290,25 @@ async fn main() {
let roots = paths::ProfileRoots::resolve(None, None);
cli::browse::suggest(cli.host, seed, limit, ids, json, &roots).await
}
Cmd::Fav { cmd } => {
let roots = paths::ProfileRoots::resolve(None, None);
match cmd {
FavCmd::List { kind, ids, json } => cli::fav::list(cli.host, kind, ids, json, &roots).await,
FavCmd::Add { fav_type, id, current } => {
cli::fav::add(cli.host, fav_type, id, current, &roots).await
}
FavCmd::Remove { fav_type, id } => cli::fav::remove(cli.host, fav_type, id, &roots).await,
}
}
Cmd::Playlist { cmd } => {
let roots = paths::ProfileRoots::resolve(None, None);
match cmd {
PlaylistCmd::List { json } => cli::playlist::list(cli.host, json, &roots).await,
PlaylistCmd::Show { id, ids, json } => {
cli::playlist::show(cli.host, id, ids, json, &roots).await
}
}
}
Cmd::Shuffle { mode } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::mode::shuffle(cli.host, mode, &roots).await