feat(qbzd): settings reload endpoint and settings/qconnect/config CLI verbs

Route table complete: 17 P0 routes, CI-counted.

cc
This commit is contained in:
vicrodh committed 2026-07-14 19:02:10 -06:00
1 parent 6fa0428127
commit a864aa8d55
9 files changed
+1369 -18

No files matched your search

+27 -8
View File
@@ -16,6 +16,7 @@
// what keeps the 01-architecture.md §8.1 boot order honest.
pub mod playback;
pub mod queue;
pub mod settings;
pub mod status;
use std::io::Cursor;
@@ -31,10 +32,11 @@ use crate::state::DaemonShared;
use qbz_app::shell::AppRuntime;
use qbz_audio::settings::AudioSettingsStore;
/// The counted P0 route table (02 §3.2). A route exists only iff a shipped
/// client calls it (§3.1.4). T1-T6 landed the first 3; T7 added the 9
/// playback + now-playing routes (rows 4-12); T8 (this task) adds the 4 queue
/// routes (rows 13-16); T11 adds `/api/settings/reload` (row 17) — the inline
/// The counted P0 route table (02 §3.2) — 17, FINAL, this test now guards the
/// budget forever. A route exists only iff a shipped client calls it (§3.1.4).
/// T1-T6 landed the first 3; T7 added the 9 playback + now-playing routes
/// (rows 4-12); T8 added the 4 queue routes (rows 13-16); T11 (this task)
/// adds `/api/settings/reload` (row 17) — the inline
/// `route_table_matches_spec_count` test pins the number so the 68-routes
/// failure shape (a route with no caller) cannot creep back in.
pub const P0_ROUTES: &[(&str, &str)] = &[
@@ -54,6 +56,7 @@ pub const P0_ROUTES: &[(&str, &str)] = &[
("POST", "/api/queue/add"),
("POST", "/api/queue/remove"),
("POST", "/api/queue/clear"),
("POST", "/api/settings/reload"),
];
/// A socket bound at boot step 5, not yet serving. Wraps the tiny_http server
@@ -85,6 +88,20 @@ pub struct ApiState {
/// Cached device enumeration for `device_present` (refreshed on a TTL so a
/// `status` poll never re-enumerates CPAL on every call).
pub devices: Mutex<DeviceCache>,
/// T11: the `AudioSettings` last applied to the `Player`, so
/// `POST /api/settings/reload` can tell whether a routing-critical field
/// changed since the previous reload (`daemon::audio_routing_changed`) —
/// reinit only when it did, never on every unrelated nudge.
pub audio_snapshot: Mutex<qbz_audio::settings::AudioSettings>,
/// T11: the live cell the playback driver's background auto-advance reads
/// for streaming quality (`daemon.rs::run`'s `quality_cell`) — reload
/// writes a fresh value here after re-reading `daemon_prefs`.
pub quality: Arc<Mutex<qbz_models::Quality>>,
/// T11: reaches the running QConnect service (`connect`/`disconnect`/
/// device-name refresh) once `qconnect::start` (boot step 12, AFTER the
/// API starts serving at step 11) publishes it — empty only in the brief
/// window between the two, which the reload handler no-ops through.
pub qconnect_control: Arc<std::sync::OnceLock<crate::qconnect::QconnectControl>>,
}
/// TTL-cached output-device names for the `device_present` check.
@@ -249,7 +266,7 @@ fn route(state: &ApiState, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
let body = read_json_body(req);
queue::clear(state, &body)
}
// T11: /api/settings/reload.
("POST", "/api/settings/reload") => settings::reload(state),
_ => err_json(404, "not_found", "unknown route", "see qbzd --help"),
}
}
@@ -374,9 +391,10 @@ mod tests {
#[test]
fn route_table_matches_spec_count() {
// 02-cli-and-api.md §3.2 — P0 = exactly 17 routes; grows ONLY with a
// shipped client. T1-T6 landed 3; T7 +9 = 12; T8 (this task) +4 = 16; T11 +1 = 17.
assert_eq!(P0_ROUTES.len(), 16);
// 02-cli-and-api.md §3.2 — P0 = exactly 17 routes, FINAL; grows ONLY
// with a shipped client. T1-T6 landed 3; T7 +9 = 12; T8 +4 = 16; T11
// (this task) +1 = 17.
assert_eq!(P0_ROUTES.len(), 17);
assert!(P0_ROUTES.contains(&("GET", "/api/ping")));
assert!(P0_ROUTES.contains(&("GET", "/api/info")));
assert!(P0_ROUTES.contains(&("GET", "/api/status")));
@@ -393,6 +411,7 @@ mod tests {
assert!(P0_ROUTES.contains(&("POST", "/api/queue/add")));
assert!(P0_ROUTES.contains(&("POST", "/api/queue/remove")));
assert!(P0_ROUTES.contains(&("POST", "/api/queue/clear")));
assert!(P0_ROUTES.contains(&("POST", "/api/settings/reload")));
}
#[test]
+22
View File
@@ -0,0 +1,22 @@
// crates/qbzd/src/api/settings.rs — `POST /api/settings/reload` (02-cli-and-
// api.md §3.3.17, route 17/17, FINAL). No body. The real work lives in
// `crate::daemon::reload` (the "reload entry point" — re-reads audio, the
// streaming-quality cell, the QConnect KV, and the credential file); this
// route handler is a thin wrapper: run it, then answer with the SAME body as
// `GET /api/status` — zero new shapes (the reinit/reload narrative is
// composed CLIENT-side from the CLI's own copy of the Apply-ladder
// classification, 03-setup-tui.md §4.3 — never carried on the wire).
use std::io::Cursor;
use tiny_http::Response;
use super::ApiState;
/// `POST /api/settings/reload`. The serving thread is a plain `std::thread`
/// (never a tokio worker), so the async reload orchestration runs via
/// `state.rt.block_on` — the same pattern `status::assemble_live` already uses
/// for its queue read.
pub fn reload(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
state.rt.block_on(crate::daemon::reload(state));
super::status::status(state)
}
+1
View File
@@ -6,5 +6,6 @@
pub mod client;
pub mod copy;
pub mod queue;
pub mod settings;
pub mod status;
pub mod transport;
+806
View File
@@ -0,0 +1,806 @@
// crates/qbzd/src/cli/settings.rs — `qbzd settings show|set`,
// `qbzd qconnect enable|disable|name`, `qbzd config path|show`
// (02-cli-and-api.md §2.2). ALL ⬇ daemon-down capable (§2.4): every verb here
// reads/writes the daemon's REAL stores directly at the daemon roots
// (`AudioSettingsStore::new_at`, `PlaybackPreferencesStore::new_at`,
// `daemon_prefs`, the qconnect KV `_at` helpers — T9) and best-effort nudges a
// running daemon via `POST /api/settings/reload` afterwards
// (`login::nudge_reload` — the same ping-then-reload pattern `login`/`logout`
// already use), never the other way around (§1.1: the CLI holds no daemon
// state of its own). Export/import land in T12.
//
// The canonical dotted-key table below is this CLI's own copy of the desktop
// Apply ladder (`qbz/src/settings.rs:87-94`, per-key classification
// `:877-967,1134-1290`; 03-setup-tui.md §4.3's 9-field Reinit list). Per
// 03-setup-tui.md §4.3 (normative): the server's reload response carries NO
// reinit/reload narrative of its own — that classification is composed
// CLIENT-side, which is exactly what [`ApplyClass`] is for.
//
// Value encoding (deliberate, P0 scope): every value round-trips as a plain
// string — `settings show --json`'s values are exactly what `settings set`
// accepts back, key for key. No per-type JSON (bool/number) encoding; that
// would need a second parser on top of the one `set` already has, for a
// convenience no shipped P0/P1 script needs (the CLI is the machine
// interface here, not `/api/settings/reload`'s response body).
use std::path::{Path, PathBuf};
use qbz_app::settings::daemon_prefs;
use qbz_app::settings::playback::{AutoplayMode, PlaybackPreferencesStore};
use qbz_audio::settings::AudioSettingsStore;
use qbz_audio::{AlsaPlugin, AudioBackendType};
use crate::paths::ProfileRoots;
use crate::qconnect::transport as qconnect_kv;
/// Whether a key's write is Reinit-class (closes/reopens the output device),
/// Reload-class (struct refresh only, no audible gap), or affects nothing the
/// live `Player`/QConnect service reads immediately (playback prefs, qconnect
/// KV — applies next play / next connect respectively). Purely this CLI's own
/// bookkeeping for the success-line hint; the daemon decides for itself via
/// `daemon::audio_routing_changed` — the two are independent copies of the
/// same table, per 03-setup-tui.md §4.3.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApplyClass {
Reinit,
Reload,
None,
}
/// The canonical dotted-key table (NORMATIVE for this build): `settings show`
/// lists exactly these, in this order; `settings set` accepts exactly these
/// keys and nothing else. Domains: `audio.*` (`AudioSettingsStore`),
/// `playback.*` (daemon_prefs.streaming_quality + `PlaybackPreferencesStore`),
/// `qconnect.*` (the daemon-root `qconnect_settings.db` KV, T9).
const KEY_TABLE: &[(&str, ApplyClass)] = &[
// --- audio (Reinit — 03-setup-tui.md §4.3's 9-field list) -------------
("audio.backend", ApplyClass::Reinit),
("audio.device", ApplyClass::Reinit),
("audio.alsa_plugin", ApplyClass::Reinit),
("audio.alsa_hardware_volume", ApplyClass::Reinit),
("audio.exclusive_mode", ApplyClass::Reinit),
("audio.dac_passthrough", ApplyClass::Reinit),
("audio.skip_sink_switch", ApplyClass::Reinit),
("audio.dsd_mode", ApplyClass::Reinit),
("audio.device_max_sample_rate", ApplyClass::Reinit),
// --- audio (Reload) -----------------------------------------------------
("audio.stream_first_track", ApplyClass::Reload),
("audio.stream_buffer_seconds", ApplyClass::Reload),
("audio.streaming_only", ApplyClass::Reload),
("audio.limit_quality_to_device", ApplyClass::Reload),
("audio.allow_quality_fallback", ApplyClass::Reload),
("audio.quality_fallback_behavior", ApplyClass::Reload),
("audio.gapless_enabled", ApplyClass::Reload),
("audio.normalization_enabled", ApplyClass::Reload),
("audio.normalization_target_lufs", ApplyClass::Reload),
("audio.pw_force_bitperfect", ApplyClass::Reload),
("audio.reserve_dac_while_running", ApplyClass::Reload),
("audio.sync_audio_on_startup", ApplyClass::Reload),
// --- playback (daemon_prefs + PlaybackPreferencesStore) ----------------
("playback.quality", ApplyClass::None),
("playback.autoplay", ApplyClass::None),
("playback.persist_session", ApplyClass::None),
("playback.resume_playback_position", ApplyClass::None),
("playback.show_context_icon", ApplyClass::None),
// --- qconnect (daemon-root qconnect_settings.db KV, T9) ----------------
("qconnect.device_name", ApplyClass::None),
("qconnect.startup_mode", ApplyClass::None),
("qconnect.volume_mode", ApplyClass::None),
];
fn classify(key: &str) -> Option<ApplyClass> {
KEY_TABLE.iter().find(|(k, _)| *k == key).map(|(_, c)| *c)
}
/// The fault + fix for an unknown key (02 §1.4 error voice: name the fault,
/// then the fix). No "error:" prefix — `set()` adds it uniformly at the print
/// site, matching every other value-parse error in this file.
fn unknown_key_error(key: &str) -> String {
let mut out = format!("unknown setting key '{key}'\n → valid keys:\n");
for (k, _) in KEY_TABLE {
out.push_str(&format!(" {k}\n"));
}
out
}
fn qconnect_db(roots: &ProfileRoots) -> PathBuf {
roots.data.join("qconnect_settings.db")
}
// ============================ value parsing ============================
fn parse_bool(v: &str) -> Result<bool, String> {
match v.to_ascii_lowercase().as_str() {
"true" | "on" | "1" | "yes" => Ok(true),
"false" | "off" | "0" | "no" => Ok(false),
other => Err(format!("invalid value '{other}' — expected true or false")),
}
}
fn render_bool(v: bool) -> String {
v.to_string()
}
fn parse_backend(v: &str) -> Result<Option<AudioBackendType>, String> {
match v.to_ascii_lowercase().as_str() {
"system" | "systemdefault" | "system_default" => Ok(Some(AudioBackendType::SystemDefault)),
"pipewire" | "pw" => Ok(Some(AudioBackendType::PipeWire)),
"alsa" => Ok(Some(AudioBackendType::Alsa)),
"pulse" | "pulseaudio" => Ok(Some(AudioBackendType::Pulse)),
"jack" => Ok(Some(AudioBackendType::Jack)),
other => Err(format!(
"invalid backend '{other}' — expected one of: system, pipewire, alsa, pulse, jack"
)),
}
}
fn render_backend(v: Option<AudioBackendType>) -> String {
match v {
Some(AudioBackendType::SystemDefault) => "system".to_string(),
Some(AudioBackendType::PipeWire) => "pipewire".to_string(),
Some(AudioBackendType::Alsa) => "alsa".to_string(),
Some(AudioBackendType::Pulse) => "pulse".to_string(),
Some(AudioBackendType::Jack) => "jack".to_string(),
None => "auto".to_string(),
}
}
fn parse_alsa_plugin(v: &str) -> Result<Option<AlsaPlugin>, String> {
match v.to_ascii_lowercase().as_str() {
"hw" => Ok(Some(AlsaPlugin::Hw)),
"plughw" => Ok(Some(AlsaPlugin::PlugHw)),
"pcm" => Ok(Some(AlsaPlugin::Pcm)),
// Not a documented TUI option (03-setup-tui.md §3.2.1 lists only the 3
// concrete plugins, default Hw) — accepted here only so `settings
// show`'s value on a never-migrated-to-a-plugin row (the seed INSERT
// leaves this column NULL, unlike `backend_type`) round-trips back
// into `set` unchanged instead of erroring on its own read value.
"auto" => Ok(None),
other => Err(format!(
"invalid ALSA plugin '{other}' — expected one of: hw, plughw, pcm"
)),
}
}
fn render_alsa_plugin(v: Option<AlsaPlugin>) -> String {
match v {
Some(AlsaPlugin::Hw) => "hw".to_string(),
Some(AlsaPlugin::PlugHw) => "plughw".to_string(),
Some(AlsaPlugin::Pcm) => "pcm".to_string(),
None => "auto".to_string(),
}
}
/// `audio.device`: empty / "system" / "default" clears to `None` (system
/// default); anything else is the device id verbatim (`hw:CARD=D30,DEV=0`,
/// a PipeWire node name, ...) — free text, not validated against a live
/// enumeration here (that is the TUI's job, T13; a headless `settings set`
/// must work with no device attached to check against).
fn parse_output_device(v: &str) -> Option<String> {
let trimmed = v.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("system") || trimmed.eq_ignore_ascii_case("default")
{
None
} else {
Some(trimmed.to_string())
}
}
fn render_opt_string(v: &Option<String>) -> String {
v.clone().unwrap_or_else(|| "system".to_string())
}
/// `audio.device_max_sample_rate`: "none"/"" clears the limit (Hz, matching
/// the stored unit directly — e.g. `192000`, not `192` kHz).
fn parse_opt_u32(v: &str) -> Result<Option<u32>, String> {
let trimmed = v.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
return Ok(None);
}
trimmed
.parse::<u32>()
.map(Some)
.map_err(|_| format!("invalid sample rate '{trimmed}' — expected a Hz integer (e.g. 192000) or none"))
}
fn render_opt_u32(v: Option<u32>) -> String {
v.map(|r| r.to_string()).unwrap_or_else(|| "none".to_string())
}
fn parse_dsd_mode(v: &str) -> Result<String, String> {
match v.to_ascii_lowercase().as_str() {
"convert" | "dop" | "native" => Ok(v.to_ascii_lowercase()),
other => Err(format!(
"invalid DSD mode '{other}' — expected one of: convert, dop, native"
)),
}
}
/// The daemon has no one to ask (03-setup-tui.md §3.3.2) — `settings set`
/// never writes `"ask"`, even though a legacy/imported store may still hold
/// it (readable via `settings show`, just not settable back to it).
fn parse_quality_fallback_behavior(v: &str) -> Result<String, String> {
match v.to_ascii_lowercase().as_str() {
"always_fallback" | "always_skip" => Ok(v.to_ascii_lowercase()),
"ask" => Err(
"'ask' needs a UI the daemon doesn't have — use always_fallback or always_skip"
.to_string(),
),
other => Err(format!(
"invalid value '{other}' — expected one of: always_fallback, always_skip"
)),
}
}
fn parse_f32(v: &str) -> Result<f32, String> {
v.trim()
.parse::<f32>()
.map_err(|_| format!("invalid number '{v}'"))
}
fn parse_stream_buffer_seconds(v: &str) -> Result<u8, String> {
let n: u8 = v
.trim()
.parse()
.map_err(|_| format!("invalid buffer size '{v}' — expected 1-10"))?;
if !(1..=10).contains(&n) {
return Err(format!("invalid buffer size '{n}' — expected 1-10"));
}
Ok(n)
}
fn parse_streaming_quality(v: &str) -> Result<String, String> {
match v.to_ascii_lowercase().as_str() {
"mp3" | "cd" | "hires" | "hires_plus" => Ok(v.to_ascii_lowercase()),
other => Err(format!(
"invalid quality '{other}' — expected one of: mp3, cd, hires, hires_plus"
)),
}
}
fn parse_autoplay(v: &str) -> Result<AutoplayMode, String> {
serde_json::from_value(serde_json::Value::String(v.to_string())).map_err(|_| {
format!("invalid autoplay mode '{v}' — expected one of: continue, track_only, infinite")
})
}
fn render_autoplay(mode: AutoplayMode) -> String {
serde_json::to_value(mode)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| "continue".to_string())
}
fn parse_volume_mode(v: &str) -> Result<String, String> {
match v.to_ascii_lowercase().as_str() {
"software" | "locked" => Ok(v.to_ascii_lowercase()),
other => Err(format!(
"invalid volume mode '{other}' — expected one of: software, locked"
)),
}
}
// ============================ store IO ============================
fn open_audio(roots: &ProfileRoots) -> Result<AudioSettingsStore, String> {
AudioSettingsStore::new_at(&roots.data)
}
fn open_playback(roots: &ProfileRoots) -> Result<PlaybackPreferencesStore, String> {
PlaybackPreferencesStore::new_at(&roots.data)
}
/// Read every canonical key's current value, in [`KEY_TABLE`] order — the
/// backing for `settings show`. Opens each store once (not once per key).
fn read_all(roots: &ProfileRoots) -> Result<Vec<(&'static str, String)>, String> {
let audio = open_audio(roots)?.get_settings()?;
let playback = open_playback(roots)?.get_preferences()?;
let prefs = daemon_prefs::load_at(&roots.data);
let db = qconnect_db(roots);
let mut out = Vec::with_capacity(KEY_TABLE.len());
for (key, _) in KEY_TABLE {
let value = match *key {
"audio.backend" => render_backend(audio.backend_type),
"audio.device" => render_opt_string(&audio.output_device),
"audio.alsa_plugin" => render_alsa_plugin(audio.alsa_plugin),
"audio.alsa_hardware_volume" => render_bool(audio.alsa_hardware_volume),
"audio.exclusive_mode" => render_bool(audio.exclusive_mode),
"audio.dac_passthrough" => render_bool(audio.dac_passthrough),
"audio.skip_sink_switch" => render_bool(audio.skip_sink_switch),
"audio.dsd_mode" => audio.dsd_mode.clone(),
"audio.device_max_sample_rate" => render_opt_u32(audio.device_max_sample_rate),
"audio.stream_first_track" => render_bool(audio.stream_first_track),
"audio.stream_buffer_seconds" => audio.stream_buffer_seconds.to_string(),
"audio.streaming_only" => render_bool(audio.streaming_only),
"audio.limit_quality_to_device" => render_bool(audio.limit_quality_to_device),
"audio.allow_quality_fallback" => render_bool(audio.allow_quality_fallback),
"audio.quality_fallback_behavior" => audio.quality_fallback_behavior.clone(),
"audio.gapless_enabled" => render_bool(audio.gapless_enabled),
"audio.normalization_enabled" => render_bool(audio.normalization_enabled),
"audio.normalization_target_lufs" => audio.normalization_target_lufs.to_string(),
"audio.pw_force_bitperfect" => render_bool(audio.pw_force_bitperfect),
"audio.reserve_dac_while_running" => render_bool(audio.reserve_dac_while_running),
"audio.sync_audio_on_startup" => render_bool(audio.sync_audio_on_startup),
"playback.quality" => prefs.streaming_quality.clone(),
"playback.autoplay" => render_autoplay(playback.autoplay_mode),
"playback.persist_session" => render_bool(playback.persist_session),
"playback.resume_playback_position" => render_bool(playback.resume_playback_position),
"playback.show_context_icon" => render_bool(playback.show_context_icon),
"qconnect.device_name" => render_opt_string(&qconnect_kv::load_device_name_at(&db)),
"qconnect.startup_mode" => qconnect_kv::load_startup_mode_at(&db).as_str().to_string(),
"qconnect.volume_mode" => {
qconnect_kv::load_volume_mode_at(&db).unwrap_or_else(|| "software".to_string())
}
other => unreachable!("KEY_TABLE/read_all drifted apart on key: {other}"),
};
out.push((*key, value));
}
Ok(out)
}
/// Validate + write ONE canonical key. Returns its [`ApplyClass`] on success
/// (the CLI's own success-line hint — see module doc).
fn write_one(roots: &ProfileRoots, key: &str, raw: &str) -> Result<ApplyClass, String> {
let Some(class) = classify(key) else {
return Err(unknown_key_error(key));
};
match key {
"audio.backend" => open_audio(roots)?.set_backend_type(parse_backend(raw)?)?,
"audio.device" => open_audio(roots)?.set_output_device(parse_output_device(raw).as_deref())?,
"audio.alsa_plugin" => open_audio(roots)?.set_alsa_plugin(parse_alsa_plugin(raw)?)?,
"audio.alsa_hardware_volume" => {
open_audio(roots)?.set_alsa_hardware_volume(parse_bool(raw)?)?
}
"audio.exclusive_mode" => open_audio(roots)?.set_exclusive_mode(parse_bool(raw)?)?,
"audio.dac_passthrough" => open_audio(roots)?.set_dac_passthrough(parse_bool(raw)?)?,
"audio.skip_sink_switch" => open_audio(roots)?.set_skip_sink_switch(parse_bool(raw)?)?,
"audio.dsd_mode" => open_audio(roots)?.set_dsd_mode(&parse_dsd_mode(raw)?)?,
"audio.device_max_sample_rate" => {
open_audio(roots)?.set_device_max_sample_rate(parse_opt_u32(raw)?)?
}
"audio.stream_first_track" => open_audio(roots)?.set_stream_first_track(parse_bool(raw)?)?,
"audio.stream_buffer_seconds" => {
open_audio(roots)?.set_stream_buffer_seconds(parse_stream_buffer_seconds(raw)?)?
}
"audio.streaming_only" => open_audio(roots)?.set_streaming_only(parse_bool(raw)?)?,
"audio.limit_quality_to_device" => {
open_audio(roots)?.set_limit_quality_to_device(parse_bool(raw)?)?
}
"audio.allow_quality_fallback" => {
open_audio(roots)?.set_allow_quality_fallback(parse_bool(raw)?)?
}
"audio.quality_fallback_behavior" => {
open_audio(roots)?.set_quality_fallback_behavior(&parse_quality_fallback_behavior(raw)?)?
}
"audio.gapless_enabled" => open_audio(roots)?.set_gapless_enabled(parse_bool(raw)?)?,
"audio.normalization_enabled" => {
open_audio(roots)?.set_normalization_enabled(parse_bool(raw)?)?
}
"audio.normalization_target_lufs" => {
open_audio(roots)?.set_normalization_target_lufs(parse_f32(raw)?)?
}
"audio.pw_force_bitperfect" => open_audio(roots)?.set_pw_force_bitperfect(parse_bool(raw)?)?,
"audio.reserve_dac_while_running" => {
open_audio(roots)?.set_reserve_dac_while_running(parse_bool(raw)?)?
}
"audio.sync_audio_on_startup" => {
open_audio(roots)?.set_sync_audio_on_startup(parse_bool(raw)?)?
}
"playback.quality" => {
let mut prefs = daemon_prefs::load_at(&roots.data);
prefs.streaming_quality = parse_streaming_quality(raw)?;
daemon_prefs::save_at(&prefs, &roots.data)?;
}
"playback.autoplay" => open_playback(roots)?.set_autoplay_mode(parse_autoplay(raw)?)?,
"playback.persist_session" => open_playback(roots)?.set_persist_session(parse_bool(raw)?)?,
"playback.resume_playback_position" => {
open_playback(roots)?.set_resume_playback_position(parse_bool(raw)?)?
}
"playback.show_context_icon" => {
open_playback(roots)?.set_show_context_icon(parse_bool(raw)?)?
}
"qconnect.device_name" => {
qconnect_kv::persist_device_name_at(&qconnect_db(roots), parse_output_device(raw).as_deref())
}
"qconnect.startup_mode" => {
let mode = match raw.to_ascii_lowercase().as_str() {
"on" => qconnect_app::QconnectStartupMode::On,
"off" => qconnect_app::QconnectStartupMode::Off,
other => {
return Err(format!(
"invalid startup mode '{other}' — expected one of: on, off (use: qbzd qconnect enable|disable)"
))
}
};
qconnect_kv::save_startup_mode_at(&qconnect_db(roots), mode)
}
"qconnect.volume_mode" => {
qconnect_kv::save_volume_mode_at(&qconnect_db(roots), &parse_volume_mode(raw)?)
}
other => unreachable!("KEY_TABLE/write_one drifted apart on key: {other}"),
}
Ok(class)
}
/// Best-effort nudge of a LOCAL running daemon (§1.1/§1.5: these verbs always
/// target the daemon whose stores they just wrote, never `--host`/
/// `QBZD_HOST` — same reasoning as `login`/`logout`). Reads the opt-in
/// `[server] token` the same way `cli/client.rs::resolve_token` does for the
/// local target, so a token-protected daemon still gets nudged.
fn nudge(roots: &ProfileRoots) -> bool {
let host = crate::login::nudge_host(roots);
let token = local_token(roots);
crate::login::nudge_reload(&host, token.as_deref())
}
fn local_token(roots: &ProfileRoots) -> Option<String> {
if let Ok(t) = std::env::var("QBZD_TOKEN") {
if !t.is_empty() {
return Some(t);
}
}
crate::config::QbzdConfig::load(&roots.config.join("qbzd.toml"))
.ok()
.and_then(|(c, _)| c.server.token)
.filter(|t| !t.trim().is_empty())
}
// ============================ CLI entry points ============================
/// `qbzd settings show [--json]` (⬇). `--json`: `{"audio.backend": "alsa", ...}`
/// — every value the plain string `settings set` would accept back (module
/// doc). Exit: 0 · 1 (a store failed to open/read).
pub fn show(json: bool, roots: &ProfileRoots) -> i32 {
let values = match read_all(roots) {
Ok(v) => v,
Err(e) => {
eprintln!("error: {e}");
return 1;
}
};
if json {
let mut map = serde_json::Map::with_capacity(values.len());
for (k, v) in &values {
map.insert((*k).to_string(), serde_json::Value::String(v.clone()));
}
println!("{}", serde_json::to_string(&serde_json::Value::Object(map)).unwrap_or_default());
} else {
let width = values.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (k, v) in &values {
println!("{k:width$} = {v}");
}
}
0
}
/// `qbzd settings set <KEY> <VALUE>` (⬇). Unknown key → exit 2 listing valid
/// keys; invalid value for a known key → exit 2 naming the valid values.
/// Writes always succeed locally before any daemon contact is attempted
/// (§2.4 daemon-down capable); daemon-down prints `changes apply when the
/// daemon starts` (this task's brief, verbatim) instead of failing.
pub fn set(roots: &ProfileRoots, key: &str, value: &str) -> i32 {
let class = match write_one(roots, key, value) {
Ok(c) => c,
Err(e) => {
eprintln!("error: {}", e.trim_end());
return 2;
}
};
if nudge(roots) {
let hint = match class {
ApplyClass::Reinit => " (daemon reinitialized the output device)",
ApplyClass::Reload | ApplyClass::None => "",
};
println!("{key} = {value}{hint}");
} else {
println!("{key} = {value}");
println!("changes apply when the daemon starts");
}
0
}
/// `qbzd qconnect enable` (⬇). Writes `startup_mode = on`, nudges reload.
/// Human line verbatim per 02 §2.2 (device name interpolated).
pub fn qconnect_enable(roots: &ProfileRoots) -> i32 {
let db = qconnect_db(roots);
qconnect_kv::save_startup_mode_at(&db, qconnect_app::QconnectStartupMode::On);
nudge(roots);
let name = qconnect_kv::resolve_qconnect_friendly_name(qconnect_kv::load_device_name_at(&db).as_deref());
println!("qconnect enabled — device \"{name}\" will appear in the Qobuz app once logged in");
0
}
/// `qbzd qconnect disable` (⬇). Writes `startup_mode = off`, nudges reload
/// (disconnects the live session, per `daemon::reload_qconnect`).
pub fn qconnect_disable(roots: &ProfileRoots) -> i32 {
let db = qconnect_db(roots);
qconnect_kv::save_startup_mode_at(&db, qconnect_app::QconnectStartupMode::Off);
nudge(roots);
println!("qconnect disabled");
0
}
/// `qbzd qconnect name "Living Room"` (⬇). Empty clears the override back to
/// the hostname default (desktop write semantics preserved, 03-setup-tui.md
/// §3.4). Applies on the NEXT connection, never forces a reconnect
/// (`daemon::reload_qconnect` / `QconnectControl::refresh_device_name`).
pub fn qconnect_name(roots: &ProfileRoots, name: &str) -> i32 {
let db = qconnect_db(roots);
qconnect_kv::persist_device_name_at(&db, parse_output_device(name).as_deref());
nudge(roots);
let effective = qconnect_kv::resolve_qconnect_friendly_name(parse_output_device(name).as_deref());
println!("qconnect device name set to \"{effective}\" — applies on the next connection");
0
}
/// `qbzd config path` (⬇). Process roots + the credential file + the
/// (conventional, not necessarily yet installed — T14) systemd user unit
/// path. No `--json` (02 §2.2 gives none for this subverb).
pub fn config_path(roots: &ProfileRoots) -> i32 {
println!("config : {}", roots.config.display());
println!("data : {}", roots.data.display());
println!("cache : {}", roots.cache.display());
println!("cred : {}", roots.config.join(".qbz-oauth-token").display());
println!("unit : {}", unit_path().display());
0
}
fn unit_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("systemd/user/qbzd.service")
}
/// `qbzd config show [--json]` (⬇) — effective `qbzd.toml`, process concerns
/// only (01-architecture.md §10.1; engine settings live in `settings show`).
/// Keys the file doesn't set are annotated `(default)` in human mode.
pub fn config_show(json: bool, roots: &ProfileRoots) -> i32 {
let path = roots.config.join("qbzd.toml");
let (cfg, _warns) = match crate::config::QbzdConfig::load(&path) {
Ok(v) => v,
Err(e) => {
eprintln!("error: {e}");
return 1;
}
};
if json {
println!("{}", serde_json::to_string(&cfg).unwrap_or_default());
return 0;
}
let present = present_keys(&path);
let line = |label: &str, dotted: &str, value: String| {
let marker = if present.contains(dotted) { "" } else { " (default)" };
println!("{label:<24}= {value}{marker}");
};
line("config_version", "config_version", cfg.config_version.to_string());
line(
"data_root",
"data_root",
cfg.data_root.clone().unwrap_or_else(|| "(auto)".to_string()),
);
line("server.bind", "server.bind", cfg.server.bind.clone());
line("server.port", "server.port", cfg.server.port.to_string());
line(
"server.token",
"server.token",
match &cfg.server.token {
Some(t) if !t.trim().is_empty() => "(set)".to_string(),
_ => "(empty = open)".to_string(),
},
);
line("log.level", "log.level", cfg.log.level.clone());
line("mpris.enabled", "mpris.enabled", cfg.mpris.enabled.to_string());
0
}
/// Which dotted config keys the on-disk `qbzd.toml` actually sets (vs.
/// defaulted) — a missing/unreadable file means every key is `(default)`.
fn present_keys(path: &Path) -> std::collections::HashSet<String> {
let mut out = std::collections::HashSet::new();
let Ok(text) = std::fs::read_to_string(path) else {
return out;
};
let Ok(value) = toml::from_str::<toml::Value>(&text) else {
return out;
};
if let toml::Value::Table(top) = &value {
for (k, v) in top {
if let toml::Value::Table(inner) = v {
for ik in inner.keys() {
out.insert(format!("{k}.{ik}"));
}
} else {
out.insert(k.clone());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch_roots(name: &str) -> ProfileRoots {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!(
"qbzd-cli-settings-{name}-{}-{nonce}",
std::process::id()
));
ProfileRoots {
config: base.join("config"),
data: base.join("data"),
cache: base.join("cache"),
}
}
fn cleanup(roots: &ProfileRoots) {
let _ = std::fs::remove_dir_all(roots.data.parent().unwrap_or(&roots.data));
}
// ------------------------- key table integrity -------------------------
#[test]
fn unknown_key_is_rejected_and_lists_every_valid_key() {
let roots = scratch_roots("unknown-key");
let err = write_one(&roots, "audio.bogus", "x").unwrap_err();
assert!(err.contains("unknown setting key 'audio.bogus'"), "{err}");
for (k, _) in KEY_TABLE {
assert!(err.contains(k), "missing '{k}' from the listed keys:\n{err}");
}
cleanup(&roots);
}
#[test]
fn key_table_has_no_duplicate_keys() {
let mut seen = std::collections::HashSet::new();
for (k, _) in KEY_TABLE {
assert!(seen.insert(*k), "duplicate canonical key: {k}");
}
}
#[test]
fn show_json_round_trips_into_set_for_every_canonical_key() {
// The brief's Step 1 property: `settings show --json` includes every
// key `settings set` accepts, AND the value it reports for a key is
// itself a valid `set` input for that same key (a real functional
// round-trip against temp on-disk stores, not just "same key names").
let roots = scratch_roots("roundtrip");
let values = read_all(&roots).expect("read_all opens fresh stores with defaults");
assert_eq!(values.len(), KEY_TABLE.len(), "read_all must cover every canonical key");
for (key, value) in &values {
// The one documented exception (03-setup-tui.md §3.3.2): a fresh
// (or desktop-imported) store's `quality_fallback_behavior`
// column defaults to `"ask"`, which `set` correctly REJECTS —
// "the daemon has no one to ask... the TUI never writes ask".
// `show` must still be able to READ it; the round-trip property
// is deliberately one-way for this single value.
if *key == "audio.quality_fallback_behavior" && value == "ask" {
continue;
}
write_one(&roots, key, value)
.unwrap_or_else(|e| panic!("show's own value for '{key}' ('{value}') was rejected by set: {e}"));
}
cleanup(&roots);
}
#[test]
fn set_then_show_persists_across_a_fresh_store_open() {
let roots = scratch_roots("persist");
write_one(&roots, "audio.backend", "alsa").expect("set backend");
write_one(&roots, "audio.exclusive_mode", "true").expect("set exclusive");
write_one(&roots, "playback.quality", "cd").expect("set quality");
write_one(&roots, "playback.autoplay", "track_only").expect("set autoplay");
write_one(&roots, "qconnect.device_name", "Kitchen").expect("set device name");
write_one(&roots, "qconnect.startup_mode", "on").expect("set startup mode");
let values: std::collections::HashMap<_, _> = read_all(&roots).unwrap().into_iter().collect();
assert_eq!(values["audio.backend"], "alsa");
assert_eq!(values["audio.exclusive_mode"], "true");
assert_eq!(values["playback.quality"], "cd");
assert_eq!(values["playback.autoplay"], "track_only");
assert_eq!(values["qconnect.device_name"], "Kitchen");
assert_eq!(values["qconnect.startup_mode"], "on");
cleanup(&roots);
}
#[test]
fn qconnect_device_name_empty_clears_to_default() {
let roots = scratch_roots("qc-clear");
write_one(&roots, "qconnect.device_name", "Studio").expect("set name");
write_one(&roots, "qconnect.device_name", "").expect("clear name");
let values: std::collections::HashMap<_, _> = read_all(&roots).unwrap().into_iter().collect();
assert_eq!(values["qconnect.device_name"], "system");
cleanup(&roots);
}
// ------------------------------ value parsing ------------------------------
#[test]
fn parse_bool_accepts_common_spellings_and_rejects_garbage() {
assert_eq!(parse_bool("true"), Ok(true));
assert_eq!(parse_bool("On"), Ok(true));
assert_eq!(parse_bool("1"), Ok(true));
assert_eq!(parse_bool("false"), Ok(false));
assert_eq!(parse_bool("Off"), Ok(false));
assert!(parse_bool("maybe").is_err());
}
#[test]
fn parse_backend_accepts_the_five_concrete_backends_only() {
assert_eq!(parse_backend("alsa"), Ok(Some(AudioBackendType::Alsa)));
assert_eq!(parse_backend("PipeWire"), Ok(Some(AudioBackendType::PipeWire)));
assert_eq!(parse_backend("system"), Ok(Some(AudioBackendType::SystemDefault)));
assert!(parse_backend("auto").is_err(), "Auto is omitted in v1 (03-setup-tui.md §3.2.1)");
assert!(parse_backend("bogus").is_err());
}
#[test]
fn parse_quality_fallback_behavior_rejects_ask() {
assert_eq!(parse_quality_fallback_behavior("always_fallback"), Ok("always_fallback".into()));
assert_eq!(parse_quality_fallback_behavior("always_skip"), Ok("always_skip".into()));
let err = parse_quality_fallback_behavior("ask").unwrap_err();
assert!(err.contains("needs a UI"), "{err}");
}
#[test]
fn parse_streaming_quality_matches_the_four_canonical_keys() {
for ok in ["mp3", "cd", "hires", "hires_plus"] {
assert!(parse_streaming_quality(ok).is_ok(), "{ok}");
}
assert!(parse_streaming_quality("hires192").is_err(), "not a real key — see report");
}
#[test]
fn parse_autoplay_matches_the_playback_preferences_wire_values() {
assert_eq!(parse_autoplay("continue").unwrap(), AutoplayMode::ContinueWithinSource);
assert_eq!(parse_autoplay("track_only").unwrap(), AutoplayMode::PlayTrackOnly);
assert_eq!(parse_autoplay("infinite").unwrap(), AutoplayMode::InfiniteRadio);
assert!(parse_autoplay("bogus").is_err());
}
#[test]
fn parse_opt_u32_clears_on_none_or_empty() {
assert_eq!(parse_opt_u32(""), Ok(None));
assert_eq!(parse_opt_u32("none"), Ok(None));
assert_eq!(parse_opt_u32("192000"), Ok(Some(192_000)));
assert!(parse_opt_u32("loud").is_err());
}
#[test]
fn parse_dsd_mode_rejects_unknown_modes() {
for ok in ["convert", "dop", "native"] {
assert!(parse_dsd_mode(ok).is_ok());
}
assert!(parse_dsd_mode("bogus").is_err());
}
#[test]
fn parse_stream_buffer_seconds_enforces_1_to_10() {
assert_eq!(parse_stream_buffer_seconds("2"), Ok(2));
assert_eq!(parse_stream_buffer_seconds("10"), Ok(10));
assert!(parse_stream_buffer_seconds("0").is_err());
assert!(parse_stream_buffer_seconds("11").is_err());
}
#[test]
fn present_keys_empty_for_a_missing_file() {
let roots = scratch_roots("config-missing");
let keys = present_keys(&roots.config.join("qbzd.toml"));
assert!(keys.is_empty());
cleanup(&roots);
}
#[test]
fn present_keys_reports_nested_and_top_level_keys() {
let roots = scratch_roots("config-present");
std::fs::create_dir_all(&roots.config).unwrap();
std::fs::write(
roots.config.join("qbzd.toml"),
"config_version = 1\n[server]\nport = 9000\n",
)
.unwrap();
let keys = present_keys(&roots.config.join("qbzd.toml"));
assert!(keys.contains("config_version"));
assert!(keys.contains("server.port"));
assert!(!keys.contains("server.bind"));
cleanup(&roots);
}
}
+409 -9
View File
@@ -84,12 +84,19 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
// (T9/T10) splice after this, reading `booted`.
let prefs = daemon_prefs::load_at(&roots.data);
let quality = playback_driver::quality_from_key(&prefs.streaming_quality);
// T11: a live-updatable cell, not a value captured once — `settings/reload`
// re-reads `daemon_prefs` and writes here so the driver's OWN auto-advance
// (gapless prefetch, natural-end advance) picks up a `playback.quality`
// change without a restart. Manual play/next/prev already re-read
// `daemon_prefs` fresh every call (api/playback.rs::resolve_quality); this
// cell is what makes the BACKGROUND driver loop equally live.
let quality_cell = Arc::new(std::sync::Mutex::new(quality));
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
// T10 (§7.2): the driver's `ReportEdge` action pulses this Notify; the
// QConnect report scheduler (step 12) waits on it. Created BEFORE the driver
// so `on_edge` can capture it, and shared with `qconnect::start`.
let report_notify = Arc::new(tokio::sync::Notify::new());
let deps = build_driver_deps(quality, booted.shared.clone(), report_notify.clone());
let deps = build_driver_deps(quality_cell.clone(), booted.shared.clone(), report_notify.clone());
let driver = tokio::spawn(playback_driver::run_driver(
booted.runtime.clone(),
deps,
@@ -110,6 +117,17 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
// [server] token (None = open). 12. QConnect (T9/T10) splices after this.
let api_audio = qbz_audio::settings::AudioSettingsStore::new_at(&roots.data)
.map_err(|e| format!("error: could not open the audio settings store for the API: {e}"))?;
// T11: the reload handler's "did a routing-critical field change" diff
// needs a starting point — seed it from what's on disk right now (the same
// settings the Player was constructed with at step 6/7).
let initial_audio_settings = api_audio.get_settings().unwrap_or_default();
// T11: the running QConnect service is not constructed until step 12
// (AFTER the API starts serving, per the normative boot order, 01 §8.1) —
// this cell lets the reload route reach it anyway: empty until `qconnect
// ::start` below populates it, harmlessly no-op'd by the reload handler in
// the vanishingly small window before that.
let qconnect_control: Arc<std::sync::OnceLock<crate::qconnect::QconnectControl>> =
Arc::new(std::sync::OnceLock::new());
let api = crate::api::serve(
bound,
crate::api::ApiState {
@@ -121,6 +139,9 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
rt: tokio::runtime::Handle::current(),
audio: api_audio,
devices: std::sync::Mutex::new(crate::api::DeviceCache::default()),
audio_snapshot: std::sync::Mutex::new(initial_audio_settings),
quality: quality_cell.clone(),
qconnect_control: qconnect_control.clone(),
},
);
log::info!("control API listening on {bind_addr}");
@@ -138,6 +159,10 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
&roots,
report_notify,
);
// T11: publish the reload route's handle onto the running service now that
// it exists (`connect`/`disconnect`/device-name refresh — see
// `qconnect::QconnectControl`).
let _ = qconnect_control.set(qconnect.control());
// 13. park on SIGTERM/SIGINT. NO startup audio "hygiene": both candidate
// fns are verified no-ops from a fresh process and re-adding them is the
@@ -239,7 +264,7 @@ async fn boot(roots: &ProfileRoots, cfg: &QbzdConfig, warn_count: usize) -> Resu
qbz_log::register_secret(token.clone());
match runtime.core().login_with_token(&token).await {
Ok(session) => {
restore_activate(&runtime, &shared, roots, session).await?;
restore_activate(&runtime, &shared, roots, session, &token).await?;
// 9½. session restore (queue/position) PAUSED: the daemon's
// session store IS its queue persistence, so a restart
// comes back with the queue armed but not auto-playing.
@@ -278,14 +303,19 @@ async fn boot(roots: &ProfileRoots, cfg: &QbzdConfig, warn_count: usize) -> Resu
/// pulses the QConnect report `Notify` so the report scheduler reports on the
/// same transition/periodic edges the driver detects (§7.2).
fn build_driver_deps(
quality: qbz_models::Quality,
quality_cell: Arc<std::sync::Mutex<qbz_models::Quality>>,
shared: Arc<Mutex<DaemonShared>>,
report_notify: Arc<tokio::sync::Notify>,
) -> DriverDeps {
let latch_shared = shared.clone();
let tick_shared = shared;
DriverDeps {
quality: Arc::new(move || quality),
quality: Arc::new(move || {
quality_cell
.lock()
.map(|q| *q)
.unwrap_or(qbz_models::Quality::UltraHiRes)
}),
// T10: signal the report scheduler on every ReportEdge. `notify_one`
// stores a single permit if the scheduler is mid-report, so no edge is
// lost and rapid edges coalesce into one report.
@@ -354,11 +384,12 @@ fn spawn_queue_persist(
/// Activate the per-user session against DAEMON paths (§8.1-9): inject the
/// session into the core, then `activate_at` the runtime with per-user daemon
/// data/cache directories — never the desktop `UserDataPaths`.
async fn restore_activate(
pub(crate) async fn restore_activate(
runtime: &Arc<AppRuntime<DaemonAdapter>>,
shared: &Arc<Mutex<DaemonShared>>,
roots: &ProfileRoots,
session: UserSession,
token: &str,
) -> Result<(), String> {
runtime
.core()
@@ -373,6 +404,12 @@ async fn restore_activate(
)
.await?;
set_logged_in(shared, &session);
// T11: remember which token this activation applied so a later
// `POST /api/settings/reload` can tell "same token" from "new token" and
// skip a redundant re-login on every unrelated settings nudge.
if let Ok(mut s) = shared.lock() {
s.credential_fingerprint = Some(crate::state::token_fingerprint(token));
}
Ok(())
}
@@ -391,17 +428,20 @@ fn new_shared(cfg: &QbzdConfig) -> Arc<Mutex<DaemonShared>> {
started_at: std::time::Instant::now(),
startup_warnings: 0,
qconnect: QconnectStatus::default(),
credential_fingerprint: None,
}))
}
/// Enter NeedsAuth. `err = None` = no saved credentials at all (the common
/// first-run case); `Some(e)` = an explicit auth rejection just cleared the
/// token. Either way the daemon STAYS UP (§6.2) and names the fix.
fn set_needs_auth(shared: &Arc<Mutex<DaemonShared>>, err: Option<CoreError>) {
pub(crate) fn set_needs_auth(shared: &Arc<Mutex<DaemonShared>>, err: Option<CoreError>) {
if let Ok(mut s) = shared.lock() {
s.auth = AuthState::NeedsAuth;
s.user_id = None;
s.subscription = None;
// T11: NeedsAuth has no applied token by definition.
s.credential_fingerprint = None;
}
match err {
None => log::info!("Not logged in — run 'qbzd setup' (or 'qbzd login')"),
@@ -429,7 +469,7 @@ fn set_logged_in(shared: &Arc<Mutex<DaemonShared>>, session: &UserSession) {
/// Latch an auth error so a `status` call remains diagnosable after the fact
/// (§9.4 — drain-once channels alone cannot answer "why did the music stop?").
fn latch_auth_error(shared: &Arc<Mutex<DaemonShared>>, e: &CoreError) {
pub(crate) fn latch_auth_error(shared: &Arc<Mutex<DaemonShared>>, e: &CoreError) {
if let Ok(mut s) = shared.lock() {
s.last_errors.auth = Some(format!("token rejected by Qobuz — cleared ({e})"));
}
@@ -440,7 +480,7 @@ fn latch_auth_error(shared: &Arc<Mutex<DaemonShared>>, e: &CoreError) {
/// failures, offline gate, 5xx, rate limiting and parse errors all return false
/// so the saved token is KEPT (mirrors crates/qbz/src/auth.rs:215-230; the
/// taxonomy — not the variant list — is the normative part).
fn is_auth_rejection(error: &CoreError) -> bool {
pub(crate) fn is_auth_rejection(error: &CoreError) -> bool {
matches!(
error,
CoreError::Api(
@@ -471,7 +511,9 @@ fn spawn_auth_retry(
log::info!("session restore retry {}/{}", i + 1, SCHEDULE_SECS.len());
match runtime.core().login_with_token(&token).await {
Ok(session) => {
if let Err(e) = restore_activate(&runtime, &shared, &roots, session).await {
if let Err(e) =
restore_activate(&runtime, &shared, &roots, session, &token).await
{
log::warn!("session activation after retry failed: {e}");
}
return;
@@ -577,6 +619,228 @@ async fn wait_for_signal() {
}
}
// ============================ T11: settings reload ============================
// `POST /api/settings/reload` (02-cli-and-api.md §3.3.17; `crate::api::settings
// ::reload` is the thin HTTP wrapper) re-reads every engine store and applies
// what changed: audio (routing-critical -> `Player::reinit_device`, the rest ->
// `Player::reload_settings`), the daemon's own streaming-quality cell (the
// driver's background auto-advance), the QConnect KV (device-name cache +
// connect/disconnect reconciliation), and finally the credential file (absent
// -> NeedsAuth; new -> session restore). Never re-reads `qbzd.toml` (§3.1.2 —
// process config is boot-only). Response = the post-reload `/api/status` body,
// composed by the caller — zero new shapes (03-setup-tui.md §4.3: the
// reinit/reload narrative is composed CLIENT-side from the CLI's own copy of
// the Apply-ladder classification, never carried on the wire).
/// The single entry point the HTTP route calls. Order matters only at the
/// margin (independent domains): audio/quality/qconnect-KV first, credentials
/// last, so a login/logout settles the auth state before QConnect decides
/// whether to (re)connect against it.
pub(crate) async fn reload(state: &crate::api::ApiState) {
reload_audio(state);
reload_quality(state);
reload_credentials(
&state.runtime,
&state.shared,
&state.roots,
state.qconnect_control.get(),
)
.await;
reload_qconnect(state).await;
}
/// Re-read `audio_settings.db` and apply it to the live `Player`. A struct
/// refresh (`reload_settings`) always happens; the output device is ADDITIONALLY
/// reinitialized only when a routing-critical field actually changed since the
/// last reload (mirrors the desktop's `Apply::Reinit` — `qbz/src/settings.rs:
/// 87-94`, per-key classification `:877-967,1134-1290`; 03-setup-tui.md §4.3
/// lists the same 9 fields).
pub(crate) fn reload_audio(state: &crate::api::ApiState) {
let fresh = match state.audio.get_settings() {
Ok(s) => s,
Err(e) => {
log::warn!("[reload] could not re-read audio settings: {e}");
return;
}
};
let player = state.runtime.core().player();
if let Err(e) = player.reload_settings(fresh.clone()) {
log::warn!("[reload] player.reload_settings failed: {e}");
}
let needs_reinit = state
.audio_snapshot
.lock()
.map(|old| audio_routing_changed(&old, &fresh))
.unwrap_or(false);
if needs_reinit {
log::info!("[reload] routing-critical audio field changed — reinitializing the output device");
if let Err(e) = player.reinit_device(fresh.output_device.clone()) {
log::warn!("[reload] player.reinit_device failed: {e}");
}
}
if let Ok(mut snap) = state.audio_snapshot.lock() {
*snap = fresh;
}
}
/// The Reinit-class field set (03-setup-tui.md §4.3 / `qbz/src/settings.rs:
/// 877-967,1134-1290`): backend, device, ALSA plugin, DSD mode, max sample
/// rate, exclusive mode, DAC passthrough, hardware volume, lock-output
/// (`skip_sink_switch`). Every other `AudioSettings` field is Reload-class —
/// `player.reload_settings` above already covers it unconditionally.
pub(crate) fn audio_routing_changed(
old: &qbz_audio::settings::AudioSettings,
new: &qbz_audio::settings::AudioSettings,
) -> bool {
old.backend_type != new.backend_type
|| old.output_device != new.output_device
|| old.alsa_plugin != new.alsa_plugin
|| old.alsa_hardware_volume != new.alsa_hardware_volume
|| old.exclusive_mode != new.exclusive_mode
|| old.dac_passthrough != new.dac_passthrough
|| old.skip_sink_switch != new.skip_sink_switch
|| old.dsd_mode != new.dsd_mode
|| old.device_max_sample_rate != new.device_max_sample_rate
}
/// Re-read `daemon_prefs.streaming_quality` into the live cell the driver's
/// background auto-advance reads (`daemon.rs::run`'s `quality_cell`). Manual
/// play/next/prev already re-read `daemon_prefs` fresh every call
/// (`api/playback.rs::resolve_quality`); this is what makes the passive
/// natural-end-of-track advance equally live.
pub(crate) fn reload_quality(state: &crate::api::ApiState) {
let prefs = daemon_prefs::load_at(&state.roots.data);
let fresh = playback_driver::quality_from_key(&prefs.streaming_quality);
if let Ok(mut q) = state.quality.lock() {
*q = fresh;
}
}
/// Re-cache the QConnect device-name override from the daemon-root KV (so the
/// NEXT connect uses whatever `qbzd qconnect name` / `settings set
/// qconnect.device_name` most recently wrote — 03-setup-tui.md §3.4: "applies
/// on the next connection", never forcing a reconnect just for a rename), then
/// reconcile the connect/disconnect state against the freshly-read
/// `startup_mode` (`qbzd qconnect enable|disable` — idempotent either way, see
/// `qconnect::QconnectControl`). A no-op before step 12 populates the cell.
pub(crate) async fn reload_qconnect(state: &crate::api::ApiState) {
let Some(qc) = state.qconnect_control.get() else {
return;
};
let db = state.roots.data.join("qconnect_settings.db");
qc.refresh_device_name(&db).await;
let mode = crate::qconnect::transport::load_startup_mode_at(&db);
let should_connect = qconnect_app::compute_effective_startup(mode, None, None);
if should_connect {
if let Err(e) = qc.connect().await {
log::info!("[reload] qconnect connect deferred: {e}");
}
} else if let Err(e) = qc.disconnect().await {
log::warn!("[reload] qconnect disconnect failed: {e}");
}
}
/// What the freshly-read credential file implies for the live session — pure
/// decision, unit-tested with no IO/network; [`reload_credentials`] just
/// executes whichever variant this returns.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CredentialAction {
/// The file matches what's already applied (or both are absent/NeedsAuth
/// already) — no network call, no state churn on an unrelated nudge.
NoOp,
/// The file is now empty but the daemon thinks it's logged in — tear the
/// session down (mirrors what `qbzd logout` does to a running daemon,
/// 02 §2.2: "QConnect session torn down, playback stopped").
EnterNeedsAuth,
/// A token is on disk that is not the one currently applied (fresh login
/// out of NeedsAuth, a retry while Restoring, or an account switch) —
/// validate and activate it.
Apply(String),
}
pub(crate) fn decide_credential_action(
current_auth: AuthState,
current_fingerprint: Option<u64>,
file_token: Option<String>,
) -> CredentialAction {
match file_token {
None => {
if current_auth == AuthState::NeedsAuth {
CredentialAction::NoOp
} else {
CredentialAction::EnterNeedsAuth
}
}
Some(token) => {
let fp = crate::state::token_fingerprint(&token);
if current_auth == AuthState::LoggedIn && current_fingerprint == Some(fp) {
CredentialAction::NoOp
} else {
CredentialAction::Apply(token)
}
}
}
}
/// Re-read the credential file and reconcile the live session against it (02
/// §3.3.17: "absent → NeedsAuth transition; new → session restore"; taxonomy
/// shared with boot, §6.2). `qconnect` is `None` only in the brief boot window
/// before step 12 populates the cell — the teardown branch just skips the
/// QConnect disconnect then (there is no session for it to hold yet).
pub(crate) async fn reload_credentials(
runtime: &Arc<AppRuntime<DaemonAdapter>>,
shared: &Arc<Mutex<DaemonShared>>,
roots: &ProfileRoots,
qconnect: Option<&crate::qconnect::QconnectControl>,
) {
let file_token = match qbz_credentials::load_oauth_token_at(&roots.config) {
Ok(t) => t,
Err(e) => {
log::warn!("[reload] could not read the credential file: {e}");
return;
}
};
let (current_auth, current_fp) = match shared.lock() {
Ok(s) => (s.auth, s.credential_fingerprint),
Err(_) => return,
};
match decide_credential_action(current_auth, current_fp, file_token) {
CredentialAction::NoOp => {}
CredentialAction::EnterNeedsAuth => {
log::info!("[reload] credential file cleared — tearing the session down (NeedsAuth)");
if let Some(qc) = qconnect {
let _ = qc.disconnect().await;
}
let _ = runtime.core().stop();
let _ = runtime.core().logout().await;
let _ = runtime.deactivate().await;
set_needs_auth(shared, None);
}
CredentialAction::Apply(token) => {
qbz_log::register_secret(token.clone());
match runtime.core().login_with_token(&token).await {
Ok(session) => {
match restore_activate(runtime, shared, roots, session, &token).await {
Ok(()) => {
playback_driver::restore_session_paused(runtime.as_ref()).await;
}
Err(e) => log::warn!("[reload] session activation failed: {e}"),
}
}
Err(e) if is_auth_rejection(&e) => {
let _ = qbz_credentials::clear_oauth_token_at(&roots.config);
latch_auth_error(shared, &e);
set_needs_auth(shared, Some(e));
}
Err(e) => {
log::warn!("[reload] session restore deferred (network-class): {e}");
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -638,4 +902,140 @@ mod tests {
assert_eq!(s.user_id, Some(1234567));
assert_eq!(s.subscription.as_deref(), Some("studio"));
}
// ======================= T11: settings/reload =======================
#[test]
fn credential_action_noop_when_absent_and_already_needs_auth() {
assert_eq!(
decide_credential_action(AuthState::NeedsAuth, None, None),
CredentialAction::NoOp
);
}
#[test]
fn credential_action_enters_needs_auth_when_file_cleared_while_logged_in() {
// The `qbzd logout` case: file now absent, daemon still thinks it's
// LoggedIn (or mid-Restoring) — must tear down.
let fp = crate::state::token_fingerprint("old-token");
assert_eq!(
decide_credential_action(AuthState::LoggedIn, Some(fp), None),
CredentialAction::EnterNeedsAuth
);
assert_eq!(
decide_credential_action(AuthState::Restoring, None, None),
CredentialAction::EnterNeedsAuth
);
}
#[test]
fn credential_action_noop_when_token_unchanged_and_logged_in() {
let fp = crate::state::token_fingerprint("same-token");
assert_eq!(
decide_credential_action(AuthState::LoggedIn, Some(fp), Some("same-token".into())),
CredentialAction::NoOp
);
}
#[test]
fn credential_action_applies_new_token_out_of_needs_auth() {
assert_eq!(
decide_credential_action(AuthState::NeedsAuth, None, Some("fresh-token".into())),
CredentialAction::Apply("fresh-token".into())
);
}
#[test]
fn credential_action_applies_changed_token_while_already_logged_in() {
// Account-switch / re-login case: fingerprint differs even though the
// daemon is already LoggedIn.
let old_fp = crate::state::token_fingerprint("old-token");
assert_eq!(
decide_credential_action(AuthState::LoggedIn, Some(old_fp), Some("new-token".into())),
CredentialAction::Apply("new-token".into())
);
}
#[test]
fn credential_action_applies_when_fingerprint_missing_even_if_marked_logged_in() {
// Defensive: a LoggedIn state with no recorded fingerprint (should not
// happen post-T11, but a stale/older state) must not be treated as a
// match — never silently skip a real token on disk.
assert_eq!(
decide_credential_action(AuthState::LoggedIn, None, Some("token".into())),
CredentialAction::Apply("token".into())
);
}
fn base_audio_settings() -> qbz_audio::settings::AudioSettings {
qbz_audio::settings::AudioSettings::default()
}
#[test]
fn audio_routing_changed_false_when_nothing_moved() {
let a = base_audio_settings();
let b = base_audio_settings();
assert!(!audio_routing_changed(&a, &b));
}
#[test]
fn audio_routing_changed_true_for_each_reinit_class_field() {
let base = base_audio_settings();
let mut backend = base.clone();
backend.backend_type = Some(qbz_audio::AudioBackendType::Alsa);
assert!(audio_routing_changed(&base, &backend), "backend_type");
let mut device = base.clone();
device.output_device = Some("hw:CARD=D30,DEV=0".into());
assert!(audio_routing_changed(&base, &device), "output_device");
let mut plugin = base.clone();
plugin.alsa_plugin = Some(qbz_audio::AlsaPlugin::PlugHw);
assert!(audio_routing_changed(&base, &plugin), "alsa_plugin");
let mut hw_vol = base.clone();
hw_vol.alsa_hardware_volume = !base.alsa_hardware_volume;
assert!(audio_routing_changed(&base, &hw_vol), "alsa_hardware_volume");
let mut excl = base.clone();
excl.exclusive_mode = !base.exclusive_mode;
assert!(audio_routing_changed(&base, &excl), "exclusive_mode");
let mut pass = base.clone();
pass.dac_passthrough = !base.dac_passthrough;
assert!(audio_routing_changed(&base, &pass), "dac_passthrough");
let mut lock_out = base.clone();
lock_out.skip_sink_switch = !base.skip_sink_switch;
assert!(audio_routing_changed(&base, &lock_out), "skip_sink_switch");
let mut dsd = base.clone();
dsd.dsd_mode = "dop".to_string();
assert!(audio_routing_changed(&base, &dsd), "dsd_mode");
let mut rate = base.clone();
rate.device_max_sample_rate = Some(192_000);
assert!(audio_routing_changed(&base, &rate), "device_max_sample_rate");
}
#[test]
fn audio_routing_changed_false_for_reload_class_fields_only() {
// Changing ONLY Reload-class fields must never trip a reinit.
let base = base_audio_settings();
let mut reload_only = base.clone();
reload_only.gapless_enabled = !base.gapless_enabled;
reload_only.stream_first_track = !base.stream_first_track;
reload_only.stream_buffer_seconds = 7;
reload_only.streaming_only = !base.streaming_only;
reload_only.limit_quality_to_device = !base.limit_quality_to_device;
reload_only.allow_quality_fallback = !base.allow_quality_fallback;
reload_only.quality_fallback_behavior = "always_skip".to_string();
reload_only.normalization_enabled = !base.normalization_enabled;
reload_only.normalization_target_lufs = -18.0;
reload_only.pw_force_bitperfect = !base.pw_force_bitperfect;
reload_only.reserve_dac_while_running = !base.reserve_dac_while_running;
reload_only.sync_audio_on_startup = !base.sync_audio_on_startup;
assert!(!audio_routing_changed(&base, &reload_only));
}
}
+1 -1
View File
@@ -388,7 +388,7 @@ fn finalize(roots: &ProfileRoots, session: &UserSession) -> Result<(), LoginErro
/// The local daemon's reload address. Credentials are written to the LOCAL
/// config root, so the daemon to nudge is always local; its port comes from the
/// same `qbzd.toml` the daemon reads (default 8182).
fn nudge_host(roots: &ProfileRoots) -> String {
pub(crate) fn nudge_host(roots: &ProfileRoots) -> String {
let port = crate::config::QbzdConfig::load(&roots.config.join("qbzd.toml"))
.map(|(c, _)| c.server.port)
.unwrap_or(8182);
+27
View File
@@ -242,6 +242,33 @@ async fn main() {
}
}
}
Cmd::Settings { cmd } => {
let roots = login_roots();
match cmd {
SettingsCmd::Show { json } => cli::settings::show(json, &roots),
SettingsCmd::Set { key, value } => cli::settings::set(&roots, &key, &value),
// Export/import land in T12 (04-settings-portability.md).
SettingsCmd::Export { .. } | SettingsCmd::Import { .. } => {
eprintln!("not implemented yet — lands in T12 (04-settings-portability.md)");
1
}
}
}
Cmd::Qconnect { cmd } => {
let roots = login_roots();
match cmd {
QconnectCmd::Enable => cli::settings::qconnect_enable(&roots),
QconnectCmd::Disable => cli::settings::qconnect_disable(&roots),
QconnectCmd::Name { name } => cli::settings::qconnect_name(&roots, &name),
}
}
Cmd::Config { cmd } => {
let roots = login_roots();
match cmd {
ConfigCmd::Path => cli::settings::config_path(&roots),
ConfigCmd::Show { json } => cli::settings::config_show(json, &roots),
}
}
_ => { eprintln!("not implemented yet"); 1 } // burned down task by task
};
std::process::exit(code);
+48
View File
@@ -298,6 +298,17 @@ impl DaemonQconnectService {
Ok(())
}
/// T11 (`POST /api/settings/reload`): re-cache the device-name override from
/// the daemon-root KV so the NEXT `connect()` (whenever that happens) uses
/// whatever `qbzd qconnect name` / `settings set qconnect.device_name` most
/// recently wrote — 03-setup-tui.md §3.4's "applies on the next connection"
/// rule. Does NOT force a reconnect by itself (a rename alone must not
/// bounce an active session).
async fn refresh_device_name(&self, settings_db: &std::path::Path) {
let name = transport::load_device_name_at(settings_db);
*self.custom_device_name.write().await = name;
}
/// Wait until the daemon is Ready (logged in + API initialized), then attempt
/// `connect()` with the bounded [2s, 5s, 15s, 30s] retry schedule. Each
/// `connect()` re-resolves the transport config internally, so a transient
@@ -351,6 +362,33 @@ pub struct QconnectHandle {
report_task: Option<JoinHandle<()>>,
}
/// T11: a `Clone`-able, `Send + Sync` handle onto the running
/// [`DaemonQconnectService`] — what the reload route reaches through
/// `ApiState.qconnect_control` (`Arc<std::sync::OnceLock<QconnectControl>>`,
/// populated by `QconnectHandle::control()` right after `start()` returns).
/// `connect`/`disconnect` are already idempotent on the service (a no-op when
/// already in the target state), so the reload path can call either
/// unconditionally without checking current status first.
#[derive(Clone)]
pub struct QconnectControl(Arc<DaemonQconnectService>);
impl QconnectControl {
pub async fn connect(&self) -> Result<(), String> {
self.0.connect().await
}
pub async fn disconnect(&self) -> Result<(), String> {
self.0.disconnect().await
}
/// Re-cache the device-name override from the daemon-root KV (§ see
/// [`DaemonQconnectService::refresh_device_name`] — applies on the NEXT
/// connect, never forces a reconnect for a rename alone).
pub async fn refresh_device_name(&self, settings_db: &std::path::Path) {
self.0.refresh_device_name(settings_db).await;
}
}
impl QconnectHandle {
/// Connect on demand (T11 `qbzd qconnect enable`).
#[allow(dead_code)]
@@ -364,6 +402,16 @@ impl QconnectHandle {
self.service.disconnect().await
}
/// A cheap, `Clone`-able handle onto the running service — what
/// `daemon.rs`'s `POST /api/settings/reload` route holds (via an
/// `Arc<OnceLock<QconnectControl>>` populated right after `start()`
/// returns, since QConnect boots AFTER the HTTP server per the normative
/// order, 01-architecture.md §8.1 steps 11/12). Carries none of the
/// `JoinHandle`s `QconnectHandle` owns — those stay daemon-shutdown-only.
pub fn control(&self) -> QconnectControl {
QconnectControl(Arc::clone(&self.service))
}
/// §8.2-1: stop the auto-connect watcher and disconnect the session BEFORE
/// the daemon stops playback. Aborts + joins the watcher and the event loop so
/// every `Arc<AppRuntime>` clone this handle owns drops ahead of
+28
View File
@@ -30,6 +30,24 @@ pub struct DaemonShared {
pub started_at: std::time::Instant,
pub startup_warnings: u32,
pub qconnect: QconnectStatus,
/// T11 (`POST /api/settings/reload`, 02 §3.3.17): a fingerprint of the
/// credential-file token currently applied to the live session, so reload
/// can tell "new token on disk" (re-login) from "same token, unrelated
/// nudge" (no-op) without keeping a second copy of the secret in memory.
/// `None` whenever the daemon is not LoggedIn against a known token
/// (cleared alongside every `set_needs_auth`).
pub credential_fingerprint: Option<u64>,
}
/// A non-reversible-in-practice fingerprint of a credential token (SipHash via
/// the stdlib default hasher) — used ONLY to detect "the file changed", never
/// to reconstruct the token. Keeps `DaemonShared` from holding a second live
/// copy of the secret alongside the credential file + the Qobuz client.
pub fn token_fingerprint(token: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
token.hash(&mut hasher);
hasher.finish()
}
#[derive(Debug, Default, Clone, serde::Serialize)]
@@ -96,8 +114,18 @@ mod tests {
started_at: std::time::Instant::now(),
startup_warnings: 0,
qconnect: QconnectStatus::default(),
credential_fingerprint: None,
};
assert_eq!(shared.auth, AuthState::LoggedIn);
assert_eq!(shared.user_id, Some(1234567));
}
#[test]
fn token_fingerprint_is_stable_and_distinguishes_tokens() {
let a = token_fingerprint("token-a");
let a_again = token_fingerprint("token-a");
let b = token_fingerprint("token-b");
assert_eq!(a, a_again);
assert_ne!(a, b);
}
}