feat(qbzd): playback routes and transport CLI verbs with full advance ritual
cc
This commit is contained in:
8 files changed
+1050
-25
No files matched your search
@@ -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<Cursor<Vec<u8>>> {
|
||||
),
|
||||
("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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<Value, CliError> {
|
||||
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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,3 +6,4 @@
|
||||
pub mod client;
|
||||
pub mod copy;
|
||||
pub mod status;
|
||||
pub mod transport;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user