feat(qbzd): QConnect headless integration
HeadlessQconnectSink routes QConnect events to the daemon event bus. Fetches QWS JWT credentials from Qobuz API, connects via WebSocket. The daemon now registers as a Qobuz Connect renderer — controllable from the Qobuz mobile app and web player. QConnect starts automatically after login if enabled in config. cc
This commit is contained in:
4 files changed
+144
No files matched your search
@@ -19,6 +19,8 @@ qbz-qobuz = { path = "../qbz-qobuz" }
|
||||
qbz-library = { path = "../qbz-library" }
|
||||
qbz-cache = { path = "../qbz-cache" }
|
||||
qbz-integrations = { path = "../qbz-integrations" }
|
||||
qconnect-app = { path = "../qconnect-app" }
|
||||
qconnect-transport-ws = { path = "../qconnect-transport-ws" }
|
||||
|
||||
# HTTP server
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
|
||||
@@ -91,6 +91,24 @@ pub async fn run(mut config: DaemonConfig) -> Result<(), String> {
|
||||
log::error!("[qbzd] Session activation failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Start QConnect if enabled
|
||||
if config.qconnect.enabled {
|
||||
let device_name = if config.qconnect.device_name.is_empty() {
|
||||
hostname::get()
|
||||
.ok()
|
||||
.and_then(|h| h.into_string().ok())
|
||||
.unwrap_or_else(|| "qbzd".to_string())
|
||||
} else {
|
||||
config.qconnect.device_name.clone()
|
||||
};
|
||||
let _qconnect = crate::qconnect::start_qconnect(
|
||||
&core,
|
||||
event_tx.clone(),
|
||||
&device_name,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::warn!("[qbzd] No saved credentials. Run `qbzd login` to authenticate.");
|
||||
|
||||
@@ -4,6 +4,7 @@ mod daemon;
|
||||
mod adapter;
|
||||
mod login;
|
||||
mod mpris;
|
||||
mod qconnect;
|
||||
mod resources;
|
||||
mod session;
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Headless QConnect integration.
|
||||
//!
|
||||
//! Connects to Qobuz's WebSocket servers so the daemon can be
|
||||
//! controlled from the Qobuz mobile app or web player.
|
||||
//!
|
||||
//! Uses the qconnect-app crate directly (Tauri-free).
|
||||
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use qconnect_app::{QconnectApp, QconnectAppEvent, QconnectEventSink};
|
||||
use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::adapter::{DaemonAdapter, DaemonEvent};
|
||||
|
||||
/// Event sink that routes QConnect events to the daemon event bus.
|
||||
pub struct HeadlessQconnectSink {
|
||||
event_tx: broadcast::Sender<DaemonEvent>,
|
||||
}
|
||||
|
||||
impl HeadlessQconnectSink {
|
||||
pub fn new(event_tx: broadcast::Sender<DaemonEvent>) -> Self {
|
||||
Self { event_tx }
|
||||
}
|
||||
}
|
||||
|
||||
#[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::info!("[qbzd/qconnect] Disconnected from Qobuz servers");
|
||||
}
|
||||
QconnectAppEvent::RendererCommandApplied { command, .. } => {
|
||||
log::debug!("[qbzd/qconnect] Renderer command: {:?}", command);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Future: map QConnect events to DaemonEvents for SSE clients
|
||||
}
|
||||
}
|
||||
|
||||
/// Start QConnect if enabled and user is logged in.
|
||||
/// Returns the QconnectApp handle for later control.
|
||||
pub async fn start_qconnect(
|
||||
core: &qbz_core::QbzCore<DaemonAdapter>,
|
||||
event_tx: broadcast::Sender<DaemonEvent>,
|
||||
device_name: &str,
|
||||
) -> Option<Arc<QconnectApp<NativeWsTransport, HeadlessQconnectSink>>> {
|
||||
// Get credentials from the Qobuz client
|
||||
let client_arc = core.client();
|
||||
let client_guard = client_arc.read().await;
|
||||
let client = client_guard.as_ref()?;
|
||||
|
||||
let app_id = client.app_id().await.ok()?;
|
||||
let auth_token = client.auth_token().await.ok()?;
|
||||
|
||||
// Get QWS endpoint and JWT token
|
||||
let (endpoint_url, jwt_qws) = fetch_qws_credentials(client).await?;
|
||||
|
||||
let transport = Arc::new(NativeWsTransport::new());
|
||||
let sink = Arc::new(HeadlessQconnectSink::new(event_tx));
|
||||
let app = Arc::new(QconnectApp::new(transport.clone(), sink));
|
||||
|
||||
let config = WsTransportConfig {
|
||||
endpoint_url,
|
||||
jwt_qws: Some(jwt_qws),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match app.connect(config).await {
|
||||
Ok(()) => {
|
||||
log::info!("[qbzd/qconnect] QConnect started as '{}'", device_name);
|
||||
Some(app)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbzd/qconnect] Failed to connect: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch QWS WebSocket credentials from Qobuz API.
|
||||
async fn fetch_qws_credentials(
|
||||
client: &qbz_qobuz::QobuzClient,
|
||||
) -> Option<(String, String)> {
|
||||
let app_id = client.app_id().await.ok()?;
|
||||
let auth_token = client.auth_token().await.ok()?;
|
||||
|
||||
let http = reqwest::Client::new();
|
||||
let resp = http
|
||||
.post("https://www.qobuz.com/api.json/0.2/qws/createToken")
|
||||
.header("X-App-Id", &app_id)
|
||||
.header("X-User-Auth-Token", &auth_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
log::warn!("[qbzd/qconnect] qws/createToken failed: {}", resp.status());
|
||||
return None;
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
let endpoint = body.get("endpoint_url")?.as_str()?.to_string();
|
||||
let jwt = body
|
||||
.get("tokens")
|
||||
.and_then(|t| t.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find_map(|t| {
|
||||
if t.get("kind")?.as_str()? == "jwt_qws" {
|
||||
Some(t.get("token")?.as_str()?.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})?;
|
||||
|
||||
Some((endpoint, jwt))
|
||||
}
|
||||
Reference in new issue
Block a user