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:
8 files changed
+514
-4
No files matched your search
@@ -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())]);
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user