From e5539132dadd791b5db50f7e644e7673f1f90966 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 9 Apr 2026 05:39:34 -0600 Subject: [PATCH] feat(qbzd): QConnect headless integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/qbzd/Cargo.toml | 2 + crates/qbzd/src/daemon.rs | 18 ++++++ crates/qbzd/src/main.rs | 1 + crates/qbzd/src/qconnect.rs | 123 ++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+) create mode 100644 crates/qbzd/src/qconnect.rs diff --git a/crates/qbzd/Cargo.toml b/crates/qbzd/Cargo.toml index d3c6c6b66..b890f5160 100644 --- a/crates/qbzd/Cargo.toml +++ b/crates/qbzd/Cargo.toml @@ -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"] } diff --git a/crates/qbzd/src/daemon.rs b/crates/qbzd/src/daemon.rs index 25d9b846a..3be0b7fa9 100644 --- a/crates/qbzd/src/daemon.rs +++ b/crates/qbzd/src/daemon.rs @@ -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."); diff --git a/crates/qbzd/src/main.rs b/crates/qbzd/src/main.rs index 49982e1f3..a3a1ee0e9 100644 --- a/crates/qbzd/src/main.rs +++ b/crates/qbzd/src/main.rs @@ -4,6 +4,7 @@ mod daemon; mod adapter; mod login; mod mpris; +mod qconnect; mod resources; mod session; diff --git a/crates/qbzd/src/qconnect.rs b/crates/qbzd/src/qconnect.rs new file mode 100644 index 000000000..ff71f8f69 --- /dev/null +++ b/crates/qbzd/src/qconnect.rs @@ -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, +} + +impl HeadlessQconnectSink { + pub fn new(event_tx: broadcast::Sender) -> 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, + event_tx: broadcast::Sender, + device_name: &str, +) -> Option>> { + // 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)) +}