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:
4 files changed
+129
No files matched your search
@@ -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"
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ mod config;
|
||||
mod daemon;
|
||||
mod adapter;
|
||||
mod login;
|
||||
mod mpris;
|
||||
mod resources;
|
||||
mod session;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user