feat(qbzd): playback routes and transport CLI verbs with full advance ritual

cc
This commit is contained in:
vicrodh committed 2026-07-14 16:35:21 -06:00
1 parent dd63b18ae9
commit e700084f0b
8 files changed
+1050 -25

No files matched your search

+19 -7
View File
@@ -484,12 +484,14 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
/// Run the advance ritual and log its outcome (queue-finished on `Ok(None)`, the /// Run the advance ritual and log its outcome (queue-finished on `Ok(None)`, the
/// error on `Err`). The daemon stops on a genuine queue edge just like the /// error on `Err`). The daemon stops on a genuine queue edge just like the
/// pure-decision `QueueFinished` branch does. /// pure-decision `QueueFinished` branch does. Always forward — the driver's
/// auto-advance on track-end never walks backward (reverse is the `qbzd prev`
/// route's concern, wired directly through `advance_and_play(..., false)`).
async fn advance_and_play_logged<A: FrontendAdapter + Send + Sync + 'static>( async fn advance_and_play_logged<A: FrontendAdapter + Send + Sync + 'static>(
runtime: &Arc<AppRuntime<A>>, runtime: &Arc<AppRuntime<A>>,
quality: Quality, quality: Quality,
) { ) {
match advance_and_play(runtime, quality).await { match advance_and_play(runtime, quality, true).await {
Ok(Some(_)) => {} Ok(Some(_)) => {}
Ok(None) => { Ok(None) => {
log::info!("[qbzd] driver: advance found nothing playable — queue finished"); log::info!("[qbzd] driver: advance found nothing playable — queue finished");
@@ -502,18 +504,28 @@ async fn advance_and_play_logged<A: FrontendAdapter + Send + Sync + 'static>(
} }
/// The FULL advance ritual, reused verbatim by the CLI next/prev routes (T7): /// The FULL advance ritual, reused verbatim by the CLI next/prev routes (T7):
/// bounded skip-walk to the next playable track → play it → warm the successors /// bounded skip-walk to the next (or previous) playable track → play it → warm
/// for gapless → persist the session. Never a bare cursor move (02 §2.2). The /// the successors for gapless → persist the session. Never a bare cursor move
/// skip-walk mirrors `playback.rs::advance_to_playable` (capped at /// (02 §2.2). The skip-walk mirrors `playback.rs::advance_to_playable` (capped
/// `MAX_OFFLINE_SKIPS`); `next_track()` is the atomic cursor mover. /// at `MAX_OFFLINE_SKIPS`); `next_track()`/`previous_track()` are the atomic
/// cursor movers — `forward` selects which one, exactly like the desktop's
/// `advance_to_playable(runtime, weak, forward)` (`crates/qbz/src/playback.rs:358`),
/// so `qbzd next` and `qbzd prev` share this one ritual instead of duplicating
/// the play → prefetch → persist tail.
pub async fn advance_and_play<A: FrontendAdapter + Send + Sync + 'static>( pub async fn advance_and_play<A: FrontendAdapter + Send + Sync + 'static>(
runtime: &AppRuntime<A>, runtime: &AppRuntime<A>,
quality: Quality, quality: Quality,
forward: bool,
) -> Result<Option<QueueTrack>, String> { ) -> Result<Option<QueueTrack>, String> {
let core = runtime.core(); let core = runtime.core();
let mut skips = 0usize; let mut skips = 0usize;
let next = loop { let next = loop {
let Some(track) = core.next_track().await else { let step = if forward {
core.next_track().await
} else {
core.previous_track().await
};
let Some(track) = step else {
break None; // queue edge break None; // queue edge
}; };
// Playable gate: local files always attempt (they play from disk); // Playable gate: local files always attempt (they play from disk);
+51 -8
View File
@@ -14,6 +14,7 @@
// The two-call split — `bind` at boot step 5 (stateless, so the foreign-occupant // The two-call split — `bind` at boot step 5 (stateless, so the foreign-occupant
// diagnosis runs BEFORE the stores/runtime exist), `serve` at boot step 11 — is // diagnosis runs BEFORE the stores/runtime exist), `serve` at boot step 11 — is
// what keeps the 01-architecture.md §8.1 boot order honest. // what keeps the 01-architecture.md §8.1 boot order honest.
pub mod playback;
pub mod status; pub mod status;
use std::io::Cursor; use std::io::Cursor;
@@ -30,14 +31,24 @@ use qbz_app::shell::AppRuntime;
use qbz_audio::settings::AudioSettingsStore; use qbz_audio::settings::AudioSettingsStore;
/// The counted P0 route table (02 §3.2). A route exists only iff a shipped /// The counted P0 route table (02 §3.2). A route exists only iff a shipped
/// client calls it (§3.1.4). This task lands **3**; T7 adds the 9 playback + /// client calls it (§3.1.4). T1-T6 landed the first 3; T7 (this task) adds the
/// now-playing routes, T8 the 4 queue routes, T11 `/api/settings/reload` — the /// 9 playback + now-playing routes (rows 4-12); T8 adds the 4 queue routes,
/// inline `route_table_matches_spec_count` test pins the number so the /// T11 `/api/settings/reload` — the inline `route_table_matches_spec_count`
/// 68-routes failure shape (a route with no caller) cannot creep back in. /// 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)] = &[ pub const P0_ROUTES: &[(&str, &str)] = &[
("GET", "/api/ping"), ("GET", "/api/ping"),
("GET", "/api/info"), ("GET", "/api/info"),
("GET", "/api/status"), ("GET", "/api/status"),
("GET", "/api/now-playing"),
("POST", "/api/playback/play"),
("POST", "/api/playback/pause"),
("POST", "/api/playback/toggle"),
("POST", "/api/playback/stop"),
("POST", "/api/playback/next"),
("POST", "/api/playback/previous"),
("POST", "/api/playback/seek"),
("POST", "/api/playback/volume"),
]; ];
/// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server /// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server
@@ -202,12 +213,35 @@ fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
), ),
("GET", "/api/info") => status::info(state), ("GET", "/api/info") => status::info(state),
("GET", "/api/status") => status::status(state), ("GET", "/api/status") => status::status(state),
// T7: /api/now-playing + 8 playback routes · T8: 4 queue routes · ("GET", "/api/now-playing") => playback::now_playing(state),
// T11: /api/settings/reload. ("POST", "/api/playback/play") => playback::play(state),
("POST", "/api/playback/pause") => playback::pause(state),
("POST", "/api/playback/toggle") => playback::toggle(state),
("POST", "/api/playback/stop") => playback::stop(state),
("POST", "/api/playback/next") => playback::next(state),
("POST", "/api/playback/previous") => playback::previous(state),
("POST", "/api/playback/seek") => {
let body = read_json_body(req);
playback::seek(state, &body)
}
("POST", "/api/playback/volume") => {
let body = read_json_body(req);
playback::volume(state, &body)
}
// T8: 4 queue routes · T11: /api/settings/reload.
_ => err_json(404, "not_found", "unknown route", "see qbzd --help"), _ => err_json(404, "not_found", "unknown route", "see qbzd --help"),
} }
} }
/// Read and parse a request body as JSON (T7's seek/volume POST bodies).
/// An unreadable or absent body parses to `Value::Null` — the route handlers
/// treat a missing expected field as `400 bad_request`, never a panic.
fn read_json_body(req: &mut Request) -> serde_json::Value {
let mut buf = String::new();
let _ = req.as_reader().read_to_string(&mut buf);
serde_json::from_str(&buf).unwrap_or(serde_json::Value::Null)
}
/// A pre-routing rejection. Carried as a small enum so [`access_gate`] stays a /// A pre-routing rejection. Carried as a small enum so [`access_gate`] stays a
/// pure decision (unit-testable without a tiny_http `Request`, which has no /// pure decision (unit-testable without a tiny_http `Request`, which has no
/// public constructor) while `route` renders the normative envelope. /// public constructor) while `route` renders the normative envelope.
@@ -308,11 +342,20 @@ mod tests {
#[test] #[test]
fn route_table_matches_spec_count() { fn route_table_matches_spec_count() {
// 02-cli-and-api.md §3.2 — P0 = exactly 17 routes; grows ONLY with a // 02-cli-and-api.md §3.2 — P0 = exactly 17 routes; grows ONLY with a
// shipped client. This task lands 3; T7 +9; T8 +4; T11 +1. // shipped client. T1-T6 landed 3; T7 (this task) +9 = 12; T8 +4; T11 +1.
assert_eq!(P0_ROUTES.len(), 3); assert_eq!(P0_ROUTES.len(), 12);
assert!(P0_ROUTES.contains(&("GET", "/api/ping"))); assert!(P0_ROUTES.contains(&("GET", "/api/ping")));
assert!(P0_ROUTES.contains(&("GET", "/api/info"))); assert!(P0_ROUTES.contains(&("GET", "/api/info")));
assert!(P0_ROUTES.contains(&("GET", "/api/status"))); assert!(P0_ROUTES.contains(&("GET", "/api/status")));
assert!(P0_ROUTES.contains(&("GET", "/api/now-playing")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/play")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/pause")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/toggle")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/stop")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/next")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/previous")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/seek")));
assert!(P0_ROUTES.contains(&("POST", "/api/playback/volume")));
} }
#[test] #[test]
+425
View File
@@ -0,0 +1,425 @@
// crates/qbzd/src/api/playback.rs — routes 4-12 (02-cli-and-api.md §3.3.4-12):
// GET /api/now-playing + the 8 POST /api/playback/* transport routes.
//
// 409 needs_auth (01-architecture.md §6.2) is gated per-route by reading the
// per-route Errors column in 02 §3.3, not blanket-applied: `/api/now-playing`
// gates unconditionally (§3.3.4); `play`/`toggle` gate ONLY the cold-start
// branch (§3.3.5-8 "cold-start needs a session"); `next`/`previous` gate
// unconditionally before running the advance ritual (§3.3.9-10); `pause`/
// `stop`/`seek`/`volume` never cold-start and are NOT listed with needs_auth
// in their own Errors columns (§3.3.11-12), so they act on whatever is
// already loaded regardless of auth state.
//
// DSD-direct guard: `Player::is_dsd_direct_active()` (qbz-player/src/player/
// mod.rs:4893, "True while a DoP stream is active (volume fixed, seek
// unsupported)") is the player's own guard — previously unconsumed anywhere
// in the workspace. `seek`/`volume` (incl. the `mute` body form) check it
// FIRST and refuse 409 rather than silently no-op (the brief's explicit
// requirement — a silent no-op reads as broken, 02 §1.4).
//
// Mute is daemon-owned state in `DaemonShared.{muted, premute_volume}` (T2
// seam), NOT the desktop's process statics (`crates/qbz/src/playback.rs:
// 3907-3930`) — same semantics (stash-then-zero / restore), different owner.
// The reported `playback.volume` is always the NOMINAL (pre-mute) level, both
// muted and unmuted: `premute_volume` when muted, the live player volume
// otherwise. This is what makes `now`'s and `mute`'s human lines ("vol 80%",
// "muted (was 80%)") trivial reads of one JSON field, and mirrors the
// desktop's PREMUTE_VOLUME/MUTED pair exactly, just relocated.
use std::io::Cursor;
use serde_json::Value;
use tiny_http::Response;
use crate::state::AuthState;
use super::{err_json, json, ApiState};
/// `GET /api/now-playing` (02 §3.3.4). `playback` is the serialized
/// `PlaybackEvent` (qbz-player/src/player/mod.rs:925) with `shuffle`/`repeat`
/// filled in from the queue (the player itself leaves them `None` — "Set by
/// caller with access to queue state") plus the daemon-owned `muted` field,
/// plus an ADDITIVE `queue_len` (02 §3.1.4 allows additive fields within
/// api_version 1; needed so `qbzd now`'s stopped-state render, "stopped ·
/// queue 14 tracks", has a count — the documented playing-state example has
/// no queue count because nothing needs one while a track is loaded).
/// `track` is the current `QueueTrack`, or `null` when nothing is loaded.
pub fn now_playing(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let player = state.runtime.core().player();
let mut ev = player.get_playback_event();
let queue = state.rt.block_on(state.runtime.core().get_queue_state());
ev.shuffle = Some(queue.shuffle);
ev.repeat = Some(repeat_str(queue.repeat));
let (muted, nominal_volume) = nominal_volume(state, ev.volume);
ev.volume = nominal_volume;
let mut playback = serde_json::to_value(&ev).unwrap_or_else(|_| serde_json::json!({}));
if let Value::Object(map) = &mut playback {
map.insert("muted".into(), serde_json::json!(muted));
map.insert("queue_len".into(), serde_json::json!(queue.total_tracks));
}
let track = queue
.current_track
.as_ref()
.map(|t| serde_json::to_value(t).unwrap_or(Value::Null))
.unwrap_or(Value::Null);
json(200, serde_json::json!({"playback": playback, "track": track}))
}
/// `POST /api/playback/play` (02 §3.3.5). Resume if paused; cold-start the
/// current queue track when `!has_loaded_audio()` (the desktop's
/// `toggle_play_pause` cold-start branch, `crates/qbz/src/playback.rs:3837-3860`).
pub fn play(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
let player = state.runtime.core().player();
if player.has_loaded_audio() {
return match state.runtime.core().resume() {
Ok(()) => json(200, serde_json::json!({"state": "playing"})),
Err(e) => runtime_error(&e.to_string()),
};
}
match cold_start(state) {
Ok(()) => json(200, serde_json::json!({"state": "playing"})),
Err(resp) => resp,
}
}
/// `POST /api/playback/pause` (02 §3.3.6). Never cold-starts; exit set is
/// 0 · 1 · 3 (no 5, §2.2) so a `Player::pause` channel failure is
/// [`runtime_error`], not [`device_error`].
pub fn pause(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
match state.runtime.core().pause() {
Ok(()) => json(200, serde_json::json!({"state": "paused"})),
Err(e) => runtime_error(&e.to_string()),
}
}
/// `POST /api/playback/toggle` (02 §3.3.7). Mirrors the desktop's
/// `toggle_play_pause`: playing -> pause; paused-with-loaded-audio -> resume;
/// nothing loaded -> cold-start (same gate as `play`). Exit 5 is reserved for
/// the cold-start branch (`cold_start`'s own [`device_error`]) — the
/// pause/resume branches use [`runtime_error`] like plain `pause`/`stop`.
pub fn toggle(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
let player = state.runtime.core().player();
let ev = player.get_playback_event();
if ev.is_playing {
return match state.runtime.core().pause() {
Ok(()) => json(200, serde_json::json!({"state": "paused"})),
Err(e) => runtime_error(&e.to_string()),
};
}
if player.has_loaded_audio() {
return match state.runtime.core().resume() {
Ok(()) => json(200, serde_json::json!({"state": "playing"})),
Err(e) => runtime_error(&e.to_string()),
};
}
match cold_start(state) {
Ok(()) => json(200, serde_json::json!({"state": "playing"})),
Err(resp) => resp,
}
}
/// `POST /api/playback/stop` (02 §3.3.8). Never cold-starts; same exit-set
/// reasoning as `pause`.
pub fn stop(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
match state.runtime.core().stop() {
Ok(()) => json(200, serde_json::json!({"state": "stopped"})),
Err(e) => runtime_error(&e.to_string()),
}
}
/// `POST /api/playback/next` (02 §3.3.9).
pub fn next(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
advance(state, true)
}
/// `POST /api/playback/previous` (02 §3.3.10).
pub fn previous(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
advance(state, false)
}
/// `POST /api/playback/seek` (02 §3.3.11). Body `{"position": N}` (absolute)
/// or `{"delta": N}` (additive seconds). Returns the CLAMPED target — the
/// value `Player::seek` will settle on (`qbz-player/src/player/mod.rs:5134`
/// clamps to duration) — rather than a live re-read, since `seek` only sends
/// an async command to the audio thread; the clamp is deterministic so the
/// "post-state" is knowable synchronously.
pub fn seek(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
let player = state.runtime.core().player();
if player.is_dsd_direct_active() {
return err_json(
409,
"seek_unsupported_dsd",
"seek is unsupported in DSD-direct mode (bit-perfect passthrough)",
"set DSD mode to \"convert\": qbzd setup (Audio screen)",
);
}
let ev = player.get_playback_event();
let target: u64 = if let Some(pos) = body.get("position").and_then(|v| v.as_u64()) {
pos
} else if let Some(delta) = body.get("delta").and_then(|v| v.as_i64()) {
(ev.position as i64 + delta).max(0) as u64
} else {
return err_json(
400,
"bad_request",
"seek requires a 'position' or 'delta' field",
"body: {\"position\": 90} or {\"delta\": -10}",
);
};
let clamped = if ev.duration > 0 { target.min(ev.duration) } else { target };
if let Err(e) = state.runtime.core().seek(clamped) {
return runtime_error(&e.to_string());
}
json(200, serde_json::json!({"position": clamped, "duration": ev.duration}))
}
/// `POST /api/playback/volume` (02 §3.3.12). One of three body forms:
/// `{"volume": F}` (absolute 0.0-1.0), `{"delta": F}` (additive), or
/// `{"mute": "on"|"off"|"toggle"}` (also `qbzd mute`'s route — no dedicated
/// route, §2.2). All three are gated by the same DSD-direct guard as `seek`.
pub fn volume(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
let player = state.runtime.core().player();
if player.is_dsd_direct_active() {
return err_json(
409,
"volume_fixed_dsd",
"volume is fixed in DSD-direct mode (bit-perfect passthrough)",
"set DSD mode to \"convert\": qbzd setup (Audio screen)",
);
}
let live = player.get_playback_event().volume;
if let Some(mute_arg) = body.get("mute").and_then(|v| v.as_str()) {
return apply_mute(state, live, mute_arg);
}
let (muted_before, nominal_before) = nominal_volume(state, live);
let target = if let Some(v) = body.get("volume").and_then(|v| v.as_f64()) {
(v as f32).clamp(0.0, 1.0)
} else if let Some(d) = body.get("delta").and_then(|v| v.as_f64()) {
(nominal_before + d as f32).clamp(0.0, 1.0)
} else {
return err_json(
400,
"bad_request",
"volume requires a 'volume', 'delta' or 'mute' field",
"body: {\"volume\": 0.75}",
);
};
// An explicit non-zero target clears an active mute (desktop parity:
// `crates/qbz/src/playback.rs:3921-3924` — "a non-zero level clears any
// active mute").
let mut muted_after = muted_before;
if target > 0.0 && muted_before {
if let Ok(mut s) = state.shared.lock() {
s.muted = false;
}
muted_after = false;
}
if let Err(e) = state.runtime.core().set_volume(target) {
return runtime_error(&e.to_string());
}
json(200, serde_json::json!({"volume": target, "muted": muted_after}))
}
// ============================ internals ============================
/// `{"mute": "on"|"off"|"toggle"}` — stash-then-zero / restore, mirroring the
/// desktop's `toggle_mute` (`crates/qbz/src/playback.rs:3936-3961`) but
/// against `DaemonShared` instead of process statics. `live` is the player's
/// volume BEFORE this call (the value to stash on a fresh mute).
fn apply_mute(state: &ApiState, live: f32, arg: &str) -> Response<Cursor<Vec<u8>>> {
let mute_on = match arg {
"on" => true,
"off" => false,
"toggle" => !state.shared.lock().map(|s| s.muted).unwrap_or(false),
other => {
return err_json(
400,
"bad_request",
&format!("invalid mute state '{other}' — use on, off, or toggle"),
"body: {\"mute\": \"toggle\"}",
)
}
};
let mut guard = match state.shared.lock() {
Ok(g) => g,
Err(_) => {
return err_json(500, "internal", "daemon state lock poisoned", "restart qbzd")
}
};
// Desktop fallback for a never-set / zero premute level (playback.rs:3944,
// 3956): 0.7, so a mute taken at volume 0 still restores to something
// audible on unmute.
let (nominal, set_result) = if mute_on && !guard.muted {
let stash = if live > 0.0 { live } else { 0.7 };
guard.premute_volume = stash;
guard.muted = true;
(stash, state.runtime.core().set_volume(0.0))
} else if !mute_on && guard.muted {
let restored = if guard.premute_volume > 0.0 { guard.premute_volume } else { 0.7 };
guard.muted = false;
(restored, state.runtime.core().set_volume(restored))
} else {
// Already in the requested state — a no-op that still reports the
// current nominal level.
let nominal = if guard.muted { guard.premute_volume } else { live };
(nominal, Ok(()))
};
let muted_now = guard.muted;
drop(guard);
if let Err(e) = set_result {
return runtime_error(&e.to_string());
}
json(200, serde_json::json!({"volume": nominal, "muted": muted_now}))
}
/// `next`/`previous` (02 §3.3.9-10): gate on NeedsAuth BEFORE running the
/// ritual (unconditional per those two rows' Errors column, unlike
/// play/toggle's cold-start-only gate), then run
/// `qbz_app::playback_driver::advance_and_play` — the FULL ritual (skip-walk →
/// play → prefetch → persist), never a bare cursor move (02 §2.2 trap). Exit
/// set is 0 · 1 · 3 · 4 (no 5, §2.2), so a ritual failure is [`runtime_error`].
fn advance(state: &ApiState, forward: bool) -> Response<Cursor<Vec<u8>>> {
if let Some(resp) = auth_gate(state) {
return resp;
}
let quality = resolve_quality(state);
let result = state.rt.block_on(qbz_app::playback_driver::advance_and_play(
state.runtime.as_ref(),
quality,
forward,
));
match result {
Ok(Some(track)) => json(200, serde_json::to_value(&track).unwrap_or(Value::Null)),
Ok(None) => json(200, Value::Null),
Err(e) => runtime_error(&e),
}
}
/// `play`/`toggle`'s cold-start branch: gate on NeedsAuth, resolve the
/// current queue track, resolve+play it, then best-effort persist the
/// session — the same ritual tail `advance_and_play` runs, minus the
/// cursor-move (we're playing the CURRENT track, not advancing to a new one)
/// and the gapless prefetch (the running driver's tick-based `ArmGapless`
/// picks that up on a later tick once playback is underway).
fn cold_start(state: &ApiState) -> Result<(), Response<Cursor<Vec<u8>>>> {
if let Some(resp) = auth_gate(state) {
return Err(resp);
}
let queue = state.rt.block_on(state.runtime.core().get_queue_state());
let Some(track) = queue.current_track else {
// No documented error code fits "empty queue" exactly; audio_unavailable
// (503, exit 5) is the closest frozen taxonomy match — "can't produce
// audio because there is nothing queued" — and the hint names the fix.
return Err(err_json(
503,
"audio_unavailable",
"queue is empty, nothing to play",
"queue a track first: qbzd queue add <TRACK_ID>",
));
};
let quality = resolve_quality(state);
let played = state.rt.block_on(state.runtime.core().play_track_resolved(
track.id,
quality,
None,
None,
0,
));
if let Err(e) = played {
return Err(device_error(&e));
}
state
.rt
.block_on(qbz_app::playback_driver::save_session_now(state.runtime.as_ref()));
Ok(())
}
/// Streaming quality for a play-time resolve, from the daemon's persisted
/// prefs — the SAME key contract `daemon.rs` uses to seed the driver's
/// `DriverDeps.quality` closure at boot (01 §10.3), so a cold-start play and
/// the next auto-advance never pick different tiers.
fn resolve_quality(state: &ApiState) -> qbz_models::Quality {
let prefs = qbz_app::settings::daemon_prefs::load_at(&state.roots.data);
qbz_app::playback_driver::quality_from_key(&prefs.streaming_quality)
}
/// 409 `needs_auth` (01 §6.2 / 02 §3.1.3 example envelope, verbatim).
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
}
}
/// 503 `audio_unavailable` — the frozen taxonomy's device/audio bucket
/// (02 §3.1.3), exit 5. Reserved for GENUINE audio/device conditions: the
/// DSD-direct guards (handled inline via `err_json`, not this helper) and
/// cold-start's `play_track_resolved` failure (no device / stream resolve
/// failed). Each route's documented exit set (02 §2.2) decides which one
/// applies — `pause`/`stop`/plain `seek`/`volume`/`next`/`prev` never list
/// exit 5, so their `Player`/`QbzCore` command failures use
/// [`runtime_error`] instead.
fn device_error(message: &str) -> Response<Cursor<Vec<u8>>> {
err_json(503, "audio_unavailable", message, "check: qbzd status")
}
/// A generic runtime failure, exit 1 (02 §1.3's catch-all) — e.g. the
/// player's command channel is dead. `code` "internal" is NOT one of
/// `error_from_envelope`'s special-cased codes, so it falls to
/// `CliError::Runtime` client-side.
fn runtime_error(message: &str) -> Response<Cursor<Vec<u8>>> {
err_json(500, "internal", message, "check: qbzd status")
}
/// The NOMINAL volume (what `now`/`volume`/`mute` all report): the live
/// player volume, EXCEPT while muted, where it's the stashed `premute_volume`
/// — the player's real output is 0.0 while muted, but the reported level
/// stays at what the user set it to, so `vol 80%` keeps reading `80%` through
/// a mute/unmute cycle. Returns `(muted, nominal)`.
fn nominal_volume(state: &ApiState, live: f32) -> (bool, f32) {
match state.shared.lock() {
Ok(s) => (s.muted, if s.muted { s.premute_volume } else { live }),
Err(_) => (false, live),
}
}
fn repeat_str(mode: qbz_models::RepeatMode) -> String {
match mode {
qbz_models::RepeatMode::Off => "off",
qbz_models::RepeatMode::All => "all",
qbz_models::RepeatMode::One => "one",
}
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repeat_str_matches_contract_lowercase() {
assert_eq!(repeat_str(qbz_models::RepeatMode::Off), "off");
assert_eq!(repeat_str(qbz_models::RepeatMode::All), "all");
assert_eq!(repeat_str(qbz_models::RepeatMode::One), "one");
}
}
+18 -4
View File
@@ -50,7 +50,16 @@ impl std::fmt::Display for CliError {
match self { match self {
CliError::Unreachable(host) => write!(f, "{}", copy::daemon_down(host)), CliError::Unreachable(host) => write!(f, "{}", copy::daemon_down(host)),
CliError::NeedsAuth => write!(f, "{}", copy::daemon_up_needs_auth()), CliError::NeedsAuth => write!(f, "{}", copy::daemon_up_needs_auth()),
CliError::Device(m) => write!(f, "error: {m}"), // `error_from_envelope` pre-builds the two DSD-specific codes into
// the full verbatim §1.4 copy (already "error: ..." prefixed); a
// plain device message from the server gets the generic prefix.
CliError::Device(m) => {
if m.starts_with("error:") {
write!(f, "{m}")
} else {
write!(f, "error: {m}")
}
}
CliError::NotFound(m) => write!(f, "error: {m}"), CliError::NotFound(m) => write!(f, "error: {m}"),
CliError::ApiSkew { daemon, cli } => write!(f, "{}", copy::api_version_skew(*daemon, *cli)), CliError::ApiSkew { daemon, cli } => write!(f, "{}", copy::api_version_skew(*daemon, *cli)),
CliError::Runtime(m) => write!(f, "error: {m}"), CliError::Runtime(m) => write!(f, "error: {m}"),
@@ -149,8 +158,8 @@ impl ApiClient {
self.send(req).await self.send(req).await
} }
/// P0 mutation transport — first consumer is the T7 transport verbs. /// P0 mutation transport — consumed by the T7 transport verbs
#[allow(dead_code)] // wired by the T7 play/pause/… verbs /// (play/pause/toggle/stop/next/prev/seek/volume/mute).
pub async fn post(&self, path: &str, body: Value) -> Result<Value, CliError> { pub async fn post(&self, path: &str, body: Value) -> Result<Value, CliError> {
let req = self.bearer(self.client.post(format!("{}{}", self.base, path)).json(&body)); let req = self.bearer(self.client.post(format!("{}{}", self.base, path)).json(&body));
self.send(req).await self.send(req).await
@@ -229,7 +238,12 @@ fn error_from_envelope(v: &Value) -> CliError {
match code { match code {
"needs_auth" => CliError::NeedsAuth, "needs_auth" => CliError::NeedsAuth,
"not_found" => CliError::NotFound(message), "not_found" => CliError::NotFound(message),
"audio_unavailable" | "volume_fixed_dsd" | "seek_unsupported_dsd" | "device_error" => { // §1.4's verbatim DSD blocks are frozen client-side copy, not the
// server's short envelope message — swap them in so `qbzd seek`/
// `volume`/`mute` print the exact documented multi-line text.
"volume_fixed_dsd" => CliError::Device(crate::cli::copy::volume_fixed_dsd()),
"seek_unsupported_dsd" => CliError::Device(crate::cli::copy::seek_unsupported_dsd()),
"audio_unavailable" | "device_error" => {
CliError::Device(message) CliError::Device(message)
} }
_ => CliError::Runtime(message), _ => CliError::Runtime(message),
+17 -6
View File
@@ -56,9 +56,9 @@ pub fn daemon_down(host: &str) -> String {
/// Daemon up but not logged in — exit 4 (02 §1.4). The down-vs-unhealthy /// Daemon up but not logged in — exit 4 (02 §1.4). The down-vs-unhealthy
/// distinction: the daemon answered, the Qobuz session is what's missing. /// distinction: the daemon answered, the Qobuz session is what's missing.
/// Consumed by the T7 transport verbs that hit a NeedsAuth daemon; `status` /// Rendered by `CliError::NeedsAuth`'s `Display` — hit whenever `now`/`play`/
/// renders the composite block instead. /// `toggle`/`next`/`prev` get a 409 `needs_auth` from a NeedsAuth daemon;
#[allow(dead_code)] // wired by the T7 verbs (now/play/…) that refuse on NeedsAuth /// `status` renders the composite block instead.
pub fn daemon_up_needs_auth() -> String { pub fn daemon_up_needs_auth() -> String {
"error: daemon is running but not logged in to Qobuz "error: daemon is running but not logged in to Qobuz
→ log in: qbzd login → log in: qbzd login
@@ -75,15 +75,26 @@ pub fn linger_off(user: &str) -> String {
) )
} }
/// Volume fixed under DSD-direct — exit 5 (02 §1.4). Consumed by the T7 /// Volume fixed under DSD-direct — exit 5 (02 §1.4, verbatim). Consumed by
/// `volume`/`mute` verbs; defined here so all four §1.4 blocks live in one place. /// `error_from_envelope` (cli/client.rs) for the `volume_fixed_dsd` code, so
#[allow(dead_code)] // wired by the T7 volume/mute verbs /// the `volume`/`mute` verbs print this exact block instead of the server's
/// short envelope message.
pub fn volume_fixed_dsd() -> String { pub fn volume_fixed_dsd() -> String {
"error: volume is fixed in DSD-direct mode (bit-perfect passthrough) "error: volume is fixed in DSD-direct mode (bit-perfect passthrough)
→ to get software volume, set DSD mode to \"convert\": qbzd setup (Audio screen)" → to get software volume, set DSD mode to \"convert\": qbzd setup (Audio screen)"
.to_string() .to_string()
} }
/// Seek unsupported under DSD-direct — exit 5. No verbatim block is given for
/// seek specifically; 02 §2.2 says it is "the same error-voice family as
/// §1.4 volume copy", so this mirrors `volume_fixed_dsd`'s structure/wording.
/// Consumed by `error_from_envelope` for the `seek_unsupported_dsd` code.
pub fn seek_unsupported_dsd() -> String {
"error: seek is unsupported in DSD-direct mode (bit-perfect passthrough)
→ to seek, set DSD mode to \"convert\": qbzd setup (Audio screen)"
.to_string()
}
/// Foreign occupant on the control port that is NOT qbzd — printed by the daemon /// Foreign occupant on the control port that is NOT qbzd — printed by the daemon
/// at boot step 5 (02 §2.2, verbatim). `port` is interpolated. /// at boot step 5 (02 §2.2, verbatim). `port` is interpolated.
pub fn port_in_use(port: u16) -> String { pub fn port_in_use(port: u16) -> String {
+1
View File
@@ -6,3 +6,4 @@
pub mod client; pub mod client;
pub mod copy; pub mod copy;
pub mod status; pub mod status;
pub mod transport;
+479
View File
@@ -0,0 +1,479 @@
// crates/qbzd/src/cli/transport.rs — the 10 transport verbs (02-cli-and-api.md
// §2.2): `now play pause toggle stop next prev seek volume mute`. Each
// networked verb is exactly one HTTP request (§1.1); the pure argument
// parsers/renderers below are unit-tested without a running daemon.
use serde_json::Value;
use crate::cli::client::ApiClient;
use crate::paths::ProfileRoots;
// ============================ `now` ============================
/// `qbzd now [--json]` — renders `GET /api/now-playing` (§2.2/§3.3.4).
/// Exit: 0 · 3 · 4 (via `ApiClient`/`error_from_envelope`, unchanged here).
pub async fn now(host: Option<String>, json: bool, roots: &ProfileRoots) -> i32 {
let client = ApiClient::new(host, roots);
match client.get("/api/now-playing").await {
Ok(v) => {
if json {
println!("{}", serde_json::to_string(&v).unwrap_or_default());
} else {
println!("{}", render_now(&v));
}
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
/// `playing · Chick Corea – Spain · 3:12/9:41 · 96kHz/24bit · vol 80%` /
/// `stopped · queue 14 tracks` (02 §2.2, verbatim shape).
fn render_now(v: &Value) -> String {
let track = v.get("track").filter(|t| !t.is_null());
let Some(track) = track else {
let queue_len = v.pointer("/playback/queue_len").and_then(|n| n.as_u64()).unwrap_or(0);
return format!("stopped · queue {queue_len} tracks");
};
let is_playing = v.pointer("/playback/is_playing").and_then(|b| b.as_bool()).unwrap_or(false);
let state = if is_playing { "playing" } else { "paused" };
let artist = track.get("artist").and_then(|a| a.as_str()).unwrap_or("");
let title = track.get("title").and_then(|a| a.as_str()).unwrap_or("");
let pos = v.pointer("/playback/position").and_then(|p| p.as_u64()).unwrap_or(0);
let dur = v.pointer("/playback/duration").and_then(|p| p.as_u64()).unwrap_or(0);
let vol = v.pointer("/playback/volume").and_then(|p| p.as_f64()).unwrap_or(0.0);
let sr = v.pointer("/playback/sample_rate").and_then(|p| p.as_u64());
let bd = v.pointer("/playback/bit_depth").and_then(|p| p.as_u64());
let mut parts = vec![
state.to_string(),
format!("{artist} – {title}"),
format!("{}/{}", fmt_mmss(pos), fmt_mmss(dur)),
];
if let (Some(sr), Some(bd)) = (sr, bd) {
parts.push(format!("{}/{bd}bit", fmt_khz(sr)));
}
parts.push(format!("vol {}%", fraction_to_pct(vol)));
parts.join(" · ")
}
// ============================ play/pause/toggle/stop ============================
/// `POST` to `path` with no body, printing the response's `state` field
/// (`play`/`pause`/`toggle`/`stop` share this exact shape — 02 §3.3.5-8).
async fn transport_state(host: Option<String>, roots: &ProfileRoots, path: &str) -> i32 {
let client = ApiClient::new(host, roots);
match client.post(path, serde_json::json!({})).await {
Ok(v) => {
let state = v.get("state").and_then(|s| s.as_str()).unwrap_or("");
println!("{state}");
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
pub async fn play(host: Option<String>, roots: &ProfileRoots) -> i32 {
transport_state(host, roots, "/api/playback/play").await
}
pub async fn pause(host: Option<String>, roots: &ProfileRoots) -> i32 {
transport_state(host, roots, "/api/playback/pause").await
}
pub async fn toggle(host: Option<String>, roots: &ProfileRoots) -> i32 {
transport_state(host, roots, "/api/playback/toggle").await
}
pub async fn stop(host: Option<String>, roots: &ProfileRoots) -> i32 {
transport_state(host, roots, "/api/playback/stop").await
}
// ============================ next/prev ============================
/// `POST` to `path` with no body; the response is the landing `QueueTrack` or
/// `null` at queue end (02 §3.3.9-10, legacy shape).
async fn transport_advance(host: Option<String>, roots: &ProfileRoots, path: &str) -> i32 {
let client = ApiClient::new(host, roots);
match client.post(path, serde_json::json!({})).await {
Ok(v) => {
println!("{}", render_advance(&v));
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
/// `-> Chick Corea – 500 Miles High` / `queue finished` (02 §2.2, verbatim).
fn render_advance(v: &Value) -> String {
if v.is_null() {
return "queue finished".to_string();
}
let artist = v.get("artist").and_then(|s| s.as_str()).unwrap_or("");
let title = v.get("title").and_then(|s| s.as_str()).unwrap_or("");
format!("-> {artist} – {title}")
}
pub async fn next(host: Option<String>, roots: &ProfileRoots) -> i32 {
transport_advance(host, roots, "/api/playback/next").await
}
pub async fn prev(host: Option<String>, roots: &ProfileRoots) -> i32 {
transport_advance(host, roots, "/api/playback/previous").await
}
// ============================ seek ============================
/// A parsed `qbzd seek` argument (02 §2.2: absolute seconds, `+N`/`-N`
/// relative, or `mm:ss`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeekArg {
Absolute(u64),
Delta(i64),
}
/// `90` -> absolute seconds · `+30`/`-10` -> relative seconds · `1:23` ->
/// absolute seconds (mm:ss). Usage errors (exit 2) name what was expected.
pub fn parse_seek_arg(s: &str) -> Result<SeekArg, String> {
let s = s.trim();
if let Some(rest) = s.strip_prefix('+') {
return rest
.parse::<i64>()
.map(SeekArg::Delta)
.map_err(|_| format!("invalid seek offset '{s}' — expected +N seconds"));
}
if let Some(rest) = s.strip_prefix('-') {
return rest
.parse::<i64>()
.map(|n| SeekArg::Delta(-n))
.map_err(|_| format!("invalid seek offset '{s}' — expected -N seconds"));
}
if let Some((mm, ss)) = s.split_once(':') {
let mm: u64 = mm
.parse()
.map_err(|_| format!("invalid seek position '{s}' — expected mm:ss"))?;
let ss: u64 = ss
.parse()
.map_err(|_| format!("invalid seek position '{s}' — expected mm:ss"))?;
if ss >= 60 {
return Err(format!("invalid seek position '{s}' — seconds must be 0-59"));
}
return Ok(SeekArg::Absolute(mm * 60 + ss));
}
s.parse::<u64>()
.map(SeekArg::Absolute)
.map_err(|_| format!("invalid seek position '{s}' — expected seconds, +N, -N, or mm:ss"))
}
/// `SeekArg` -> the `POST /api/playback/seek` body (02 §3.3.11): `{"position"}`
/// (absolute, legacy field name) or `{"delta"}` (additive).
pub fn seek_body(arg: SeekArg) -> Value {
match arg {
SeekArg::Absolute(n) => serde_json::json!({"position": n}),
SeekArg::Delta(n) => serde_json::json!({"delta": n}),
}
}
/// `qbzd seek <POS|+N|-N|mm:ss>` — human `at 1:30 / 9:41` (02 §2.2, verbatim
/// — note the spaced slash, unlike `now`'s unspaced `3:12/9:41`). Exit:
/// 0 · 1 · 2 (local parse failure) · 3 · 5.
pub async fn seek(host: Option<String>, roots: &ProfileRoots, arg: String) -> i32 {
let parsed = match parse_seek_arg(&arg) {
Ok(a) => a,
Err(msg) => {
eprintln!("error: {msg}");
return 2;
}
};
let client = ApiClient::new(host, roots);
match client.post("/api/playback/seek", seek_body(parsed)).await {
Ok(v) => {
let pos = v.get("position").and_then(|p| p.as_u64()).unwrap_or(0);
let dur = v.get("duration").and_then(|d| d.as_u64()).unwrap_or(0);
println!("at {} / {}", fmt_mmss(pos), fmt_mmss(dur));
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
// ============================ volume ============================
/// A parsed `qbzd volume` argument (02 §2.2: 0-100 absolute, or `+N`/`-N`
/// relative — both in CLI percent-space).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VolumeArg {
Absolute(u8),
Delta(i32),
}
/// `80` -> absolute 0-100 · `+5`/`-5` -> relative percent.
pub fn parse_volume_arg(s: &str) -> Result<VolumeArg, String> {
let s = s.trim();
if let Some(rest) = s.strip_prefix('+') {
return rest
.parse::<i32>()
.map(VolumeArg::Delta)
.map_err(|_| format!("invalid volume offset '{s}' — expected +N"));
}
if let Some(rest) = s.strip_prefix('-') {
return rest
.parse::<i32>()
.map(|n| VolumeArg::Delta(-n))
.map_err(|_| format!("invalid volume offset '{s}' — expected -N"));
}
let n: u8 = s
.parse()
.map_err(|_| format!("invalid volume '{s}' — expected 0-100, +N, or -N"))?;
if n > 100 {
return Err(format!("invalid volume '{s}' — must be 0-100"));
}
Ok(VolumeArg::Absolute(n))
}
/// CLI 0-100 <-> API 0.0-1.0 (02 §2.2: "CLI speaks 0-100; the API speaks
/// 0.0-1.0 — legacy contract"). `f64` throughout — JSON numbers ARE f64
/// (`serde_json::Number`), and computing in f32 then widening to build the
/// request body round-trips imprecisely (`0.8f32 as f64` != the `0.8` JSON
/// literal); the eventual `as f32` narrowing happens once, server-side,
/// right before the `Player::set_volume` call.
pub fn pct_to_fraction(pct: u8) -> f64 {
(pct as f64 / 100.0).clamp(0.0, 1.0)
}
pub fn fraction_to_pct(frac: f64) -> i64 {
(frac.clamp(0.0, 1.0) * 100.0).round() as i64
}
/// `VolumeArg` -> the `POST /api/playback/volume` body: `{"volume"}`
/// (absolute fraction, legacy field name) or `{"delta"}` (additive fraction —
/// the CLI's `+N`/`-N` percent converted to the API's 0.0-1.0 space).
pub fn volume_body(arg: VolumeArg) -> Value {
match arg {
VolumeArg::Absolute(pct) => serde_json::json!({"volume": pct_to_fraction(pct)}),
VolumeArg::Delta(pct) => serde_json::json!({"delta": pct as f64 / 100.0}),
}
}
/// `qbzd volume [<0-100>|+N|-N] [--json]`. Bare = read via `GET
/// /api/now-playing`, extracting `{volume, muted}` (no dedicated read route,
/// 02 §2.2). With an argument: `POST /api/playback/volume`. Exit:
/// 0 · 1 · 2 (local parse failure) · 3 · 5.
pub async fn volume(host: Option<String>, roots: &ProfileRoots, value: Option<String>, json: bool) -> i32 {
let client = ApiClient::new(host, roots);
match value {
None => match client.get("/api/now-playing").await {
Ok(v) => {
let vol = v.pointer("/playback/volume").and_then(|x| x.as_f64()).unwrap_or(0.0);
let muted = v.pointer("/playback/muted").and_then(|x| x.as_bool()).unwrap_or(false);
if json {
let out = serde_json::json!({"volume": vol, "muted": muted});
println!("{}", serde_json::to_string(&out).unwrap_or_default());
} else {
let pct = fraction_to_pct(vol);
if muted {
println!("vol {pct}% (muted)");
} else {
println!("vol {pct}%");
}
}
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
},
Some(arg) => {
let parsed = match parse_volume_arg(&arg) {
Ok(a) => a,
Err(msg) => {
eprintln!("error: {msg}");
return 2;
}
};
match client.post("/api/playback/volume", volume_body(parsed)).await {
Ok(v) => {
let vol = v.get("volume").and_then(|x| x.as_f64()).unwrap_or(0.0);
println!("vol {}%", fraction_to_pct(vol));
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
}
}
// ============================ mute ============================
/// `None` (bare = toggle) / `Some("on")` / `Some("off")` -> the
/// `{"mute": "on"|"off"|"toggle"}` body form (02 §2.2/§3.3.12 — "same route
/// as volume, costs no extra route").
pub fn mute_body(arg: Option<&str>) -> Result<Value, String> {
match arg {
None => Ok(serde_json::json!({"mute": "toggle"})),
Some("on") => Ok(serde_json::json!({"mute": "on"})),
Some("off") => Ok(serde_json::json!({"mute": "off"})),
Some(other) => Err(format!("invalid mute state '{other}' — use on or off")),
}
}
/// `qbzd mute [on|off]` — human `muted (was 80%)` / `unmuted · vol 80%`
/// (02 §2.2, verbatim). Exit: 0 · 1 · 2 (local parse failure) · 3 · 5.
pub async fn mute(host: Option<String>, roots: &ProfileRoots, state_arg: Option<String>) -> i32 {
let body = match mute_body(state_arg.as_deref()) {
Ok(b) => b,
Err(msg) => {
eprintln!("error: {msg}");
return 2;
}
};
let client = ApiClient::new(host, roots);
match client.post("/api/playback/volume", body).await {
Ok(v) => {
let vol = v.get("volume").and_then(|x| x.as_f64()).unwrap_or(0.0);
let muted = v.get("muted").and_then(|x| x.as_bool()).unwrap_or(false);
let pct = fraction_to_pct(vol);
if muted {
println!("muted (was {pct}%)");
} else {
println!("unmuted · vol {pct}%");
}
0
}
Err(e) => {
eprintln!("{e}");
e.exit_code()
}
}
}
// ============================ shared rendering ============================
fn fmt_mmss(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
/// `96000` Hz -> `"96kHz"`; `44100` Hz -> `"44.1kHz"` — the `now` human line's
/// own Hz->kHz rendering choice (independent of the documented Hz-vs-kHz JSON
/// quirk between `playback.sample_rate` and `track.sample_rate`, which is
/// left as-is on the wire, 02 §2.2).
fn fmt_khz(hz: u64) -> String {
if hz % 1000 == 0 {
format!("{}kHz", hz / 1000)
} else {
format!("{:.1}kHz", hz as f64 / 1000.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seek_arg_parses_absolute_relative_and_mmss() {
assert_eq!(parse_seek_arg("90"), Ok(SeekArg::Absolute(90)));
assert_eq!(parse_seek_arg("+30"), Ok(SeekArg::Delta(30)));
assert_eq!(parse_seek_arg("-10"), Ok(SeekArg::Delta(-10)));
assert_eq!(parse_seek_arg("1:23"), Ok(SeekArg::Absolute(83)));
assert!(parse_seek_arg("1:99").is_err());
assert!(parse_seek_arg("nonsense").is_err());
}
#[test]
fn seek_body_maps_to_legacy_position_or_additive_delta() {
assert_eq!(seek_body(SeekArg::Absolute(90)), serde_json::json!({"position": 90}));
assert_eq!(seek_body(SeekArg::Delta(-10)), serde_json::json!({"delta": -10}));
}
#[test]
fn volume_arg_parses_absolute_and_relative() {
assert_eq!(parse_volume_arg("80"), Ok(VolumeArg::Absolute(80)));
assert_eq!(parse_volume_arg("+5"), Ok(VolumeArg::Delta(5)));
assert_eq!(parse_volume_arg("-5"), Ok(VolumeArg::Delta(-5)));
assert!(parse_volume_arg("101").is_err());
assert!(parse_volume_arg("nonsense").is_err());
}
#[test]
fn cli_percent_and_api_fraction_convert_both_ways() {
// 02 §2.2 — "CLI speaks 0-100; the API speaks 0.0-1.0".
assert_eq!(pct_to_fraction(80), 0.8);
assert_eq!(fraction_to_pct(0.8), 80);
assert_eq!(fraction_to_pct(0.75), 75);
assert_eq!(pct_to_fraction(0), 0.0);
assert_eq!(pct_to_fraction(100), 1.0);
}
#[test]
fn volume_body_converts_absolute_and_delta_percent_to_fraction() {
assert_eq!(volume_body(VolumeArg::Absolute(80)), serde_json::json!({"volume": 0.8}));
assert_eq!(volume_body(VolumeArg::Delta(5)), serde_json::json!({"delta": 0.05}));
assert_eq!(volume_body(VolumeArg::Delta(-5)), serde_json::json!({"delta": -0.05}));
}
#[test]
fn mute_body_maps_bare_on_off_to_the_three_states() {
assert_eq!(mute_body(None).unwrap(), serde_json::json!({"mute": "toggle"}));
assert_eq!(mute_body(Some("on")).unwrap(), serde_json::json!({"mute": "on"}));
assert_eq!(mute_body(Some("off")).unwrap(), serde_json::json!({"mute": "off"}));
assert!(mute_body(Some("bogus")).is_err());
}
#[test]
fn render_now_matches_the_documented_playing_example() {
// 02 §2.2 `now --json` example, human line: "playing · Chick Corea –
// Spain · 3:12/9:41 · 96kHz/24bit · vol 80%".
let v = serde_json::json!({
"playback": {
"is_playing": true, "position": 192, "duration": 581,
"volume": 0.8, "muted": false, "sample_rate": 96000, "bit_depth": 24,
"queue_len": 14
},
"track": {"id": 176544871, "title": "Spain", "artist": "Chick Corea"}
});
assert_eq!(
render_now(&v),
"playing · Chick Corea – Spain · 3:12/9:41 · 96kHz/24bit · vol 80%"
);
}
#[test]
fn render_now_stopped_state_shows_queue_count_and_no_track() {
let v = serde_json::json!({
"playback": {"is_playing": false, "position": 0, "duration": 0,
"volume": 0.8, "muted": false, "queue_len": 14},
"track": null
});
assert_eq!(render_now(&v), "stopped · queue 14 tracks");
}
#[test]
fn render_advance_shows_landing_track_or_queue_finished() {
let landing = serde_json::json!({"artist": "Chick Corea", "title": "500 Miles High"});
assert_eq!(render_advance(&landing), "-> Chick Corea – 500 Miles High");
assert_eq!(render_advance(&serde_json::Value::Null), "queue finished");
}
#[test]
fn fmt_khz_rounds_only_when_not_exact() {
assert_eq!(fmt_khz(96000), "96kHz");
assert_eq!(fmt_khz(192000), "192kHz");
assert_eq!(fmt_khz(44100), "44.1kHz");
}
}
+40
View File
@@ -188,6 +188,46 @@ async fn main() {
let roots = paths::ProfileRoots::resolve(None, None); let roots = paths::ProfileRoots::resolve(None, None);
cli::status::ping(cli.host, json, &roots).await cli::status::ping(cli.host, json, &roots).await
} }
Cmd::Now { json } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::transport::now(cli.host, json, &roots).await
}
Cmd::Play => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::transport::play(cli.host, &roots).await
}
Cmd::Pause => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::transport::pause(cli.host, &roots).await
}
Cmd::Toggle => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::transport::toggle(cli.host, &roots).await
}
Cmd::Stop => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::transport::stop(cli.host, &roots).await
}
Cmd::Next => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::transport::next(cli.host, &roots).await
}
Cmd::Prev => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::transport::prev(cli.host, &roots).await
}
Cmd::Seek { position } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::transport::seek(cli.host, &roots, position).await
}
Cmd::Volume { value, json } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::transport::volume(cli.host, &roots, value, json).await
}
Cmd::Mute { state } => {
let roots = paths::ProfileRoots::resolve(None, None);
cli::transport::mute(cli.host, &roots, state).await
}
_ => { eprintln!("not implemented yet"); 1 } // burned down task by task _ => { eprintln!("not implemented yet"); 1 } // burned down task by task
}; };
std::process::exit(code); std::process::exit(code);