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:
7 files changed
+611
No files matched your search
@@ -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"] }
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user