feat(qbzd): wire QbzCore, Player, AudioCache, keyring (Phase 1)

DaemonCore now initializes the real QbzCore with DaemonAdapter,
creates Player with audio settings from shared database, and
attempts auto-login from saved OAuth token in system keyring.

Working features:
- QbzCore init (bundle token extraction from Qobuz web player)
- Player audio thread (ready for playback commands)
- AudioCache with auto-detected size from RAM
- Shared audio settings with desktop app (same SQLite DB)
- Keyring credential lookup (same service as desktop)
- HTTP endpoints: /api/ping, /api/info, /api/status
- Graceful shutdown (SIGTERM/SIGINT -> stop player -> exit)

cc
This commit is contained in:
vicrodh committed 2026-04-08 21:29:28 -06:00
1 parent b0ed467664
commit 2916dee42c
2 files changed
+145 -14

No files matched your search

+3
View File
@@ -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"] }
+142 -14
View File
@@ -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::<T>() with direct field access.
pub struct DaemonCore {
pub config: DaemonConfig,
pub core: Arc<QbzCore<DaemonAdapter>>,
pub audio_cache: Arc<AudioCache>,
pub event_bus: broadcast::Sender<DaemonEvent>,
// Phase 1: Core crates initialized after login
// pub core: Arc<QbzCore<DaemonAdapter>>,
// pub audio_cache: Arc<qbz_cache::AudioCache>,
}
/// 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::<DaemonEvent>(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<DaemonAdapter>) -> 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<String> {
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<DaemonCore>) -> 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<DaemonCore>) -> axum::Json<serde_json::Value>
},
}))
}
async fn status_handler(daemon: Arc<DaemonCore>) -> axum::Json<serde_json::Value> {
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,
},
}))
}