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:
2 files changed
+145
-14
No files matched your search
@@ -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
@@ -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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
Reference in new issue
Block a user