Files
qbz/crates/qbz-app/src/settings/daemon_prefs.rs
T
Filippo VicentiniandClaude Fable 5 ee1bbf9b76 feat(qbzd): run a user hook script on daemon events (hooks.script)
Adds an event-hook facility for headless integrators (moOde, Volumio,
DIY audio boxes): 'qbzd settings set hooks.script /path/to/script' (or
the QBZD_HOOK env var) names an executable the daemon forks once per
daemon event with the event described in QBZ_* environment variables --
the same push-integration contract pleezer (--hook) and shairport-sync
(run_this_...) established, so a box can stop its local player when a
Qobuz Connect session starts and restore state when it ends, without
keeping a poller or SSE client alive.

Getting there required closing a gap on the CoreEvent bus: the playback
variants (TrackStarted, PlaybackStateChanged, PositionUpdated,
VolumeChanged) were defined and consumed -- mpris.rs keeps its widget
fresh from them, scrobble_engine.rs times scrobbles by them -- but
nothing ever emitted them, so those consumer arms were dead and
/api/events (SSE) / 'qbzd watch' carried no playback traffic. A new
events_bridge task wakes on the driver's existing ReportEdge pulse
(plus a 2 s poll fallback for track-id-clearing stops), dedups against
what it last published, and sends the deltas to the bus. This revives
live MPRIS updates and scrobble timing as a side effect.

Also emitted now: QconnectSessionChanged (new CoreEvent variant) from
the qconnect lifecycle latches -- off/connecting/connected/retrying/
exhausted -- and PlaybackError from the driver's stream-error latch, so
integrators can react to a busy audio device at play time.

The hook dispatcher never waits for the script inside the recv loop
(each event forks detached, a reaper task collects the exit status) and
the LoggedIn session payload (auth token) is never forwarded to child
environments. hooks.script validates as absolute-or-empty; the setting
lives in daemon_prefs.json and is deliberately NOT part of the settings
bundle (a machine-local path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 01:57:56 +02:00

137 lines
4.9 KiB
Rust

// crates/qbz-app/src/settings/daemon_prefs.rs
//! The daemon's 2 player prefs (01-architecture.md §10.3). Lives in qbz-app so
//! qbzd AND settings::bundle share one struct (D2). NOT read by the desktop.
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DaemonPrefs {
/// Same key contract as desktop ui_prefs.streaming_quality:
/// "mp3" | "cd" | "hires" | "hires_plus" (crates/qbz/src/ui_prefs.rs:307-308)
pub streaming_quality: String,
/// Restored at boot; NEVER imported (04 §3 — power-amp hazard).
pub volume: f32,
/// Whether the daemon publishes MPRIS system media controls at boot
/// (CONSOLE ext). Default ON; the `QBZD_MPRIS` env var overrides it when
/// set. Toggling this needs a daemon restart (the service spawns at boot).
pub mpris_enabled: bool,
/// Absolute path of an executable the daemon runs on playback/session
/// events (CONSOLE ext), with the event in `QBZ_*` environment variables.
/// Empty = hooks off (the default). The `QBZD_HOOK` env var overrides it
/// when set. Changing it needs a daemon restart (the dispatcher spawns at
/// boot). Machine-local by design — NEVER part of the settings bundle.
pub hook_script: String,
}
impl Default for DaemonPrefs {
fn default() -> Self {
Self {
streaming_quality: "hires_plus".into(),
volume: 0.5,
mpris_enabled: true,
hook_script: String::new(),
}
}
}
pub fn load_at(data_root: &Path) -> DaemonPrefs {
let path = data_root.join("daemon_prefs.json");
match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
Ok(v) => {
// forward-compatible: unknown fields ignored WITH a warning (01 §10.3)
if let Some(obj) = v.as_object() {
for k in obj.keys() {
if k != "streaming_quality"
&& k != "volume"
&& k != "mpris_enabled"
&& k != "hook_script"
{
log::warn!("[daemon_prefs] unknown field ignored: {k}");
}
}
}
serde_json::from_value(v).unwrap_or_default()
}
Err(e) => {
log::warn!("[daemon_prefs] unreadable, using defaults: {e}");
DaemonPrefs::default()
}
},
Err(_) => DaemonPrefs::default(),
}
}
pub fn save_at(prefs: &DaemonPrefs, data_root: &Path) -> Result<(), String> {
std::fs::create_dir_all(data_root).map_err(|e| e.to_string())?;
std::fs::write(
data_root.join("daemon_prefs.json"),
serde_json::to_vec_pretty(prefs).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"qbz-app-daemon-prefs-{name}-{}-{nonce}",
std::process::id()
))
}
#[test]
fn missing_file_returns_defaults() {
let dir = unique_test_dir("missing");
let prefs = load_at(&dir);
assert_eq!(prefs.streaming_quality, "hires_plus");
assert_eq!(prefs.volume, 0.5);
}
#[test]
fn roundtrip_preserves_values_at_tempdir() {
let dir = unique_test_dir("roundtrip");
let prefs = DaemonPrefs {
streaming_quality: "cd".into(),
volume: 0.72,
// Non-default (default is true) so the roundtrip actually exercises
// the field, not just its presence.
mpris_enabled: false,
// Non-default (default is empty) for the same reason.
hook_script: "/usr/local/bin/qbz-hook.sh".into(),
};
save_at(&prefs, &dir).expect("save daemon prefs");
let loaded = load_at(&dir);
assert_eq!(loaded.streaming_quality, "cd");
assert_eq!(loaded.volume, 0.72);
assert!(!loaded.mpris_enabled);
assert_eq!(loaded.hook_script, "/usr/local/bin/qbz-hook.sh");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unknown_field_ignored_defaults_intact_for_known_fields() {
let dir = unique_test_dir("unknown-field");
std::fs::create_dir_all(&dir).expect("create temp dir");
std::fs::write(
dir.join("daemon_prefs.json"),
r#"{"streaming_quality":"mp3","volume":0.3,"bitrate_kbps":320}"#,
)
.expect("write raw prefs file");
let loaded = load_at(&dir);
assert_eq!(loaded.streaming_quality, "mp3");
assert_eq!(loaded.volume, 0.3);
let _ = std::fs::remove_dir_all(&dir);
}
}