From e700084f0b2a1d8d9e60600294871d517e32e9ff Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:35:21 -0600 Subject: [PATCH] feat(qbzd): playback routes and transport CLI verbs with full advance ritual cc --- crates/qbz-app/src/playback_driver.rs | 26 +- crates/qbzd/src/api/mod.rs | 59 +++- crates/qbzd/src/api/playback.rs | 425 +++++++++++++++++++++++ crates/qbzd/src/cli/client.rs | 22 +- crates/qbzd/src/cli/copy.rs | 23 +- crates/qbzd/src/cli/mod.rs | 1 + crates/qbzd/src/cli/transport.rs | 479 ++++++++++++++++++++++++++ crates/qbzd/src/main.rs | 40 +++ 8 files changed, 1050 insertions(+), 25 deletions(-) create mode 100644 crates/qbzd/src/api/playback.rs create mode 100644 crates/qbzd/src/cli/transport.rs diff --git a/crates/qbz-app/src/playback_driver.rs b/crates/qbz-app/src/playback_driver.rs index 136d551f9..01e359d61 100644 --- a/crates/qbz-app/src/playback_driver.rs +++ b/crates/qbz-app/src/playback_driver.rs @@ -484,12 +484,14 @@ pub async fn run_driver( /// 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 -/// 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( runtime: &Arc>, quality: Quality, ) { - match advance_and_play(runtime, quality).await { + match advance_and_play(runtime, quality, true).await { Ok(Some(_)) => {} Ok(None) => { log::info!("[qbzd] driver: advance found nothing playable — queue finished"); @@ -502,18 +504,28 @@ async fn advance_and_play_logged( } /// 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 -/// for gapless → persist the session. Never a bare cursor move (02 §2.2). The -/// skip-walk mirrors `playback.rs::advance_to_playable` (capped at -/// `MAX_OFFLINE_SKIPS`); `next_track()` is the atomic cursor mover. +/// bounded skip-walk to the next (or previous) playable track → play it → warm +/// the successors for gapless → persist the session. Never a bare cursor move +/// (02 §2.2). The skip-walk mirrors `playback.rs::advance_to_playable` (capped +/// 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( runtime: &AppRuntime, quality: Quality, + forward: bool, ) -> Result, String> { let core = runtime.core(); let mut skips = 0usize; 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 }; // Playable gate: local files always attempt (they play from disk); diff --git a/crates/qbzd/src/api/mod.rs b/crates/qbzd/src/api/mod.rs index 8ebbb2f81..99c033592 100644 --- a/crates/qbzd/src/api/mod.rs +++ b/crates/qbzd/src/api/mod.rs @@ -14,6 +14,7 @@ // 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 // what keeps the 01-architecture.md §8.1 boot order honest. +pub mod playback; pub mod status; use std::io::Cursor; @@ -30,14 +31,24 @@ 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). This task lands **3**; T7 adds the 9 playback + -/// now-playing routes, T8 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 (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. pub const P0_ROUTES: &[(&str, &str)] = &[ ("GET", "/api/ping"), ("GET", "/api/info"), ("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 @@ -202,12 +213,35 @@ fn route(state: &ApiState, req: &mut Request) -> Response>> { ), ("GET", "/api/info") => status::info(state), ("GET", "/api/status") => status::status(state), - // T7: /api/now-playing + 8 playback routes · T8: 4 queue routes · - // T11: /api/settings/reload. + ("GET", "/api/now-playing") => playback::now_playing(state), + ("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"), } } +/// 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 /// pure decision (unit-testable without a tiny_http `Request`, which has no /// public constructor) while `route` renders the normative envelope. @@ -308,11 +342,20 @@ 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. This task lands 3; T7 +9; T8 +4; T11 +1. - assert_eq!(P0_ROUTES.len(), 3); + // shipped client. T1-T6 landed 3; T7 (this task) +9 = 12; T8 +4; T11 +1. + assert_eq!(P0_ROUTES.len(), 12); assert!(P0_ROUTES.contains(&("GET", "/api/ping"))); assert!(P0_ROUTES.contains(&("GET", "/api/info"))); 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] diff --git a/crates/qbzd/src/api/playback.rs b/crates/qbzd/src/api/playback.rs new file mode 100644 index 000000000..8f475c4d5 --- /dev/null +++ b/crates/qbzd/src/api/playback.rs @@ -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>> { + 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>> { + 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>> { + 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>> { + 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>> { + 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>> { + advance(state, true) +} + +/// `POST /api/playback/previous` (02 §3.3.10). +pub fn previous(state: &ApiState) -> Response>> { + 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>> { + 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>> { + 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>> { + 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>> { + 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>>> { + 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 ", + )); + }; + 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>>> { + 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>> { + 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>> { + 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"); + } +} diff --git a/crates/qbzd/src/cli/client.rs b/crates/qbzd/src/cli/client.rs index bfffff9cc..99f5d8d83 100644 --- a/crates/qbzd/src/cli/client.rs +++ b/crates/qbzd/src/cli/client.rs @@ -50,7 +50,16 @@ impl std::fmt::Display for CliError { match self { CliError::Unreachable(host) => write!(f, "{}", copy::daemon_down(host)), 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::ApiSkew { daemon, cli } => write!(f, "{}", copy::api_version_skew(*daemon, *cli)), CliError::Runtime(m) => write!(f, "error: {m}"), @@ -149,8 +158,8 @@ impl ApiClient { self.send(req).await } - /// P0 mutation transport — first consumer is the T7 transport verbs. - #[allow(dead_code)] // wired by the T7 play/pause/… verbs + /// P0 mutation transport — consumed by the T7 transport verbs + /// (play/pause/toggle/stop/next/prev/seek/volume/mute). pub async fn post(&self, path: &str, body: Value) -> Result { let req = self.bearer(self.client.post(format!("{}{}", self.base, path)).json(&body)); self.send(req).await @@ -229,7 +238,12 @@ fn error_from_envelope(v: &Value) -> CliError { match code { "needs_auth" => CliError::NeedsAuth, "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::Runtime(message), diff --git a/crates/qbzd/src/cli/copy.rs b/crates/qbzd/src/cli/copy.rs index db62d75ff..d0dbae26c 100644 --- a/crates/qbzd/src/cli/copy.rs +++ b/crates/qbzd/src/cli/copy.rs @@ -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 /// distinction: the daemon answered, the Qobuz session is what's missing. -/// Consumed by the T7 transport verbs that hit a NeedsAuth daemon; `status` -/// renders the composite block instead. -#[allow(dead_code)] // wired by the T7 verbs (now/play/…) that refuse on NeedsAuth +/// Rendered by `CliError::NeedsAuth`'s `Display` — hit whenever `now`/`play`/ +/// `toggle`/`next`/`prev` get a 409 `needs_auth` from a NeedsAuth daemon; +/// `status` renders the composite block instead. pub fn daemon_up_needs_auth() -> String { "error: daemon is running but not logged in to Qobuz → 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`/`mute` verbs; defined here so all four §1.4 blocks live in one place. -#[allow(dead_code)] // wired by the T7 volume/mute verbs +/// Volume fixed under DSD-direct — exit 5 (02 §1.4, verbatim). Consumed by +/// `error_from_envelope` (cli/client.rs) for the `volume_fixed_dsd` code, so +/// the `volume`/`mute` verbs print this exact block instead of the server's +/// short envelope message. pub fn volume_fixed_dsd() -> String { "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_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 /// at boot step 5 (02 §2.2, verbatim). `port` is interpolated. pub fn port_in_use(port: u16) -> String { diff --git a/crates/qbzd/src/cli/mod.rs b/crates/qbzd/src/cli/mod.rs index 6c0568232..b1efdcc48 100644 --- a/crates/qbzd/src/cli/mod.rs +++ b/crates/qbzd/src/cli/mod.rs @@ -6,3 +6,4 @@ pub mod client; pub mod copy; pub mod status; +pub mod transport; diff --git a/crates/qbzd/src/cli/transport.rs b/crates/qbzd/src/cli/transport.rs new file mode 100644 index 000000000..1164cc71a --- /dev/null +++ b/crates/qbzd/src/cli/transport.rs @@ -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, 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, 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, roots: &ProfileRoots) -> i32 { + transport_state(host, roots, "/api/playback/play").await +} + +pub async fn pause(host: Option, roots: &ProfileRoots) -> i32 { + transport_state(host, roots, "/api/playback/pause").await +} + +pub async fn toggle(host: Option, roots: &ProfileRoots) -> i32 { + transport_state(host, roots, "/api/playback/toggle").await +} + +pub async fn stop(host: Option, 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, 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, roots: &ProfileRoots) -> i32 { + transport_advance(host, roots, "/api/playback/next").await +} + +pub async fn prev(host: Option, 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 { + let s = s.trim(); + if let Some(rest) = s.strip_prefix('+') { + return rest + .parse::() + .map(SeekArg::Delta) + .map_err(|_| format!("invalid seek offset '{s}' — expected +N seconds")); + } + if let Some(rest) = s.strip_prefix('-') { + return rest + .parse::() + .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::() + .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 ` — 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, 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 { + let s = s.trim(); + if let Some(rest) = s.strip_prefix('+') { + return rest + .parse::() + .map(VolumeArg::Delta) + .map_err(|_| format!("invalid volume offset '{s}' — expected +N")); + } + if let Some(rest) = s.strip_prefix('-') { + return rest + .parse::() + .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, roots: &ProfileRoots, value: Option, 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 { + 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, roots: &ProfileRoots, state_arg: Option) -> 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"); + } +} diff --git a/crates/qbzd/src/main.rs b/crates/qbzd/src/main.rs index 32ca0eef7..981db948b 100644 --- a/crates/qbzd/src/main.rs +++ b/crates/qbzd/src/main.rs @@ -188,6 +188,46 @@ async fn main() { let roots = paths::ProfileRoots::resolve(None, None); 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 }; std::process::exit(code);