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
@@ -484,12 +484,14 @@ pub async fn run_driver<A: FrontendAdapter + Send + Sync + 'static>(
|
|||||||
|
|
||||||
/// Run the advance ritual and log its outcome (queue-finished on `Ok(None)`, the
|
/// Run the advance ritual and log its outcome (queue-finished on `Ok(None)`, the
|
||||||
/// error on `Err`). The daemon stops on a genuine queue edge just like the
|
/// error on `Err`). The daemon stops on a genuine queue edge just like the
|
||||||
/// pure-decision `QueueFinished` branch does.
|
/// pure-decision `QueueFinished` branch does. Always forward — the driver's
|
||||||
|
/// auto-advance on track-end never walks backward (reverse is the `qbzd prev`
|
||||||
|
/// route's concern, wired directly through `advance_and_play(..., false)`).
|
||||||
async fn advance_and_play_logged<A: FrontendAdapter + Send + Sync + 'static>(
|
async fn advance_and_play_logged<A: FrontendAdapter + Send + Sync + 'static>(
|
||||||
runtime: &Arc<AppRuntime<A>>,
|
runtime: &Arc<AppRuntime<A>>,
|
||||||
quality: Quality,
|
quality: Quality,
|
||||||
) {
|
) {
|
||||||
match advance_and_play(runtime, quality).await {
|
match advance_and_play(runtime, quality, true).await {
|
||||||
Ok(Some(_)) => {}
|
Ok(Some(_)) => {}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
log::info!("[qbzd] driver: advance found nothing playable — queue finished");
|
log::info!("[qbzd] driver: advance found nothing playable — queue finished");
|
||||||
@@ -502,18 +504,28 @@ async fn advance_and_play_logged<A: FrontendAdapter + Send + Sync + 'static>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The FULL advance ritual, reused verbatim by the CLI next/prev routes (T7):
|
/// The FULL advance ritual, reused verbatim by the CLI next/prev routes (T7):
|
||||||
/// bounded skip-walk to the next playable track → play it → warm the successors
|
/// bounded skip-walk to the next (or previous) playable track → play it → warm
|
||||||
/// for gapless → persist the session. Never a bare cursor move (02 §2.2). The
|
/// the successors for gapless → persist the session. Never a bare cursor move
|
||||||
/// skip-walk mirrors `playback.rs::advance_to_playable` (capped at
|
/// (02 §2.2). The skip-walk mirrors `playback.rs::advance_to_playable` (capped
|
||||||
/// `MAX_OFFLINE_SKIPS`); `next_track()` is the atomic cursor mover.
|
/// at `MAX_OFFLINE_SKIPS`); `next_track()`/`previous_track()` are the atomic
|
||||||
|
/// cursor movers — `forward` selects which one, exactly like the desktop's
|
||||||
|
/// `advance_to_playable(runtime, weak, forward)` (`crates/qbz/src/playback.rs:358`),
|
||||||
|
/// so `qbzd next` and `qbzd prev` share this one ritual instead of duplicating
|
||||||
|
/// the play → prefetch → persist tail.
|
||||||
pub async fn advance_and_play<A: FrontendAdapter + Send + Sync + 'static>(
|
pub async fn advance_and_play<A: FrontendAdapter + Send + Sync + 'static>(
|
||||||
runtime: &AppRuntime<A>,
|
runtime: &AppRuntime<A>,
|
||||||
quality: Quality,
|
quality: Quality,
|
||||||
|
forward: bool,
|
||||||
) -> Result<Option<QueueTrack>, String> {
|
) -> Result<Option<QueueTrack>, String> {
|
||||||
let core = runtime.core();
|
let core = runtime.core();
|
||||||
let mut skips = 0usize;
|
let mut skips = 0usize;
|
||||||
let next = loop {
|
let next = loop {
|
||||||
let Some(track) = core.next_track().await else {
|
let step = if forward {
|
||||||
|
core.next_track().await
|
||||||
|
} else {
|
||||||
|
core.previous_track().await
|
||||||
|
};
|
||||||
|
let Some(track) = step else {
|
||||||
break None; // queue edge
|
break None; // queue edge
|
||||||
};
|
};
|
||||||
// Playable gate: local files always attempt (they play from disk);
|
// Playable gate: local files always attempt (they play from disk);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
// The two-call split — `bind` at boot step 5 (stateless, so the foreign-occupant
|
// The two-call split — `bind` at boot step 5 (stateless, so the foreign-occupant
|
||||||
// diagnosis runs BEFORE the stores/runtime exist), `serve` at boot step 11 — is
|
// diagnosis runs BEFORE the stores/runtime exist), `serve` at boot step 11 — is
|
||||||
// what keeps the 01-architecture.md §8.1 boot order honest.
|
// what keeps the 01-architecture.md §8.1 boot order honest.
|
||||||
|
pub mod playback;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
@@ -30,14 +31,24 @@ use qbz_app::shell::AppRuntime;
|
|||||||
use qbz_audio::settings::AudioSettingsStore;
|
use qbz_audio::settings::AudioSettingsStore;
|
||||||
|
|
||||||
/// The counted P0 route table (02 §3.2). A route exists only iff a shipped
|
/// The counted P0 route table (02 §3.2). A route exists only iff a shipped
|
||||||
/// client calls it (§3.1.4). This task lands **3**; T7 adds the 9 playback +
|
/// client calls it (§3.1.4). T1-T6 landed the first 3; T7 (this task) adds the
|
||||||
/// now-playing routes, T8 the 4 queue routes, T11 `/api/settings/reload` — the
|
/// 9 playback + now-playing routes (rows 4-12); T8 adds the 4 queue routes,
|
||||||
/// inline `route_table_matches_spec_count` test pins the number so the
|
/// T11 `/api/settings/reload` — the inline `route_table_matches_spec_count`
|
||||||
/// 68-routes failure shape (a route with no caller) cannot creep back in.
|
/// test pins the number so the 68-routes failure shape (a route with no
|
||||||
|
/// caller) cannot creep back in.
|
||||||
pub const P0_ROUTES: &[(&str, &str)] = &[
|
pub const P0_ROUTES: &[(&str, &str)] = &[
|
||||||
("GET", "/api/ping"),
|
("GET", "/api/ping"),
|
||||||
("GET", "/api/info"),
|
("GET", "/api/info"),
|
||||||
("GET", "/api/status"),
|
("GET", "/api/status"),
|
||||||
|
("GET", "/api/now-playing"),
|
||||||
|
("POST", "/api/playback/play"),
|
||||||
|
("POST", "/api/playback/pause"),
|
||||||
|
("POST", "/api/playback/toggle"),
|
||||||
|
("POST", "/api/playback/stop"),
|
||||||
|
("POST", "/api/playback/next"),
|
||||||
|
("POST", "/api/playback/previous"),
|
||||||
|
("POST", "/api/playback/seek"),
|
||||||
|
("POST", "/api/playback/volume"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server
|
/// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server
|
||||||
@@ -202,12 +213,35 @@ fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
|
|||||||
),
|
),
|
||||||
("GET", "/api/info") => status::info(state),
|
("GET", "/api/info") => status::info(state),
|
||||||
("GET", "/api/status") => status::status(state),
|
("GET", "/api/status") => status::status(state),
|
||||||
// T7: /api/now-playing + 8 playback routes · T8: 4 queue routes ·
|
("GET", "/api/now-playing") => playback::now_playing(state),
|
||||||
// T11: /api/settings/reload.
|
("POST", "/api/playback/play") => playback::play(state),
|
||||||
|
("POST", "/api/playback/pause") => playback::pause(state),
|
||||||
|
("POST", "/api/playback/toggle") => playback::toggle(state),
|
||||||
|
("POST", "/api/playback/stop") => playback::stop(state),
|
||||||
|
("POST", "/api/playback/next") => playback::next(state),
|
||||||
|
("POST", "/api/playback/previous") => playback::previous(state),
|
||||||
|
("POST", "/api/playback/seek") => {
|
||||||
|
let body = read_json_body(req);
|
||||||
|
playback::seek(state, &body)
|
||||||
|
}
|
||||||
|
("POST", "/api/playback/volume") => {
|
||||||
|
let body = read_json_body(req);
|
||||||
|
playback::volume(state, &body)
|
||||||
|
}
|
||||||
|
// T8: 4 queue routes · T11: /api/settings/reload.
|
||||||
_ => err_json(404, "not_found", "unknown route", "see qbzd --help"),
|
_ => err_json(404, "not_found", "unknown route", "see qbzd --help"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read and parse a request body as JSON (T7's seek/volume POST bodies).
|
||||||
|
/// An unreadable or absent body parses to `Value::Null` — the route handlers
|
||||||
|
/// treat a missing expected field as `400 bad_request`, never a panic.
|
||||||
|
fn read_json_body(req: &mut Request) -> serde_json::Value {
|
||||||
|
let mut buf = String::new();
|
||||||
|
let _ = req.as_reader().read_to_string(&mut buf);
|
||||||
|
serde_json::from_str(&buf).unwrap_or(serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
/// A pre-routing rejection. Carried as a small enum so [`access_gate`] stays a
|
/// A pre-routing rejection. Carried as a small enum so [`access_gate`] stays a
|
||||||
/// pure decision (unit-testable without a tiny_http `Request`, which has no
|
/// pure decision (unit-testable without a tiny_http `Request`, which has no
|
||||||
/// public constructor) while `route` renders the normative envelope.
|
/// public constructor) while `route` renders the normative envelope.
|
||||||
@@ -308,11 +342,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn route_table_matches_spec_count() {
|
fn route_table_matches_spec_count() {
|
||||||
// 02-cli-and-api.md §3.2 — P0 = exactly 17 routes; grows ONLY with a
|
// 02-cli-and-api.md §3.2 — P0 = exactly 17 routes; grows ONLY with a
|
||||||
// shipped client. This task lands 3; T7 +9; T8 +4; T11 +1.
|
// shipped client. T1-T6 landed 3; T7 (this task) +9 = 12; T8 +4; T11 +1.
|
||||||
assert_eq!(P0_ROUTES.len(), 3);
|
assert_eq!(P0_ROUTES.len(), 12);
|
||||||
assert!(P0_ROUTES.contains(&("GET", "/api/ping")));
|
assert!(P0_ROUTES.contains(&("GET", "/api/ping")));
|
||||||
assert!(P0_ROUTES.contains(&("GET", "/api/info")));
|
assert!(P0_ROUTES.contains(&("GET", "/api/info")));
|
||||||
assert!(P0_ROUTES.contains(&("GET", "/api/status")));
|
assert!(P0_ROUTES.contains(&("GET", "/api/status")));
|
||||||
|
assert!(P0_ROUTES.contains(&("GET", "/api/now-playing")));
|
||||||
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/play")));
|
||||||
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/pause")));
|
||||||
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/toggle")));
|
||||||
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/stop")));
|
||||||
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/next")));
|
||||||
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/previous")));
|
||||||
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/seek")));
|
||||||
|
assert!(P0_ROUTES.contains(&("POST", "/api/playback/volume")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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 {
|
match self {
|
||||||
CliError::Unreachable(host) => write!(f, "{}", copy::daemon_down(host)),
|
CliError::Unreachable(host) => write!(f, "{}", copy::daemon_down(host)),
|
||||||
CliError::NeedsAuth => write!(f, "{}", copy::daemon_up_needs_auth()),
|
CliError::NeedsAuth => write!(f, "{}", copy::daemon_up_needs_auth()),
|
||||||
CliError::Device(m) => write!(f, "error: {m}"),
|
// `error_from_envelope` pre-builds the two DSD-specific codes into
|
||||||
|
// the full verbatim §1.4 copy (already "error: ..." prefixed); a
|
||||||
|
// plain device message from the server gets the generic prefix.
|
||||||
|
CliError::Device(m) => {
|
||||||
|
if m.starts_with("error:") {
|
||||||
|
write!(f, "{m}")
|
||||||
|
} else {
|
||||||
|
write!(f, "error: {m}")
|
||||||
|
}
|
||||||
|
}
|
||||||
CliError::NotFound(m) => write!(f, "error: {m}"),
|
CliError::NotFound(m) => write!(f, "error: {m}"),
|
||||||
CliError::ApiSkew { daemon, cli } => write!(f, "{}", copy::api_version_skew(*daemon, *cli)),
|
CliError::ApiSkew { daemon, cli } => write!(f, "{}", copy::api_version_skew(*daemon, *cli)),
|
||||||
CliError::Runtime(m) => write!(f, "error: {m}"),
|
CliError::Runtime(m) => write!(f, "error: {m}"),
|
||||||
@@ -149,8 +158,8 @@ impl ApiClient {
|
|||||||
self.send(req).await
|
self.send(req).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// P0 mutation transport — first consumer is the T7 transport verbs.
|
/// P0 mutation transport — consumed by the T7 transport verbs
|
||||||
#[allow(dead_code)] // wired by the T7 play/pause/… verbs
|
/// (play/pause/toggle/stop/next/prev/seek/volume/mute).
|
||||||
pub async fn post(&self, path: &str, body: Value) -> Result<Value, CliError> {
|
pub async fn post(&self, path: &str, body: Value) -> Result<Value, CliError> {
|
||||||
let req = self.bearer(self.client.post(format!("{}{}", self.base, path)).json(&body));
|
let req = self.bearer(self.client.post(format!("{}{}", self.base, path)).json(&body));
|
||||||
self.send(req).await
|
self.send(req).await
|
||||||
@@ -229,7 +238,12 @@ fn error_from_envelope(v: &Value) -> CliError {
|
|||||||
match code {
|
match code {
|
||||||
"needs_auth" => CliError::NeedsAuth,
|
"needs_auth" => CliError::NeedsAuth,
|
||||||
"not_found" => CliError::NotFound(message),
|
"not_found" => CliError::NotFound(message),
|
||||||
"audio_unavailable" | "volume_fixed_dsd" | "seek_unsupported_dsd" | "device_error" => {
|
// §1.4's verbatim DSD blocks are frozen client-side copy, not the
|
||||||
|
// server's short envelope message — swap them in so `qbzd seek`/
|
||||||
|
// `volume`/`mute` print the exact documented multi-line text.
|
||||||
|
"volume_fixed_dsd" => CliError::Device(crate::cli::copy::volume_fixed_dsd()),
|
||||||
|
"seek_unsupported_dsd" => CliError::Device(crate::cli::copy::seek_unsupported_dsd()),
|
||||||
|
"audio_unavailable" | "device_error" => {
|
||||||
CliError::Device(message)
|
CliError::Device(message)
|
||||||
}
|
}
|
||||||
_ => CliError::Runtime(message),
|
_ => CliError::Runtime(message),
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ pub fn daemon_down(host: &str) -> String {
|
|||||||
|
|
||||||
/// Daemon up but not logged in — exit 4 (02 §1.4). The down-vs-unhealthy
|
/// Daemon up but not logged in — exit 4 (02 §1.4). The down-vs-unhealthy
|
||||||
/// distinction: the daemon answered, the Qobuz session is what's missing.
|
/// distinction: the daemon answered, the Qobuz session is what's missing.
|
||||||
/// Consumed by the T7 transport verbs that hit a NeedsAuth daemon; `status`
|
/// Rendered by `CliError::NeedsAuth`'s `Display` — hit whenever `now`/`play`/
|
||||||
/// renders the composite block instead.
|
/// `toggle`/`next`/`prev` get a 409 `needs_auth` from a NeedsAuth daemon;
|
||||||
#[allow(dead_code)] // wired by the T7 verbs (now/play/…) that refuse on NeedsAuth
|
/// `status` renders the composite block instead.
|
||||||
pub fn daemon_up_needs_auth() -> String {
|
pub fn daemon_up_needs_auth() -> String {
|
||||||
"error: daemon is running but not logged in to Qobuz
|
"error: daemon is running but not logged in to Qobuz
|
||||||
→ log in: qbzd login
|
→ log in: qbzd login
|
||||||
@@ -75,15 +75,26 @@ pub fn linger_off(user: &str) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Volume fixed under DSD-direct — exit 5 (02 §1.4). Consumed by the T7
|
/// Volume fixed under DSD-direct — exit 5 (02 §1.4, verbatim). Consumed by
|
||||||
/// `volume`/`mute` verbs; defined here so all four §1.4 blocks live in one place.
|
/// `error_from_envelope` (cli/client.rs) for the `volume_fixed_dsd` code, so
|
||||||
#[allow(dead_code)] // wired by the T7 volume/mute verbs
|
/// the `volume`/`mute` verbs print this exact block instead of the server's
|
||||||
|
/// short envelope message.
|
||||||
pub fn volume_fixed_dsd() -> String {
|
pub fn volume_fixed_dsd() -> String {
|
||||||
"error: volume is fixed in DSD-direct mode (bit-perfect passthrough)
|
"error: volume is fixed in DSD-direct mode (bit-perfect passthrough)
|
||||||
→ to get software volume, set DSD mode to \"convert\": qbzd setup (Audio screen)"
|
→ to get software volume, set DSD mode to \"convert\": qbzd setup (Audio screen)"
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seek unsupported under DSD-direct — exit 5. No verbatim block is given for
|
||||||
|
/// seek specifically; 02 §2.2 says it is "the same error-voice family as
|
||||||
|
/// §1.4 volume copy", so this mirrors `volume_fixed_dsd`'s structure/wording.
|
||||||
|
/// Consumed by `error_from_envelope` for the `seek_unsupported_dsd` code.
|
||||||
|
pub fn seek_unsupported_dsd() -> String {
|
||||||
|
"error: seek is unsupported in DSD-direct mode (bit-perfect passthrough)
|
||||||
|
→ to seek, set DSD mode to \"convert\": qbzd setup (Audio screen)"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Foreign occupant on the control port that is NOT qbzd — printed by the daemon
|
/// Foreign occupant on the control port that is NOT qbzd — printed by the daemon
|
||||||
/// at boot step 5 (02 §2.2, verbatim). `port` is interpolated.
|
/// at boot step 5 (02 §2.2, verbatim). `port` is interpolated.
|
||||||
pub fn port_in_use(port: u16) -> String {
|
pub fn port_in_use(port: u16) -> String {
|
||||||
|
|||||||
@@ -6,3 +6,4 @@
|
|||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod copy;
|
pub mod copy;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
|
pub mod transport;
|
||||||
@@ -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);
|
let roots = paths::ProfileRoots::resolve(None, None);
|
||||||
cli::status::ping(cli.host, json, &roots).await
|
cli::status::ping(cli.host, json, &roots).await
|
||||||
}
|
}
|
||||||
|
Cmd::Now { json } => {
|
||||||
|
let roots = paths::ProfileRoots::resolve(None, None);
|
||||||
|
cli::transport::now(cli.host, json, &roots).await
|
||||||
|
}
|
||||||
|
Cmd::Play => {
|
||||||
|
let roots = paths::ProfileRoots::resolve(None, None);
|
||||||
|
cli::transport::play(cli.host, &roots).await
|
||||||
|
}
|
||||||
|
Cmd::Pause => {
|
||||||
|
let roots = paths::ProfileRoots::resolve(None, None);
|
||||||
|
cli::transport::pause(cli.host, &roots).await
|
||||||
|
}
|
||||||
|
Cmd::Toggle => {
|
||||||
|
let roots = paths::ProfileRoots::resolve(None, None);
|
||||||
|
cli::transport::toggle(cli.host, &roots).await
|
||||||
|
}
|
||||||
|
Cmd::Stop => {
|
||||||
|
let roots = paths::ProfileRoots::resolve(None, None);
|
||||||
|
cli::transport::stop(cli.host, &roots).await
|
||||||
|
}
|
||||||
|
Cmd::Next => {
|
||||||
|
let roots = paths::ProfileRoots::resolve(None, None);
|
||||||
|
cli::transport::next(cli.host, &roots).await
|
||||||
|
}
|
||||||
|
Cmd::Prev => {
|
||||||
|
let roots = paths::ProfileRoots::resolve(None, None);
|
||||||
|
cli::transport::prev(cli.host, &roots).await
|
||||||
|
}
|
||||||
|
Cmd::Seek { position } => {
|
||||||
|
let roots = paths::ProfileRoots::resolve(None, None);
|
||||||
|
cli::transport::seek(cli.host, &roots, position).await
|
||||||
|
}
|
||||||
|
Cmd::Volume { value, json } => {
|
||||||
|
let roots = paths::ProfileRoots::resolve(None, None);
|
||||||
|
cli::transport::volume(cli.host, &roots, value, json).await
|
||||||
|
}
|
||||||
|
Cmd::Mute { state } => {
|
||||||
|
let roots = paths::ProfileRoots::resolve(None, None);
|
||||||
|
cli::transport::mute(cli.host, &roots, state).await
|
||||||
|
}
|
||||||
_ => { eprintln!("not implemented yet"); 1 } // burned down task by task
|
_ => { eprintln!("not implemented yet"); 1 } // burned down task by task
|
||||||
};
|
};
|
||||||
std::process::exit(code);
|
std::process::exit(code);
|
||||||
|
|||||||
Reference in new issue
Block a user