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:
vicrodh committed 2026-04-09 05:39:34 -06:00
1 parent b3e5be39eb
commit e5539132da
4 files changed
+144

No files matched your search

+2
View File
@@ -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"] }
+18
View File
@@ -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.");
+1
View File
@@ -4,6 +4,7 @@ mod daemon;
mod adapter;
mod login;
mod mpris;
mod qconnect;
mod resources;
mod session;
+123
View File
@@ -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))
}