// crates/qbzd/src/daemon.rs — the `qbzd run` boot sequence (01-architecture.md // §8.1, NORMATIVE order), the NeedsAuth-stays-up state machine (§6.2) and the // graceful shutdown (§8.2). Later tasks splice into the numbered steps: the // playback driver (T4) at step 10, the HTTP server (T6) at step 11, QConnect // (T9/T10) at step 12. Until they land the daemon boots a playable core and // parks on signals — API-less but fully diagnosable in-process. use std::sync::{Arc, Mutex}; use qbz_app::playback_driver::{self, DriverDeps}; use qbz_app::settings::daemon_prefs; use qbz_app::shell::AppRuntime; use qbz_core::CoreError; use qbz_models::{CoreEvent, UserSession}; use tokio::sync::broadcast; use tokio::task::JoinHandle; use crate::adapter::DaemonAdapter; use crate::config::QbzdConfig; use crate::lock::{InstanceLock, LockError}; use crate::paths::ProfileRoots; use crate::state::{AuthState, DaemonShared, LatchedErrors, QconnectStatus}; /// The composed runtime handoff produced by [`boot`] and consumed by later /// tasks: T4 spawns the playback driver on `runtime` + `shared`, T6 serves /// `bus` over HTTP/SSE, T9/T10 wire QConnect. Held alive by [`run`] through the /// signal park so the core stays up. #[allow(dead_code)] // fields are the seam later tasks (T6/T9/T10) read. pub struct BootedRuntime { pub runtime: Arc>, pub shared: Arc>, pub bus: broadcast::Sender, /// Background session-restore retry (network-class boot failure only). Held /// so shutdown can abort+join it BEFORE releasing the audio device: it holds /// an `Arc` clone, so leaving it running would keep the Player /// alive past `drop(booted)` and break the #521 clock-release ordering (§8.2). pub auth_retry: Option>, } /// `qbzd run` — boot the daemon in the foreground, park on signals, shut down /// gracefully. Returns the process exit code (0 = clean shutdown). `warns` are /// the unknown-key warnings surfaced by [`QbzdConfig::load`] in `main`. pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec, orbit: bool) -> Result { // 1. argv parse happened in main(). 2. logging — to its OWN file, never // the desktop app's (a shared qbz.log was rotated from under the GUI): qbz_log::install_named(&cfg.log.level, "qbzd.log"); // A headless daemon that dies of a signal leaves even less behind than // the GUI does: no window, no user watching. Same reporter, same reason // (qbz-log/src/fatal.rs). qbz_log::install_fatal_signal_reporter(); // 2b. Host memory profile (issue #660: qbzd OOM-killed on a 1 GB Pi 3B): // one-shot Normal/LowMemory detection, pushed into the player as the // initial-buffer cap, the L1 cache budget and the low-memory class // gate. Must run BEFORE boot() builds the AppRuntime (and with it the // Player + its L1 cache); after the logger so the profile's own info // line lands in the log. { let profile = qbz_core::system_capabilities::memory_profile(); qbz_player::player::apply_memory_tuning( profile.class == qbz_core::system_capabilities::MemoryClass::LowMemory, profile.audio_cache_l1_max_bytes, profile.max_initial_buffer_bytes, ); } // 3. config: surface unknown-key warnings (they never abort — D14). for w in &warns { log::warn!("[config] unknown key: {w}"); } // 4. instance lock on the DATA ROOT, taken BEFORE any port bind (§8.3): it, // not the port, protects the single-device_uuid / single-session.db // invariants. A second daemon on the same root is diagnosed → exit 3. let _lock = InstanceLock::acquire(&roots.data).map_err(diagnose_lock)?; // 5. port bind + foreign-occupant diagnosis — STATELESS, so it runs BEFORE // stores (6) and runtime composition (7) per the §8.1 order. On a bind // conflict the occupant is probed with GET /api/ping: a qbzd answer means // a stale foreign root (the lock said this root was free), anything else // the §2.2 "another process" copy. The socket is bound here but not served // until step 11 — connections queue in the listen backlog through boot. let bind_addr = resolve_bind_addr(&cfg)?; let bound = match crate::api::bind(bind_addr) { Ok(b) => b, Err(crate::api::BindError::AddrInUse(addr)) => return Err(diagnose_port_conflict(addr)), Err(crate::api::BindError::Other(msg)) => { return Err(format!( "error: could not bind the control API on {bind_addr}: {msg}\n → check [server] bind/port in ~/.config/qbzd/qbzd.toml" )); } }; if !bind_addr.ip().is_loopback() { // FB6: the default bind is now 0.0.0.0 — LAN-first posture (Sonos/ // Chromecast parity), not a misconfiguration. One INFO line, not a // stderr warning; loopback binds stay silent. log::info!("{}", crate::cli::copy::lan_posture_note(&bind_addr.to_string())); } // 6.-9. compose stores + runtime + restore credentials + restore session. let mut booted = boot(&roots, &cfg, warns.len()).await?; // 9b. Memory-pressure watchdog (issue #660): 15 s loop that evicts the // L1 audio cache and halts prefetch under critical pressure. Holds an // Arc (not Arc), so it sits outside the #521/§8.2 // ordering — aborted alongside the scrobbler for a clean shutdown. let memwatch = qbz_app::memory_watchdog::spawn(booted.runtime.core().player()); // Attach the CoreEvent bus to the shared state so the qconnect lifecycle // latches can publish `QconnectSessionChanged` (SSE + the event hook). if let Ok(mut s) = booted.shared.lock() { s.bus = Some(booted.bus.clone()); } // 10. playback driver (T4). Spawn the 450 ms headless orchestrator on the // booted runtime + shared state. It runs safely regardless of auth: with // no session the queue is empty and each tick is a near-no-op. The // streaming quality is resolved from daemon_prefs through the SAME key // contract the desktop uses (playback_quality(), playback.rs:170-172), // so hi-res never silently downgrades. 11. HTTP serve (T6) · 12. QConnect // (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); let qconnect_authority = Arc::new(crate::qconnect::authority::AuthorityCell::new()); // Owner-only playback consumers use a parallel, daemon-local bus whose // events carry the exact authority observation made by events_bridge. // Keep the public CoreEvent bus unchanged for SSE/MPRIS/hooks. let (owner_event_tx, _) = broadcast::channel::(256); // 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()); // The same edge, pulsed onto a SECOND Notify for the events bridge (10e): // two subscribers on one Notify would race for the single stored permit. let edge_notify = Arc::new(tokio::sync::Notify::new()); let deps = build_driver_deps( quality_cell.clone(), booted.shared.clone(), report_notify.clone(), edge_notify.clone(), booted.bus.clone(), qconnect_authority.clone(), ); let driver = tokio::spawn(playback_driver::run_driver( booted.runtime.clone(), deps, shutdown_rx, )); // 10b. Queue-persistence subscriber (T10, §7.5): a `CoreEvent::QueueUpdated` // on the DaemonAdapter bus — from driver auto-advance, the CLI queue // verbs OR a QConnect-driven remote mutation — is debounced 2 s and then // flushed to the session store, so a restart resumes the remote-set queue // PAUSED (boot already restores it, §8.1-9½). Holds an `Arc` // clone, so it is aborted+joined ahead of `drop(booted)` (#521 ordering). let queue_persist = spawn_queue_persist( booted.runtime.clone(), booted.bus.subscribe(), qconnect_authority.clone(), ); // 10c. Scrobble-on-play (CONSOLE): an authority-stamped event subscriber that sends // "now playing" on TrackStarted and scrobbles once past the Last.fm // threshold, to whichever of Last.fm / ListenBrainz is connected + // enabled in the scrobbler store. Holds NO Arc, so it sits // outside the #521/§8.2 ordering — aborted for a clean shutdown below. let scrobbler = crate::scrobble_engine::spawn( roots.clone(), owner_event_tx.subscribe(), qconnect_authority.clone(), ); // 10c¼. Listen log (§12.1): the scrobbler's sibling on the same stamped bus, // WITHOUT its enabled gate — the log is local and on by default, so // a headless streamer finally contributes history. Holds no // Arc; stopped (abort + close the open row) below. let listen_log = crate::listen_log_engine::spawn( &roots, owner_event_tx.subscribe(), qconnect_authority.clone(), ) .await; // 10c½. Events bridge: translate the driver's transition edges into the // playback CoreEvents the bus consumers above (and SSE, and the event // hook below) are written for — TrackStarted / PlaybackStateChanged / // PositionUpdated / VolumeChanged. Holds only a Weak // upgraded per wake, but is still aborted+joined ahead of // `drop(booted)` so a mid-wake strong Arc can't outlive the ordering. let events_bridge = crate::events_bridge::spawn( &booted.runtime, booted.bus.clone(), owner_event_tx, edge_notify, qconnect_authority.clone(), ); // 10c¾. Event hook (CONSOLE): fork `hooks.script` (or `QBZD_HOOK`) once per // forwarded bus event with QBZ_* variables in its environment — push // integration for headless boxes (moOde-style renderer coordination). // Holds no Arc; aborted for a clean shutdown. let hooks = match crate::hooks::script(&roots) { Some(path) => { log::info!("[hooks] running {} on daemon events (hooks.script)", path.display()); Some(crate::hooks::spawn(path, booted.bus.subscribe())) } None => None, }; // 10d. MPRIS media controls (CONSOLE): publish org.mpris.MediaPlayer2 so a // KDE/GNOME media widget, a plasmoid, or hardware media keys drive the // daemon with no custom client. The inbound callback holds a // Weak (never pins the runtime), so it too sits outside the // #521 ordering; None on a headless box / when QBZD_MPRIS disables it. let mpris = crate::mpris::spawn( &booted.runtime, roots.clone(), booted.bus.subscribe(), tokio::runtime::Handle::current(), qconnect_authority.clone(), ); // 11. HTTP serve (02 §3) on the already-bound socket. `ApiState` carries a // second read-only audio-store connection (WAL) for the status audio // block, the tokio handle for the async queue read, and the opt-in // [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> = Arc::new(std::sync::OnceLock::new()); // Orbit uses the daemon's OWN roots. Construction is inert and local // library access never depends on Qobuz authentication. No second port. let library = orbit.then(|| Arc::new(qbz_library::service::LibraryService::new( qbz_library::LibraryStore::new(roots.data.join("library.db")), roots.cache.join("artwork"), ))); let library_endpoint = library.as_ref().map(|service| qbz_control::library::LibraryEndpoint::new( service.clone(), std::env::var("HOSTNAME").ok().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "qbzd".into()), true, )); let api = crate::api::serve( bound, crate::api::ApiState { library: library_endpoint, runtime: booted.runtime.clone(), shared: booted.shared.clone(), bus: booted.bus.clone(), roots: roots.clone(), token: cfg.server.token.filter(|t| !t.trim().is_empty()), bind: bind_addr.to_string(), 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}"); // 12. QConnect (T9): mint the daemon's OWN device identity in the daemon-root // KV, decide auto-connect from the persisted startup mode (cli_override = // None so the KV that `qbzd qconnect enable|disable` writes is never // shadowed), and — when enabled — connect-on-Ready with the bounded retry // schedule. Reads NOTHING from qbzd.toml. Held to shut the session down // ahead of playback (§8.2-1); it also clones `Arc`, so it must // drop before `drop(booted)` (the #521 ordering). let mut qconnect = crate::qconnect::start( booted.runtime.clone(), booted.shared.clone(), qconnect_authority.clone(), &roots, quality_cell.clone(), report_notify, booted.bus.subscribe(), ); // 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 // documented skeptic-correction #1 trap (§8.1). wait_for_signal().await; // Reject new library work immediately; join after the API stops serving. if let Some(library) = &library { library.close(); } // ── Shutdown (§8.2, ordered). Step 1: disconnect the QConnect session (and // stop its auto-connect watcher) BEFORE playback is stopped, then drop the // handle so its Arc clone is released ahead of `drop(booted)`. qconnect.shutdown().await; drop(qconnect); // Step 2: stop the playback driver. It holds an Arc clone, so its // task must finish (dropping that Arc) before `drop(booted)` can release // the audio device ahead of the #521 pair. Signal, then join. let _ = shutdown_tx.send(true); if let Err(e) = driver.await { log::warn!("driver task join failed: {e:?}"); } // T10 (§7.5): stop the queue-persistence subscriber before the authoritative // final save, so it neither races the flush below nor keeps its // `Arc` clone alive past `drop(booted)` (#521 ordering). queue_persist.abort(); let _ = queue_persist.await; // Stop the scrobble-on-play subscriber (holds no Arc; order-free). scrobbler.abort(); let _ = scrobbler.await; // Stop the listen log: abort, then close the row in flight as `shutdown`. listen_log.stop().await; // Stop the memory-pressure watchdog and JOIN it: it holds an Arc // clone, which must drop before `drop(booted)` can release the audio // device — the same ordering constraint as the driver (§8.2). memwatch.abort(); let _ = memwatch.await; // Stop the events bridge BEFORE `drop(booted)`: it upgrades its Weak to a // strong Arc for the span of each wake (#521 ordering). events_bridge.abort(); let _ = events_bridge.await; // Stop the event-hook dispatcher (holds no Arc; order-free). if let Some(hooks) = hooks { hooks.abort(); let _ = hooks.await; } // Tear down MPRIS: abort its updater and drop the D-Bus handle. Its inbound // callback held only a Weak, so this is order-free too. if let Some(mpris) = mpris { mpris.shutdown().await; } // Final full session save (queue + position) now that playback is quiesced. playback_driver::save_session_now(booted.runtime.as_ref()).await; // The background auth-retry task also holds an Arc clone — abort // AND join it so its Arc is dropped before `drop(booted)`; otherwise the // ordering claim below (drop releases the device) breaks once playback has // engaged a real device. if let Some(retry) = booted.auth_retry.take() { retry.abort(); let _ = retry.await; } // Stop the API thread and JOIN it: its `ApiState` holds an `Arc` // clone, which must drop before `drop(booted)` releases the audio device // ahead of the #521 pair — the same ordering constraint as the driver and // auth-retry tasks (§8.2). api.shutdown(); if let Some(library) = library { let _ = tokio::task::spawn_blocking(move || library.shutdown()).await; } // The reload route's OnceLock handle also clones `QconnectControl`, which // holds an `Arc` (via `DaemonQconnectService.runtime`) — drop // it before `drop(booted)` too, same #521/§8.2 ordering as the driver, // queue-persist and auth-retry tasks above. drop(qconnect_control); // Release the audio device by dropping the runtime (its Player) BEFORE the // #521 pair (§8.2 step 3 precedes step 4). drop(booted); // THE #521 PAIR runs unconditionally on Linux — exactly the desktop quit // choke-point (crates/qbz/src/main.rs:20393): a forced PipeWire clock left // set would pin the whole system's sample rate after the process dies. // Both calls self-gate to no-ops when QBZ forced nothing. #[cfg(target_os = "linux")] { let _ = qbz_audio::alsa_backend::resume_suspended_sink(); qbz_audio::pipewire_backend::PipeWireBackend::reset_pipewire_clock(); } Ok(0) // instance lock released on drop of `_lock` } /// Steps 6-9 of §8.1: open the daemon-root stores, compose the runtime with the /// two NORMATIVE substitutions (`with_audio_settings` + `activate_at`), and /// restore the saved session per the §6.2 clearing taxonomy. fn revalidate_alsa_hardware_volume( store: &qbz_audio::settings::AudioSettingsStore, ) -> Result<(qbz_audio::settings::AudioSettings, Option), String> { use qbz_audio::alsa_hardware_volume::{ HardwareVolumeDecision, HardwareVolumeSelectionSource, HardwareVolumeUnsupportedReason, }; let audio = store.get_settings()?; if !audio.alsa_hardware_volume { return Ok((audio, None)); } if !qbz_audio::alsa_direct::uses_alsa_direct_route(&audio) { log::warn!( "[ALSA HW Volume] enabled outside ALSA Direct; disabling without touching a mixer" ); store.set_alsa_hardware_volume(false)?; return Ok((store.get_settings()?, None)); } let device_id = audio .output_device .as_deref() .expect("ALSA Direct route has a device id"); let probe = qbz_audio::alsa_hardware_volume::enumerate_hardware_volume_controls(device_id); let decision = qbz_audio::alsa_hardware_volume::decide_hardware_volume( &probe, &audio.alsa_hardware_volume_controls, ); match decision { HardwareVolumeDecision::Selected { selection } => { let Some(volume) = selection.candidate.current_volume else { log::warn!( "[ALSA HW Volume] selected control '{}' has no readable level; disabling", selection.control ); store.set_alsa_hardware_volume(false)?; return Ok((store.get_settings()?, None)); }; if selection.source != HardwareVolumeSelectionSource::Persisted { // Backward-compatible migration for an old bare boolean. The // probe is read-only; persist only an unequivocal identity. store.set_alsa_hardware_volume_control( selection.route_key, selection.control.clone(), )?; } log::info!( "[ALSA HW Volume] validated '{}' at {:.0}%", selection.control, volume * 100.0 ); Ok((store.get_settings()?, Some(volume))) } HardwareVolumeDecision::NeedsChoice { reason, .. } => { log::warn!( "[ALSA HW Volume] explicit mixer choice required ({reason:?}); disabling without writing" ); store.set_alsa_hardware_volume(false)?; Ok((store.get_settings()?, None)) } HardwareVolumeDecision::Unsupported { reason, .. } => { let detail = match &reason { HardwareVolumeUnsupportedReason::Probe(error) => error.message.clone(), other => format!("{other:?}"), }; log::warn!( "[ALSA HW Volume] saved mixer control unavailable ({detail}); disabling without fallback" ); store.set_alsa_hardware_volume(false)?; Ok((store.get_settings()?, None)) } } } async fn boot( roots: &ProfileRoots, cfg: &QbzdConfig, warn_count: usize, ) -> Result { // 6.+7. stores + runtime composition. The two substitutions (01 §2.2): // - with_audio_settings, NOT AppRuntime::new (which hardcodes the // desktop-global AudioSettingsStore — shell.rs:87-101); // - activate_at (below), NOT activate (which resolves desktop // UserDataPaths — shell.rs:195-203). // Everything routes through the T2 daemon roots. let store = qbz_audio::settings::AudioSettingsStore::new_at(&roots.data)?; // settings.rs:263 let (settings, hardware_volume) = revalidate_alsa_hardware_volume(&store)?; let (adapter, _rx) = DaemonAdapter::new(); let bus = adapter.sender(); let runtime = Arc::new(AppRuntime::with_audio_settings( adapter, settings.output_device.clone(), settings, None, )); // shell.rs:64 if let Some(volume) = hardware_volume { runtime.core().player().seed_volume_state(volume); } // Offline-tolerant (§8.1-8): a network failure here still leaves a locally // usable core; a missing DAC is likewise non-fatal (Player starts deviceless // and retries with backoff — never the spotifyd #1097 crash-exit). if let Err(e) = runtime.init().await { log::warn!("core init did not complete (continuing offline-tolerant): {e}"); } // Playlist recommendations (CONSOLE): open the per-user artist-vector store // at the DAEMON root — mirrors qbz/src/auth.rs:145-149, but slint-free and // session-independent. The store is a CACHE the suggestions engine reads/ // writes; vectors are built on demand from MusicBrainz + Qobuz, so this // needs no listening history. NOT optional for the feature, though: with // `artist_vectors = None` the engine does not run un-cached — both // `suggestions.rs` and `builder.rs` answer "No active session" — so a // failed open is logged loudly instead of leaving a silent, dead route. match qbz_reco::ArtistVectorStore::open_at(&roots.data) { Ok(store) => runtime.core().set_artist_vectors(store).await, Err(e) => log::warn!( "artist-vector store open failed ({e}); playlist suggestions answer \"No active session\" until it opens" ), } let shared = new_shared(cfg); if let Ok(mut s) = shared.lock() { s.startup_warnings = warn_count as u32; } // 8. credential restore per the §6.2 taxonomy (mirrors qbz/src/auth.rs: // 215-230): clear the token ONLY on explicit auth rejection; KEEP it on // every network-class failure (clearing on transient errors is the // documented boot-token-loss bug class). let auth_retry = match qbz_credentials::load_oauth_token_at(&roots.config)? { None => { set_needs_auth(&shared, None); // `None` covers both "no token saved" and "token saved but this // process cannot decrypt it" — the decrypt failure is swallowed by // design so a broken file can never abort boot. Tell them apart // here, or `status` reports "not logged in / last error: none" and // the real cause stays buried in the log. if qbz_credentials::oauth_token_file_present_at(&roots.config) { latch_undecryptable_token(&shared); } None } Some(token) => { // Register before the token can reach any log line (§6.3). qbz_log::register_secret(token.clone()); match runtime.core().login_with_token(&token).await { Ok(session) => { 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. playback_driver::restore_session_paused(runtime.as_ref()).await; None } Err(e) if is_auth_rejection(&e) => { qbz_credentials::clear_oauth_token_at(&roots.config)?; latch_auth_error(&shared, &e); set_needs_auth(&shared, Some(e)); None } Err(e) => { // network-class: KEEP token, stay Restoring, retry w/ backoff. log::warn!("session restore deferred (network-class): {e}"); // 01 §9.3: a real network-class outcome — latch `network.online` // false so `/api/status` reflects it until a retry succeeds. if let Ok(s) = shared.lock() { s.set_network_online(false); } Some(spawn_auth_retry( runtime.clone(), shared.clone(), roots.clone(), )) } } } }; Ok(BootedRuntime { runtime, shared, bus, auth_retry, }) } /// Assemble the driver's host side channels: the streaming-quality resolver and /// the daemon-shared latching / tick-timestamping hooks. T10: `on_edge` now /// pulses the QConnect report `Notify` so the report scheduler reports on the /// same transition/periodic edges the driver detects (§7.2) — and, on a second /// `Notify`, the events bridge (10c½) that publishes those edges to the bus. fn build_driver_deps( quality_cell: Arc>, shared: Arc>, report_notify: Arc, edge_notify: Arc, bus: tokio::sync::broadcast::Sender, qconnect_authority: Arc, ) -> DriverDeps { let latch_shared = shared.clone(); let tick_shared = shared; let qconnect_authority_allowed = Arc::clone(&qconnect_authority); let qconnect_authority_observe = Arc::clone(&qconnect_authority); let qconnect_authority_readmit = qconnect_authority; DriverDeps { quality: Arc::new(move || { quality_cell .lock() .map(|q| *q) .unwrap_or(qbz_models::Quality::UltraHiRes) }), owner_actions_allowed: Arc::new(move || qconnect_authority_allowed.owner_actions_allowed()), observe_authority: Arc::new(move || { match qconnect_authority_observe.observe_owner_authority() { crate::qconnect::authority::OwnerAuthorityObservation::Owner { token, permit, } => qbz_app::playback_driver::DriverAuthorityObservation::Owner( qbz_app::playback_driver::DriverOwnerAdmission::new( qbz_app::playback_driver::DriverOwnerToken::new(token), qbz_app::playback_driver::DriverActionPermit::new(permit), ), ), crate::qconnect::authority::OwnerAuthorityObservation::Delegated => { qbz_app::playback_driver::DriverAuthorityObservation::Delegated } crate::qconnect::authority::OwnerAuthorityObservation::Fenced => { qbz_app::playback_driver::DriverAuthorityObservation::Fenced } } }), readmit_owner_action: Arc::new(move |token| { let token = token.downcast_ref::()?; qconnect_authority_readmit .try_owner_action_permit_exact(*token) .map(qbz_app::playback_driver::DriverActionPermit::new) }), // 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. on_edge: Arc::new(move || { report_notify.notify_one(); edge_notify.notify_one(); }), on_latch: Arc::new(move |category, message| { if let Ok(mut s) = latch_shared.lock() { match category { "stream" => s.last_errors.stream = Some(message.clone()), "transport" => s.last_errors.transport = Some(message.clone()), "auth" => s.last_errors.auth = Some(message.clone()), _ => {} } } // Surface stream failures on the bus too, so `/api/events` and the // event hook see them live (e.g. a busy ALSA device at play time) // instead of only the polled /api/status error latch. if category == "stream" { let _ = bus.send(qbz_models::CoreEvent::PlaybackError { track_id: 0, message, }); } }), on_tick: Arc::new(move || { if let Ok(mut s) = tick_shared.lock() { s.driver_last_tick = Some(std::time::Instant::now()); } }), } } /// T10 (§7.5): the queue-persistence subscriber. Debounces `CoreEvent::QueueUpdated` /// bursts by 2 s, then flushes the live queue + position to the session store via /// `save_session_now`. QConnect-driven mutations (`materialize_remote_queue` -> /// `set_queue`) also emit `QueueUpdated`, so a remote-set queue survives a restart /// (boot restores it PAUSED). Non-queue events (e.g. position ticks) are drained /// WITHOUT extending the debounce window, so they can never starve the flush. fn spawn_queue_persist( runtime: Arc>, mut rx: broadcast::Receiver, qconnect_authority: Arc, ) -> JoinHandle<()> { use tokio::sync::broadcast::error::RecvError; const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(2); tokio::spawn(async move { loop { // Block until the FIRST queue mutation of a burst. match rx.recv().await { Ok(CoreEvent::QueueUpdated { .. }) => {} Ok(_) => continue, Err(RecvError::Lagged(_)) => continue, Err(RecvError::Closed) => return, } // Debounce: a fixed deadline that only a further QueueUpdated extends. // Other events are consumed but never push the deadline out. let mut deadline = tokio::time::Instant::now() + DEBOUNCE; loop { tokio::select! { _ = tokio::time::sleep_until(deadline) => break, r = rx.recv() => match r { Ok(CoreEvent::QueueUpdated { .. }) => { deadline = tokio::time::Instant::now() + DEBOUNCE; } Ok(_) => {} Err(RecvError::Lagged(_)) => {} Err(RecvError::Closed) => return, } } } let Some(_owner_permit) = qconnect_authority.try_owner_action_permit() else { log::debug!("[qbzd] queue-persist: skipped delegated QConnect queue"); continue; }; playback_driver::save_session_now(runtime.as_ref()).await; log::debug!("[qbzd] queue-persist: session flushed after QueueUpdated burst"); } }) } /// 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`. pub(crate) async fn restore_activate( runtime: &Arc>, shared: &Arc>, roots: &ProfileRoots, session: UserSession, token: &str, ) -> Result<(), String> { runtime .core() .set_session(session.clone()) .await .map_err(|e| e.to_string())?; runtime .activate_at( session.user_id, &roots.data.join(format!("users/{}", session.user_id)), &roots.cache.join(format!("users/{}", session.user_id)), ) .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)); // 01 §9.3: a real login/restore success (boot, background auth-retry, // or a reload's credential re-validation — every caller of this fn) // means the network is reachable — latch `network.online` back true. s.set_network_online(true); } Ok(()) } /// Fresh shared state. Starts in `Restoring` — credential restore drives the /// terminal transition to `LoggedIn` or `NeedsAuth` (§6.2 diagram). fn new_shared(cfg: &QbzdConfig) -> Arc> { let _ = cfg; // reserved: premute/mpris defaults wire in with later tasks. Arc::new(Mutex::new(DaemonShared { auth: AuthState::Restoring, user_id: None, subscription: None, last_errors: LatchedErrors::default(), driver_last_tick: None, muted: false, premute_volume: 1.0, started_at: std::time::Instant::now(), startup_warnings: 0, qconnect: QconnectStatus::default(), credential_fingerprint: None, network_online: std::sync::atomic::AtomicBool::new(true), // Attached by daemon::run right after boot (the bus outlives boot). bus: 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. pub(crate) fn set_needs_auth(shared: &Arc>, err: Option) { 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')"), Some(e) => { log::warn!("Qobuz rejected the saved session ({e}) — run 'qbzd login' to re-authenticate") } } } /// Enter LoggedIn (Ready). Records the user id + subscription label for /// `/api/status` (T6). The auth token itself is never stored here — it is a /// registered secret and lives only in the credential file. fn set_logged_in(shared: &Arc>, session: &UserSession) { if let Ok(mut s) = shared.lock() { s.auth = AuthState::LoggedIn; s.user_id = Some(session.user_id); s.subscription = Some(session.subscription_label.clone()); } log::info!( "Logged in (user {}, subscription '{}')", session.user_id, session.subscription_label ); } /// Latch the "saved token is present but undecryptable" case. Reachable when a /// token was written under a key this process cannot derive — e.g. a login that /// ran in a graphical session against a build that still mixed the XDG portal /// secret in, now read by an init-started daemon with no session bus. The /// credential store migrates that token itself where it can (a daemon that CAN /// reach the portal rewrites it portal-free); when it cannot, the only exit is a /// fresh login, so say exactly that instead of a bare "not logged in". pub(crate) fn latch_undecryptable_token(shared: &Arc>) { if let Ok(mut s) = shared.lock() { s.last_errors.auth = Some( "the saved token could not be decrypted by this daemon — run 'qbzd login' to re-authenticate".into(), ); } log::warn!( "saved token present but undecryptable — run 'qbzd login' to re-authenticate" ); } /// 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?"). pub(crate) fn latch_auth_error(shared: &Arc>, e: &CoreError) { if let Ok(mut s) = shared.lock() { s.last_errors.auth = Some(format!("token rejected by Qobuz — cleared ({e})")); } } /// True ONLY for an explicit auth rejection from Qobuz — a 401 on the token /// login (`AuthenticationError`) or an ineligible-account verdict. Network /// 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). pub(crate) fn is_auth_rejection(error: &CoreError) -> bool { matches!( error, CoreError::Api( qbz_qobuz::ApiError::AuthenticationError(_) | qbz_qobuz::ApiError::IneligibleUser ) ) } /// Background retry for a network-class restore failure (§6.2: stay in the /// authenticating state, KEEP the token, retry with backoff). On success the /// session activates; on a now-explicit auth rejection the token is cleared and /// the daemon drops to NeedsAuth; if the whole schedule sees only network-class /// failures the token is KEPT and the daemon surfaces NeedsAuth so it stays /// diagnosable and a later `qbzd login` / settings reload can retry. fn spawn_auth_retry( runtime: Arc>, shared: Arc>, roots: ProfileRoots, ) -> JoinHandle<()> { const SCHEDULE_SECS: [u64; 4] = [2, 5, 15, 30]; tokio::spawn(async move { let token = match qbz_credentials::load_oauth_token_at(&roots.config) { Ok(Some(t)) => t, _ => return, // token vanished (concurrent logout) — nothing to retry. }; for (i, delay) in SCHEDULE_SECS.iter().enumerate() { tokio::time::sleep(std::time::Duration::from_secs(*delay)).await; 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, &token).await { log::warn!("session activation after retry failed: {e}"); } return; } 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)); return; } Err(e) => { log::warn!("session restore retry {} failed (network-class): {e}", i + 1); // 01 §9.3: latch `network.online` false on every real // network-class outcome, not just the first. if let Ok(s) = shared.lock() { s.set_network_online(false); } } } } // Schedule exhausted with only network-class failures: KEEP the token, // surface NeedsAuth, latch the reason for `qbzd status`. if let Ok(mut s) = shared.lock() { s.auth = AuthState::NeedsAuth; s.set_network_online(false); s.last_errors.auth = Some( "could not reach Qobuz to restore the saved session — token kept, retry with 'qbzd login' or 'qbzd settings reload'".into(), ); } log::warn!( "session restore gave up after {} network-class attempts — token KEPT", SCHEDULE_SECS.len() ); }) } /// Resolve `[server] bind:port` to a `SocketAddr` (01 §10.1). A malformed value /// is a fatal boot error that names the fix (exit 1). fn resolve_bind_addr(cfg: &QbzdConfig) -> Result { use std::net::ToSocketAddrs; let hostport = format!("{}:{}", cfg.server.bind, cfg.server.port); hostport .to_socket_addrs() .map_err(|e| { format!("error: invalid [server] bind/port '{hostport}': {e}\n → set a valid ip and port in ~/.config/qbzd/qbzd.toml") })? .next() .ok_or_else(|| { format!("error: [server] '{hostport}' resolved to no address\n → set a valid ip and port in ~/.config/qbzd/qbzd.toml") }) } /// The step-5 bind-conflict diagnosis (02 §8.1-5 / §2.2): a qbzd occupant on a /// different data root vs. an unrelated process on the port. fn diagnose_port_conflict(addr: std::net::SocketAddr) -> String { if crate::api::probe_is_qbzd(addr) { crate::cli::copy::foreign_qbzd(&addr.to_string()) } else { crate::cli::copy::port_in_use(addr.port()) } } /// Render an [`InstanceLock`] failure. For the already-running case this prints /// the frozen exit-3 error voice (02 §1.3/§1.4) and exits 3 directly — the new /// process must never clobber the running one. An I/O failure returns a String /// that propagates to a generic exit 1. fn diagnose_lock(e: LockError) -> String { match e { LockError::AlreadyRunning(pid) => { let who = pid .map(|p| format!("(pid {p})")) .unwrap_or_else(|| "(pid unknown)".to_string()); eprintln!("error: qbzd is already running {who}"); eprintln!(" → stop it first: systemctl --user stop qbzd"); eprintln!(" → or inspect it: systemctl --user status qbzd"); log::logger().flush(); std::process::exit(3); } LockError::Io(msg) => { format!("error: could not take the instance lock: {msg}\n → check permissions on the data root") } } } /// Park until SIGTERM or SIGINT. A second signal after this returns lets the /// default handler take over → immediate exit (§8.2). async fn wait_for_signal() { #[cfg(unix)] { use tokio::signal::unix::{signal, SignalKind}; match ( signal(SignalKind::terminate()), signal(SignalKind::interrupt()), ) { (Ok(mut term), Ok(mut int)) => { tokio::select! { _ = term.recv() => log::info!("SIGTERM received — shutting down"), _ = int.recv() => log::info!("SIGINT received — shutting down"), } } _ => { // Fall back to Ctrl-C if the SIGTERM handler could not install. let _ = tokio::signal::ctrl_c().await; log::info!("Ctrl-C received — shutting down"); } } } #[cfg(not(unix))] { let _ = tokio::signal::ctrl_c().await; log::info!("Ctrl-C received — shutting down"); } } // ============================ 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. Audio/quality are independent; /// credentials are reconciled next so their branch can retire LAN/delegated /// QConnect before mutating owner auth, and QConnect KV reconciliation runs last /// so an enabled service reconnects only against the settled credential state. 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) { // Audio reload is an owner-side live-player action. Keep this permit only // for the audio phase: `reload_credentials`/`reload_qconnect` below may // intentionally tear down delegated authority and must be free to acquire // and drain their own transition fence. let _owner_action = match state.qconnect_control.get() { None => None, // boot window: no delegated runtime can exist yet Some(qconnect) => { let Some(permit) = qconnect.try_owner_action_permit() else { log::info!( "[reload] live audio settings not applied while QConnect owns playback; retry reload after owner authority returns" ); return; }; Some(permit) } }; let (fresh, hardware_volume) = match revalidate_alsa_hardware_volume(&state.audio) { Ok(result) => result, Err(e) => { log::warn!("[reload] could not re-read audio settings: {e}"); return; } }; let player = state.runtime.core().player(); if let Some(volume) = hardware_volume { // Seed before a routing-critical reinit so the new engine starts at // the physical level just read, never at synthetic unity. player.seed_volume_state(volume); } 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.alsa_hardware_volume_controls != new.alsa_hardware_volume_controls || 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, file_token: Option, ) -> 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 — credential-change teardown is skipped /// only then, because no LAN/coordinator/runtime exists yet. pub(crate) async fn reload_credentials( runtime: &Arc>, shared: &Arc>, 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 { if let Err(e) = qc.disconnect_for_credentials_change().await { // Fail closed: owner logout/deactivation must never race a // surviving LAN candidate or delegated authority. log::warn!( "[reload] QConnect credential-change teardown failed; keeping current auth: {e}" ); return; } } let _ = runtime.core().stop(); let _ = runtime.core().logout().await; let _ = runtime.deactivate().await; set_needs_auth(shared, None); } CredentialAction::Apply(token) => { if let Some(qc) = qconnect { if let Err(e) = qc.disconnect_for_credentials_change().await { // Do not install/account-switch the owner token until LAN, // candidate validation, and delegated authority are gone. log::warn!( "[reload] QConnect credential-change teardown failed; token not applied: {e}" ); return; } } 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}"); // 01 §9.3: real network-class outcome — latch it false. if let Ok(s) = shared.lock() { s.set_network_online(false); } } } } } } #[cfg(test)] mod tests { use super::*; #[test] fn is_auth_rejection_matches_only_explicit_rejections() { // Explicit rejections → clear the token. assert!(is_auth_rejection(&CoreError::Api( qbz_qobuz::ApiError::AuthenticationError("401".into()) ))); assert!(is_auth_rejection(&CoreError::Api( qbz_qobuz::ApiError::IneligibleUser ))); // Network-class / other → KEEP the token (the boot-token-loss guard). assert!(!is_auth_rejection(&CoreError::Api( qbz_qobuz::ApiError::ServerError(503) ))); assert!(!is_auth_rejection(&CoreError::Api( qbz_qobuz::ApiError::RateLimited(30) ))); assert!(!is_auth_rejection(&CoreError::NotInitialized)); } #[test] fn no_credentials_enters_needs_auth() { let shared = new_shared(&QbzdConfig::default()); set_needs_auth(&shared, None); let s = shared.lock().unwrap(); assert_eq!(s.auth, AuthState::NeedsAuth); assert!(s.user_id.is_none()); assert!(s.last_errors.auth.is_none()); } #[test] fn explicit_rejection_latches_and_needs_auth() { let shared = new_shared(&QbzdConfig::default()); let err = CoreError::Api(qbz_qobuz::ApiError::AuthenticationError("401".into())); latch_auth_error(&shared, &err); set_needs_auth(&shared, Some(err)); let s = shared.lock().unwrap(); assert_eq!(s.auth, AuthState::NeedsAuth); assert!(s.last_errors.auth.is_some()); } #[test] fn logged_in_records_user_and_subscription() { let shared = new_shared(&QbzdConfig::default()); let session = UserSession { user_auth_token: "secret".into(), user_id: 1234567, email: "a@b.c".into(), display_name: "Tester".into(), subscription_label: "studio".into(), ..UserSession::default() }; set_logged_in(&shared, &session); let s = shared.lock().unwrap(); assert_eq!(s.auth, AuthState::LoggedIn); 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 hw_control = base.clone(); let route = serde_json::from_str("\"alsa-route-v1|card=card:D50|pcm=hw|DEV=0\"") .expect("stable route key"); hw_control.alsa_hardware_volume_controls.insert( route, qbz_audio::alsa_hardware_volume::AlsaMixerControlId { name: "D50 III".to_string(), index: 0, }, ); assert!( audio_routing_changed(&base, &hw_control), "alsa_hardware_volume_controls" ); 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)); } }