feat(qbzd): queue routes and CLI verbs with server-side track materialization

cc
This commit is contained in:
vicrodh committed 2026-07-14 17:05:15 -06:00
1 parent 393a6e01a7
commit 92c49e648b
5 files changed
+901 -9

No files matched your search

+34 -9
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 playback;
pub mod queue;
pub mod status;
use std::io::Cursor;
@@ -31,11 +32,11 @@ use qbz_app::shell::AppRuntime;
use qbz_audio::settings::AudioSettingsStore;
/// The counted P0 route table (02 §3.2). A route exists only iff a shipped
/// client calls it (§3.1.4). T1-T6 landed the first 3; T7 (this task) adds the
/// 9 playback + now-playing routes (rows 4-12); T8 adds the 4 queue routes,
/// T11 `/api/settings/reload` — the inline `route_table_matches_spec_count`
/// test pins the number so the 68-routes failure shape (a route with no
/// caller) cannot creep back in.
/// client calls it (§3.1.4). T1-T6 landed the first 3; T7 added the 9
/// playback + now-playing routes (rows 4-12); T8 (this task) adds the 4 queue
/// routes (rows 13-16); T11 adds `/api/settings/reload` (row 17) — the inline
/// `route_table_matches_spec_count` test pins the number so the 68-routes
/// failure shape (a route with no caller) cannot creep back in.
pub const P0_ROUTES: &[(&str, &str)] = &[
("GET", "/api/ping"),
("GET", "/api/info"),
@@ -49,6 +50,10 @@ pub const P0_ROUTES: &[(&str, &str)] = &[
("POST", "/api/playback/previous"),
("POST", "/api/playback/seek"),
("POST", "/api/playback/volume"),
("GET", "/api/queue"),
("POST", "/api/queue/add"),
("POST", "/api/queue/remove"),
("POST", "/api/queue/clear"),
];
/// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server
@@ -191,7 +196,10 @@ pub fn serve(server: BoundServer, state: ApiState) -> ApiHandle {
fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
let method = req.method().as_str().to_owned();
let path = req.url().split('?').next().unwrap_or("").to_owned();
let url = req.url().to_owned();
let mut url_parts = url.splitn(2, '?');
let path = url_parts.next().unwrap_or("").to_owned();
let query = url_parts.next().unwrap_or("").to_owned();
let has_origin = req.headers().iter().any(|h| h.field.equiv("Origin"));
let auth_header = req
.headers()
@@ -228,7 +236,20 @@ fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
let body = read_json_body(req);
playback::volume(state, &body)
}
// T8: 4 queue routes · T11: /api/settings/reload.
("GET", "/api/queue") => queue::list(state, &query),
("POST", "/api/queue/add") => {
let body = read_json_body(req);
queue::add(state, &body)
}
("POST", "/api/queue/remove") => {
let body = read_json_body(req);
queue::remove(state, &body)
}
("POST", "/api/queue/clear") => {
let body = read_json_body(req);
queue::clear(state, &body)
}
// T11: /api/settings/reload.
_ => err_json(404, "not_found", "unknown route", "see qbzd --help"),
}
}
@@ -354,8 +375,8 @@ mod tests {
#[test]
fn route_table_matches_spec_count() {
// 02-cli-and-api.md §3.2 — P0 = exactly 17 routes; grows ONLY with a
// shipped client. T1-T6 landed 3; T7 (this task) +9 = 12; T8 +4; T11 +1.
assert_eq!(P0_ROUTES.len(), 12);
// shipped client. T1-T6 landed 3; T7 +9 = 12; T8 (this task) +4 = 16; T11 +1 = 17.
assert_eq!(P0_ROUTES.len(), 16);
assert!(P0_ROUTES.contains(&("GET", "/api/ping")));
assert!(P0_ROUTES.contains(&("GET", "/api/info")));
assert!(P0_ROUTES.contains(&("GET", "/api/status")));
@@ -368,6 +389,10 @@ mod tests {
assert!(P0_ROUTES.contains(&("POST", "/api/playback/previous")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/seek")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/volume")));
assert!(P0_ROUTES.contains(&("GET", "/api/queue")));
assert!(P0_ROUTES.contains(&("POST", "/api/queue/add")));
assert!(P0_ROUTES.contains(&("POST", "/api/queue/remove")));
assert!(P0_ROUTES.contains(&("POST", "/api/queue/clear")));
}
#[test]
+510
View File
@@ -0,0 +1,510 @@
// crates/qbzd/src/api/queue.rs — routes 13-16 (02-cli-and-api.md §3.3.13-16):
// GET /api/queue, POST /api/queue/{add,remove,clear}.
//
// INDEX CONVENTION (normative, §3.3.13/§3.3.15, cross-doc-fixed): every index
// on the wire is 0-based, the SAME space as `current_index` in the
// `GET /api/queue` response. The CLI is the only place a 1-based position
// exists (§2.2's `queue list` table, `queue remove <INDEX>`) — the translation
// happens ONLY at the CLI boundary (`cli/queue.rs`), never here. This file
// speaks 0-based exclusively, straight from `QueueState.current_index`
// (`crates/qbz-models/src/playback.rs:94-103`) with no shift.
//
// 409 needs_auth is gated per-route by the §3.3.14-16 Errors columns, same
// discipline as api/playback.rs's header comment: `add` gates (needs a
// session to resolve tracks via `core.get_track`); `list`/`remove`/`clear`
// carry no needs_auth in their Errors column and act on whatever queue state
// already exists regardless of auth.
//
// Server-side materialization (brief, non-negotiable): `add` NEVER accepts a
// client-built `QueueTrack`. It resolves each `track_id` via `core.get_track`
// (the Qobuz catalog `Track`) and maps it to a `QueueTrack` with
// `track_to_queue_track` below — the same shape the desktop's single-track
// play path builds server-side (`crates/qbz/src/playback.rs:2028-2073`,
// off-limits here since `qbz` is the Slint crate; this is an independent,
// Slint-free re-derivation from `qbz_models::Track`, not a copy of that file).
//
// Response shape note on `add`: 02 §3.3.14's sketch is `{"added","total_tracks"}`.
// This handler additively includes `"tracks"`: the materialized `QueueTrack`
// objects, in request order — the same reasoning T7 used for `next`/`previous`
// returning the full landing `QueueTrack` (02 §3.3.9-10) rather than a bare id:
// the CLI's documented human line (`added: Spain – Chick Corea (next)`, §2.2)
// needs title/artist, and §1.1's one-request-per-verb rule forbids a second
// GET to fetch them. §3.1.4 explicitly allows additive fields within
// api_version 1; `total_tracks`/`added` are unchanged and still exactly what
// §3.3.14 documents.
use std::io::Cursor;
use serde_json::Value;
use tiny_http::Response;
use qbz_models::{QueueTrack, RepeatMode, Track};
use crate::state::AuthState;
use super::{err_json, json, ApiState};
/// `GET /api/queue` (02 §3.3.13). `query` is the raw query string (no leading
/// `?`) — `route()` strips it off the path before dispatch, so it is threaded
/// through separately. Reads the FULL queue state (`get_queue_state_full`,
/// not the UI-capped `get_queue_state`) so `offset`/`limit` paginate over the
/// complete `upcoming` list rather than an already-truncated 20-entry window.
pub fn list(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
let (offset, limit) = parse_offset_limit(query);
let queue = state.rt.block_on(state.runtime.core().get_queue_state_full());
let current_track = queue
.current_track
.as_ref()
.map(|t| serde_json::to_value(t).unwrap_or(Value::Null))
.unwrap_or(Value::Null);
let upcoming: Vec<Value> = paginate(&queue.upcoming, offset, limit)
.iter()
.map(|t| serde_json::to_value(t).unwrap_or(Value::Null))
.collect();
json(
200,
serde_json::json!({
"current_track": current_track,
"current_index": queue.current_index,
"upcoming": upcoming,
"history_len": queue.history.len(),
"shuffle": queue.shuffle,
"repeat": repeat_str(queue.repeat),
"total_tracks": queue.total_tracks,
"stop_after_track_id": queue.stop_after_track_id,
"offset": offset,
"limit": limit,
}),
)
}
/// `POST /api/queue/add` (02 §3.3.14). Body `{"track_ids": [...], "position":
/// "end"|"next"}` (`position` default `"end"`). Errors: 409 `needs_auth`,
/// 404 `not_found` (any unresolvable id — resolution stops at the first
/// failure, nothing partially added).
pub fn add(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let track_ids: Vec<u64> = match body.get("track_ids").and_then(|v| v.as_array()) {
Some(arr) if !arr.is_empty() => arr.iter().filter_map(|v| v.as_u64()).collect(),
_ => {
return err_json(
400,
"bad_request",
"add requires a non-empty 'track_ids' array",
"body: {\"track_ids\": [176544872]}",
)
}
};
let position = body.get("position").and_then(|v| v.as_str()).unwrap_or("end");
let mut resolved: Vec<QueueTrack> = Vec::with_capacity(track_ids.len());
for id in &track_ids {
match state.rt.block_on(state.runtime.core().get_track(*id)) {
Ok(track) => resolved.push(track_to_queue_track(&track)),
Err(_) => {
return err_json(
404,
"not_found",
&format!("track {id} not found"),
"check the track id: qbzd search <QUERY>",
)
}
}
}
let added = resolved.len();
let tracks_json: Vec<Value> = resolved
.iter()
.map(|t| serde_json::to_value(t).unwrap_or(Value::Null))
.collect();
if position == "next" {
// `add_track_next` always inserts immediately after the current
// track, so reverse iteration is what lands multiple tracks in
// request order (matches the desktop's multi-add-next convention).
for track in resolved.into_iter().rev() {
state.rt.block_on(state.runtime.core().add_track_next(track));
}
} else {
state.rt.block_on(state.runtime.core().add_tracks(resolved));
}
let total_tracks = state.rt.block_on(state.runtime.core().get_queue_state()).total_tracks;
json(
200,
serde_json::json!({"added": added, "total_tracks": total_tracks, "tracks": tracks_json}),
)
}
/// `POST /api/queue/remove` (02 §3.3.15). Body `{"index": N}`, 0-based —
/// SAME space as `current_index` (no CLI-boundary shift here). Errors:
/// 404 `not_found` (index out of range), 400 `bad_request` (index is the
/// playing track — the verbatim hint below is quoted §3.3.15).
pub fn remove(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
let index = match body.get("index").and_then(|v| v.as_u64()) {
Some(i) => i as usize,
None => {
return err_json(
400,
"bad_request",
"remove requires an 'index' field",
"body: {\"index\": 3}",
)
}
};
let queue = state.rt.block_on(state.runtime.core().get_queue_state_full());
match check_remove_index(index, queue.total_tracks, queue.current_index) {
RemoveCheck::OutOfRange => {
return err_json(
404,
"not_found",
&format!("queue index {index} is out of range"),
"check: qbzd queue list",
)
}
RemoveCheck::PlayingIndex => {
return err_json(
400,
"bad_request",
&format!("index {index} is the playing track"),
"use: qbzd next, or qbzd queue clear",
)
}
RemoveCheck::Ok => {}
}
match state.rt.block_on(state.runtime.core().remove_track(index)) {
Some(track) => {
let total_tracks =
state.rt.block_on(state.runtime.core().get_queue_state()).total_tracks;
json(200, serde_json::json!({"removed": track.id, "total_tracks": total_tracks}))
}
// Unreachable given the bounds check above (queue cannot shrink
// between the read and this call — the API thread is single-threaded,
// §"one serving thread" in api/mod.rs), but a defensive 404 beats a
// 500 if that invariant ever breaks.
None => err_json(
404,
"not_found",
&format!("queue index {index} is out of range"),
"check: qbzd queue list",
),
}
}
/// `POST /api/queue/clear` (02 §3.3.16). Body `{"keep_current": bool}`
/// (default `true` when the field is absent — the CLI always sends it
/// explicitly, §"queue clear" in cli/queue.rs).
pub fn clear(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
let keep_current = body.get("keep_current").and_then(|v| v.as_bool()).unwrap_or(true);
state.rt.block_on(state.runtime.core().clear_queue(keep_current));
let total_tracks = state.rt.block_on(state.runtime.core().get_queue_state()).total_tracks;
json(200, serde_json::json!({"total_tracks": total_tracks}))
}
// ============================ internals ============================
/// 409 `needs_auth` — only `add` gates (§3.3.14's Errors column; `list`/
/// `remove`/`clear` carry no needs_auth entry and act on whatever queue
/// already exists). Mirrors `playback::auth_gate` exactly; kept local per
/// this file's self-contained-helpers convention (see api/status.rs's own
/// `bitperfect_label`/`backend_label`).
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
}
}
/// The pure remove-index decision (brief step 1: "the remove-playing-index
/// rejection body"). Bounds-check wins over the playing-index check when
/// both would fire (an out-of-range index cannot simultaneously BE the
/// playing index, so order does not matter in practice — bounds first reads
/// more naturally as "does this index exist at all").
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RemoveCheck {
Ok,
OutOfRange,
PlayingIndex,
}
fn check_remove_index(index: usize, total_tracks: usize, current_index: Option<usize>) -> RemoveCheck {
if index >= total_tracks {
return RemoveCheck::OutOfRange;
}
if current_index == Some(index) {
return RemoveCheck::PlayingIndex;
}
RemoveCheck::Ok
}
/// Pure pagination slicing (brief step 1) — `GET /api/queue`'s `offset`/
/// `limit` applied to the full `upcoming` list.
fn paginate(items: &[QueueTrack], offset: usize, limit: usize) -> Vec<QueueTrack> {
items.iter().skip(offset).take(limit).cloned().collect()
}
/// `?offset=0&limit=100` (02 §3.3.13). Malformed/missing values fall back to
/// the documented defaults; there is no 400 for a bad query param (a read
/// route degrades gracefully rather than failing the request).
fn parse_offset_limit(query: &str) -> (usize, usize) {
let mut offset = 0usize;
let mut limit = 100usize;
for pair in query.split('&') {
let mut kv = pair.splitn(2, '=');
let key = kv.next().unwrap_or("");
let val = kv.next().unwrap_or("");
match key {
"offset" => {
if let Ok(n) = val.parse() {
offset = n;
}
}
"limit" => {
if let Ok(n) = val.parse() {
limit = n;
}
}
_ => {}
}
}
(offset, limit)
}
fn repeat_str(mode: RepeatMode) -> String {
match mode {
RepeatMode::Off => "off",
RepeatMode::All => "all",
RepeatMode::One => "one",
}
.to_string()
}
/// Qobuz catalog `Track` (`crates/qbz-models/src/types.rs:301-342`, what
/// `core.get_track` returns) -> `QueueTrack` (`crates/qbz-models/src/
/// playback.rs:15`, what the queue stores). An independent Slint-free
/// re-derivation of the same mapping the desktop's single-track play path
/// performs (`crates/qbz/src/playback.rs:2028-2073`) and `qbz-mixtape`'s
/// `track_to_queue_track_from_api` (`crates/qbz-mixtape/src/enqueue.rs:
/// 430-472`) — `qbzd` cannot depend on `qbz` (Slint) and this task's file
/// list does not add a new workspace dependency, so the ~20-line mapping is
/// duplicated rather than imported. `source_item_id_hint`/`context_kind`/
/// `context_id` are left `None`: those are "playing from" provenance fields
/// with no equivalent in a bare `qbzd queue add <TRACK_ID>` call (no album/
/// playlist/artist container in play).
fn track_to_queue_track(track: &Track) -> QueueTrack {
let artwork_url = track.album.as_ref().and_then(|a| a.image.best().cloned());
let artist = track
.performer
.as_ref()
.map(|p| p.name.clone())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "Unknown Artist".to_string());
let album = track
.album
.as_ref()
.map(|a| a.title.clone())
.unwrap_or_else(|| "Unknown Album".to_string());
let album_id = track.album.as_ref().map(|a| a.id.clone());
let artist_id = track.performer.as_ref().map(|p| p.id);
QueueTrack {
id: track.id,
title: track.title.clone(),
version: track.version.clone(),
artist,
album,
album_version: None,
duration_secs: track.duration as u64,
artwork_url,
hires: track.hires,
bit_depth: track.maximum_bit_depth,
sample_rate: track.maximum_sampling_rate,
is_local: false,
album_id,
artist_id,
streamable: track.streamable,
source: Some("qobuz".to_string()),
parental_warning: track.parental_warning,
source_item_id_hint: None,
context_kind: None,
context_id: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_track(id: u64) -> QueueTrack {
QueueTrack {
id,
title: format!("Track {id}"),
version: None,
artist: "Chick Corea".into(),
album: "Light as a Feather".into(),
album_version: None,
duration_secs: 300,
artwork_url: None,
hires: true,
bit_depth: Some(24),
sample_rate: Some(96.0),
is_local: false,
album_id: Some("0060253776847".into()),
artist_id: Some(123206),
streamable: true,
source: Some("qobuz".into()),
parental_warning: false,
source_item_id_hint: None,
context_kind: None,
context_id: None,
}
}
// ---------------------------- pagination ----------------------------
#[test]
fn paginate_slices_offset_and_limit() {
let items: Vec<QueueTrack> = (0..5).map(sample_track).collect();
let page = paginate(&items, 1, 2);
assert_eq!(page.iter().map(|t| t.id).collect::<Vec<_>>(), vec![1, 2]);
}
#[test]
fn paginate_offset_past_end_is_empty() {
let items: Vec<QueueTrack> = (0..3).map(sample_track).collect();
assert!(paginate(&items, 10, 5).is_empty());
}
#[test]
fn paginate_limit_larger_than_remaining_returns_the_rest() {
let items: Vec<QueueTrack> = (0..3).map(sample_track).collect();
let page = paginate(&items, 1, 100);
assert_eq!(page.iter().map(|t| t.id).collect::<Vec<_>>(), vec![1, 2]);
}
#[test]
fn parse_offset_limit_defaults_when_absent() {
assert_eq!(parse_offset_limit(""), (0, 100));
assert_eq!(parse_offset_limit("foo=bar"), (0, 100));
}
#[test]
fn parse_offset_limit_reads_both_params() {
assert_eq!(parse_offset_limit("offset=5&limit=10"), (5, 10));
assert_eq!(parse_offset_limit("limit=10&offset=5"), (5, 10));
}
#[test]
fn parse_offset_limit_ignores_malformed_values() {
assert_eq!(parse_offset_limit("offset=nope&limit=10"), (0, 10));
}
// -------------------------- remove-index gate --------------------------
#[test]
fn check_remove_index_ok_for_a_non_playing_in_range_index() {
assert_eq!(check_remove_index(2, 14, Some(1)), RemoveCheck::Ok);
}
#[test]
fn check_remove_index_rejects_out_of_range() {
assert_eq!(check_remove_index(14, 14, Some(1)), RemoveCheck::OutOfRange);
assert_eq!(check_remove_index(100, 14, None), RemoveCheck::OutOfRange);
}
#[test]
fn check_remove_index_rejects_the_playing_index() {
// 02 §3.3.15's own example state: current_index=1, removing index 1.
assert_eq!(check_remove_index(1, 14, Some(1)), RemoveCheck::PlayingIndex);
}
#[test]
fn remove_playing_index_error_body_matches_the_documented_hint() {
// 02 §3.3.15 verbatim: {"error":{"code":"bad_request",
// "message":"index 1 is the playing track","hint":"use: qbzd next, or qbzd queue clear"}}
let body = super::super::error_body(
"bad_request",
"index 1 is the playing track",
"use: qbzd next, or qbzd queue clear",
);
assert_eq!(body["error"]["code"], "bad_request");
assert_eq!(body["error"]["message"], "index 1 is the playing track");
assert_eq!(body["error"]["hint"], "use: qbzd next, or qbzd queue clear");
}
// ------------------------------ misc ------------------------------
#[test]
fn repeat_str_matches_contract_lowercase() {
assert_eq!(repeat_str(RepeatMode::Off), "off");
assert_eq!(repeat_str(RepeatMode::All), "all");
assert_eq!(repeat_str(RepeatMode::One), "one");
}
#[test]
fn track_to_queue_track_maps_the_catalog_shape() {
let mut track = Track {
id: 176544872,
title: "500 Miles High".into(),
duration: 547,
hires: true,
maximum_bit_depth: Some(24),
maximum_sampling_rate: Some(96.0),
streamable: true,
parental_warning: false,
..Default::default()
};
track.performer = Some(qbz_models::Artist {
id: 123206,
name: "Chick Corea".into(),
..Default::default()
});
track.album = Some(qbz_models::AlbumSummary {
id: "0060253776847".into(),
title: "Light as a Feather".into(),
image: qbz_models::ImageSet {
large: Some("https://static.qobuz.com/large.jpg".into()),
..Default::default()
},
label: None,
genre: None,
});
let qt = track_to_queue_track(&track);
assert_eq!(qt.id, 176544872);
assert_eq!(qt.title, "500 Miles High");
assert_eq!(qt.artist, "Chick Corea");
assert_eq!(qt.album, "Light as a Feather");
assert_eq!(qt.duration_secs, 547);
assert_eq!(qt.album_id.as_deref(), Some("0060253776847"));
assert_eq!(qt.artist_id, Some(123206));
assert_eq!(qt.source.as_deref(), Some("qobuz"));
assert_eq!(qt.artwork_url.as_deref(), Some("https://static.qobuz.com/large.jpg"));
}
#[test]
fn track_to_queue_track_falls_back_when_performer_and_album_are_absent() {
let track = Track {
id: 1,
title: "Untitled".into(),
..Default::default()
};
let qt = track_to_queue_track(&track);
assert_eq!(qt.artist, "Unknown Artist");
assert_eq!(qt.album, "Unknown Album");
assert!(qt.album_id.is_none());
assert!(qt.artwork_url.is_none());
}
}
+1
View File
@@ -5,5 +5,6 @@
// one place lets the spec and the code diff cleanly.
pub mod client;
pub mod copy;
pub mod queue;
pub mod status;
pub mod transport;
+343
View File
@@ -0,0 +1,343 @@
// crates/qbzd/src/cli/queue.rs — the `queue list/add/remove/clear` verbs
// (02-cli-and-api.md §2.2). Each is exactly one HTTP request (§1.1); the pure
// index-translation and rendering helpers below are unit-tested without a
// running daemon.
//
// INDEX CONVENTION (normative, cross-doc-fixed, §3.3.13/§3.3.15/§2.2 queue
// remove note): the wire is 0-based everywhere (the same space as
// `GET /api/queue`'s `current_index`). This file is the ONLY place a 1-based
// position exists — `queue list`'s `#` column and `queue remove <INDEX>`'s
// argument. The translation happens exactly at this boundary:
// - display: `cli_position(api_index_0based) -> 1-based` (used by the
// `queue list` table renderer).
// - input: `cli_index_to_api(cli_position_1based) -> 0-based, Result` (used
// by `queue remove`; position 0 is a usage error — there is no "0th"
// track).
use serde_json::Value;
use crate::cli::client::ApiClient;
use crate::paths::ProfileRoots;
// ============================ list ============================
/// `qbzd queue list [--json]` — renders `GET /api/queue` (§2.2/§3.3.13).
/// Exit: 0 · 1 · 3 (no needs_auth in `list`'s Errors column, §3.3.13).
pub async fn list(host: Option<String>, json: bool, roots: &ProfileRoots) -> i32 {
let client = ApiClient::new(host, roots);
match client.get("/api/queue").await {
Ok(v) => {
if json {
println!("{}", serde_json::to_string(&v).unwrap_or_default());
} else {
print!("{}", render_queue_list(&v));
}
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
/// The §2.2 table header, verbatim (column starts: `#`@4, `track`@7,
/// `artist`@47, `len` right-aligned in the trailing 4-wide field).
const HEADER: &str = " # track artist len";
/// The §2.2 `queue list` table. `current_track` + `upcoming` are the ONLY
/// track objects `GET /api/queue` returns (`history_len` is a bare count, no
/// track objects, §3.3.13) — rows before the current track are therefore not
/// rendered (there is no title/artist to print for them); the table starts
/// at the current track (marked `->`) and lists `upcoming` below it, both
/// numbered with the true 1-based absolute queue position via
/// `cli_position`. When nothing is current (`current_index: null`),
/// `upcoming` already holds the ENTIRE queue
/// (`QueueManager::get_state_full`, qbz-player/src/queue.rs:1036-1082) and
/// numbering starts at 1.
fn render_queue_list(v: &Value) -> String {
let current_index = v.get("current_index").and_then(|i| i.as_u64()).map(|i| i as usize);
let total = v.get("total_tracks").and_then(|t| t.as_u64()).unwrap_or(0);
let shuffle = v.get("shuffle").and_then(|s| s.as_bool()).unwrap_or(false);
let repeat = v.get("repeat").and_then(|r| r.as_str()).unwrap_or("off");
let mut out = String::new();
out.push_str(HEADER);
out.push('\n');
let current_track = v.get("current_track").filter(|t| !t.is_null());
if let (Some(idx), Some(track)) = (current_index, current_track) {
out.push_str(&render_row(true, cli_position(idx), track));
}
let upcoming_start = current_index.map(|i| cli_position(i) + 1).unwrap_or(1);
if let Some(upcoming) = v.get("upcoming").and_then(|u| u.as_array()) {
for (i, track) in upcoming.iter().enumerate() {
out.push_str(&render_row(false, upcoming_start + i, track));
}
}
out.push_str(&format!(
"{total} tracks · shuffle {} · repeat {repeat}\n",
if shuffle { "on" } else { "off" }
));
out
}
fn render_row(is_current: bool, position: usize, track: &Value) -> String {
let marker = if is_current { "->" } else { " " };
let title = track.get("title").and_then(|t| t.as_str()).unwrap_or("");
let artist = track.get("artist").and_then(|a| a.as_str()).unwrap_or("");
let dur = track.get("duration_secs").and_then(|d| d.as_u64()).unwrap_or(0);
format!(
"{marker}{position:>3} {title:<40}{artist:<17}{len:>4}\n",
len = fmt_mmss(dur)
)
}
// ============================ add ============================
/// `qbzd queue add <TRACK_ID> [--next]` -> `POST /api/queue/add` (§2.2/
/// §3.3.14). Exit: 0 · 1 · 3 · 4 (needs_auth) · 6 (unknown id).
pub async fn add(host: Option<String>, roots: &ProfileRoots, track_id: u64, next: bool) -> i32 {
let client = ApiClient::new(host, roots);
let position = if next { "next" } else { "end" };
let body = serde_json::json!({"track_ids": [track_id], "position": position});
match client.post("/api/queue/add", body).await {
Ok(v) => {
println!("{}", render_added(&v, next));
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
/// `added: Spain – Chick Corea (next)` (§2.2, verbatim). The daemon
/// additively returns the materialized `tracks` (api/queue.rs's `add` doc
/// comment explains why: the documented `{"added","total_tracks"}` sketch
/// alone has no title/artist, and §1.1 forbids a second request to fetch
/// them). A response without `tracks` (an older daemon, or the batch path
/// with `track_ids.len() > 1`, which this CLI never sends) falls back to the
/// bare count.
fn render_added(v: &Value, next: bool) -> String {
let suffix = if next { " (next)" } else { "" };
let track = v
.get("tracks")
.and_then(|t| t.as_array())
.filter(|a| a.len() == 1)
.and_then(|a| a.first());
match track {
Some(t) => {
let title = t.get("title").and_then(|x| x.as_str()).unwrap_or("");
let artist = t.get("artist").and_then(|x| x.as_str()).unwrap_or("");
format!("added: {title} – {artist}{suffix}")
}
None => {
let added = v.get("added").and_then(|a| a.as_u64()).unwrap_or(0);
format!("added: {added} track(s){suffix}")
}
}
}
// ============================ remove ============================
/// `qbzd queue remove <INDEX>` -> `POST /api/queue/remove` (§2.2/§3.3.15).
/// `index` is the 1-based position `queue list` displayed; translated to the
/// API's 0-based index at this boundary. Exit: 0 · 1 (removing the playing
/// track — server `bad_request`, not one of `error_from_envelope`'s
/// special-cased codes, §3.1.3) · 2 (position 0, local usage error) · 3 ·
/// 6 (out of range — server `not_found`).
pub async fn remove(host: Option<String>, roots: &ProfileRoots, index: usize) -> i32 {
let api_index = match cli_index_to_api(index) {
Ok(i) => i,
Err(msg) => {
eprintln!("error: {msg}");
return 2;
}
};
let client = ApiClient::new(host, roots);
let body = serde_json::json!({"index": api_index});
match client.post("/api/queue/remove", body).await {
Ok(v) => {
println!("{}", render_removed(&v));
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
/// 1-based CLI position -> 0-based API index. Position 0 is a usage error
/// (there is no "0th" track) — the ONLY local validation this verb does;
/// everything else (out of range, playing track) is a server-side 404/400
/// the daemon is authoritative on.
pub fn cli_index_to_api(position: usize) -> Result<usize, String> {
position.checked_sub(1).ok_or_else(|| {
format!("invalid queue position '{position}' — positions start at 1 (see: qbzd queue list)")
})
}
/// 0-based API index -> 1-based CLI display position (the inverse of
/// [`cli_index_to_api`] — used by `queue list`'s row numbering).
fn cli_position(api_index: usize) -> usize {
api_index + 1
}
fn render_removed(v: &Value) -> String {
let id = v.get("removed").and_then(|r| r.as_u64()).unwrap_or(0);
let total = v.get("total_tracks").and_then(|t| t.as_u64()).unwrap_or(0);
format!("removed: track {id} · {total} left")
}
// ============================ clear ============================
/// `qbzd queue clear [--keep-current]` -> `POST /api/queue/clear` (§2.2/
/// §3.3.16). The flag is a plain bool (clap default `false` when absent):
/// bare `queue clear` sends `keep_current: false` (a full reset — the
/// harshest reading of "clear"); `--keep-current` sends `true` (preserve the
/// now-playing track). The API's own default (`true` when the field is
/// OMITTED, §3.3.16) never applies here — this CLI always sends the field
/// explicitly. Exit: 0 · 1 · 3.
pub async fn clear(host: Option<String>, roots: &ProfileRoots, keep_current: bool) -> i32 {
let client = ApiClient::new(host, roots);
let body = serde_json::json!({"keep_current": keep_current});
match client.post("/api/queue/clear", body).await {
Ok(v) => {
let total = v.get("total_tracks").and_then(|t| t.as_u64()).unwrap_or(0);
println!("queue cleared · {total} left");
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
// ============================ shared rendering ============================
fn fmt_mmss(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
#[cfg(test)]
mod tests {
use super::*;
// -------------------- index translation (both directions) --------------------
#[test]
fn cli_index_to_api_shifts_1based_to_0based() {
// 02 §3.3.15's own example state: current_index=1 (0-based) IS
// position 2 (1-based) in the §2.2 table — this is the inverse.
assert_eq!(cli_index_to_api(1), Ok(0));
assert_eq!(cli_index_to_api(2), Ok(1));
assert_eq!(cli_index_to_api(14), Ok(13));
}
#[test]
fn cli_index_to_api_rejects_position_zero() {
assert!(cli_index_to_api(0).is_err());
}
#[test]
fn cli_position_shifts_0based_to_1based() {
assert_eq!(cli_position(0), 1);
assert_eq!(cli_position(1), 2);
assert_eq!(cli_position(13), 14);
}
#[test]
fn index_translation_round_trips() {
for api_index in 0..20usize {
assert_eq!(cli_index_to_api(cli_position(api_index)), Ok(api_index));
}
}
// -------------------------- queue list rendering --------------------------
#[test]
fn render_queue_list_marks_the_current_track_and_numbers_absolutely() {
// 02 §2.2's documented state: current_index=1 (0-based) -> "Spain" at
// row #2 with the `->` marker; "500 Miles High" (the sole `upcoming`
// entry) at row #3.
let v = serde_json::json!({
"current_track": {"id": 176544871, "title": "Spain", "artist": "Chick Corea", "duration_secs": 581},
"current_index": 1,
"upcoming": [
{"id": 176544872, "title": "500 Miles High", "artist": "Chick Corea", "duration_secs": 547}
],
"history_len": 1, "shuffle": false, "repeat": "off",
"total_tracks": 14, "stop_after_track_id": null, "offset": 0, "limit": 100
});
let rendered = render_queue_list(&v);
assert!(rendered.starts_with(HEADER), "{rendered}");
assert!(rendered.contains("-> 2 Spain"), "{rendered}");
assert!(rendered.contains(" 3 500 Miles High"), "{rendered}");
assert!(rendered.ends_with("14 tracks · shuffle off · repeat off\n"), "{rendered}");
}
#[test]
fn render_queue_list_numbers_from_one_when_nothing_is_current() {
let v = serde_json::json!({
"current_track": null, "current_index": null,
"upcoming": [
{"id": 1, "title": "A", "artist": "X", "duration_secs": 60},
{"id": 2, "title": "B", "artist": "Y", "duration_secs": 60}
],
"history_len": 0, "shuffle": false, "repeat": "off",
"total_tracks": 2, "stop_after_track_id": null, "offset": 0, "limit": 100
});
let rendered = render_queue_list(&v);
assert!(rendered.contains(" 1 A"), "{rendered}");
assert!(rendered.contains(" 2 B"), "{rendered}");
assert!(!rendered.contains("->"), "{rendered}");
}
#[test]
fn render_queue_list_empty_queue_shows_zero_tracks() {
let v = serde_json::json!({
"current_track": null, "current_index": null, "upcoming": [],
"history_len": 0, "shuffle": false, "repeat": "off",
"total_tracks": 0, "stop_after_track_id": null, "offset": 0, "limit": 100
});
let rendered = render_queue_list(&v);
assert_eq!(rendered, format!("{HEADER}\n0 tracks · shuffle off · repeat off\n"));
}
// ------------------------------ add rendering ------------------------------
#[test]
fn render_added_uses_the_materialized_track_and_next_suffix() {
let v = serde_json::json!({
"added": 1, "total_tracks": 15,
"tracks": [{"id": 176544872, "title": "Spain", "artist": "Chick Corea"}]
});
assert_eq!(render_added(&v, true), "added: Spain – Chick Corea (next)");
assert_eq!(render_added(&v, false), "added: Spain – Chick Corea");
}
#[test]
fn render_added_falls_back_to_the_bare_count_without_tracks() {
let v = serde_json::json!({"added": 1, "total_tracks": 15});
assert_eq!(render_added(&v, false), "added: 1 track(s)");
}
// ---------------------------- remove rendering ----------------------------
#[test]
fn render_removed_shows_id_and_remaining_count() {
let v = serde_json::json!({"removed": 176544872, "total_tracks": 14});
assert_eq!(render_removed(&v), "removed: track 176544872 · 14 left");
}
#[test]
fn fmt_mmss_pads_seconds() {
assert_eq!(fmt_mmss(581), "9:41");
assert_eq!(fmt_mmss(65), "1:05");
}
}
+13
View File
@@ -228,6 +228,19 @@ async fn main() {
let roots = paths::ProfileRoots::resolve(None, None);
cli::transport::mute(cli.host, &roots, state).await
}
Cmd::Queue { cmd } => {
let roots = paths::ProfileRoots::resolve(None, None);
match cmd {
QueueCmd::List { json } => cli::queue::list(cli.host, json, &roots).await,
QueueCmd::Add { track_id, next } => {
cli::queue::add(cli.host, &roots, track_id, next).await
}
QueueCmd::Remove { index } => cli::queue::remove(cli.host, &roots, index).await,
QueueCmd::Clear { keep_current } => {
cli::queue::clear(cli.host, &roots, keep_current).await
}
}
}
_ => { eprintln!("not implemented yet"); 1 } // burned down task by task
};
std::process::exit(code);