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:
9 files changed
+1369
-18
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -6,5 +6,6 @@
|
||||
pub mod client;
|
||||
pub mod copy;
|
||||
pub mod queue;
|
||||
pub mod settings;
|
||||
pub mod status;
|
||||
pub mod transport;
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user