feat(qbzd): scaffold headless daemon binary (Phase 0)

New workspace member crates/qbzd/ with:
- CLI interface (clap): port, bind, data-dir, config, token, log-level
- Config system (TOML): server, audio, cache, data, mpris, qconnect, mdns
- Resource detection (sysinfo): auto-size cache from available RAM
- DaemonAdapter: FrontendAdapter impl routing CoreEvents to broadcast channel
- DaemonCore: state container replacing Tauri's app.state
- Minimal HTTP server (Axum): /api/ping + /api/info
- Graceful shutdown on SIGTERM/SIGINT

Zero Tauri dependency. Desktop app unaffected.

cc
This commit is contained in:
vicrodh committed 2026-04-08 21:18:01 -06:00
1 parent 242b27bb1b
commit b0ed467664
7 files changed
+611

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
[package]
name = "qbzd"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "QBZ headless music daemon for SBCs and servers"
[[bin]]
name = "qbzd"
path = "src/main.rs"
[dependencies]
# Core crates (all Tauri-free)
qbz-models = { path = "../qbz-models" }
qbz-core = { path = "../qbz-core" }
qbz-player = { path = "../qbz-player" }
qbz-audio = { path = "../qbz-audio" }
qbz-qobuz = { path = "../qbz-qobuz" }
qbz-library = { path = "../qbz-library" }
qbz-cache = { path = "../qbz-cache" }
qbz-integrations = { path = "../qbz-integrations" }
# HTTP server
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["cors"] }
# Async
tokio = { workspace = true }
async-trait = { workspace = true }
# Serialization
serde = { workspace = true }
serde_json = { workspace = true }
# Config
toml = "0.8"
clap = { version = "4", features = ["derive"] }
# System
dirs = "6"
log = { workspace = true }
env_logger = "0.11"
sysinfo = "0.34"
# Database
rusqlite = { version = "0.31", features = ["bundled"] }
+51
View File
@@ -0,0 +1,51 @@
use async_trait::async_trait;
use qbz_models::FrontendAdapter;
use tokio::sync::broadcast;
/// Events distributed by the daemon to all connected clients.
#[derive(Debug, Clone)]
pub enum DaemonEvent {
/// Events from qbz-core (track started, queue updated, favorites changed, etc.)
Core(qbz_models::CoreEvent),
/// Periodic playback state snapshot (250ms while playing)
Playback(PlaybackSnapshot),
/// Runtime lifecycle (login, logout, ready, degraded)
Runtime(RuntimeEvent),
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PlaybackSnapshot {
pub state: String,
pub track_id: u64,
pub position_secs: u64,
pub duration_secs: u64,
pub volume: f32,
pub sample_rate: u32,
pub bit_depth: u32,
}
#[derive(Debug, Clone, serde::Serialize)]
pub enum RuntimeEvent {
Ready { user_id: u64 },
LoggedOut,
Degraded { reason: String },
}
/// FrontendAdapter implementation for the headless daemon.
/// Routes core events to the broadcast channel for SSE distribution.
pub struct DaemonAdapter {
event_tx: broadcast::Sender<DaemonEvent>,
}
impl DaemonAdapter {
pub fn new(event_tx: broadcast::Sender<DaemonEvent>) -> Self {
Self { event_tx }
}
}
#[async_trait]
impl FrontendAdapter for DaemonAdapter {
async fn on_event(&self, event: qbz_models::CoreEvent) {
let _ = self.event_tx.send(DaemonEvent::Core(event));
}
}
+259
View File
@@ -0,0 +1,259 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonConfig {
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub audio: AudioConfig,
#[serde(default)]
pub cache: CacheConfig,
#[serde(default)]
pub data: DataConfig,
#[serde(default)]
pub mpris: MprisConfig,
#[serde(default)]
pub qconnect: QConnectConfig,
#[serde(default)]
pub mdns: MdnsConfig,
#[serde(default)]
pub logging: LoggingConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub bind: String,
pub port: u16,
/// "auto" generates on first run
pub token: String,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind: "0.0.0.0".to_string(),
port: 8182,
token: "auto".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioConfig {
/// pipewire | alsa | pulse | system
pub backend: String,
/// Empty = default device
pub device: String,
pub gapless: bool,
pub normalization: bool,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
backend: "pipewire".to_string(),
device: String::new(),
gapless: true,
normalization: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
/// L1 in-memory cache in MB. 0 = auto-detect from RAM.
pub memory_mb: usize,
/// L2 persistent disk cache in MB. 0 = disable.
pub disk_mb: usize,
/// Tracks to prefetch ahead
pub prefetch_count: usize,
/// Simultaneous track downloads
pub prefetch_concurrent: usize,
/// Parallel CMAF segment downloads per track
pub cmaf_concurrent_segments: usize,
#[serde(default)]
pub auto: CacheAutoConfig,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
memory_mb: 0, // 0 = auto-detect
disk_mb: 400,
prefetch_count: 2,
prefetch_concurrent: 1,
cmaf_concurrent_segments: 2,
auto: CacheAutoConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheAutoConfig {
/// Auto-detect RAM and adjust cache sizes
pub enabled: bool,
}
impl Default for CacheAutoConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataConfig {
pub dir: String,
}
impl Default for DataConfig {
fn default() -> Self {
let default_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("/var/lib"))
.join("qbz")
.to_string_lossy()
.to_string();
Self { dir: default_dir }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MprisConfig {
pub enabled: bool,
}
impl Default for MprisConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QConnectConfig {
pub enabled: bool,
/// Empty = hostname
pub device_name: String,
}
impl Default for QConnectConfig {
fn default() -> Self {
Self {
enabled: true,
device_name: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MdnsConfig {
pub enabled: bool,
/// Empty = hostname
pub name: String,
}
impl Default for MdnsConfig {
fn default() -> Self {
Self {
enabled: true,
name: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
pub level: String,
pub journal: bool,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: "info".to_string(),
journal: true,
}
}
}
impl DaemonConfig {
/// Load config from TOML file, falling back to defaults.
pub fn load(path: Option<&str>) -> Self {
let config_path = path
.map(PathBuf::from)
.or_else(|| {
dirs::config_dir().map(|d| d.join("qbz").join("qbzd.toml"))
})
.unwrap_or_else(|| PathBuf::from("qbzd.toml"));
if config_path.exists() {
match std::fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str(&content) {
Ok(cfg) => {
log::info!("Loaded config from {}", config_path.display());
return cfg;
}
Err(e) => {
log::warn!("Failed to parse config {}: {}", config_path.display(), e);
}
},
Err(e) => {
log::warn!("Failed to read config {}: {}", config_path.display(), e);
}
}
} else {
log::info!(
"No config file at {}, using defaults",
config_path.display()
);
}
Self::default()
}
/// Resolve the token — generate if "auto".
pub fn resolve_token(&mut self) {
if self.token_is_auto() {
self.server.token = generate_token();
log::info!("Generated API token (first run)");
}
}
pub fn token_is_auto(&self) -> bool {
self.server.token == "auto" || self.server.token.is_empty()
}
/// Memory cache size in bytes
pub fn memory_cache_bytes(&self) -> usize {
self.cache.memory_mb * 1024 * 1024
}
/// Disk cache size in bytes
pub fn disk_cache_bytes(&self) -> usize {
self.cache.disk_mb * 1024 * 1024
}
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
server: ServerConfig::default(),
audio: AudioConfig::default(),
cache: CacheConfig::default(),
data: DataConfig::default(),
mpris: MprisConfig::default(),
qconnect: QConnectConfig::default(),
mdns: MdnsConfig::default(),
logging: LoggingConfig::default(),
}
}
}
/// Generate a random 32-character hex token
pub fn generate_token() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("{:032x}", seed)
}
+97
View File
@@ -0,0 +1,97 @@
use std::sync::Arc;
use tokio::sync::broadcast;
use crate::adapter::{DaemonAdapter, DaemonEvent};
use crate::config::DaemonConfig;
/// Central state container for the headless daemon.
/// Replaces Tauri's app.state::<T>() with direct field access.
pub struct DaemonCore {
pub config: DaemonConfig,
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]);
// Create event bus
let (event_tx, _) = broadcast::channel::<DaemonEvent>(256);
// Create adapter (will be used by QbzCore)
let _adapter = DaemonAdapter::new(event_tx.clone());
let daemon = Arc::new(DaemonCore {
config: config.clone(),
event_bus: event_tx,
});
log::info!(
"[qbzd] Daemon ready on {}:{}",
config.server.bind,
config.server.port
);
// Start HTTP server
let bind_addr = format!("{}:{}", config.server.bind, config.server.port);
let listener = tokio::net::TcpListener::bind(&bind_addr)
.await
.map_err(|e| format!("Failed to bind {}: {}", bind_addr, e))?;
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())
}));
// Graceful shutdown on SIGTERM/SIGINT
let shutdown = async {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("Failed to register SIGTERM handler");
#[cfg(unix)]
tokio::select! {
_ = ctrl_c => log::info!("[qbzd] Received SIGINT, shutting down..."),
_ = sigterm.recv() => log::info!("[qbzd] Received SIGTERM, shutting down..."),
}
#[cfg(not(unix))]
ctrl_c.await.ok();
};
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await
.map_err(|e| format!("HTTP server error: {}", e))?;
log::info!("[qbzd] Shutdown complete");
Ok(())
}
async fn ping_handler() -> &'static str {
"pong"
}
async fn info_handler(daemon: Arc<DaemonCore>) -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({
"name": "qbzd",
"version": env!("CARGO_PKG_VERSION"),
"cache": {
"memory_mb": daemon.config.cache.memory_mb,
"disk_mb": daemon.config.cache.disk_mb,
"prefetch_count": daemon.config.cache.prefetch_count,
},
}))
}
+120
View File
@@ -0,0 +1,120 @@
mod config;
mod daemon;
mod adapter;
mod resources;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "qbzd", about = "QBZ headless music daemon")]
#[command(version)]
struct Cli {
/// HTTP port
#[arg(short, long, default_value_t = 8182)]
port: u16,
/// Bind address
#[arg(short, long, default_value = "0.0.0.0")]
bind: String,
/// Data directory
#[arg(short, long)]
data_dir: Option<String>,
/// Config file path
#[arg(short, long)]
config: Option<String>,
/// Auth token (auto-generated if not provided)
#[arg(long)]
token: Option<String>,
/// Disable MPRIS/D-Bus integration
#[arg(long)]
no_mpris: bool,
/// Log level
#[arg(long, default_value = "info")]
log_level: String,
/// Print a new token and exit
#[arg(long)]
generate_token: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Interactive Qobuz login (OAuth via system browser)
Login,
/// Show daemon status
Status,
/// Show or regenerate API token
Token,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
// Init logging
env_logger::Builder::new()
.filter_level(cli.log_level.parse().unwrap_or(log::LevelFilter::Info))
.format_timestamp_millis()
.init();
log::info!("qbzd starting...");
// Load config (TOML file -> env vars -> CLI overrides)
let mut cfg = config::DaemonConfig::load(cli.config.as_deref());
// CLI overrides
cfg.server.port = cli.port;
cfg.server.bind = cli.bind.clone();
if let Some(ref dir) = cli.data_dir {
cfg.data.dir = dir.clone();
}
if let Some(ref token) = cli.token {
cfg.server.token = token.clone();
}
cfg.mpris.enabled = !cli.no_mpris;
// Generate token and exit
if cli.generate_token {
let token = config::generate_token();
println!("{}", token);
return;
}
// Auto-detect resources
resources::auto_detect_cache_config(&mut cfg);
// Handle subcommands
match cli.command {
Some(Commands::Login) => {
log::info!("Starting interactive login...");
// TODO: Phase 1 — OAuth via system browser
eprintln!("Login not yet implemented. Use qbzd.toml to configure credentials.");
}
Some(Commands::Status) => {
println!("qbzd status: not running (status check requires daemon to be active)");
}
Some(Commands::Token) => {
if cfg.server.token == "auto" {
let token = config::generate_token();
println!("Generated token: {}", token);
} else {
println!("Current token: {}", cfg.server.token);
}
}
None => {
// Main daemon mode
if let Err(e) = daemon::run(cfg).await {
log::error!("Daemon exited with error: {}", e);
std::process::exit(1);
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
use crate::config::DaemonConfig;
/// Auto-detect system RAM and adjust cache configuration.
/// Only runs if cache.auto.enabled is true.
pub fn auto_detect_cache_config(config: &mut DaemonConfig) {
let sys = sysinfo::System::new_with_specifics(
sysinfo::RefreshKind::nothing().with_memory(sysinfo::MemoryRefreshKind::everything()),
);
let total_ram_mb = sys.total_memory() / (1024 * 1024);
log::info!("[qbzd] System RAM: {} MB", total_ram_mb);
if config.cache.auto.enabled && config.cache.memory_mb == 0 {
// Conservative: 1/8 of RAM, capped at 400 MB, minimum 50 MB
let auto_memory = (total_ram_mb / 8).min(400).max(50) as usize;
config.cache.memory_mb = auto_memory;
log::info!("[qbzd] Auto-detected L1 cache: {} MB", auto_memory);
}
// Adjust prefetch for low-memory devices
if total_ram_mb <= 1024 && config.cache.prefetch_count > 2 {
config.cache.prefetch_count = 2;
config.cache.prefetch_concurrent = 1;
config.cache.cmaf_concurrent_segments = 2;
log::info!("[qbzd] Low RAM detected, reduced prefetch to 2 tracks / 1 concurrent");
}
log::info!(
"[qbzd] Cache config: L1={} MB, L2={} MB, prefetch={} tracks, concurrent={}, cmaf_segments={}",
config.cache.memory_mb,
config.cache.disk_mb,
config.cache.prefetch_count,
config.cache.prefetch_concurrent,
config.cache.cmaf_concurrent_segments,
);
}