feat(qbzd): LAN CORS, headless MPRIS, security model

LAN-only CORS: allows any origin on local network for qbz-control PWA.

MPRIS via souvlaki (D-Bus, no window): play/pause/toggle/next/prev/stop
media keys work out of the box on any Linux desktop or headless system
with D-Bus. Metadata and playback state updates available via helpers.

Security model finalized:
- LAN: open access, no auth needed (private IP check)
- WAN: completely blocked (403 for non-private IPs)
- Remote: QConnect only (cloud, Qobuz-managed auth)

cc
This commit is contained in:
vicrodh committed 2026-04-09 05:04:09 -06:00
1 parent 892560dff4
commit f9d6e551e2
4 files changed
+129

No files matched your search

+3
View File
@@ -57,3 +57,6 @@ rusqlite = { version = "0.31", features = ["bundled"] }
# Credentials (shared keyring with desktop app)
keyring = { version = "3", features = ["async-secret-service", "crypto-rust"] }
# MPRIS media controls (Linux D-Bus, headless)
souvlaki = "0.8"
+15
View File
@@ -100,6 +100,14 @@ pub async fn run(mut config: DaemonConfig) -> Result<(), String> {
// Start playback state polling loop (broadcasts to event bus)
spawn_playback_loop(daemon.core.clone(), daemon.event_bus.clone());
// Start MPRIS media controls (Linux D-Bus, headless)
let _mpris = if config.mpris.enabled {
crate::mpris::start_mpris(daemon.core.clone())
} else {
log::info!("[qbzd] MPRIS disabled via config");
None
};
// Register mDNS service for LAN discovery
let _mdns_handle = if config.mdns.enabled {
match register_mdns(&config) {
@@ -422,6 +430,13 @@ fn build_router(daemon: Arc<DaemonCore>) -> axum::Router {
// System / Resources
.route("/api/system/resources", get(with_daemon!(daemon, system::get_resources)))
.route("/api/cache", delete(with_daemon!(daemon, system::clear_cache)))
// CORS: allow qbz-control PWA and any LAN origin
.layer(
tower_http::cors::CorsLayer::new()
.allow_origin(tower_http::cors::Any)
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any)
)
// LAN-only: reject requests from non-private IPs
.layer(axum_mw::from_fn(middleware::lan_only))
}
+1
View File
@@ -3,6 +3,7 @@ mod config;
mod daemon;
mod adapter;
mod login;
mod mpris;
mod resources;
mod session;
+110
View File
@@ -0,0 +1,110 @@
//! 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<qbz_core::QbzCore<DaemonAdapter>>,
) -> Option<Arc<std::sync::Mutex<MediaControls>>> {
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<std::sync::Mutex<MediaControls>>,
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<std::sync::Mutex<MediaControls>>,
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);
}
}