diff --git a/crates/qbzd/Cargo.toml b/crates/qbzd/Cargo.toml index 7669da894..e5215e081 100644 --- a/crates/qbzd/Cargo.toml +++ b/crates/qbzd/Cargo.toml @@ -44,3 +44,6 @@ sysinfo = "0.34" # Database rusqlite = { version = "0.31", features = ["bundled"] } + +# Credentials (shared keyring with desktop app) +keyring = { version = "3", features = ["async-secret-service", "crypto-rust"] } diff --git a/crates/qbzd/src/daemon.rs b/crates/qbzd/src/daemon.rs index f3fa09215..cb7526522 100644 --- a/crates/qbzd/src/daemon.rs +++ b/crates/qbzd/src/daemon.rs @@ -1,6 +1,12 @@ use std::sync::Arc; use tokio::sync::broadcast; +use qbz_audio::{AudioDiagnostic, AudioSettings}; +use qbz_audio::settings::AudioSettingsStore; +use qbz_cache::AudioCache; +use qbz_core::QbzCore; +use qbz_player::Player; + use crate::adapter::{DaemonAdapter, DaemonEvent}; use crate::config::DaemonConfig; @@ -8,26 +14,68 @@ use crate::config::DaemonConfig; /// Replaces Tauri's app.state::() with direct field access. pub struct DaemonCore { pub config: DaemonConfig, + pub core: Arc>, + pub audio_cache: Arc, pub event_bus: broadcast::Sender, - // Phase 1: Core crates initialized after login - // pub core: Arc>, - // pub audio_cache: Arc, } /// Run the daemon main loop. pub async fn run(mut config: DaemonConfig) -> Result<(), String> { // Resolve token config.resolve_token(); - log::info!("[qbzd] API token: {}", &config.server.token[..8]); + log::info!("[qbzd] API token: {}...", &config.server.token[..8.min(config.server.token.len())]); - // Create event bus + // Create event bus (bounded, slow SSE clients get dropped) let (event_tx, _) = broadcast::channel::(256); - // Create adapter (will be used by QbzCore) - let _adapter = DaemonAdapter::new(event_tx.clone()); + // Create adapter for QbzCore events + let adapter = DaemonAdapter::new(event_tx.clone()); + + // Load audio settings (from shared database if exists) + let audio_settings = AudioSettingsStore::new() + .ok() + .and_then(|store| store.get_settings().ok()) + .unwrap_or_else(|| { + log::info!("[qbzd] No saved audio settings, using defaults"); + AudioSettings::default() + }); + + let device_name = audio_settings.output_device.clone(); + log::info!( + "[qbzd] Audio: backend={:?}, device={:?}, exclusive={}, gapless={}", + audio_settings.backend_type, + device_name, + audio_settings.exclusive_mode, + config.audio.gapless, + ); + + // Create player (audio thread starts immediately) + let diagnostic = AudioDiagnostic::new(); + let player = Player::new(device_name, audio_settings, None, diagnostic); + + // Create QbzCore — this extracts Qobuz bundle tokens (requires network) + let core = QbzCore::new(adapter, player); + log::info!("[qbzd] Initializing QbzCore (extracting Qobuz bundle tokens)..."); + core.init().await.map_err(|e| format!("QbzCore init failed: {}", e))?; + log::info!("[qbzd] QbzCore initialized"); + + // Try auto-login from saved OAuth token + let logged_in = try_auto_login(&core).await; + if logged_in { + log::info!("[qbzd] Auto-login successful"); + } else { + log::warn!("[qbzd] No saved credentials. Run `qbzd login` to authenticate."); + } + + // Create audio cache with configured sizes + let cache_bytes = config.memory_cache_bytes(); + let audio_cache = Arc::new(AudioCache::new(cache_bytes)); + log::info!("[qbzd] Audio cache: {} MB", config.cache.memory_mb); let daemon = Arc::new(DaemonCore { config: config.clone(), + core: Arc::new(core), + audio_cache, event_bus: event_tx, }); @@ -45,13 +93,8 @@ pub async fn run(mut config: DaemonConfig) -> Result<(), String> { log::info!("[qbzd] HTTP server listening on {}", bind_addr); - // Minimal Axum router (Phase 0 — just ping) - let app = axum::Router::new() - .route("/api/ping", axum::routing::get(ping_handler)) - .route("/api/info", axum::routing::get({ - let daemon = daemon.clone(); - move || info_handler(daemon.clone()) - })); + // Axum router + let app = build_router(daemon.clone()); // Graceful shutdown on SIGTERM/SIGINT let shutdown = async { @@ -76,10 +119,84 @@ pub async fn run(mut config: DaemonConfig) -> Result<(), String> { .await .map_err(|e| format!("HTTP server error: {}", e))?; + // Graceful shutdown: stop player + log::info!("[qbzd] Stopping player..."); + let _ = daemon.core.stop(); + log::info!("[qbzd] Shutdown complete"); Ok(()) } +/// Try to restore session from saved OAuth token in keyring. +async fn try_auto_login(core: &QbzCore) -> bool { + // Try loading token from keyring (same storage as desktop app) + let token = match load_oauth_token() { + Some(t) => t, + None => return false, + }; + + match core.login_with_token(&token).await { + Ok(session) => { + log::info!( + "[qbzd] Session restored for user {} ({})", + session.display_name, + session.user_id + ); + true + } + Err(e) => { + log::warn!("[qbzd] Saved token expired or invalid: {}", e); + false + } + } +} + +/// Load OAuth token from system keyring. +/// Uses the same service/key as the desktop app so credentials are shared. +fn load_oauth_token() -> Option { + const SERVICE: &str = "qbz-player"; + const KEY: &str = "qobuz-oauth-token"; + + let entry = keyring::Entry::new(SERVICE, KEY).ok()?; + match entry.get_password() { + Ok(token) if !token.is_empty() => { + log::info!("[qbzd] OAuth token loaded from keyring"); + Some(token) + } + Ok(_) => None, + Err(keyring::Error::NoEntry) => { + log::debug!("[qbzd] No OAuth token in keyring"); + None + } + Err(e) => { + log::warn!("[qbzd] Keyring access failed: {}", e); + None + } + } +} + +/// Build the Axum HTTP router. +fn build_router(daemon: Arc) -> axum::Router { + use axum::routing::get; + + axum::Router::new() + .route("/api/ping", get(ping_handler)) + .route( + "/api/info", + get({ + let d = daemon.clone(); + move || info_handler(d.clone()) + }), + ) + .route( + "/api/status", + get({ + let d = daemon.clone(); + move || status_handler(d.clone()) + }), + ) +} + async fn ping_handler() -> &'static str { "pong" } @@ -95,3 +212,14 @@ async fn info_handler(daemon: Arc) -> axum::Json }, })) } + +async fn status_handler(daemon: Arc) -> axum::Json { + let logged_in = daemon.core.has_session().await; + axum::Json(serde_json::json!({ + "state": if logged_in { "ready" } else { "no_session" }, + "logged_in": logged_in, + "audio": { + "cache_mb": daemon.config.cache.memory_mb, + }, + })) +}