feat(qbzd): queue routes and CLI verbs with server-side track materialization
cc
This commit is contained in:
5 files changed
+901
-9
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user