>| {
- let tx = tx.clone();
- async move {
- if let Some(code) = params.get("code_autorisation").or_else(|| params.get("code")) {
- let _ = tx.send(code.clone()).await;
- axum::response::Html(
- "\
- Login successful!
\
- You can close this tab and return to the terminal.
\
- "
- )
- } else {
- axum::response::Html(
- "\
- Login failed
\
- No authorization code received.
\
- "
- )
- }
- }
- }),
- );
-
- let server_handle = tokio::spawn(async move {
- axum::serve(listener, handler).await.ok();
- });
-
- // Always print the URL (works for both local and headless)
- println!("\n╔══════════════════════════════════════════════════╗");
- println!("║ Open this URL in any browser on your network: ║");
- println!("╚══════════════════════════════════════════════════╝\n");
- println!(" {}\n", oauth_url);
- println!("Callback listening on: {}", redirect_url);
- println!("Waiting for login ({}s timeout)...\n", OAUTH_TIMEOUT_SECS);
-
- // Try to open browser (works on local, silently fails on headless)
- let _ = open::that(&oauth_url);
-
- // Wait for code
- let code = tokio::time::timeout(
- std::time::Duration::from_secs(OAUTH_TIMEOUT_SECS),
- rx.recv(),
- )
- .await;
-
- server_handle.abort();
-
- let code = match code {
- Ok(Some(c)) => c,
- Ok(None) => return Err("Login cancelled".to_string()),
- Err(_) => return Err(format!("Login timed out after {}s", OAUTH_TIMEOUT_SECS)),
- };
-
- println!("Authorization code received. Exchanging for session...");
-
- let session = client
- .login_with_oauth_code(&code)
- .await
- .map_err(|e| format!("OAuth exchange failed: {}", e))?;
-
- println!(
- "\nLogged in as: {} (user_id: {})",
- session.display_name, session.user_id
- );
- println!("Subscription: {}", session.subscription_label);
-
- // Save token to keyring
- let token = session.user_auth_token.clone();
- save_token_to_keyring(&token)?;
-
- println!("\nCredentials saved. The daemon will auto-login on next start.");
- Ok(())
-}
-
-/// Save OAuth token — tries keyring first, falls back to encrypted file.
-fn save_token_to_keyring(token: &str) -> Result<(), String> {
- const SERVICE: &str = "qbz-player";
- const KEY: &str = "qobuz-oauth-token";
-
- // Try keyring first
- match keyring::Entry::new(SERVICE, KEY) {
- Ok(entry) => match entry.set_password(token) {
- Ok(()) => {
- println!("Token saved to system keyring");
- return Ok(());
- }
- Err(e) => {
- println!("Keyring unavailable ({}), using file fallback", e);
- }
- },
- Err(e) => {
- println!("Keyring unavailable ({}), using file fallback", e);
- }
- }
-
- // Fallback: save to file
- save_token_to_file(token)
-}
-
-fn token_file_path() -> Option {
- dirs::data_dir().map(|d| d.join("qbz").join(".oauth-token"))
-}
-
-fn save_token_to_file(token: &str) -> Result<(), String> {
- let path = token_file_path().ok_or("Cannot determine data directory")?;
- if let Some(parent) = path.parent() {
- std::fs::create_dir_all(parent).ok();
- }
- std::fs::write(&path, token).map_err(|e| format!("Failed to write token file: {}", e))?;
- // Restrict permissions on Unix
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
- }
- println!("Token saved to {}", path.display());
- Ok(())
-}
-
-pub fn load_token_from_file() -> Option {
- let path = token_file_path()?;
- std::fs::read_to_string(&path).ok().filter(|t| !t.trim().is_empty())
-}
-
-/// Detect the primary LAN IP address of this machine.
-fn detect_lan_ip() -> Option {
- // Try to get the default route interface IP by connecting to a public DNS
- // (no actual data is sent, just gets the local IP used for routing)
- let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
- socket.connect("8.8.8.8:80").ok()?;
- let addr = socket.local_addr().ok()?;
- let ip = addr.ip().to_string();
- if ip == "0.0.0.0" || ip == "127.0.0.1" {
- return None;
- }
- Some(ip)
-}
diff --git a/crates/qbzd/src/main.rs b/crates/qbzd/src/main.rs
deleted file mode 100644
index b3f0d9346..000000000
--- a/crates/qbzd/src/main.rs
+++ /dev/null
@@ -1,157 +0,0 @@
-mod api;
-mod config;
-mod daemon;
-mod adapter;
-mod login;
-mod mpris;
-mod qconnect;
-mod resources;
-mod wizard;
-mod session;
-
-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,
-
- /// Config file path
- #[arg(short, long)]
- config: Option,
-
- /// Auth token (auto-generated if not provided)
- #[arg(long)]
- token: Option,
-
- /// 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,
-}
-
-#[derive(Subcommand)]
-enum Commands {
- /// Interactive Qobuz login (OAuth via system browser)
- Login,
- /// Show daemon status
- Status,
- /// Show or regenerate API token
- Token,
- /// Interactive setup wizard (audio, playback, integrations)
- Setup {
- /// Run only a specific section (audio, playback, cache, integrations, qconnect)
- #[arg(long)]
- section: Option,
- },
-}
-
-#[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) => {
- if let Err(e) = login::interactive_login().await {
- eprintln!("Login failed: {}", e);
- std::process::exit(1);
- }
- }
- Some(Commands::Status) => {
- let port = cfg.server.port;
- match reqwest::Client::new()
- .get(format!("http://127.0.0.1:{}/api/status", port))
- .timeout(std::time::Duration::from_secs(3))
- .send()
- .await
- {
- Ok(resp) if resp.status().is_success() => {
- let body: serde_json::Value = resp.json().await.unwrap_or_default();
- println!("qbzd running on port {}", port);
- println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default());
- }
- Ok(resp) => {
- println!("qbzd responded with HTTP {}", resp.status());
- }
- Err(_) => {
- println!("qbzd not running (no response on port {})", port);
- }
- }
- }
- Some(Commands::Setup { section }) => {
- if let Err(e) = wizard::run(section.as_deref()) {
- eprintln!("Setup wizard error: {}", e);
- std::process::exit(1);
- }
- }
- 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);
- }
- }
- }
-}
diff --git a/crates/qbzd/src/mpris.rs b/crates/qbzd/src/mpris.rs
deleted file mode 100644
index b16ea56b2..000000000
--- a/crates/qbzd/src/mpris.rs
+++ /dev/null
@@ -1,110 +0,0 @@
-//! Headless MPRIS media controls via D-Bus (Linux).
-//!
-//! Uses souvlaki with hwnd: None — works without a window on Linux.
-//! Receives media key events (play/pause/next/prev) and routes them
-//! to QbzCore.
-
-use std::sync::Arc;
-use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
-
-use crate::adapter::DaemonAdapter;
-
-/// Start MPRIS media controls in a background thread.
-/// Returns a handle to update metadata/playback state.
-pub fn start_mpris(
- core: Arc>,
-) -> Option>> {
- let config = PlatformConfig {
- dbus_name: "com.blitzfc.qbzd",
- display_name: "QBZ Daemon",
- hwnd: None,
- };
-
- let mut controls = match MediaControls::new(config) {
- Ok(mc) => mc,
- Err(e) => {
- log::warn!("[qbzd/mpris] Failed to create media controls: {:?}", e);
- return None;
- }
- };
-
- let core_for_handler = core.clone();
- if let Err(e) = controls.attach(move |event: MediaControlEvent| {
- let core = core_for_handler.clone();
- match event {
- MediaControlEvent::Play => { let _ = core.resume(); }
- MediaControlEvent::Pause => { let _ = core.pause(); }
- MediaControlEvent::Toggle => {
- if core.player().state.is_playing() {
- let _ = core.pause();
- } else {
- let _ = core.resume();
- }
- }
- MediaControlEvent::Next => {
- tokio::task::block_in_place(|| {
- tokio::runtime::Handle::current().block_on(async {
- core.next_track().await;
- });
- });
- }
- MediaControlEvent::Previous => {
- tokio::task::block_in_place(|| {
- tokio::runtime::Handle::current().block_on(async {
- core.previous_track().await;
- });
- });
- }
- MediaControlEvent::Stop => { let _ = core.stop(); }
- _ => {}
- }
- }) {
- log::warn!("[qbzd/mpris] Failed to attach handler: {:?}", e);
- return None;
- }
-
- // Set initial state
- let _ = controls.set_playback(MediaPlayback::Stopped);
- log::info!("[qbzd/mpris] MPRIS media controls initialized (D-Bus)");
-
- Some(Arc::new(std::sync::Mutex::new(controls)))
-}
-
-/// Update MPRIS metadata from playback state.
-pub fn update_mpris_metadata(
- controls: &Arc>,
- title: &str,
- artist: &str,
- album: &str,
- duration_secs: u64,
-) {
- if let Ok(mut mc) = controls.lock() {
- let _ = mc.set_metadata(MediaMetadata {
- title: Some(title),
- artist: Some(artist),
- album: Some(album),
- duration: Some(std::time::Duration::from_secs(duration_secs)),
- ..Default::default()
- });
- }
-}
-
-/// Update MPRIS playback state.
-pub fn update_mpris_playback(
- controls: &Arc>,
- is_playing: bool,
- position_secs: u64,
-) {
- if let Ok(mut mc) = controls.lock() {
- let playback = if is_playing {
- MediaPlayback::Playing {
- progress: Some(souvlaki::MediaPosition(std::time::Duration::from_secs(position_secs))),
- }
- } else {
- MediaPlayback::Paused {
- progress: Some(souvlaki::MediaPosition(std::time::Duration::from_secs(position_secs))),
- }
- };
- let _ = mc.set_playback(playback);
- }
-}
diff --git a/crates/qbzd/src/qconnect.rs b/crates/qbzd/src/qconnect.rs
deleted file mode 100644
index fb69f0470..000000000
--- a/crates/qbzd/src/qconnect.rs
+++ /dev/null
@@ -1,681 +0,0 @@
-//! Headless QConnect integration.
-//!
-//! Replicates the exact flow from qconnect_service.rs in the desktop app:
-//! 1. Create transport + sink + app
-//! 2. Connect transport
-//! 3. Subscribe to transport events
-//! 4. Start event loop (checks InboundQueueServerEvent for SESSION_STATE)
-//! 5. Bootstrap: CtrlSrvrJoinSession + AskForQueueState
-//! 6. Event loop receives SESSION_STATE → deferred renderer join
-
-use std::sync::Arc;
-use async_trait::async_trait;
-use qconnect_app::{
- QconnectApp, QconnectAppEvent, QconnectEventSink,
- QueueCommandType, RendererReport, RendererReportType,
-};
-use qconnect_core::{QConnectQueueState, RendererCommand};
-use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig};
-use qbz_models::{QueueTrack, Track};
-use tokio::sync::broadcast;
-use uuid::Uuid;
-
-use crate::adapter::{DaemonAdapter, DaemonEvent};
-
-const PLAYING_STATE_STOPPED: i32 = 1;
-const PLAYING_STATE_PLAYING: i32 = 2;
-const PLAYING_STATE_PAUSED: i32 = 3;
-const BUFFER_STATE_OK: i32 = 2;
-const JOIN_SESSION_REASON_CONTROLLER_REQUEST: i32 = 1;
-const AUDIO_QUALITY_HIRES_LEVEL2: i32 = 4;
-
-type App = QconnectApp;
-
-/// Event sink — handles renderer commands for playback.
-pub struct HeadlessQconnectSink {
- event_tx: broadcast::Sender,
- core: Arc>,
- qconnect_next_track_id: Arc,
-}
-
-impl HeadlessQconnectSink {
- pub fn new(
- event_tx: broadcast::Sender,
- core: Arc>,
- qconnect_next_track_id: Arc,
- ) -> Self {
- Self { event_tx, core, qconnect_next_track_id }
- }
-}
-
-#[async_trait]
-impl QconnectEventSink for HeadlessQconnectSink {
- async fn on_event(&self, event: QconnectAppEvent) {
- match &event {
- QconnectAppEvent::TransportConnected => {
- log::info!("[qbzd/qconnect] Connected to Qobuz servers");
- }
- QconnectAppEvent::TransportDisconnected => {
- log::warn!("[qbzd/qconnect] Disconnected");
- }
- QconnectAppEvent::RendererCommandApplied { command, .. } => {
- log::info!("[qbzd/qconnect] Command: {:?}", command);
- handle_renderer_command(&self.core, command, &self.qconnect_next_track_id).await;
- }
- QconnectAppEvent::QueueUpdated(queue_state) => {
- log::info!("[qbzd/qconnect] Queue: {} items", queue_state.queue_items.len());
- if let Err(e) = materialize_remote_queue(&self.core, queue_state).await {
- log::warn!("[qbzd/qconnect] Failed to materialize queue: {}", e);
- }
- }
- QconnectAppEvent::SessionManagementEvent { message_type, payload } => {
- log::info!("[qbzd/qconnect] Session mgmt: {}", message_type);
- }
- _ => {}
- }
- }
-}
-
-async fn handle_renderer_command(
- core: &qbz_core::QbzCore,
- command: &RendererCommand,
- qconnect_next_track_id: &Arc,
-) {
- match command {
- RendererCommand::SetState { playing_state, current_position_ms, current_track, next_track } => {
- // Track the next_track for orchestrator's auto-advance fallback
- if let Some(nt) = next_track {
- qconnect_next_track_id.store(nt.track_id, std::sync::atomic::Ordering::Release);
- log::info!("[qbzd/qconnect] Stored next_track for auto-advance: {}", nt.track_id);
- }
-
- // Step 1: If a current_track is specified, ensure it's loaded
- // (matches desktop's ensure_remote_track_loaded pattern)
- if let Some(track) = current_track {
- let player = core.player();
- let loaded_track_id = player.state.current_track_id();
- if loaded_track_id != track.track_id {
- log::info!(
- "[qbzd/qconnect] New track from renderer: {} (was {})",
- track.track_id, loaded_track_id
- );
- match download_and_play_track(core, track.track_id).await {
- Ok(()) => log::info!("[qbzd/qconnect] Track {} loaded", track.track_id),
- Err(e) => {
- log::error!("[qbzd/qconnect] Failed to load track {}: {}", track.track_id, e);
- return;
- }
- }
- }
- }
-
- // Step 2: Apply playing state (only if it actually changes)
- if let Some(state) = playing_state {
- let player = core.player();
- let already_playing = player.state.is_playing();
- match *state {
- PLAYING_STATE_PLAYING if !already_playing => { let _ = core.resume(); }
- PLAYING_STATE_PAUSED if already_playing => { let _ = core.pause(); }
- PLAYING_STATE_STOPPED => { let _ = core.stop(); }
- _ => {}
- }
- }
-
- // Step 3: Seek only for intentional seeks (>2s diff),
- // but ignore server echoes that want position 0 when we're already playing
- if let Some(pos_ms) = current_position_ms {
- let target_secs = *pos_ms / 1000;
- let current_secs = core.player().state.current_position();
- let is_playing = core.player().state.is_playing();
- // Skip seek-to-zero echoes from server refresh while playing
- let is_server_echo = target_secs <= 1 && is_playing && current_secs > 2;
- if current_secs.abs_diff(target_secs) > 2 && !is_server_echo {
- let _ = core.seek(target_secs);
- }
- }
- }
- RendererCommand::SetVolume { volume, .. } => {
- if let Some(vol) = volume {
- let _ = core.set_volume((*vol as f32 / 100.0).clamp(0.0, 1.0));
- }
- }
- RendererCommand::SetShuffleMode { shuffle_mode } => {
- core.set_shuffle(*shuffle_mode).await;
- }
- RendererCommand::SetLoopMode { loop_mode } => {
- let mode = match *loop_mode {
- 1 => qbz_models::RepeatMode::One,
- 2 => qbz_models::RepeatMode::All,
- _ => qbz_models::RepeatMode::Off,
- };
- core.set_repeat_mode(mode).await;
- }
- RendererCommand::MuteVolume { value } => {
- if *value { let _ = core.set_volume(0.0); }
- }
- _ => {}
- }
-}
-
-/// Download a track and feed it to the player.
-/// Mirrors desktop's load_remote_track_into_player → download fallback path.
-async fn download_and_play_track(
- core: &qbz_core::QbzCore,
- track_id: u64,
-) -> Result<(), String> {
- let quality = qbz_models::Quality::HiRes;
- let stream_url = core.get_stream_url(track_id, quality).await
- .map_err(|e| format!("Stream URL failed for {}: {}", track_id, e))?;
-
- log::info!(
- "[qbzd/qconnect] Downloading track {} ({:.0}kHz/{}bit)",
- track_id, stream_url.sampling_rate, stream_url.bit_depth.unwrap_or(0)
- );
-
- let http = reqwest::Client::builder()
- .connect_timeout(std::time::Duration::from_secs(10))
- .build()
- .map_err(|e| format!("HTTP client error: {}", e))?;
-
- let data = http.get(&stream_url.url).send().await
- .map_err(|e| format!("Download failed for {}: {}", track_id, e))?
- .bytes().await
- .map_err(|e| format!("Read failed for {}: {}", track_id, e))?
- .to_vec();
-
- log::info!("[qbzd/qconnect] Track {} downloaded ({} bytes)", track_id, data.len());
-
- core.player()
- .play_data(data, track_id)
- .map_err(|e| format!("Player error for {}: {}", track_id, e))?;
-
- Ok(())
-}
-
-/// Materialize the QConnect remote queue into the local QbzCore queue.
-/// This enables the local orchestrator's auto-advance and gapless logic
-/// to work when QConnect is the playback source.
-/// Mirrors desktop's materialize_remote_queue from qconnect_service.rs.
-async fn materialize_remote_queue(
- core: &qbz_core::QbzCore,
- queue_state: &QConnectQueueState,
-) -> Result<(), String> {
- if queue_state.queue_items.is_empty() {
- log::info!("[qbzd/qconnect] Empty queue, skipping materialization");
- return Ok(());
- }
-
- // Dedupe track_ids before batch fetch
- let mut unique_ids: Vec = Vec::with_capacity(queue_state.queue_items.len());
- for item in &queue_state.queue_items {
- if !unique_ids.contains(&item.track_id) {
- unique_ids.push(item.track_id);
- }
- }
-
- log::info!(
- "[qbzd/qconnect] Materializing remote queue: {} items, {} unique tracks",
- queue_state.queue_items.len(),
- unique_ids.len()
- );
-
- let fetched_tracks = core
- .get_tracks_batch(&unique_ids)
- .await
- .map_err(|e| format!("batch fetch failed: {}", e))?;
-
- let mut tracks_by_id: std::collections::HashMap =
- std::collections::HashMap::with_capacity(fetched_tracks.len());
- for track in fetched_tracks {
- tracks_by_id.insert(track.id, track_to_queue_track(&track));
- }
-
- // Build the queue in QConnect order
- let mut queue_tracks = Vec::with_capacity(queue_state.queue_items.len());
- for item in &queue_state.queue_items {
- if let Some(qt) = tracks_by_id.get(&item.track_id) {
- queue_tracks.push(qt.clone());
- } else {
- log::warn!(
- "[qbzd/qconnect] Track {} missing from batch fetch, skipping",
- item.track_id
- );
- }
- }
-
- if queue_tracks.is_empty() {
- return Err("zero playable tracks after materialization".to_string());
- }
-
- // Determine start index from currently playing track (if any)
- let player = core.player();
- let current_track_id = player.state.current_track_id();
- let start_index = if current_track_id != 0 {
- queue_state
- .queue_items
- .iter()
- .position(|item| item.track_id == current_track_id)
- } else {
- Some(0)
- };
-
- log::info!(
- "[qbzd/qconnect] Setting local queue: {} tracks, start_index={:?}",
- queue_tracks.len(),
- start_index
- );
-
- core.set_queue(queue_tracks, start_index).await;
- Ok(())
-}
-
-/// Convert qbz_models::Track to QueueTrack.
-/// Mirrors desktop's model_track_to_core_queue_track.
-fn track_to_queue_track(track: &Track) -> QueueTrack {
- let artwork_url = track
- .album
- .as_ref()
- .and_then(|album| album.image.large.clone());
- let artist = track
- .performer
- .as_ref()
- .map(|p| p.name.clone())
- .unwrap_or_else(|| "Unknown Artist".to_string());
- let album_title = track
- .album
- .as_ref()
- .map(|a| a.title.clone())
- .unwrap_or_else(|| "Unknown Album".to_string());
- let album_id = track.album.as_ref().and_then(|a| {
- let trimmed = a.id.trim();
- if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
- });
- let artist_id = track.performer.as_ref().map(|p| p.id);
-
- QueueTrack {
- id: track.id,
- title: track.title.clone(),
- artist,
- album: album_title,
- duration_secs: track.duration as u64,
- artwork_url,
- hires: track.hires,
- bit_depth: track.maximum_bit_depth,
- sample_rate: track.maximum_sampling_rate,
- is_local: false,
- album_id: album_id.clone(),
- artist_id,
- streamable: track.streamable,
- source: Some("qobuz".to_string()),
- parental_warning: track.parental_warning,
- source_item_id_hint: album_id,
- }
-}
-
-/// Start QConnect — exact replica of desktop's QconnectServiceState::connect().
-pub async fn start_qconnect(
- core: &Arc>,
- event_tx: broadcast::Sender,
- device_name: &str,
- qconnect_next_track_id: Arc