feat(qt): Qobuz Connect lists renderers on this network and pairs them

A LAN-only receiver — a BluOS player, another QBZ — was invisible until an
official app paired it into the session; that is the "it showed up once,
for a day" report. The Qt LAN runtime now browses _qobuz-connect._tcp
alongside its own receiver (same lifetime: Qobuz Connect enabled and
ready), probes every announcement that is not us, and the picker shows the
answers under "On this network" with a Pair action. Pairing mints the
delegated pair with /qws/delegateAuth for the renderer's app id and hands
it over with this controller's session uuid and become_active, exactly as
the official controllers do; the cloud then lists the device and its LAN
row drops out by uuid. scripts/qconnect-lan-fake-renderer.sh is a receiver
that never joins the cloud, for exercising this without a Node. Pending:
qbzd parity, the 5-minute retry backoff, become_active=false.

cc
This commit is contained in:
vicrodh committed 2026-09-13 13:46:13 -06:00
1 parent c60cdec064
commit a106ef790c
14 files changed
+536 -20

No files matched your search

@@ -7618,3 +7618,11 @@ msgstr "Benutzerdefiniert ({} LUFS)"
#: qbz-qt/qml/settings/NavigationSettings.qml
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
msgstr "Zeigt den Bereich Käufe neben den anderen Bereichen – in der Seitenleiste oder in der Kopfzeile, wo immer sie sind – zum Stöbern und Herunterladen deiner gekauften Musik"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "On this network"
msgstr "In diesem Netzwerk"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Koppeln"
@@ -3257,3 +3257,11 @@ msgstr "Custom ({} LUFS)"
#: qbz-qt/qml/settings/NavigationSettings.qml
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
msgstr "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "On this network"
msgstr "On this network"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Pair"
@@ -7624,3 +7624,11 @@ msgstr "Personalizado ({} LUFS)"
#: qbz-qt/qml/settings/NavigationSettings.qml
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
msgstr "Muestra la sección Compras junto a las demás secciones (en la barra lateral o en el encabezado, donde estén) para explorar y descargar tu música comprada"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "On this network"
msgstr "En esta red"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Vincular"
@@ -7616,3 +7616,11 @@ msgstr "Personnalisé ({} LUFS)"
#: qbz-qt/qml/settings/NavigationSettings.qml
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
msgstr "Affiche la section Achats à côté des autres sections — dans la barre latérale ou l'en-tête, où qu'elles soient — pour parcourir et télécharger votre musique achetée"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "On this network"
msgstr "Sur ce réseau"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Associer"
@@ -7599,3 +7599,11 @@ msgstr "カスタム({} LUFS)"
#: qbz-qt/qml/settings/NavigationSettings.qml
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
msgstr "購入セクションを他のセクションと同じ場所(サイドバーまたはヘッダー)に表示し、購入した音楽を閲覧・ダウンロードできます"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "On this network"
msgstr "このネットワーク上"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "ペアリング"
@@ -7606,3 +7606,11 @@ msgstr "Aangepast ({} LUFS)"
#: qbz-qt/qml/settings/NavigationSettings.qml
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
msgstr "Toont de sectie Aankopen naast de andere secties — in de zijbalk of de koptekst, waar ze ook staan — om je gekochte muziek te bekijken en te downloaden"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "On this network"
msgstr "In dit netwerk"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Koppelen"
@@ -7616,3 +7616,11 @@ msgstr "Personalizado ({} LUFS)"
#: qbz-qt/qml/settings/NavigationSettings.qml
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
msgstr "Mostra a seção Compras ao lado das outras seções (na barra lateral ou no cabeçalho, onde estiverem) para navegar e baixar suas músicas compradas"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "On this network"
msgstr "Nesta rede"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Parear"
@@ -7637,3 +7637,11 @@ msgstr "Своё значение ({} LUFS)"
#: qbz-qt/qml/settings/NavigationSettings.qml
msgid "Show the Purchases section beside the other sections — in the sidebar or the header, wherever they are — for browsing and downloading your purchased music"
msgstr "Показывает раздел «Покупки» рядом с остальными разделами — в боковой панели или в заголовке, где бы они ни были — для просмотра и загрузки купленной музыки"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "On this network"
msgstr "В этой сети"
#: qbz-qt/qml/shell/QconnectFlyout.qml
msgid "Pair"
msgstr "Подключить"
+77 -2
View File
@@ -49,7 +49,8 @@ Popup {
// The parsed device document (QbzQConnect.devicesJson). Rows:
// { renderer_id, name, is_local, is_active, icon } — the exact shape the
// bridge header documents.
// bridge header documents — plus, for a renderer seen on this network
// that the session does not list yet, `lan: true` and `lan_uuid`.
readonly property var devices: {
try {
var d = JSON.parse(QbzQConnect.devicesJson || "[]")
@@ -58,6 +59,8 @@ Popup {
return []
}
}
readonly property var sessionDevices: root.devices.filter(function (d) { return d.lan !== true })
readonly property var lanDevices: root.devices.filter(function (d) { return d.lan === true })
readonly property var conflictPolicyOptions: [
QbzSession.tr("Ask every time", QbzSession.trRev),
@@ -329,7 +332,7 @@ Popup {
spacing: 2
Repeater {
model: root.devices
model: root.sessionDevices
delegate: Rectangle {
id: deviceRow
@@ -399,6 +402,78 @@ Popup {
}
}
}
// Renderers on this network that the session does not
// list yet (the controller's LAN half, 2026-09-13). The
// official apps show these the same way; picking one
// pairs it — delegated credentials over the LAN — and the
// cloud then lists it above like any other renderer.
Text {
visible: root.lanDevices.length > 0
width: devRows.width
leftPadding: 8
topPadding: 8
bottomPadding: 2
text: QbzSession.tr("On this network", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
}
Repeater {
model: root.lanDevices
delegate: Rectangle {
id: lanRow
required property var modelData
width: devRows.width
height: 34
radius: theme.radiusSm
color: lanArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
QbzIcon {
name: lanRow.modelData.icon === "mobile" ? "smartphone"
: lanRow.modelData.icon === "computer" ? "monitor"
: "speaker"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "secondary"
}
Text {
width: parent.width - 14 - 8 - pairLabel.implicitWidth - 8
text: lanRow.modelData.name || ""
color: theme.textSecondary
font.pixelSize: 12
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: pairLabel
text: QbzSession.tr("Pair", QbzSession.trRev)
color: theme.accent
font.pixelSize: 11
font.weight: theme.weightSemibold
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: lanArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
QbzQConnect.pairLan(lanRow.modelData.lan_uuid)
root.close()
}
}
}
}
}
}
}
+24
View File
@@ -91,6 +91,13 @@ pub mod qbz_qconnect {
#[qinvokable]
fn set_active(self: Pin<&mut QbzQConnect>, renderer_id: i32);
/// Picker row "On this network": hand the renderer this account's
/// delegated credentials so it joins the session (LAN pairing, the
/// same step the official apps take). `device_uuid` is the row's
/// `lan_uuid`.
#[qinvokable]
fn pair_lan(self: Pin<&mut QbzQConnect>, device_uuid: QString);
/// Resolve the playback-conflict modal. Values 1..=4 match the visual
/// order and are validated again by the Rust facade.
#[qinvokable]
@@ -252,6 +259,23 @@ impl qbz_qconnect::QbzQConnect {
});
}
pub fn pair_lan(self: Pin<&mut Self>, device_uuid: QString) {
let uuid = device_uuid.to_string();
let Some(service) = crate::qconnect_qt::service() else {
crate::toast_qt::error("Qobuz Connect is not running");
return;
};
crate::spawn(async move {
match service.pair_lan(&uuid).await {
Ok(name) => crate::toast_qt::info(format!("Pairing with {name}…")),
Err(err) => {
log::warn!("[QConnect LAN] pair_lan({uuid}) failed: {err}");
crate::toast_qt::error("Could not pair with that device");
}
}
});
}
pub fn resolve_playback_conflict(self: Pin<&mut Self>, choice: i32) {
crate::qconnect_qt::resolve_playback_conflict_choice(choice);
}
@@ -210,6 +210,7 @@ impl QtQconnectEventSink {
is_local: Some(r.renderer_id) == session.local_renderer_id,
is_active: Some(r.renderer_id) == session.active_renderer_id,
icon: device_icon_key(r.device_type, r.friendly_name.as_deref().unwrap_or("")),
device_uuid: r.device_uuid.clone(),
})
.collect();
(devices, session.active_renderer_id.unwrap_or(-1))
+141 -3
View File
@@ -5,18 +5,20 @@
//! admission slot on one dedicated thread. Cloud validation and transactional
//! authority switching deliberately remain outside this adapter.
use std::collections::HashMap;
use std::error::Error;
use std::fmt;
use std::future::Future;
use std::sync::Arc;
use std::sync::{Arc, Mutex as StdMutex};
use std::thread::JoinHandle;
use std::time::Duration;
use qbz_models::Quality;
use qconnect_app::LanProjectionSlot;
use qconnect_lan::{
ConnectInfo, DeviceType, DisplayInfo, EndpointPolicy, HandoffCandidate, LanError,
LanProjection, LanService, LanServiceConfig, MaxAudioQuality,
ConnectInfo, DeviceType, DisplayInfo, EndpointPolicy, HandoffCandidate, LanBrowseEvent,
LanBrowser, LanControllerClient, LanError, LanProjection, LanRendererCandidate,
LanRendererProbe, LanService, LanServiceConfig, MaxAudioQuality,
};
use crate::qconnect_transport_qt::default_qconnect_device_info;
@@ -140,10 +142,58 @@ fn build_projection(
/// exact order observed from the latest-wins slot without blocking a Tokio
/// worker or the Qt UI thread. The callback must provide its own bounded and
/// cancellation-safe transaction semantics.
/// A renderer seen on this network: the row the controller half adds to the
/// picker (`lan: true`) until the cloud lists the device itself.
#[derive(Debug, Clone)]
pub(crate) struct LanPickerRow {
pub device_uuid: String,
pub name: String,
pub brand: String,
pub model: String,
pub device_type: DeviceType,
}
/// What the browser found and what the probe learned, by device uuid.
#[derive(Default)]
struct LanCandidates {
found: HashMap<String, LanRendererCandidate>,
probed: HashMap<String, LanRendererProbe>,
fullname_uuid: HashMap<String, String>,
}
impl LanCandidates {
fn rows(&self) -> Vec<LanPickerRow> {
let mut rows: Vec<LanPickerRow> = self
.probed
.iter()
.map(|(uuid, probe)| LanPickerRow {
device_uuid: uuid.clone(),
name: probe.display.friendly_name.clone(),
brand: probe.display.brand_display_name.clone(),
model: probe.display.model_display_name.clone(),
device_type: probe.display.device_type,
})
.collect();
rows.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
rows
}
}
fn publish_lan_rows(candidates: &StdMutex<LanCandidates>) {
let rows = candidates.lock().map(|c| c.rows()).unwrap_or_default();
crate::qconnect_qt::publish::set_lan_rows(rows);
}
pub struct QtLanRuntime {
service: Option<LanService>,
projection: LanProjection,
admission_bridge: Option<JoinHandle<()>>,
/// The controller half (2026-09-13): browse + probe, same lifetime as
/// the receiver above. `None` when the browser could not start — the
/// receiver keeps working without it.
browser: Option<LanBrowser>,
candidates: Arc<StdMutex<LanCandidates>>,
client: Option<Arc<LanControllerClient>>,
}
impl QtLanRuntime {
@@ -160,11 +210,13 @@ impl QtLanRuntime {
Fut: Future<Output = ()> + Send + 'static,
{
let identity = ExistingIdentity::resolve()?;
let own_device_uuid = identity.device_uuid.clone();
let projection = build_projection(&identity, app_id, max_audio_quality, current_session_id);
let config =
LanServiceConfig::new(projection.clone(), endpoint_policy, identity.device_uuid);
let (mut service, inbox) = LanService::start(config)?;
let on_handoff = Arc::new(on_handoff);
let browse_handle = runtime_handle.clone();
let admission_bridge = match std::thread::Builder::new()
.name(ADMISSION_THREAD_NAME.to_string())
@@ -185,10 +237,77 @@ impl QtLanRuntime {
}
};
// The controller half: browse `_qobuz-connect._tcp`, probe every
// announcement that is not us, and offer what answers as picker rows.
// A failure here only costs the LAN candidates; the receiver stays.
let candidates: Arc<StdMutex<LanCandidates>> = Arc::default();
let client = match LanControllerClient::new() {
Ok(client) => Some(Arc::new(client)),
Err(error) => {
log::warn!("[QConnect LAN] controller client unavailable: {error}");
None
}
};
let browser = client.as_ref().and_then(|client| {
let client = Arc::clone(client);
let candidates = Arc::clone(&candidates);
match LanBrowser::start(Some(own_device_uuid), move |event| match event {
LanBrowseEvent::Found(candidate) => {
if let Ok(mut c) = candidates.lock() {
c.fullname_uuid
.insert(candidate.fullname.clone(), candidate.device_uuid.clone());
c.found
.insert(candidate.device_uuid.clone(), candidate.clone());
}
let client = Arc::clone(&client);
let candidates = Arc::clone(&candidates);
browse_handle.spawn(async move {
match client.probe(&candidate).await {
Ok(probe) => {
log::info!(
"[QConnect LAN] on this network: {} ({} {}) at {}",
probe.display.friendly_name,
probe.display.brand_display_name,
probe.display.model_display_name,
probe.address
);
if let Ok(mut c) = candidates.lock() {
c.probed.insert(candidate.device_uuid.clone(), probe);
}
publish_lan_rows(&candidates);
}
Err(error) => log::info!(
"[QConnect LAN] {} did not answer the probe: {error}",
candidate.instance
),
}
});
}
LanBrowseEvent::Lost(fullname) => {
if let Ok(mut c) = candidates.lock() {
if let Some(uuid) = c.fullname_uuid.remove(&fullname) {
c.found.remove(&uuid);
c.probed.remove(&uuid);
}
}
publish_lan_rows(&candidates);
}
}) {
Ok(browser) => Some(browser),
Err(error) => {
log::warn!("[QConnect LAN] browse unavailable: {error}");
None
}
}
});
Ok(Self {
service: Some(service),
projection,
admission_bridge: Some(admission_bridge),
browser,
candidates,
client,
})
}
@@ -196,6 +315,18 @@ impl QtLanRuntime {
self.projection.clone()
}
/// A probed candidate by device uuid, with the HTTP client to reach it.
pub(crate) fn lan_candidate(
&self,
device_uuid: &str,
) -> Option<(LanRendererCandidate, LanRendererProbe, Arc<LanControllerClient>)> {
let client = Arc::clone(self.client.as_ref()?);
let c = self.candidates.lock().ok()?;
let candidate = c.found.get(device_uuid)?.clone();
let probe = c.probed.get(device_uuid)?.clone();
Some((candidate, probe, client))
}
pub fn port(&self) -> Option<u16> {
self.service.as_ref().map(LanService::port)
}
@@ -205,6 +336,13 @@ impl QtLanRuntime {
/// bridge to exit. Callers in async or UI contexts must run this method on
/// a blocking worker.
pub fn shutdown_blocking(&mut self) {
if let Some(mut browser) = self.browser.take() {
browser.shutdown();
if let Ok(mut c) = self.candidates.lock() {
*c = LanCandidates::default();
}
crate::qconnect_qt::publish::set_lan_rows(Vec::new());
}
if let Some(mut service) = self.service.take() {
service.shutdown();
}
+158 -15
View File
@@ -67,7 +67,7 @@ use qconnect_app::{
QconnectSessionState, QueueCommandType, RendererPlaybackSnapshot, RendererReport,
RendererReportType, SessionLoopHost,
};
use qconnect_lan::EndpointPolicy;
use qconnect_lan::{EndpointPolicy, HandoffBody, LanTokenOut};
use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig};
use serde_json::{json, Value};
use tokio::sync::Mutex;
@@ -275,6 +275,38 @@ pub(crate) mod publish {
/// Icon key from `device_icon_key`: "mobile" | "web" | "computer" |
/// "speaker".
pub icon: &'static str,
/// The cloud's device uuid, when it carries one — what lets a LAN
/// candidate drop out of the picker once the session lists it.
pub device_uuid: Option<String>,
}
/// The session rows as last published, kept so a LAN change can
/// republish the merged list without waiting for a cloud event.
static LAST_SESSION_ROWS: std::sync::Mutex<Vec<QconnectDeviceRow>> =
std::sync::Mutex::new(Vec::new());
/// Renderers on this network, from the LAN controller half.
static LAN_ROWS: std::sync::Mutex<Vec<crate::qconnect_lan_qt::LanPickerRow>> =
std::sync::Mutex::new(Vec::new());
fn lan_icon(device_type: qconnect_lan::DeviceType) -> &'static str {
use qconnect_lan::DeviceType;
match device_type {
DeviceType::Phone | DeviceType::Tablet => "mobile",
DeviceType::Computer => "computer",
_ => "speaker",
}
}
/// The LAN half's rows changed: store them and republish the merged list.
pub(crate) fn set_lan_rows(rows: Vec<crate::qconnect_lan_qt::LanPickerRow>) {
if let Ok(mut slot) = LAN_ROWS.lock() {
*slot = rows;
}
let session_rows = LAST_SESSION_ROWS
.lock()
.map(|rows| rows.clone())
.unwrap_or_default();
devices(session_rows);
}
/// `NowPlayingState.qconnect-connected` -> `QbzQConnect.qconnect_connected`
@@ -290,21 +322,48 @@ pub(crate) mod publish {
/// these keys — the full contract is documented in qconnect_bridge.rs):
/// `[{ renderer_id, name, is_local, is_active, icon }]`.
pub(crate) fn devices(rows: Vec<QconnectDeviceRow>) {
let json = serde_json::to_string(
&rows
.iter()
.map(|row| {
serde_json::json!({
"renderer_id": row.renderer_id,
"name": row.name,
"is_local": row.is_local,
"is_active": row.is_active,
"icon": row.icon,
})
if let Ok(mut slot) = LAST_SESSION_ROWS.lock() {
*slot = rows.clone();
}
// Session rows first, then the LAN candidates the session does not
// list yet (`lan: true`, `lan_uuid`): once a paired device is
// announced by the cloud its LAN row disappears by uuid.
let mut entries: Vec<serde_json::Value> = rows
.iter()
.map(|row| {
serde_json::json!({
"renderer_id": row.renderer_id,
"name": row.name,
"is_local": row.is_local,
"is_active": row.is_active,
"icon": row.icon,
})
.collect::<Vec<_>>(),
)
.unwrap_or_else(|_| "[]".to_string());
})
.collect();
let in_session: std::collections::HashSet<String> = rows
.iter()
.filter_map(|row| row.device_uuid.clone())
.collect();
if let Ok(lan) = LAN_ROWS.lock() {
entries.extend(
lan.iter()
.filter(|row| !in_session.contains(&row.device_uuid))
.map(|row| {
serde_json::json!({
"renderer_id": -1,
"name": row.name,
"is_local": false,
"is_active": false,
"icon": lan_icon(row.device_type),
"lan": true,
"lan_uuid": row.device_uuid,
"brand": row.brand,
"model": row.model,
})
}),
);
}
let json = serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string());
crate::qconnect_bridge::ui(move |mut b| {
b.as_mut().set_devices_json(QString::from(json.as_str()));
});
@@ -748,6 +807,9 @@ pub struct QtQconnectService {
coordinator: QtDelegationCoordinator,
lan: Mutex<Option<QtLanRuntime>>,
lan_lifecycle: LanRuntimeLifecycle<QtLanRuntime>,
/// The QWS endpoint the live transport uses — the fallback endpoint for
/// a delegated QWS token the cloud returns without one (LAN pairing).
lan_qws_endpoint: Mutex<Option<String>>,
lifecycle_gate: Mutex<()>,
/// Controller-mode mirror of the local queue's manual block (#442 "Play
/// later"): steers `insert_after` for play-later routing. See the struct
@@ -1261,6 +1323,7 @@ impl QtQconnectService {
lan_lifecycle: LanRuntimeLifecycle::new(|runtime: &mut QtLanRuntime| {
runtime.shutdown_blocking()
}),
lan_qws_endpoint: Mutex::new(None),
lifecycle_gate: Mutex::new(()),
controller_manual: Mutex::new(ControllerManualBlock::default()),
teardown_incomplete: AtomicBool::new(false),
@@ -1445,6 +1508,7 @@ impl QtQconnectService {
let endpoint_policy =
EndpointPolicy::from_trusted_endpoints(qbz_qobuz::endpoints::BASE_URL, qws_endpoint)
.map_err(|_| "qconnect-lan-endpoint-policy-invalid".to_string())?;
*self.lan_qws_endpoint.lock().await = Some(qws_endpoint.to_string());
let app_id = self
.await_while_enabled(enable_token, self.owner_app_id())
.await??;
@@ -1551,6 +1615,85 @@ impl QtQconnectService {
Err("qconnect-lan-disabled-or-owner-superseded".to_string())
}
/// Pair a renderer found on this network into the account's session —
/// the step the official apps take when a LAN-only receiver (a BluOS
/// player, another QBZ) is picked: `/qws/delegateAuth` for its app id,
/// then the LAN handoff carrying OUR session uuid and `become_active`.
/// From there the cloud announces it like any other renderer. Never
/// account-less: the delegated pair is minted from the owner's session.
/// Returns the renderer's friendly name.
pub async fn pair_lan(&self, device_uuid: &str) -> Result<String, String> {
let (candidate, probe, lan_client) = {
let lan = self.lan.lock().await;
let runtime = lan
.as_ref()
.ok_or_else(|| "Qobuz Connect is not running".to_string())?;
runtime
.lan_candidate(device_uuid)
.ok_or_else(|| "That device is no longer on this network".to_string())?
};
let sync_state = self
.inner
.lock()
.map_err(|_| "Qobuz Connect state is poisoned".to_string())?
.runtime
.as_ref()
.map(|rt| Arc::clone(&rt.sync_state))
.ok_or_else(|| "Qobuz Connect is not connected".to_string())?;
let session_id = sync_state
.lock()
.await
.session
.session_uuid
.clone()
.ok_or_else(|| "Qobuz Connect has no session yet".to_string())?;
let qws_endpoint = self.lan_qws_endpoint.lock().await.clone();
let qobuz = self
.runtime
.core()
.client()
.read()
.await
.clone()
.ok_or_else(|| "Not signed in".to_string())?;
let delegated = qobuz
.delegate_qconnect_auth(&probe.connect.app_id)
.await
.map_err(|e| format!("qws/delegateAuth failed: {e}"))?;
let (api_endpoint, api_exp, api_jwt) = delegated.jwt_api.into_parts();
let (qws_ep, qws_exp, qws_jwt) = delegated.jwt_qws.into_parts();
let api_endpoint =
api_endpoint.unwrap_or_else(|| qbz_qobuz::endpoints::BASE_URL.to_string());
let qws_ep = qws_ep
.or(qws_endpoint)
.ok_or_else(|| "the QWS endpoint is unknown".to_string())?;
let body = HandoffBody {
session_id,
jwt_api: LanTokenOut {
endpoint: api_endpoint,
exp: api_exp,
jwt: api_jwt,
},
jwt_qconnect: LanTokenOut {
endpoint: qws_ep,
exp: qws_exp,
jwt: qws_jwt,
},
become_active: true,
};
lan_client
.hand_off(&candidate, &probe.address, &body)
.await
.map_err(|e| format!("connect-to-qconnect failed: {e}"))?;
log::info!(
"[QConnect LAN] paired {} ({} {}) into the session; waiting for the cloud to list it",
probe.display.friendly_name,
probe.display.brand_display_name,
probe.display.model_display_name
);
Ok(probe.display.friendly_name.clone())
}
async fn stop_lan(&self) -> Result<(), String> {
self.delegation_host.detach_projection();
let runtime = self.lan.lock().await.take();