feat(qbzd): harden qconnect ownership lifecycle
cx
This commit is contained in:
22 files changed
+5560
-401
No files matched your search
+108
-1
@@ -32,7 +32,7 @@ pub mod status;
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Instant;
|
||||
|
||||
use tiny_http::{Header, Method, Request, Response};
|
||||
@@ -536,6 +536,75 @@ pub(crate) fn err_json(
|
||||
json(status, error_body(code, message, hint))
|
||||
}
|
||||
|
||||
/// Refuse owner-only queue and track-selection actions while the QConnect
|
||||
/// authority cell belongs to a delegated runtime (or is fenced during an
|
||||
/// authority transition). Transport controls that are safe for either origin
|
||||
/// -- pause/resume/seek/volume -- deliberately do not call this OWNER gate;
|
||||
/// they use [`transport_action_lease`] so they still drain before a handoff.
|
||||
pub(crate) fn owner_action_gate(state: &ApiState) -> Option<Response<Cursor<Vec<u8>>>> {
|
||||
owner_actions_blocked(state.qconnect_control.as_ref())
|
||||
.then(|| json(409, owner_action_conflict_body()))
|
||||
}
|
||||
|
||||
/// Atomically admit one owner-only action. The returned permit must stay alive
|
||||
/// through every queue/playback mutation and await in that action; authority
|
||||
/// handoff closes new admission and drains these permits before snapshotting.
|
||||
/// `None` inside `Ok` is the short boot window before QConnect publishes its
|
||||
/// control handle, when no delegated runtime can exist yet.
|
||||
pub(crate) fn owner_action_lease(
|
||||
state: &ApiState,
|
||||
) -> Result<Option<crate::qconnect::authority::AuthorityActionPermit>, Response<Cursor<Vec<u8>>>> {
|
||||
let Some(qconnect) = state.qconnect_control.get() else {
|
||||
return Ok(None);
|
||||
};
|
||||
qconnect
|
||||
.try_owner_action_permit()
|
||||
.map(Some)
|
||||
.ok_or_else(|| json(409, owner_action_conflict_body()))
|
||||
}
|
||||
|
||||
/// Atomically admit an origin-agnostic transport action. A stable owner or
|
||||
/// delegated renderer may be controlled locally, but a transition fence
|
||||
/// refuses the action so it cannot land on the runtime installed after the
|
||||
/// handoff. As with owner leases, `None` is only the pre-QConnect boot window.
|
||||
pub(crate) fn transport_action_lease(
|
||||
state: &ApiState,
|
||||
) -> Result<Option<crate::qconnect::authority::AuthorityActionPermit>, Response<Cursor<Vec<u8>>>> {
|
||||
let Some(qconnect) = state.qconnect_control.get() else {
|
||||
return Ok(None);
|
||||
};
|
||||
qconnect
|
||||
.try_transport_action_permit()
|
||||
.map(Some)
|
||||
.ok_or_else(|| json(409, transport_action_conflict_body()))
|
||||
}
|
||||
|
||||
/// Cheap form used by spawn-and-ack handlers immediately before their async
|
||||
/// owner action starts. An unpublished control means QConnect has not reached
|
||||
/// daemon boot step 12 yet, so there is no delegated authority to conflict
|
||||
/// with.
|
||||
pub(crate) fn owner_actions_blocked(control: &OnceLock<crate::qconnect::QconnectControl>) -> bool {
|
||||
control
|
||||
.get()
|
||||
.is_some_and(|qconnect| !qconnect.owner_actions_allowed())
|
||||
}
|
||||
|
||||
fn owner_action_conflict_body() -> serde_json::Value {
|
||||
error_body(
|
||||
"qconnect_authority_conflict",
|
||||
"the owner queue is unavailable while QConnect controls playback",
|
||||
"return playback control to the QBZ owner and try again",
|
||||
)
|
||||
}
|
||||
|
||||
fn transport_action_conflict_body() -> serde_json::Value {
|
||||
error_body(
|
||||
"qconnect_authority_transition",
|
||||
"playback control is temporarily unavailable during a QConnect handoff",
|
||||
"retry the transport command",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn error_body(code: &str, message: &str, hint: &str) -> serde_json::Value {
|
||||
serde_json::json!({"error": {"code": code, "message": message, "hint": hint}})
|
||||
}
|
||||
@@ -683,4 +752,42 @@ mod tests {
|
||||
assert_eq!(body["error"]["message"], "refused");
|
||||
assert_eq!(body["error"]["hint"], "not a browser API");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_action_conflict_is_stable_and_contains_no_handoff_details() {
|
||||
let body = owner_action_conflict_body();
|
||||
assert_eq!(body["error"]["code"], "qconnect_authority_conflict");
|
||||
assert_eq!(
|
||||
body["error"]["message"],
|
||||
"the owner queue is unavailable while QConnect controls playback"
|
||||
);
|
||||
|
||||
let encoded = serde_json::to_string(&body).unwrap();
|
||||
for secret_shape in [
|
||||
"Authorization",
|
||||
"Bearer",
|
||||
"jwt",
|
||||
"session_id",
|
||||
"endpoint",
|
||||
"http://",
|
||||
"https://",
|
||||
"?",
|
||||
] {
|
||||
assert!(
|
||||
!encoded.contains(secret_shape),
|
||||
"conflict response reflected sensitive shape {secret_shape}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_transition_conflict_is_retryable_and_origin_agnostic() {
|
||||
let body = transport_action_conflict_body();
|
||||
assert_eq!(body["error"]["code"], "qconnect_authority_transition");
|
||||
assert_eq!(body["error"]["hint"], "retry the transport command");
|
||||
let message = body["error"]["message"].as_str().unwrap();
|
||||
assert!(message.contains("handoff"));
|
||||
assert!(!message.contains("owner"));
|
||||
assert!(!message.contains("delegated"));
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,16 @@ use qbz_qobuz::link_resolver::{resolve_link, ResolvedLink};
|
||||
use crate::state::AuthState;
|
||||
|
||||
use super::queue::track_to_queue_track;
|
||||
use super::{err_json, json, ApiState};
|
||||
use super::{err_json, json, owner_action_gate, owner_action_lease, ApiState};
|
||||
|
||||
/// Top-tracks cap when playing a whole artist — a sane "play this artist" set,
|
||||
/// not their entire catalogue.
|
||||
const ARTIST_TOP_LIMIT: u32 = 50;
|
||||
|
||||
pub fn play(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
if let Some(resp) = owner_action_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
if let Some(resp) = auth_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
@@ -65,6 +68,9 @@ pub(crate) fn start_resolved(
|
||||
context: Option<(&'static str, String)>,
|
||||
start_index: Option<u64>,
|
||||
) -> Response<Cursor<Vec<u8>>> {
|
||||
if let Some(resp) = owner_action_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
if tracks.is_empty() {
|
||||
return err_json(404, "not_found", "nothing to play", "check the id: qbzd search <QUERY>");
|
||||
}
|
||||
@@ -82,6 +88,14 @@ pub(crate) fn start_resolved(
|
||||
let start_track_id = queue_tracks[start].id;
|
||||
let start_summary = summary(&queue_tracks[start]);
|
||||
|
||||
// `play` and `radio` both perform owner catalog I/O before reaching this
|
||||
// point. A handoff may commit during that I/O, so fence again immediately
|
||||
// before replacing the queue.
|
||||
let owner_lease = match owner_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
state
|
||||
.rt
|
||||
.block_on(state.runtime.core().set_queue(queue_tracks, Some(start)));
|
||||
@@ -94,6 +108,7 @@ pub(crate) fn start_resolved(
|
||||
let runtime = std::sync::Arc::clone(&state.runtime);
|
||||
let shared = std::sync::Arc::clone(&state.shared);
|
||||
state.rt.spawn(async move {
|
||||
let _owner_lease = owner_lease;
|
||||
let played = runtime
|
||||
.core()
|
||||
.play_track_resolved(start_track_id, quality, None, None, 0)
|
||||
|
||||
+119
-15
@@ -33,7 +33,10 @@ use tiny_http::Response;
|
||||
|
||||
use crate::state::AuthState;
|
||||
|
||||
use super::{canon_volume, err_json, json, ApiState};
|
||||
use super::{
|
||||
canon_volume, err_json, json, owner_action_gate, owner_action_lease, transport_action_lease,
|
||||
ApiState,
|
||||
};
|
||||
|
||||
/// `GET /api/now-playing` (02 §3.3.4). `playback` is the serialized
|
||||
/// `PlaybackEvent` (qbz-player/src/player/mod.rs:925) with `shuffle`/`repeat`
|
||||
@@ -74,20 +77,30 @@ pub fn now_playing(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
|
||||
.map(|t| serde_json::to_value(t).unwrap_or(Value::Null))
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
json(200, serde_json::json!({"playback": playback, "track": track}))
|
||||
json(
|
||||
200,
|
||||
serde_json::json!({"playback": playback, "track": track}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `POST /api/playback/play` (02 §3.3.5). Resume if paused; cold-start the
|
||||
/// current queue track when `!has_loaded_audio()` (the desktop's
|
||||
/// `toggle_play_pause` cold-start branch, `crates/qbz/src/playback.rs:3837-3860`).
|
||||
pub fn play(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
|
||||
let player = state.runtime.core().player();
|
||||
if player.has_loaded_audio() {
|
||||
let transport_lease = match transport_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if state.runtime.core().player().has_loaded_audio() {
|
||||
return match state.runtime.core().resume() {
|
||||
Ok(()) => json(200, serde_json::json!({"state": "playing"})),
|
||||
Err(e) => runtime_error(&e.to_string()),
|
||||
};
|
||||
}
|
||||
// Cold start selects/resolves the owner queue and therefore needs the
|
||||
// stricter owner lease. Drop the transport permit first; `cold_start`
|
||||
// revalidates authority before its first mutation.
|
||||
drop(transport_lease);
|
||||
// The cold-start load is spawn-and-ack: report "loading" (honest ack);
|
||||
// `qbzd now` / SSE show the transition to playing.
|
||||
match cold_start(state) {
|
||||
@@ -100,6 +113,10 @@ pub fn play(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
|
||||
/// 0 · 1 · 3 (no 5, §2.2) so a `Player::pause` channel failure is
|
||||
/// [`runtime_error`], not [`device_error`].
|
||||
pub fn pause(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
|
||||
let _transport_lease = match transport_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
match state.runtime.core().pause() {
|
||||
Ok(()) => json(200, serde_json::json!({"state": "paused"})),
|
||||
Err(e) => runtime_error(&e.to_string()),
|
||||
@@ -112,6 +129,10 @@ pub fn pause(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
|
||||
/// the cold-start branch (`cold_start`'s own [`device_error`]) — the
|
||||
/// pause/resume branches use [`runtime_error`] like plain `pause`/`stop`.
|
||||
pub fn toggle(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
|
||||
let transport_lease = match transport_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let player = state.runtime.core().player();
|
||||
let ev = player.get_playback_event();
|
||||
if ev.is_playing {
|
||||
@@ -126,6 +147,9 @@ pub fn toggle(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
|
||||
Err(e) => runtime_error(&e.to_string()),
|
||||
};
|
||||
}
|
||||
// The remaining branch is owner queue selection/stream resolution, not a
|
||||
// primitive transport command. Re-admit it under owner authority.
|
||||
drop(transport_lease);
|
||||
// The cold-start load is spawn-and-ack: report "loading" (honest ack);
|
||||
// `qbzd now` / SSE show the transition to playing.
|
||||
match cold_start(state) {
|
||||
@@ -137,6 +161,10 @@ pub fn toggle(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
|
||||
/// `POST /api/playback/stop` (02 §3.3.8). Never cold-starts; same exit-set
|
||||
/// reasoning as `pause`.
|
||||
pub fn stop(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
|
||||
let _transport_lease = match transport_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
match state.runtime.core().stop() {
|
||||
Ok(()) => json(200, serde_json::json!({"state": "stopped"})),
|
||||
Err(e) => runtime_error(&e.to_string()),
|
||||
@@ -160,6 +188,10 @@ pub fn previous(state: &ApiState) -> Response<Cursor<Vec<u8>>> {
|
||||
/// an async command to the audio thread; the clamp is deterministic so the
|
||||
/// "post-state" is knowable synchronously.
|
||||
pub fn seek(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
let _transport_lease = match transport_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let player = state.runtime.core().player();
|
||||
if player.is_dsd_direct_active() {
|
||||
return err_json(
|
||||
@@ -182,11 +214,18 @@ pub fn seek(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
"body: {\"position\": 90} or {\"delta\": -10}",
|
||||
);
|
||||
};
|
||||
let clamped = if ev.duration > 0 { target.min(ev.duration) } else { target };
|
||||
let clamped = if ev.duration > 0 {
|
||||
target.min(ev.duration)
|
||||
} else {
|
||||
target
|
||||
};
|
||||
if let Err(e) = state.runtime.core().seek(clamped) {
|
||||
return runtime_error(&e.to_string());
|
||||
}
|
||||
json(200, serde_json::json!({"position": clamped, "duration": ev.duration}))
|
||||
json(
|
||||
200,
|
||||
serde_json::json!({"position": clamped, "duration": ev.duration}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `POST /api/playback/volume` (02 §3.3.12). One of three body forms:
|
||||
@@ -194,6 +233,10 @@ pub fn seek(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
/// `{"mute": "on"|"off"|"toggle"}` (also `qbzd mute`'s route — no dedicated
|
||||
/// route, §2.2). All three are gated by the same DSD-direct guard as `seek`.
|
||||
pub fn volume(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
let _transport_lease = match transport_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let player = state.runtime.core().player();
|
||||
if player.is_dsd_direct_active() {
|
||||
return err_json(
|
||||
@@ -236,7 +279,10 @@ pub fn volume(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
if let Err(e) = state.runtime.core().set_volume(target) {
|
||||
return runtime_error(&e.to_string());
|
||||
}
|
||||
json(200, serde_json::json!({"volume": canon_volume(target), "muted": muted_after}))
|
||||
json(
|
||||
200,
|
||||
serde_json::json!({"volume": canon_volume(target), "muted": muted_after}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `POST /api/playback/shuffle` (CONSOLE). Body `{"mode": "on"|"off"|"toggle"}`
|
||||
@@ -244,7 +290,17 @@ pub fn volume(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
/// queue-mode toggle touches no Qobuz session; state also surfaces in
|
||||
/// `/api/status` and `/api/now-playing`.
|
||||
pub fn shuffle(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
let mode = body.get("mode").and_then(|v| v.as_str()).unwrap_or("toggle");
|
||||
if let Some(resp) = owner_action_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
let mode = body
|
||||
.get("mode")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("toggle");
|
||||
let _owner_lease = match owner_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let enabled = match mode {
|
||||
"on" => {
|
||||
state.rt.block_on(state.runtime.core().set_shuffle(true));
|
||||
@@ -256,7 +312,12 @@ pub fn shuffle(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
}
|
||||
"toggle" => state.rt.block_on(state.runtime.core().toggle_shuffle()),
|
||||
other => {
|
||||
return err_json(400, "bad_request", &format!("invalid mode '{other}'"), "mode: on | off | toggle")
|
||||
return err_json(
|
||||
400,
|
||||
"bad_request",
|
||||
&format!("invalid mode '{other}'"),
|
||||
"mode: on | off | toggle",
|
||||
)
|
||||
}
|
||||
};
|
||||
json(200, serde_json::json!({"shuffle": enabled}))
|
||||
@@ -265,15 +326,27 @@ pub fn shuffle(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
/// `POST /api/playback/repeat` (CONSOLE). Body `{"mode": "off"|"all"|"one"}`.
|
||||
/// No auth. Returns the applied mode.
|
||||
pub fn repeat(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
if let Some(resp) = owner_action_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
let mode = body.get("mode").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let rm = match mode {
|
||||
"off" => qbz_models::RepeatMode::Off,
|
||||
"all" => qbz_models::RepeatMode::All,
|
||||
"one" => qbz_models::RepeatMode::One,
|
||||
other => {
|
||||
return err_json(400, "bad_request", &format!("invalid repeat mode '{other}'"), "mode: off | all | one")
|
||||
return err_json(
|
||||
400,
|
||||
"bad_request",
|
||||
&format!("invalid repeat mode '{other}'"),
|
||||
"mode: off | all | one",
|
||||
)
|
||||
}
|
||||
};
|
||||
let _owner_lease = match owner_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
state.rt.block_on(state.runtime.core().set_repeat_mode(rm));
|
||||
json(200, serde_json::json!({"repeat": mode}))
|
||||
}
|
||||
@@ -302,7 +375,12 @@ fn apply_mute(state: &ApiState, live: f32, arg: &str) -> Response<Cursor<Vec<u8>
|
||||
let mut guard = match state.shared.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
return err_json(500, "internal", "daemon state lock poisoned", "restart qbzd")
|
||||
return err_json(
|
||||
500,
|
||||
"internal",
|
||||
"daemon state lock poisoned",
|
||||
"restart qbzd",
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -315,13 +393,21 @@ fn apply_mute(state: &ApiState, live: f32, arg: &str) -> Response<Cursor<Vec<u8>
|
||||
guard.muted = true;
|
||||
(stash, state.runtime.core().set_volume(0.0))
|
||||
} else if !mute_on && guard.muted {
|
||||
let restored = if guard.premute_volume > 0.0 { guard.premute_volume } else { 0.7 };
|
||||
let restored = if guard.premute_volume > 0.0 {
|
||||
guard.premute_volume
|
||||
} else {
|
||||
0.7
|
||||
};
|
||||
guard.muted = false;
|
||||
(restored, state.runtime.core().set_volume(restored))
|
||||
} else {
|
||||
// Already in the requested state — a no-op that still reports the
|
||||
// current nominal level.
|
||||
let nominal = if guard.muted { guard.premute_volume } else { live };
|
||||
let nominal = if guard.muted {
|
||||
guard.premute_volume
|
||||
} else {
|
||||
live
|
||||
};
|
||||
(nominal, Ok(()))
|
||||
};
|
||||
let muted_now = guard.muted;
|
||||
@@ -330,7 +416,10 @@ fn apply_mute(state: &ApiState, live: f32, arg: &str) -> Response<Cursor<Vec<u8>
|
||||
if let Err(e) = set_result {
|
||||
return runtime_error(&e.to_string());
|
||||
}
|
||||
json(200, serde_json::json!({"volume": canon_volume(nominal), "muted": muted_now}))
|
||||
json(
|
||||
200,
|
||||
serde_json::json!({"volume": canon_volume(nominal), "muted": muted_now}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `next`/`previous` (02 §3.3.9-10): gate on NeedsAuth BEFORE running the
|
||||
@@ -347,13 +436,21 @@ fn apply_mute(state: &ApiState, live: f32, arg: &str) -> Response<Cursor<Vec<u8>
|
||||
/// The landing track is no longer reported synchronously — follow it via
|
||||
/// `qbzd now` / SSE.
|
||||
fn advance(state: &ApiState, forward: bool) -> Response<Cursor<Vec<u8>>> {
|
||||
if let Some(resp) = owner_action_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
if let Some(resp) = auth_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
let owner_lease = match owner_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let quality = resolve_quality(state);
|
||||
let runtime = std::sync::Arc::clone(&state.runtime);
|
||||
let shared = Arc::clone(&state.shared);
|
||||
state.rt.spawn(async move {
|
||||
let _owner_lease = owner_lease;
|
||||
if let Err(err) =
|
||||
qbz_app::playback_driver::advance_and_play(runtime.as_ref(), quality, forward).await
|
||||
{
|
||||
@@ -382,6 +479,7 @@ fn advance(state: &ApiState, forward: bool) -> Response<Cursor<Vec<u8>>> {
|
||||
/// `last_errors.stream` instead of a 5xx. Ok(()) means "load queued" — the
|
||||
/// callers answer `{"state": "loading"}`.
|
||||
fn cold_start(state: &ApiState) -> Result<(), Response<Cursor<Vec<u8>>>> {
|
||||
let owner_lease = owner_action_lease(state)?;
|
||||
if let Some(resp) = auth_gate(state) {
|
||||
return Err(resp);
|
||||
}
|
||||
@@ -402,6 +500,7 @@ fn cold_start(state: &ApiState) -> Result<(), Response<Cursor<Vec<u8>>>> {
|
||||
let runtime = std::sync::Arc::clone(&state.runtime);
|
||||
let shared = Arc::clone(&state.shared);
|
||||
state.rt.spawn(async move {
|
||||
let _owner_lease = owner_lease;
|
||||
let played = runtime
|
||||
.core()
|
||||
.play_track_resolved(track_id, quality, None, None, 0)
|
||||
@@ -435,7 +534,12 @@ fn auth_gate(state: &ApiState) -> Option<Response<Cursor<Vec<u8>>>> {
|
||||
.map(|s| s.auth == AuthState::NeedsAuth)
|
||||
.unwrap_or(false);
|
||||
if needs_auth {
|
||||
Some(err_json(409, "needs_auth", "not logged in to Qobuz", "run: qbzd login"))
|
||||
Some(err_json(
|
||||
409,
|
||||
"needs_auth",
|
||||
"not logged in to Qobuz",
|
||||
"run: qbzd login",
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ use qbz_models::{QueueTrack, RepeatMode, Track};
|
||||
|
||||
use crate::state::AuthState;
|
||||
|
||||
use super::{err_json, json, ApiState};
|
||||
use super::{err_json, json, owner_action_gate, owner_action_lease, ApiState};
|
||||
|
||||
/// `GET /api/queue` (02 §3.3.13). `query` is the raw query string (no leading
|
||||
/// `?`) — `route()` strips it off the path before dispatch, so it is threaded
|
||||
@@ -100,6 +100,9 @@ pub fn list(state: &ApiState, query: &str) -> Response<Cursor<Vec<u8>>> {
|
||||
/// STRICT and run before any core call, so a rejected body never partially
|
||||
/// mutates the queue).
|
||||
pub fn add(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
if let Some(resp) = owner_action_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
if let Some(resp) = auth_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
@@ -134,6 +137,14 @@ pub fn add(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
.map(|t| serde_json::to_value(t).unwrap_or(Value::Null))
|
||||
.collect();
|
||||
|
||||
// Catalog resolution can outlive an authority handoff. Revalidate before
|
||||
// the first owner queue mutation so a guest epoch cannot inherit this
|
||||
// request merely because it began under owner authority.
|
||||
let _owner_lease = match owner_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
if position == AddPosition::Next {
|
||||
// `add_track_next` always inserts immediately after the current
|
||||
// track, so reverse iteration is what lands multiple tracks in
|
||||
@@ -158,6 +169,10 @@ pub fn add(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
/// playing track — the verbatim hint below is quoted §3.3.15; also a
|
||||
/// missing or non-integer `index` field, with distinct messages).
|
||||
pub fn remove(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
let _owner_lease = match owner_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let index = match parse_remove_index(body) {
|
||||
Ok(i) => i,
|
||||
Err((message, hint)) => return err_json(400, "bad_request", &message, &hint),
|
||||
@@ -214,6 +229,10 @@ pub fn remove(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
/// (default `true` when the field is absent — the CLI always sends it
|
||||
/// explicitly, §"queue clear" in cli/queue.rs).
|
||||
pub fn clear(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
let _owner_lease = match owner_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let keep_current = body.get("keep_current").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
state.rt.block_on(state.runtime.core().clear_queue(keep_current));
|
||||
let total_tracks = state.rt.block_on(state.runtime.core().get_queue_state()).total_tracks;
|
||||
@@ -223,6 +242,9 @@ pub fn clear(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
/// `POST /api/queue/move` (CONSOLE). Body `{"from": N, "to": N}` (0-based).
|
||||
/// GUI drag-reorder (core `move_track`). 404 when either index is out of range.
|
||||
pub fn reorder(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
if let Some(resp) = owner_action_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
let from = match body.get("from").and_then(|v| v.as_u64()) {
|
||||
Some(n) => n as usize,
|
||||
None => return err_json(400, "bad_request", "move requires 'from' and 'to'", "body: {\"from\": 7, \"to\": 2}"),
|
||||
@@ -231,6 +253,10 @@ pub fn reorder(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
Some(n) => n as usize,
|
||||
None => return err_json(400, "bad_request", "move requires 'from' and 'to'", "body: {\"from\": 7, \"to\": 2}"),
|
||||
};
|
||||
let _owner_lease = match owner_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if state.rt.block_on(state.runtime.core().move_track(from, to)) {
|
||||
json(200, serde_json::json!({"from": from, "to": to}))
|
||||
} else {
|
||||
@@ -246,6 +272,9 @@ pub fn reorder(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
/// spawn-and-ack (see `playback::advance`) so a slow fetch can't starve the
|
||||
/// single-threaded API — failures latch into `last_errors.stream`.
|
||||
pub fn jump(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
if let Some(resp) = owner_action_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
if let Some(resp) = auth_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
@@ -253,6 +282,10 @@ pub fn jump(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
Some(n) => n as usize,
|
||||
None => return err_json(400, "bad_request", "jump requires an 'index'", "body: {\"index\": 2}"),
|
||||
};
|
||||
let owner_lease = match owner_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let track = match state.rt.block_on(state.runtime.core().play_index(index)) {
|
||||
Some(t) => t,
|
||||
None => return err_json(404, "not_found", &format!("queue index {index} is out of range"), "check: qbzd queue list"),
|
||||
@@ -262,6 +295,7 @@ pub fn jump(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
let runtime = std::sync::Arc::clone(&state.runtime);
|
||||
let shared = std::sync::Arc::clone(&state.shared);
|
||||
state.rt.spawn(async move {
|
||||
let _owner_lease = owner_lease;
|
||||
if let Err(err) = runtime
|
||||
.core()
|
||||
.play_track_resolved(track_id, quality, None, None, 0)
|
||||
@@ -285,6 +319,10 @@ pub fn jump(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
/// `{"current": true}` | `{"off": true}`. Sets/clears the stop-after gate
|
||||
/// (core `set_stop_after`/`clear_stop_after`).
|
||||
pub fn stop_after(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
let _owner_lease = match owner_action_lease(state) {
|
||||
Ok(lease) => lease,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if body.get("off").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
state.rt.block_on(state.runtime.core().clear_stop_after());
|
||||
return json(200, serde_json::json!({"stop_after_track_id": Value::Null}));
|
||||
|
||||
@@ -17,12 +17,15 @@ use qbz_models::Track;
|
||||
|
||||
use crate::state::AuthState;
|
||||
|
||||
use super::{err_json, ApiState};
|
||||
use super::{err_json, owner_action_gate, ApiState};
|
||||
|
||||
/// `POST /api/radio`. Body: one of `{"artist_id": N}` | `{"track_id": N}` |
|
||||
/// `{"album_id": "..."}`. Errors: 409 needs_auth, 400 bad_request (no seed),
|
||||
/// 502 radio_failed (upstream), 404/503 from `start_resolved` (empty / start).
|
||||
pub fn radio(state: &ApiState, body: &Value) -> Response<Cursor<Vec<u8>>> {
|
||||
if let Some(resp) = owner_action_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
if let Some(resp) = auth_gate(state) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
+115
-16
@@ -6,9 +6,9 @@
|
||||
// parks on signals — API-less but fully diagnosable in-process.
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use qbz_app::playback_driver::{self, DriverDeps};
|
||||
use qbz_app::settings::daemon_prefs;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_app::playback_driver::{self, DriverDeps};
|
||||
use qbz_core::CoreError;
|
||||
use qbz_models::{CoreEvent, UserSession};
|
||||
use tokio::sync::broadcast;
|
||||
@@ -111,6 +111,12 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
|
||||
// (T9/T10) splice after this, reading `booted`.
|
||||
let prefs = daemon_prefs::load_at(&roots.data);
|
||||
let quality = playback_driver::quality_from_key(&prefs.streaming_quality);
|
||||
let qconnect_authority = Arc::new(crate::qconnect::authority::AuthorityCell::new());
|
||||
// Owner-only playback consumers use a parallel, daemon-local bus whose
|
||||
// events carry the exact authority observation made by events_bridge.
|
||||
// Keep the public CoreEvent bus unchanged for SSE/MPRIS/hooks.
|
||||
let (owner_event_tx, _) =
|
||||
broadcast::channel::<crate::events_bridge::AuthorityStampedEvent>(256);
|
||||
// T11: a live-updatable cell, not a value captured once — `settings/reload`
|
||||
// re-reads `daemon_prefs` and writes here so the driver's OWN auto-advance
|
||||
// (gapless prefetch, natural-end advance) picks up a `playback.quality`
|
||||
@@ -132,6 +138,7 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
|
||||
report_notify.clone(),
|
||||
edge_notify.clone(),
|
||||
booted.bus.clone(),
|
||||
qconnect_authority.clone(),
|
||||
);
|
||||
let driver = tokio::spawn(playback_driver::run_driver(
|
||||
booted.runtime.clone(),
|
||||
@@ -145,20 +152,33 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
|
||||
// flushed to the session store, so a restart resumes the remote-set queue
|
||||
// PAUSED (boot already restores it, §8.1-9½). Holds an `Arc<AppRuntime>`
|
||||
// clone, so it is aborted+joined ahead of `drop(booted)` (#521 ordering).
|
||||
let queue_persist = spawn_queue_persist(booted.runtime.clone(), booted.bus.subscribe());
|
||||
let queue_persist = spawn_queue_persist(
|
||||
booted.runtime.clone(),
|
||||
booted.bus.subscribe(),
|
||||
qconnect_authority.clone(),
|
||||
);
|
||||
|
||||
// 10c. Scrobble-on-play (CONSOLE): a CoreEvent-bus subscriber that sends
|
||||
// 10c. Scrobble-on-play (CONSOLE): an authority-stamped event subscriber that sends
|
||||
// "now playing" on TrackStarted and scrobbles once past the Last.fm
|
||||
// threshold, to whichever of Last.fm / ListenBrainz is connected +
|
||||
// enabled in the scrobbler store. Holds NO Arc<AppRuntime>, so it sits
|
||||
// outside the #521/§8.2 ordering — aborted for a clean shutdown below.
|
||||
let scrobbler = crate::scrobble_engine::spawn(roots.clone(), booted.bus.subscribe());
|
||||
let scrobbler = crate::scrobble_engine::spawn(
|
||||
roots.clone(),
|
||||
owner_event_tx.subscribe(),
|
||||
qconnect_authority.clone(),
|
||||
);
|
||||
|
||||
// 10c¼. Listen log (§12.1): the scrobbler's sibling on the same bus,
|
||||
// 10c¼. Listen log (§12.1): the scrobbler's sibling on the same stamped bus,
|
||||
// WITHOUT its enabled gate — the log is local and on by default, so
|
||||
// a headless streamer finally contributes history. Holds no
|
||||
// Arc<AppRuntime>; stopped (abort + close the open row) below.
|
||||
let listen_log = crate::listen_log_engine::spawn(&roots, booted.bus.subscribe()).await;
|
||||
let listen_log = crate::listen_log_engine::spawn(
|
||||
&roots,
|
||||
owner_event_tx.subscribe(),
|
||||
qconnect_authority.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// 10c½. Events bridge: translate the driver's transition edges into the
|
||||
// playback CoreEvents the bus consumers above (and SSE, and the event
|
||||
@@ -166,8 +186,13 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
|
||||
// PositionUpdated / VolumeChanged. Holds only a Weak<AppRuntime>
|
||||
// upgraded per wake, but is still aborted+joined ahead of
|
||||
// `drop(booted)` so a mid-wake strong Arc can't outlive the ordering.
|
||||
let events_bridge =
|
||||
crate::events_bridge::spawn(&booted.runtime, booted.bus.clone(), edge_notify);
|
||||
let events_bridge = crate::events_bridge::spawn(
|
||||
&booted.runtime,
|
||||
booted.bus.clone(),
|
||||
owner_event_tx,
|
||||
edge_notify,
|
||||
qconnect_authority.clone(),
|
||||
);
|
||||
|
||||
// 10c¾. Event hook (CONSOLE): fork `hooks.script` (or `QBZD_HOOK`) once per
|
||||
// forwarded bus event with QBZ_* variables in its environment — push
|
||||
@@ -191,6 +216,7 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
|
||||
roots.clone(),
|
||||
booted.bus.subscribe(),
|
||||
tokio::runtime::Handle::current(),
|
||||
qconnect_authority.clone(),
|
||||
);
|
||||
|
||||
// 11. HTTP serve (02 §3) on the already-bound socket. `ApiState` carries a
|
||||
@@ -239,6 +265,7 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
|
||||
let mut qconnect = crate::qconnect::start(
|
||||
booted.runtime.clone(),
|
||||
booted.shared.clone(),
|
||||
qconnect_authority.clone(),
|
||||
&roots,
|
||||
quality_cell.clone(),
|
||||
report_notify,
|
||||
@@ -407,7 +434,11 @@ fn revalidate_alsa_hardware_volume(
|
||||
}
|
||||
}
|
||||
|
||||
async fn boot(roots: &ProfileRoots, cfg: &QbzdConfig, warn_count: usize) -> Result<BootedRuntime, String> {
|
||||
async fn boot(
|
||||
roots: &ProfileRoots,
|
||||
cfg: &QbzdConfig,
|
||||
warn_count: usize,
|
||||
) -> Result<BootedRuntime, String> {
|
||||
// 6.+7. stores + runtime composition. The two substitutions (01 §2.2):
|
||||
// - with_audio_settings, NOT AppRuntime::new (which hardcodes the
|
||||
// desktop-global AudioSettingsStore — shell.rs:87-101);
|
||||
@@ -527,9 +558,13 @@ fn build_driver_deps(
|
||||
report_notify: Arc<tokio::sync::Notify>,
|
||||
edge_notify: Arc<tokio::sync::Notify>,
|
||||
bus: tokio::sync::broadcast::Sender<qbz_models::CoreEvent>,
|
||||
qconnect_authority: Arc<crate::qconnect::authority::AuthorityCell>,
|
||||
) -> DriverDeps {
|
||||
let latch_shared = shared.clone();
|
||||
let tick_shared = shared;
|
||||
let qconnect_authority_allowed = Arc::clone(&qconnect_authority);
|
||||
let qconnect_authority_observe = Arc::clone(&qconnect_authority);
|
||||
let qconnect_authority_readmit = qconnect_authority;
|
||||
DriverDeps {
|
||||
quality: Arc::new(move || {
|
||||
quality_cell
|
||||
@@ -537,6 +572,32 @@ fn build_driver_deps(
|
||||
.map(|q| *q)
|
||||
.unwrap_or(qbz_models::Quality::UltraHiRes)
|
||||
}),
|
||||
owner_actions_allowed: Arc::new(move || qconnect_authority_allowed.owner_actions_allowed()),
|
||||
observe_authority: Arc::new(move || {
|
||||
match qconnect_authority_observe.observe_owner_authority() {
|
||||
crate::qconnect::authority::OwnerAuthorityObservation::Owner {
|
||||
token,
|
||||
permit,
|
||||
} => qbz_app::playback_driver::DriverAuthorityObservation::Owner(
|
||||
qbz_app::playback_driver::DriverOwnerAdmission::new(
|
||||
qbz_app::playback_driver::DriverOwnerToken::new(token),
|
||||
qbz_app::playback_driver::DriverActionPermit::new(permit),
|
||||
),
|
||||
),
|
||||
crate::qconnect::authority::OwnerAuthorityObservation::Delegated => {
|
||||
qbz_app::playback_driver::DriverAuthorityObservation::Delegated
|
||||
}
|
||||
crate::qconnect::authority::OwnerAuthorityObservation::Fenced => {
|
||||
qbz_app::playback_driver::DriverAuthorityObservation::Fenced
|
||||
}
|
||||
}
|
||||
}),
|
||||
readmit_owner_action: Arc::new(move |token| {
|
||||
let token = token.downcast_ref::<crate::qconnect::authority::OwnerAuthorityToken>()?;
|
||||
qconnect_authority_readmit
|
||||
.try_owner_action_permit_exact(*token)
|
||||
.map(qbz_app::playback_driver::DriverActionPermit::new)
|
||||
}),
|
||||
// T10: signal the report scheduler on every ReportEdge. `notify_one`
|
||||
// stores a single permit if the scheduler is mid-report, so no edge is
|
||||
// lost and rapid edges coalesce into one report.
|
||||
@@ -580,6 +641,7 @@ fn build_driver_deps(
|
||||
fn spawn_queue_persist(
|
||||
runtime: Arc<AppRuntime<DaemonAdapter>>,
|
||||
mut rx: broadcast::Receiver<CoreEvent>,
|
||||
qconnect_authority: Arc<crate::qconnect::authority::AuthorityCell>,
|
||||
) -> JoinHandle<()> {
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
@@ -608,6 +670,10 @@ fn spawn_queue_persist(
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(_owner_permit) = qconnect_authority.try_owner_action_permit() else {
|
||||
log::debug!("[qbzd] queue-persist: skipped delegated QConnect queue");
|
||||
continue;
|
||||
};
|
||||
playback_driver::save_session_now(runtime.as_ref()).await;
|
||||
log::debug!("[qbzd] queue-persist: session flushed after QueueUpdated burst");
|
||||
}
|
||||
@@ -898,10 +964,10 @@ async fn wait_for_signal() {
|
||||
// reinit/reload narrative is composed CLIENT-side from the CLI's own copy of
|
||||
// the Apply-ladder classification, never carried on the wire).
|
||||
|
||||
/// The single entry point the HTTP route calls. Order matters only at the
|
||||
/// margin (independent domains): audio/quality/qconnect-KV first, credentials
|
||||
/// last, so a login/logout settles the auth state before QConnect decides
|
||||
/// whether to (re)connect against it.
|
||||
/// The single entry point the HTTP route calls. Audio/quality are independent;
|
||||
/// credentials are reconciled next so their branch can retire LAN/delegated
|
||||
/// QConnect before mutating owner auth, and QConnect KV reconciliation runs last
|
||||
/// so an enabled service reconnects only against the settled credential state.
|
||||
pub(crate) async fn reload(state: &crate::api::ApiState) {
|
||||
reload_audio(state);
|
||||
reload_quality(state);
|
||||
@@ -922,6 +988,22 @@ pub(crate) async fn reload(state: &crate::api::ApiState) {
|
||||
/// 87-94`, per-key classification `:877-967,1134-1290`; 03-setup-tui.md §4.3
|
||||
/// lists the same 9 fields).
|
||||
pub(crate) fn reload_audio(state: &crate::api::ApiState) {
|
||||
// Audio reload is an owner-side live-player action. Keep this permit only
|
||||
// for the audio phase: `reload_credentials`/`reload_qconnect` below may
|
||||
// intentionally tear down delegated authority and must be free to acquire
|
||||
// and drain their own transition fence.
|
||||
let _owner_action = match state.qconnect_control.get() {
|
||||
None => None, // boot window: no delegated runtime can exist yet
|
||||
Some(qconnect) => {
|
||||
let Some(permit) = qconnect.try_owner_action_permit() else {
|
||||
log::info!(
|
||||
"[reload] live audio settings not applied while QConnect owns playback; retry reload after owner authority returns"
|
||||
);
|
||||
return;
|
||||
};
|
||||
Some(permit)
|
||||
}
|
||||
};
|
||||
let (fresh, hardware_volume) = match revalidate_alsa_hardware_volume(&state.audio) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
@@ -1057,8 +1139,8 @@ pub(crate) fn decide_credential_action(
|
||||
/// Re-read the credential file and reconcile the live session against it (02
|
||||
/// §3.3.17: "absent → NeedsAuth transition; new → session restore"; taxonomy
|
||||
/// shared with boot, §6.2). `qconnect` is `None` only in the brief boot window
|
||||
/// before step 12 populates the cell — the teardown branch just skips the
|
||||
/// QConnect disconnect then (there is no session for it to hold yet).
|
||||
/// before step 12 populates the cell — credential-change teardown is skipped
|
||||
/// only then, because no LAN/coordinator/runtime exists yet.
|
||||
pub(crate) async fn reload_credentials(
|
||||
runtime: &Arc<AppRuntime<DaemonAdapter>>,
|
||||
shared: &Arc<Mutex<DaemonShared>>,
|
||||
@@ -1082,7 +1164,14 @@ pub(crate) async fn reload_credentials(
|
||||
CredentialAction::EnterNeedsAuth => {
|
||||
log::info!("[reload] credential file cleared — tearing the session down (NeedsAuth)");
|
||||
if let Some(qc) = qconnect {
|
||||
let _ = qc.disconnect().await;
|
||||
if let Err(e) = qc.disconnect_for_credentials_change().await {
|
||||
// Fail closed: owner logout/deactivation must never race a
|
||||
// surviving LAN candidate or delegated authority.
|
||||
log::warn!(
|
||||
"[reload] QConnect credential-change teardown failed; keeping current auth: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = runtime.core().stop();
|
||||
let _ = runtime.core().logout().await;
|
||||
@@ -1090,6 +1179,16 @@ pub(crate) async fn reload_credentials(
|
||||
set_needs_auth(shared, None);
|
||||
}
|
||||
CredentialAction::Apply(token) => {
|
||||
if let Some(qc) = qconnect {
|
||||
if let Err(e) = qc.disconnect_for_credentials_change().await {
|
||||
// Do not install/account-switch the owner token until LAN,
|
||||
// candidate validation, and delegated authority are gone.
|
||||
log::warn!(
|
||||
"[reload] QConnect credential-change teardown failed; token not applied: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
qbz_log::register_secret(token.clone());
|
||||
match runtime.core().login_with_token(&token).await {
|
||||
Ok(session) => {
|
||||
|
||||
@@ -25,6 +25,9 @@ use tokio::sync::{broadcast, Notify};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::adapter::DaemonAdapter;
|
||||
use crate::qconnect::authority::{
|
||||
AuthorityCell, OwnerAuthorityObservation, OwnerAuthorityToken,
|
||||
};
|
||||
|
||||
type Runtime = Arc<AppRuntime<DaemonAdapter>>;
|
||||
|
||||
@@ -32,6 +35,17 @@ type Runtime = Arc<AppRuntime<DaemonAdapter>>;
|
||||
/// a stop cleared the track id, which suppresses `ReportEdge`).
|
||||
const FALLBACK_POLL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// One playback observation plus the exact owner authority that produced it.
|
||||
///
|
||||
/// `None` is deliberately meaningful: the observation began while delegated
|
||||
/// authority (or a handoff fence) was active and owner-only consumers must
|
||||
/// treat it as a handoff, even if owner authority is restored before delivery.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthorityStampedEvent {
|
||||
pub event: CoreEvent,
|
||||
pub owner_token: Option<OwnerAuthorityToken>,
|
||||
}
|
||||
|
||||
/// Spawn the bridge task. Emits, deduped against the last published values:
|
||||
/// * `TrackStarted` on a track-id change while playing (with the queue's
|
||||
/// current-track metadata),
|
||||
@@ -39,17 +53,53 @@ const FALLBACK_POLL: Duration = Duration::from_secs(2);
|
||||
/// * `PositionUpdated` on every wake while playing (~2 s cadence, the
|
||||
/// scrobbler's timing source and the MPRIS progress feed),
|
||||
/// * `VolumeChanged` on a volume change.
|
||||
pub fn spawn(runtime: &Runtime, bus: broadcast::Sender<CoreEvent>, edge: Arc<Notify>) -> JoinHandle<()> {
|
||||
pub fn spawn(
|
||||
runtime: &Runtime,
|
||||
bus: broadcast::Sender<CoreEvent>,
|
||||
owner_bus: broadcast::Sender<AuthorityStampedEvent>,
|
||||
edge: Arc<Notify>,
|
||||
authority: Arc<AuthorityCell>,
|
||||
) -> JoinHandle<()> {
|
||||
let weak: Weak<AppRuntime<DaemonAdapter>> = Arc::downgrade(runtime);
|
||||
tokio::spawn(async move {
|
||||
let mut last_track_id: u64 = 0;
|
||||
let mut last_state: Option<PlaybackState> = None;
|
||||
let mut last_volume: Option<f32> = None;
|
||||
let mut last_owner_token: Option<OwnerAuthorityToken> = None;
|
||||
loop {
|
||||
// Wake on a driver edge, or fall back to a slow poll so a
|
||||
// track-id-clearing stop still surfaces as a transition.
|
||||
let _ = tokio::time::timeout(FALLBACK_POLL, edge.notified()).await;
|
||||
|
||||
// The observation is admitted before the first player/queue read.
|
||||
// Keeping the permit alive through publication makes a handoff wait
|
||||
// for this complete read -> event transaction. A delegated/fenced
|
||||
// observation remains stamped `None` even if an owner is installed
|
||||
// while the async queue lookup below is pending.
|
||||
let (owner_token, owner_permit) = match authority.observe_owner_authority() {
|
||||
OwnerAuthorityObservation::Owner { token, permit } => {
|
||||
(Some(token), Some(permit))
|
||||
}
|
||||
OwnerAuthorityObservation::Delegated => (None, None),
|
||||
OwnerAuthorityObservation::Fenced => {
|
||||
// A candidate activation can hold the fence while the
|
||||
// installed authority is still owner and may ultimately
|
||||
// fail. Do not turn that transient pause into a fabricated
|
||||
// guest edge or disturb the dedup baseline.
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Authority identity is part of the dedup key. In particular, a
|
||||
// guest -> owner restore of the same track/state must publish fresh
|
||||
// owner-stamped edges rather than leaving consumers on guest data.
|
||||
if owner_token != last_owner_token {
|
||||
last_track_id = 0;
|
||||
last_state = None;
|
||||
last_volume = None;
|
||||
last_owner_token = owner_token;
|
||||
}
|
||||
|
||||
let Some(rt) = weak.upgrade() else { return };
|
||||
let core = rt.core();
|
||||
let player = core.player();
|
||||
@@ -62,10 +112,15 @@ pub fn spawn(runtime: &Runtime, bus: broadcast::Sender<CoreEvent>, edge: Arc<Not
|
||||
// wake retries because last_track_id only advances on a send.
|
||||
let queue = core.get_queue_state().await;
|
||||
if let Some(track) = queue.current_track.as_ref().filter(|t| t.id == ev.track_id) {
|
||||
let _ = bus.send(CoreEvent::TrackStarted {
|
||||
track: track.clone(),
|
||||
position_secs: ev.position,
|
||||
});
|
||||
publish(
|
||||
&bus,
|
||||
&owner_bus,
|
||||
owner_token,
|
||||
CoreEvent::TrackStarted {
|
||||
track: track.clone(),
|
||||
position_secs: ev.position,
|
||||
},
|
||||
);
|
||||
last_track_id = ev.track_id;
|
||||
}
|
||||
}
|
||||
@@ -82,7 +137,12 @@ pub fn spawn(runtime: &Runtime, bus: broadcast::Sender<CoreEvent>, edge: Arc<Not
|
||||
PlaybackState::Stopped
|
||||
};
|
||||
if last_state != Some(state) {
|
||||
let _ = bus.send(CoreEvent::PlaybackStateChanged { state });
|
||||
publish(
|
||||
&bus,
|
||||
&owner_bus,
|
||||
owner_token,
|
||||
CoreEvent::PlaybackStateChanged { state },
|
||||
);
|
||||
last_state = Some(state);
|
||||
// A stop ends the "current track": forget it so replaying the
|
||||
// same track emits a fresh TrackStarted (scrobbling, hooks).
|
||||
@@ -92,16 +152,42 @@ pub fn spawn(runtime: &Runtime, bus: broadcast::Sender<CoreEvent>, edge: Arc<Not
|
||||
}
|
||||
|
||||
if ev.is_playing {
|
||||
let _ = bus.send(CoreEvent::PositionUpdated {
|
||||
position_secs: ev.position,
|
||||
duration_secs: ev.duration,
|
||||
});
|
||||
publish(
|
||||
&bus,
|
||||
&owner_bus,
|
||||
owner_token,
|
||||
CoreEvent::PositionUpdated {
|
||||
position_secs: ev.position,
|
||||
duration_secs: ev.duration,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if last_volume.is_none_or(|v| (v - ev.volume).abs() > 0.001) {
|
||||
let _ = bus.send(CoreEvent::VolumeChanged { volume: ev.volume });
|
||||
publish(
|
||||
&bus,
|
||||
&owner_bus,
|
||||
owner_token,
|
||||
CoreEvent::VolumeChanged { volume: ev.volume },
|
||||
);
|
||||
last_volume = Some(ev.volume);
|
||||
}
|
||||
// Owner observations remain drain-visible through every async read
|
||||
// and both bus publications in this wake.
|
||||
drop(owner_permit);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn publish(
|
||||
bus: &broadcast::Sender<CoreEvent>,
|
||||
owner_bus: &broadcast::Sender<AuthorityStampedEvent>,
|
||||
owner_token: Option<OwnerAuthorityToken>,
|
||||
event: CoreEvent,
|
||||
) {
|
||||
let _ = owner_bus.send(AuthorityStampedEvent {
|
||||
event: event.clone(),
|
||||
owner_token,
|
||||
});
|
||||
let _ = bus.send(event);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// crates/qbzd/src/listen_log_engine.rs — the daemon's listen log.
|
||||
//
|
||||
// A sibling of `scrobble_engine` on the same CoreEvent bus, WITHOUT its
|
||||
// A sibling of `scrobble_engine` on the same authority-stamped playback bus, WITHOUT its
|
||||
// `settings.enabled` gate: the scrobbler's switch is about sending plays
|
||||
// elsewhere; the log is local and on by default (its own switch lives in
|
||||
// `listen_meta.paused`, shared with the desktop's Settings toggle). Until
|
||||
@@ -21,7 +21,9 @@ use qbz_models::{CoreEvent, PlaybackState};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::events_bridge::AuthorityStampedEvent;
|
||||
use crate::paths::ProfileRoots;
|
||||
use crate::qconnect::authority::{AuthorityCell, OwnerAuthorityToken};
|
||||
|
||||
/// The bound logger, so the daemon's shutdown path can close the open row
|
||||
/// after aborting the task.
|
||||
@@ -44,7 +46,11 @@ impl ListenLogTask {
|
||||
/// Open the daemon-root store (`<data>/listen/listen_log.db`, origin
|
||||
/// `qbzd:<hostname>`) and subscribe. A failed open logs and yields an inert
|
||||
/// task — the daemon must boot without a history file.
|
||||
pub async fn spawn(roots: &ProfileRoots, rx: broadcast::Receiver<CoreEvent>) -> ListenLogTask {
|
||||
pub async fn spawn(
|
||||
roots: &ProfileRoots,
|
||||
rx: broadcast::Receiver<AuthorityStampedEvent>,
|
||||
authority: Arc<AuthorityCell>,
|
||||
) -> ListenLogTask {
|
||||
let hostname = qbz_app::settings::bundle::hostname();
|
||||
let logger = match ListenLogger::open(roots.data.clone(), Origin::Daemon { hostname }).await {
|
||||
Ok(l) => l,
|
||||
@@ -56,37 +62,221 @@ pub async fn spawn(roots: &ProfileRoots, rx: broadcast::Receiver<CoreEvent>) ->
|
||||
};
|
||||
}
|
||||
};
|
||||
let handle = tokio::spawn(run(Arc::clone(&logger), rx));
|
||||
let handle = tokio::spawn(run(Arc::clone(&logger), rx, authority));
|
||||
ListenLogTask {
|
||||
handle,
|
||||
logger: Some(logger),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(logger: Arc<ListenLogger>, mut rx: broadcast::Receiver<CoreEvent>) {
|
||||
async fn run(
|
||||
logger: Arc<ListenLogger>,
|
||||
mut rx: broadcast::Receiver<AuthorityStampedEvent>,
|
||||
authority: Arc<AuthorityCell>,
|
||||
) {
|
||||
use broadcast::error::RecvError;
|
||||
let mut active_owner_token: Option<OwnerAuthorityToken> = None;
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(CoreEvent::TrackStarted { track, .. }) => {
|
||||
let meta = meta_from_queue_track(&track, None, None, None);
|
||||
logger.track_started(meta, true).await;
|
||||
Ok(event) => {
|
||||
handle_event(&logger, &authority, &mut active_owner_token, event).await
|
||||
}
|
||||
Ok(CoreEvent::PositionUpdated { position_secs, .. }) => {
|
||||
logger.tick(position_secs * 1_000, true).await;
|
||||
}
|
||||
Ok(CoreEvent::PlaybackStateChanged { state }) => {
|
||||
if state == PlaybackState::Stopped {
|
||||
logger.stopped(true).await;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(RecvError::Lagged(n)) => {
|
||||
// Position ticks were dropped: the accumulator simply misses
|
||||
// them (a gap > 5 s credits nothing), which is the honest
|
||||
// outcome — never extrapolate.
|
||||
// outcome — never extrapolate. Also close the open row: the
|
||||
// missing span may contain an authority handoff.
|
||||
log::debug!("[listen-log] bus lagged by {n} events");
|
||||
active_owner_token = None;
|
||||
logger.handoff().await;
|
||||
}
|
||||
Err(RecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_event(
|
||||
logger: &ListenLogger,
|
||||
authority: &Arc<AuthorityCell>,
|
||||
active_owner_token: &mut Option<OwnerAuthorityToken>,
|
||||
stamped: AuthorityStampedEvent,
|
||||
) {
|
||||
let Some(owner_token) = stamped.owner_token else {
|
||||
*active_owner_token = None;
|
||||
logger.handoff().await;
|
||||
return;
|
||||
};
|
||||
let Some(permit) = authority
|
||||
.wait_for_exact_owner_action_permit(owner_token)
|
||||
.await
|
||||
else {
|
||||
// A stale old-owner delivery must not close a row already opened by a
|
||||
// newer owner generation. Close only the row that this rejected event
|
||||
// could have belonged to (or an untracked open row).
|
||||
if active_owner_token
|
||||
.as_ref()
|
||||
.is_none_or(|current| *current == owner_token)
|
||||
{
|
||||
*active_owner_token = None;
|
||||
logger.handoff().await;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// A later owner generation is a real authority boundary even when the bus
|
||||
// lagged over the intervening guest event. Never let the restored owner
|
||||
// continue accumulating into the pre-handoff row.
|
||||
if active_owner_token.is_some_and(|current| current != owner_token) {
|
||||
logger.handoff().await;
|
||||
}
|
||||
*active_owner_token = Some(owner_token);
|
||||
|
||||
match stamped.event {
|
||||
CoreEvent::TrackStarted { track, .. } => {
|
||||
let meta = meta_from_queue_track(&track, None, None, None);
|
||||
logger.track_started(meta, true).await;
|
||||
}
|
||||
CoreEvent::PositionUpdated { position_secs, .. } => {
|
||||
logger.tick(position_secs * 1_000, true).await;
|
||||
}
|
||||
CoreEvent::PlaybackStateChanged { state } if state == PlaybackState::Stopped => {
|
||||
logger.stopped(true).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// The exact admission covers all logger awaits and their SQLite writes.
|
||||
drop(permit);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use qbz_app::listen_log::{EndReason, Origin};
|
||||
use qbz_models::QueueTrack;
|
||||
|
||||
use crate::qconnect::authority::AuthorityOrigin;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn track(id: u64) -> QueueTrack {
|
||||
QueueTrack {
|
||||
id,
|
||||
title: format!("Track {id}"),
|
||||
version: None,
|
||||
artist: "Artist".into(),
|
||||
album: "Album".into(),
|
||||
album_version: None,
|
||||
duration_secs: 180,
|
||||
artwork_url: None,
|
||||
hires: false,
|
||||
bit_depth: None,
|
||||
sample_rate: None,
|
||||
is_local: false,
|
||||
album_id: None,
|
||||
artist_id: None,
|
||||
streamable: true,
|
||||
source: Some("qobuz".into()),
|
||||
parental_warning: false,
|
||||
source_item_id_hint: None,
|
||||
context_kind: None,
|
||||
context_id: None,
|
||||
isrc: None,
|
||||
recording_mbid: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guest_handoff_closes_owner_and_ignores_guest_playback() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let logger = ListenLogger::open(dir.path().to_path_buf(), Origin::Install)
|
||||
.await
|
||||
.unwrap();
|
||||
let authority = Arc::new(AuthorityCell::new());
|
||||
let owner = authority.reserve(AuthorityOrigin::Owner);
|
||||
assert!(authority.install(owner));
|
||||
let (owner_token, permit) = authority
|
||||
.try_owner_action_permit_observed()
|
||||
.expect("owner observation");
|
||||
drop(permit);
|
||||
let mut active_owner_token = None;
|
||||
|
||||
handle_event(
|
||||
&logger,
|
||||
&authority,
|
||||
&mut active_owner_token,
|
||||
AuthorityStampedEvent {
|
||||
event: CoreEvent::TrackStarted {
|
||||
track: track(1),
|
||||
position_secs: 0,
|
||||
},
|
||||
owner_token: Some(owner_token),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
handle_event(
|
||||
&logger,
|
||||
&authority,
|
||||
&mut active_owner_token,
|
||||
AuthorityStampedEvent {
|
||||
event: CoreEvent::PositionUpdated {
|
||||
position_secs: 1,
|
||||
duration_secs: 180,
|
||||
},
|
||||
owner_token: Some(owner_token),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let guest = authority.reserve(AuthorityOrigin::Delegated { generation: 9 });
|
||||
assert!(authority.install(guest));
|
||||
handle_event(
|
||||
&logger,
|
||||
&authority,
|
||||
&mut active_owner_token,
|
||||
AuthorityStampedEvent {
|
||||
event: CoreEvent::TrackStarted {
|
||||
track: track(2),
|
||||
position_secs: 0,
|
||||
},
|
||||
owner_token: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
handle_event(
|
||||
&logger,
|
||||
&authority,
|
||||
&mut active_owner_token,
|
||||
AuthorityStampedEvent {
|
||||
event: CoreEvent::PositionUpdated {
|
||||
position_secs: 30,
|
||||
duration_secs: 180,
|
||||
},
|
||||
owner_token: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let restored_owner = authority.reserve(AuthorityOrigin::Owner);
|
||||
assert!(authority.install(restored_owner));
|
||||
handle_event(
|
||||
&logger,
|
||||
&authority,
|
||||
&mut active_owner_token,
|
||||
AuthorityStampedEvent {
|
||||
event: CoreEvent::TrackStarted {
|
||||
track: track(3),
|
||||
position_secs: 0,
|
||||
},
|
||||
// A late event from the pre-handoff owner must not be promoted
|
||||
// merely because owner authority is available again.
|
||||
owner_token: Some(owner_token),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let rows = logger.rows().await.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].source_item_id, "1");
|
||||
assert_eq!(rows[0].end_reason, Some(EndReason::Handoff));
|
||||
assert!(!logger.has_open_row());
|
||||
}
|
||||
}
|
||||
+80
-12
@@ -32,6 +32,7 @@ use tokio::task::JoinHandle;
|
||||
|
||||
use crate::adapter::DaemonAdapter;
|
||||
use crate::paths::ProfileRoots;
|
||||
use crate::qconnect::authority::AuthorityCell;
|
||||
|
||||
type Runtime = Arc<AppRuntime<DaemonAdapter>>;
|
||||
|
||||
@@ -59,7 +60,10 @@ impl MprisHandle {
|
||||
/// `qbzd settings set playback.mpris` write.
|
||||
fn enabled(roots: &ProfileRoots) -> bool {
|
||||
if let Ok(v) = std::env::var("QBZD_MPRIS") {
|
||||
return !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no");
|
||||
return !matches!(
|
||||
v.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
);
|
||||
}
|
||||
qbz_app::settings::daemon_prefs::load_at(&roots.data).mpris_enabled
|
||||
}
|
||||
@@ -71,6 +75,7 @@ pub fn spawn(
|
||||
roots: ProfileRoots,
|
||||
mut bus: broadcast::Receiver<CoreEvent>,
|
||||
handle: Handle,
|
||||
authority: Arc<AuthorityCell>,
|
||||
) -> Option<MprisHandle> {
|
||||
if !enabled(&roots) {
|
||||
log::info!("[mpris] disabled (playback.mpris / QBZD_MPRIS)");
|
||||
@@ -81,13 +86,14 @@ pub fn spawn(
|
||||
// integration never keeps the runtime alive.
|
||||
let weak: Weak<AppRuntime<DaemonAdapter>> = Arc::downgrade(runtime);
|
||||
let cb_handle = handle.clone();
|
||||
let cb_authority = Arc::clone(&authority);
|
||||
// Headless: the daemon owns no window, so there is no HWND to give.
|
||||
// On Linux, which is the only place qbzd ships, MPRIS keys off the D-Bus
|
||||
// name and ignores this entirely.
|
||||
let integration: Arc<dyn MediaIntegration> = Arc::from(qbz_media_controls::spawn(
|
||||
move |ev| {
|
||||
if let Some(rt) = weak.upgrade() {
|
||||
handle_media_event(&rt, &roots, &cb_handle, ev);
|
||||
handle_media_event(&rt, &roots, &cb_handle, &cb_authority, ev);
|
||||
}
|
||||
},
|
||||
qbz_media_controls::NativeWindow::default(),
|
||||
@@ -123,7 +129,10 @@ pub fn spawn(
|
||||
|
||||
loop {
|
||||
match bus.recv().await {
|
||||
Ok(CoreEvent::TrackStarted { track, position_secs }) => {
|
||||
Ok(CoreEvent::TrackStarted {
|
||||
track,
|
||||
position_secs,
|
||||
}) => {
|
||||
updater_integ.set_metadata(&track_meta(&track));
|
||||
last = PlaybackStatus::Playing;
|
||||
updater_integ.set_playback(last, Some(Duration::from_secs(position_secs)));
|
||||
@@ -146,7 +155,10 @@ pub fn spawn(
|
||||
}
|
||||
});
|
||||
|
||||
Some(MprisHandle { integration, updater })
|
||||
Some(MprisHandle {
|
||||
integration,
|
||||
updater,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================ inbound ============================
|
||||
@@ -155,16 +167,31 @@ pub fn spawn(
|
||||
/// mpris-server (D-Bus) thread — NOT a tokio worker — so the sync core commands
|
||||
/// are called directly; the async advance ritual is spawned fire-and-forget so
|
||||
/// the D-Bus thread never blocks on a network resolve. Time values are micros.
|
||||
fn handle_media_event(rt: &Runtime, roots: &ProfileRoots, handle: &Handle, ev: MediaEvent) {
|
||||
fn handle_media_event(
|
||||
rt: &Runtime,
|
||||
roots: &ProfileRoots,
|
||||
handle: &Handle,
|
||||
authority: &Arc<AuthorityCell>,
|
||||
ev: MediaEvent,
|
||||
) {
|
||||
let core = rt.core();
|
||||
match ev {
|
||||
MediaEvent::Play => {
|
||||
let Some(_transport_permit) = authority.try_transport_action_permit() else {
|
||||
return;
|
||||
};
|
||||
let _ = core.resume();
|
||||
}
|
||||
MediaEvent::Pause => {
|
||||
let Some(_transport_permit) = authority.try_transport_action_permit() else {
|
||||
return;
|
||||
};
|
||||
let _ = core.pause();
|
||||
}
|
||||
MediaEvent::Toggle => {
|
||||
let Some(_transport_permit) = authority.try_transport_action_permit() else {
|
||||
return;
|
||||
};
|
||||
let player = core.player();
|
||||
if player.get_playback_event().is_playing {
|
||||
let _ = core.pause();
|
||||
@@ -173,31 +200,59 @@ fn handle_media_event(rt: &Runtime, roots: &ProfileRoots, handle: &Handle, ev: M
|
||||
}
|
||||
}
|
||||
MediaEvent::Stop => {
|
||||
let Some(_transport_permit) = authority.try_transport_action_permit() else {
|
||||
return;
|
||||
};
|
||||
let _ = core.stop();
|
||||
}
|
||||
MediaEvent::Next => spawn_advance(rt, roots, handle, true),
|
||||
MediaEvent::Previous => spawn_advance(rt, roots, handle, false),
|
||||
MediaEvent::Next => {
|
||||
if let Some(permit) = authority.try_owner_action_permit() {
|
||||
spawn_advance(rt, roots, handle, permit, true);
|
||||
}
|
||||
}
|
||||
MediaEvent::Previous => {
|
||||
if let Some(permit) = authority.try_owner_action_permit() {
|
||||
spawn_advance(rt, roots, handle, permit, false);
|
||||
}
|
||||
}
|
||||
MediaEvent::SeekBy(micros) => {
|
||||
let Some(_transport_permit) = authority.try_transport_action_permit() else {
|
||||
return;
|
||||
};
|
||||
let player = core.player();
|
||||
if player.is_dsd_direct_active() {
|
||||
return;
|
||||
}
|
||||
let ev = player.get_playback_event();
|
||||
let target = (ev.position as i64 + micros / 1_000_000).max(0) as u64;
|
||||
let clamped = if ev.duration > 0 { target.min(ev.duration) } else { target };
|
||||
let clamped = if ev.duration > 0 {
|
||||
target.min(ev.duration)
|
||||
} else {
|
||||
target
|
||||
};
|
||||
let _ = core.seek(clamped);
|
||||
}
|
||||
MediaEvent::SetPosition(micros) => {
|
||||
let Some(_transport_permit) = authority.try_transport_action_permit() else {
|
||||
return;
|
||||
};
|
||||
let player = core.player();
|
||||
if player.is_dsd_direct_active() {
|
||||
return;
|
||||
}
|
||||
let ev = player.get_playback_event();
|
||||
let target = (micros.max(0) as u64) / 1_000_000;
|
||||
let clamped = if ev.duration > 0 { target.min(ev.duration) } else { target };
|
||||
let clamped = if ev.duration > 0 {
|
||||
target.min(ev.duration)
|
||||
} else {
|
||||
target
|
||||
};
|
||||
let _ = core.seek(clamped);
|
||||
}
|
||||
MediaEvent::SetVolume(vol) => {
|
||||
let Some(_transport_permit) = authority.try_transport_action_permit() else {
|
||||
return;
|
||||
};
|
||||
let player = core.player();
|
||||
if !player.is_dsd_direct_active() {
|
||||
let _ = core.set_volume((vol as f32).clamp(0.0, 1.0));
|
||||
@@ -212,12 +267,19 @@ fn handle_media_event(rt: &Runtime, roots: &ProfileRoots, handle: &Handle, ev: M
|
||||
/// Fire-and-forget the FULL advance ritual (skip-walk → play → prefetch →
|
||||
/// persist) off the D-Bus thread, at the daemon's persisted streaming quality
|
||||
/// (the same key the driver seeds at boot).
|
||||
fn spawn_advance(rt: &Runtime, roots: &ProfileRoots, handle: &Handle, forward: bool) {
|
||||
fn spawn_advance(
|
||||
rt: &Runtime,
|
||||
roots: &ProfileRoots,
|
||||
handle: &Handle,
|
||||
permit: crate::qconnect::authority::AuthorityActionPermit,
|
||||
forward: bool,
|
||||
) {
|
||||
let rt = rt.clone();
|
||||
let quality = qbz_app::playback_driver::quality_from_key(
|
||||
&qbz_app::settings::daemon_prefs::load_at(&roots.data).streaming_quality,
|
||||
);
|
||||
handle.spawn(async move {
|
||||
let _permit = permit;
|
||||
let _ = qbz_app::playback_driver::advance_and_play(rt.as_ref(), quality, forward).await;
|
||||
});
|
||||
}
|
||||
@@ -264,13 +326,19 @@ mod tests {
|
||||
// classification the getter uses.
|
||||
for v in ["0", "false", "off", "no", "FALSE", " Off "] {
|
||||
assert!(
|
||||
matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no"),
|
||||
matches!(
|
||||
v.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
),
|
||||
"{v:?} should read as disabled"
|
||||
);
|
||||
}
|
||||
for v in ["1", "true", "on", "yes", "anything"] {
|
||||
assert!(
|
||||
!matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no"),
|
||||
!matches!(
|
||||
v.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | "no"
|
||||
),
|
||||
"{v:?} should read as enabled"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Daemon compatibility shim for the shared QConnect authority barrier.
|
||||
//!
|
||||
//! The implementation lives in `qconnect-app` so every frontend can use the
|
||||
//! exact same stamps, action permits, and handoff drain boundary.
|
||||
|
||||
pub use qconnect_app::{
|
||||
AuthorityActionPermit, AuthorityCell, AuthorityOrigin, AuthorityStamp,
|
||||
OwnerAuthorityObservation, OwnerAuthorityToken,
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -25,13 +25,18 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::QbzCore;
|
||||
use qbz_core::{CoreError, QbzCore};
|
||||
use qbz_models::{Quality, QueueTrack, RepeatMode, Track};
|
||||
use qbz_player::PlaybackState;
|
||||
use qconnect_app::QconnectRendererEngine;
|
||||
use qbz_qobuz::{ApiError, DelegatedQobuzClient};
|
||||
use qconnect_app::{QconnectOwnerFailure, QconnectRendererEngine};
|
||||
|
||||
use crate::adapter::DaemonAdapter;
|
||||
|
||||
use super::authority::{AuthorityActionPermit, AuthorityCell, AuthorityOrigin, AuthorityStamp};
|
||||
|
||||
const RETIRED_AUTHORITY_ERROR: &str = "qconnect renderer authority is retired";
|
||||
|
||||
// T10 (OD4, §7.4): daemon-only volume policy. The desktop has no equivalent —
|
||||
// it always applies remote volume. The mode is read from the daemon-root
|
||||
// `qconnect_settings.db` `volume_mode` KV key (transport::load_volume_mode_at)
|
||||
@@ -76,33 +81,280 @@ impl VolumeMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential boundary used for all Qobuz catalog and stream-resolution calls
|
||||
/// made by this renderer engine.
|
||||
///
|
||||
/// A delegated client is a complete, isolated API context. Keeping the origin
|
||||
/// in this enum makes it impossible for the guest path to silently consult the
|
||||
/// owner's `QbzCore` API client when delegated metadata or stream resolution
|
||||
/// fails.
|
||||
pub enum RendererCatalogAuthority {
|
||||
Owner,
|
||||
Delegated(Arc<DelegatedQobuzClient>),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RendererCatalogAuthority {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Owner => formatter.write_str("Owner"),
|
||||
Self::Delegated(_) => formatter.write_str("Delegated(<redacted>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RendererAuthorityOrigin {
|
||||
Owner,
|
||||
Delegated,
|
||||
}
|
||||
|
||||
/// Collapse owner API failures before they cross the QConnect engine seam.
|
||||
/// Never format `error`: its nested reqwest URL or remote body can be sensitive.
|
||||
fn classify_owner_api_failure(error: &ApiError) -> QconnectOwnerFailure {
|
||||
match error {
|
||||
ApiError::AuthenticationError(_)
|
||||
| ApiError::InvalidAppId
|
||||
| ApiError::InvalidAppSecret
|
||||
| ApiError::BundleExtractionError(_) => QconnectOwnerFailure::Authentication,
|
||||
ApiError::IneligibleUser | ApiError::Forbidden(_) | ApiError::ForbiddenCircuitOpen(_) => {
|
||||
QconnectOwnerFailure::Authorization
|
||||
}
|
||||
ApiError::NonStreamable
|
||||
| ApiError::InvalidQuality(_)
|
||||
| ApiError::NoQualityAvailable
|
||||
| ApiError::TrackUnavailable(_) => QconnectOwnerFailure::TrackUnavailable,
|
||||
ApiError::OfflineMode => QconnectOwnerFailure::Offline,
|
||||
ApiError::NetworkError(_) => QconnectOwnerFailure::Network,
|
||||
ApiError::ParseError(_) | ApiError::ApiResponse(_) => QconnectOwnerFailure::InvalidResponse,
|
||||
ApiError::RateLimited(_) => QconnectOwnerFailure::RateLimited,
|
||||
ApiError::ServerError(_) => QconnectOwnerFailure::Server,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed owner-core classifier paired with [`classify_owner_api_failure`].
|
||||
/// String-bearing variants are intentionally reduced to stable categories.
|
||||
fn classify_owner_core_failure(error: &CoreError) -> QconnectOwnerFailure {
|
||||
match error {
|
||||
CoreError::AuthRequired | CoreError::AuthFailed(_) => QconnectOwnerFailure::Authentication,
|
||||
CoreError::Api(error) => classify_owner_api_failure(error),
|
||||
CoreError::Player(_) | CoreError::Playback(_) | CoreError::Audio(_) => {
|
||||
QconnectOwnerFailure::Playback
|
||||
}
|
||||
CoreError::Queue(_) | CoreError::Internal(_) => QconnectOwnerFailure::Internal,
|
||||
CoreError::NotInitialized => QconnectOwnerFailure::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererCatalogAuthority {
|
||||
pub const fn origin(&self) -> RendererAuthorityOrigin {
|
||||
match self {
|
||||
Self::Owner => RendererAuthorityOrigin::Owner,
|
||||
Self::Delegated(_) => RendererAuthorityOrigin::Delegated,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_track(
|
||||
&self,
|
||||
core: &QbzCore<DaemonAdapter>,
|
||||
track_id: u64,
|
||||
) -> Result<Track, String> {
|
||||
match self {
|
||||
Self::Owner => core
|
||||
.get_track(track_id)
|
||||
.await
|
||||
.map_err(|err| classify_owner_core_failure(&err).to_string()),
|
||||
Self::Delegated(client) => client
|
||||
.get_track(track_id)
|
||||
.await
|
||||
.map_err(|err| format!("delegated catalog request for track {track_id}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_tracks_batch(
|
||||
&self,
|
||||
core: &QbzCore<DaemonAdapter>,
|
||||
track_ids: &[u64],
|
||||
) -> Result<Vec<Track>, String> {
|
||||
match self {
|
||||
Self::Owner => core
|
||||
.get_tracks_batch(track_ids)
|
||||
.await
|
||||
.map_err(|err| classify_owner_core_failure(&err).to_string()),
|
||||
Self::Delegated(client) => client
|
||||
.get_tracks_batch(track_ids)
|
||||
.await
|
||||
.map_err(|err| format!("delegated catalog batch request failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
core: &QbzCore<DaemonAdapter>,
|
||||
track_id: u64,
|
||||
quality: Quality,
|
||||
) -> Result<qbz_models::StreamUrl, String> {
|
||||
match self {
|
||||
Self::Owner => core.get_stream_url(track_id, quality).await.map_err(|err| {
|
||||
format!(
|
||||
"resolve owner stream for track {track_id}: {}",
|
||||
classify_owner_core_failure(&err)
|
||||
)
|
||||
}),
|
||||
Self::Delegated(client) => client
|
||||
.get_stream_url_with_fallback(track_id, quality)
|
||||
.await
|
||||
.map_err(|err| format!("resolve delegated stream for track {track_id}: {err}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn authority_origins_match(catalog: RendererAuthorityOrigin, stamp: AuthorityOrigin) -> bool {
|
||||
matches!(
|
||||
(catalog, stamp),
|
||||
(RendererAuthorityOrigin::Owner, AuthorityOrigin::Owner)
|
||||
| (
|
||||
RendererAuthorityOrigin::Delegated,
|
||||
AuthorityOrigin::Delegated { .. }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum StreamRecoveryAction {
|
||||
FullDownload,
|
||||
OwnerCmaf,
|
||||
FailClosed,
|
||||
}
|
||||
|
||||
fn recovery_action(origin: RendererAuthorityOrigin, header_flood: bool) -> StreamRecoveryAction {
|
||||
match (origin, header_flood) {
|
||||
(RendererAuthorityOrigin::Owner, true) => StreamRecoveryAction::OwnerCmaf,
|
||||
(RendererAuthorityOrigin::Delegated, true) => StreamRecoveryAction::FailClosed,
|
||||
(_, false) => StreamRecoveryAction::FullDownload,
|
||||
}
|
||||
}
|
||||
|
||||
/// QConnect renderer engine backed by the daemon `AppRuntime`. Holds the shared
|
||||
/// runtime and forwards every trait method through `runtime.core()`; the async
|
||||
/// feeder spawns on the ambient tokio runtime (`start_track_stream` is always
|
||||
/// awaited from a runtime task).
|
||||
pub struct DaemonRendererEngine {
|
||||
runtime: Arc<AppRuntime<DaemonAdapter>>,
|
||||
catalog_authority: RendererCatalogAuthority,
|
||||
authority: Arc<AuthorityCell>,
|
||||
stamp: AuthorityStamp,
|
||||
/// T10 (OD4): resolved volume policy for this session (from the KV at connect).
|
||||
volume_mode: VolumeMode,
|
||||
/// Live daemon `playback.quality` preference. The settings reload route
|
||||
/// updates this same cell, so an active QConnect session honors changes
|
||||
/// without reconnecting (#693).
|
||||
quality_cap: Arc<std::sync::Mutex<Quality>>,
|
||||
/// The progressive HTTP task currently feeding the player. Replacing a
|
||||
/// track, stopping playback, or dropping this engine aborts the old task so
|
||||
/// a retired authority cannot keep writing bytes in the background.
|
||||
active_feeder: std::sync::Mutex<Option<super::remote_stream::RemoteStreamFeeder>>,
|
||||
}
|
||||
|
||||
impl DaemonRendererEngine {
|
||||
/// Compatibility alias for the pre-delegation owner constructor. New
|
||||
/// integration code should call [`Self::owner`] or [`Self::delegated`] so
|
||||
/// the credential origin is explicit at the construction boundary.
|
||||
pub fn new(
|
||||
runtime: Arc<AppRuntime<DaemonAdapter>>,
|
||||
volume_mode: VolumeMode,
|
||||
quality_cap: Arc<std::sync::Mutex<Quality>>,
|
||||
authority: Arc<AuthorityCell>,
|
||||
stamp: AuthorityStamp,
|
||||
) -> Self {
|
||||
Self {
|
||||
Self::owner(runtime, volume_mode, quality_cap, authority, stamp)
|
||||
}
|
||||
|
||||
pub fn owner(
|
||||
runtime: Arc<AppRuntime<DaemonAdapter>>,
|
||||
volume_mode: VolumeMode,
|
||||
quality_cap: Arc<std::sync::Mutex<Quality>>,
|
||||
authority: Arc<AuthorityCell>,
|
||||
stamp: AuthorityStamp,
|
||||
) -> Self {
|
||||
Self::with_authority(
|
||||
runtime,
|
||||
RendererCatalogAuthority::Owner,
|
||||
volume_mode,
|
||||
quality_cap,
|
||||
authority,
|
||||
stamp,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delegated(
|
||||
runtime: Arc<AppRuntime<DaemonAdapter>>,
|
||||
delegated_client: Arc<DelegatedQobuzClient>,
|
||||
volume_mode: VolumeMode,
|
||||
quality_cap: Arc<std::sync::Mutex<Quality>>,
|
||||
authority: Arc<AuthorityCell>,
|
||||
stamp: AuthorityStamp,
|
||||
) -> Self {
|
||||
Self::with_authority(
|
||||
runtime,
|
||||
RendererCatalogAuthority::Delegated(delegated_client),
|
||||
volume_mode,
|
||||
quality_cap,
|
||||
authority,
|
||||
stamp,
|
||||
)
|
||||
}
|
||||
|
||||
fn with_authority(
|
||||
runtime: Arc<AppRuntime<DaemonAdapter>>,
|
||||
catalog_authority: RendererCatalogAuthority,
|
||||
volume_mode: VolumeMode,
|
||||
quality_cap: Arc<std::sync::Mutex<Quality>>,
|
||||
authority: Arc<AuthorityCell>,
|
||||
stamp: AuthorityStamp,
|
||||
) -> Self {
|
||||
assert!(
|
||||
authority_origins_match(catalog_authority.origin(), stamp.origin()),
|
||||
"renderer catalog authority must match its runtime authority stamp"
|
||||
);
|
||||
Self {
|
||||
runtime,
|
||||
catalog_authority,
|
||||
authority,
|
||||
stamp,
|
||||
volume_mode,
|
||||
quality_cap,
|
||||
active_feeder: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn authority_origin(&self) -> RendererAuthorityOrigin {
|
||||
self.catalog_authority.origin()
|
||||
}
|
||||
|
||||
pub const fn authority_stamp(&self) -> AuthorityStamp {
|
||||
self.stamp
|
||||
}
|
||||
|
||||
fn is_current(&self) -> bool {
|
||||
self.authority.is_current(self.stamp)
|
||||
&& (!matches!(self.stamp.origin(), AuthorityOrigin::Owner)
|
||||
|| self.authority.owner_actions_allowed())
|
||||
}
|
||||
|
||||
fn ensure_current(&self) -> Result<(), String> {
|
||||
if self.is_current() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(RETIRED_AUTHORITY_ERROR.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn action_permit(&self) -> Result<AuthorityActionPermit, String> {
|
||||
self.authority
|
||||
.try_runtime_action_permit(self.stamp)
|
||||
.ok_or_else(|| RETIRED_AUTHORITY_ERROR.to_string())
|
||||
}
|
||||
|
||||
fn core(&self) -> &Arc<QbzCore<DaemonAdapter>> {
|
||||
self.runtime.core()
|
||||
}
|
||||
@@ -116,6 +368,24 @@ impl DaemonRendererEngine {
|
||||
clamp_quality(requested, cap)
|
||||
}
|
||||
|
||||
fn replace_active_feeder(&self, feeder: super::remote_stream::RemoteStreamFeeder) {
|
||||
let previous = self
|
||||
.active_feeder
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.replace(feeder);
|
||||
drop(previous);
|
||||
}
|
||||
|
||||
fn cancel_active_feeder(&self) {
|
||||
let feeder = self
|
||||
.active_feeder
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
drop(feeder);
|
||||
}
|
||||
|
||||
/// Last-resort load for tracks the raw-URL path cannot fetch (the CDN
|
||||
/// header flood defeats every reqwest attempt — see
|
||||
/// `remote_stream::is_header_flood_error`): the CMAF path is unaffected by
|
||||
@@ -124,37 +394,65 @@ impl DaemonRendererEngine {
|
||||
/// only fires on a playing->playing track edge), so sync it explicitly or
|
||||
/// `qbzd status` / the local now-playing truth keep showing the PREVIOUS
|
||||
/// track while the recovered one plays.
|
||||
async fn play_via_cmaf(
|
||||
async fn play_via_owner_cmaf(
|
||||
&self,
|
||||
track_id: u64,
|
||||
quality: Quality,
|
||||
start_position_secs: u64,
|
||||
) -> Result<(), String> {
|
||||
self.core()
|
||||
let _permit = self.action_permit()?;
|
||||
if self.authority_origin() != RendererAuthorityOrigin::Owner {
|
||||
return Err(format!(
|
||||
"delegated stream for track {track_id} cannot use owner CMAF fallback"
|
||||
));
|
||||
}
|
||||
let playback_result = self
|
||||
.core()
|
||||
.play_track_resolved(track_id, quality, None, None, start_position_secs)
|
||||
.await
|
||||
.map_err(|err| format!("CMAF fallback for remote track {track_id}: {err}"))?;
|
||||
.await;
|
||||
self.ensure_current()?;
|
||||
playback_result.map_err(|error| {
|
||||
format!(
|
||||
"CMAF fallback for remote track {track_id}: {}",
|
||||
QconnectOwnerFailure::from_opaque_playback_error(&error)
|
||||
)
|
||||
})?;
|
||||
self.core().sync_current_to_id(track_id).await;
|
||||
self.ensure_current()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DaemonRendererEngine {
|
||||
fn drop(&mut self) {
|
||||
self.cancel_active_feeder();
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl QconnectRendererEngine for DaemonRendererEngine {
|
||||
// ---- transport (sync) ----
|
||||
fn resume(&self) -> Result<(), String> {
|
||||
let _permit = self.action_permit()?;
|
||||
self.core().resume().map_err(|err| err.to_string())
|
||||
}
|
||||
fn pause(&self) -> Result<(), String> {
|
||||
let _permit = self.action_permit()?;
|
||||
self.core().pause().map_err(|err| err.to_string())
|
||||
}
|
||||
fn stop(&self) -> Result<(), String> {
|
||||
self.cancel_active_feeder();
|
||||
let _permit = self.action_permit()?;
|
||||
self.core().stop().map_err(|err| err.to_string())
|
||||
}
|
||||
fn seek(&self, position_secs: u64) -> Result<(), String> {
|
||||
self.core().seek(position_secs).map_err(|err| err.to_string())
|
||||
let _permit = self.action_permit()?;
|
||||
self.core()
|
||||
.seek(position_secs)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
fn set_volume(&self, fraction: f32) -> Result<(), String> {
|
||||
let _permit = self.action_permit()?;
|
||||
// T10 (OD4, §7.4): volume-mode gate. In `Locked` mode the player stays
|
||||
// at 100 % and a controller's remote SetVolume is acknowledged-but-
|
||||
// ignored (logged at info), so the DAC keeps receiving full-scale,
|
||||
@@ -166,30 +464,52 @@ impl QconnectRendererEngine for DaemonRendererEngine {
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
self.core().set_volume(fraction).map_err(|err| err.to_string())
|
||||
self.core()
|
||||
.set_volume(fraction)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
fn get_playback_state(&self) -> PlaybackState {
|
||||
self.core().get_playback_state()
|
||||
if self.is_current() {
|
||||
self.core().get_playback_state()
|
||||
} else {
|
||||
PlaybackState::default()
|
||||
}
|
||||
}
|
||||
fn has_loaded_audio(&self) -> bool {
|
||||
self.core().player().has_loaded_audio()
|
||||
self.is_current() && self.core().player().has_loaded_audio()
|
||||
}
|
||||
|
||||
// ---- queue / mode (async) ----
|
||||
async fn set_repeat_mode(&self, mode: RepeatMode) {
|
||||
self.core().set_repeat_mode(mode).await
|
||||
if let Ok(_permit) = self.action_permit() {
|
||||
self.core().set_repeat_mode(mode).await;
|
||||
}
|
||||
}
|
||||
async fn set_shuffle(&self, enabled: bool) {
|
||||
self.core().set_shuffle(enabled).await
|
||||
if let Ok(_permit) = self.action_permit() {
|
||||
self.core().set_shuffle(enabled).await;
|
||||
}
|
||||
}
|
||||
async fn set_shuffle_flag(&self, enabled: bool) {
|
||||
self.core().set_shuffle_with_order(enabled, None).await
|
||||
if let Ok(_permit) = self.action_permit() {
|
||||
self.core().set_shuffle_with_order(enabled, None).await;
|
||||
}
|
||||
}
|
||||
async fn get_all_queue_tracks(&self) -> (Vec<QueueTrack>, Option<usize>) {
|
||||
self.core().get_all_queue_tracks().await
|
||||
if !self.is_current() {
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
let tracks = self.core().get_all_queue_tracks().await;
|
||||
if self.is_current() {
|
||||
tracks
|
||||
} else {
|
||||
(Vec::new(), None)
|
||||
}
|
||||
}
|
||||
async fn set_queue(&self, tracks: Vec<QueueTrack>, start_index: Option<usize>) {
|
||||
self.core().set_queue(tracks, start_index).await
|
||||
if let Ok(_permit) = self.action_permit() {
|
||||
self.core().set_queue(tracks, start_index).await;
|
||||
}
|
||||
}
|
||||
async fn set_queue_with_order(
|
||||
&self,
|
||||
@@ -198,29 +518,41 @@ impl QconnectRendererEngine for DaemonRendererEngine {
|
||||
shuffle_enabled: bool,
|
||||
shuffle_order: Option<Vec<usize>>,
|
||||
) {
|
||||
self.core()
|
||||
.set_queue_with_order(tracks, start_index, shuffle_enabled, shuffle_order)
|
||||
.await
|
||||
if let Ok(_permit) = self.action_permit() {
|
||||
self.core()
|
||||
.set_queue_with_order(tracks, start_index, shuffle_enabled, shuffle_order)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
async fn clear_queue(&self, keep_current: bool) {
|
||||
self.core().clear_queue(keep_current).await
|
||||
if let Ok(_permit) = self.action_permit() {
|
||||
self.core().clear_queue(keep_current).await;
|
||||
}
|
||||
}
|
||||
async fn play_index(&self, index: usize) -> Option<QueueTrack> {
|
||||
self.core().play_index(index).await
|
||||
let _permit = self.action_permit().ok()?;
|
||||
let track = self.core().play_index(index).await;
|
||||
track
|
||||
}
|
||||
|
||||
// ---- catalog (async) ----
|
||||
async fn get_track(&self, track_id: u64) -> Result<Track, String> {
|
||||
self.core()
|
||||
.get_track(track_id)
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
self.ensure_current()?;
|
||||
let result = self
|
||||
.catalog_authority
|
||||
.get_track(self.core(), track_id)
|
||||
.await;
|
||||
self.ensure_current()?;
|
||||
result
|
||||
}
|
||||
async fn get_tracks_batch(&self, track_ids: &[u64]) -> Result<Vec<Track>, String> {
|
||||
self.core()
|
||||
.get_tracks_batch(track_ids)
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
self.ensure_current()?;
|
||||
let result = self
|
||||
.catalog_authority
|
||||
.get_tracks_batch(self.core(), track_ids)
|
||||
.await;
|
||||
self.ensure_current()?;
|
||||
result
|
||||
}
|
||||
|
||||
// ---- protected audio seam (the only protected touch) ----
|
||||
@@ -231,6 +563,8 @@ impl QconnectRendererEngine for DaemonRendererEngine {
|
||||
duration_secs: u64,
|
||||
start_position_secs: u64,
|
||||
) -> Result<(), String> {
|
||||
self.cancel_active_feeder();
|
||||
let _permit = self.action_permit()?;
|
||||
let quality = self.effective_quality(requested_quality);
|
||||
if quality != requested_quality {
|
||||
log::info!(
|
||||
@@ -239,11 +573,12 @@ impl QconnectRendererEngine for DaemonRendererEngine {
|
||||
quality
|
||||
);
|
||||
}
|
||||
let stream_url = self
|
||||
.core()
|
||||
.get_stream_url(track_id, quality)
|
||||
.await
|
||||
.map_err(|err| format!("resolve stream url for remote track {track_id}: {err}"))?;
|
||||
let stream_url_result = self
|
||||
.catalog_authority
|
||||
.get_stream_url(self.core(), track_id, quality)
|
||||
.await;
|
||||
self.ensure_current()?;
|
||||
let stream_url = stream_url_result?;
|
||||
|
||||
let player = self.core().player();
|
||||
let stream_result = super::remote_stream::stream_remote_track_into_player(
|
||||
@@ -253,24 +588,43 @@ impl QconnectRendererEngine for DaemonRendererEngine {
|
||||
start_position_secs,
|
||||
&stream_url.url,
|
||||
"QConnect",
|
||||
|| self.ensure_current(),
|
||||
)
|
||||
.await;
|
||||
self.ensure_current()?;
|
||||
|
||||
let Err(stream_err) = stream_result else {
|
||||
return Ok(());
|
||||
let stream_err = match stream_result {
|
||||
Ok(feeder) => {
|
||||
self.ensure_current()?;
|
||||
self.replace_active_feeder(feeder);
|
||||
if let Err(error) = self.ensure_current() {
|
||||
self.cancel_active_feeder();
|
||||
return Err(error);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
// Akamai small-object header flood: SMALL raw-url objects come back
|
||||
// with ~106 headers, over hyper's hard-coded 100-header h1 cap, so
|
||||
// EVERY reqwest fetch of this URL fails — the full download would die
|
||||
// the same death. Skip it and go straight to the CMAF last resort.
|
||||
if super::remote_stream::is_header_flood_error(&stream_err) {
|
||||
log::warn!(
|
||||
"[QConnect] Raw-URL streaming hit the CDN header flood for track {track_id}: {stream_err}. Skipping full download; last resort: CMAF."
|
||||
);
|
||||
return self
|
||||
.play_via_cmaf(track_id, quality, start_position_secs)
|
||||
.await;
|
||||
let header_flood = super::remote_stream::is_header_flood_error(&stream_err);
|
||||
match recovery_action(self.authority_origin(), header_flood) {
|
||||
StreamRecoveryAction::OwnerCmaf => {
|
||||
log::warn!(
|
||||
"[QConnect] Owner raw-URL streaming hit the CDN header limit for track {track_id}: {stream_err}. Skipping full download; last resort: CMAF."
|
||||
);
|
||||
return self
|
||||
.play_via_owner_cmaf(track_id, quality, start_position_secs)
|
||||
.await;
|
||||
}
|
||||
StreamRecoveryAction::FailClosed => {
|
||||
log::warn!(
|
||||
"[QConnect] Delegated raw-URL streaming hit the CDN header limit for track {track_id}: {stream_err}. Owner and CMAF fallback are forbidden."
|
||||
);
|
||||
return Err(format!(
|
||||
"delegated CDN stream for track {track_id} failed: {stream_err}"
|
||||
));
|
||||
}
|
||||
StreamRecoveryAction::FullDownload => {}
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
@@ -280,17 +634,22 @@ impl QconnectRendererEngine for DaemonRendererEngine {
|
||||
);
|
||||
match download_remote_audio(&stream_url.url).await {
|
||||
Ok(audio_data) => {
|
||||
self.core()
|
||||
.player()
|
||||
.play_data(audio_data, track_id)
|
||||
.map_err(|err| format!("play remote track {track_id}: {err}"))?;
|
||||
self.ensure_current()?;
|
||||
let playback_result = self.core().player().play_data(audio_data, track_id);
|
||||
self.ensure_current()?;
|
||||
playback_result.map_err(|err| format!("play remote track {track_id}: {err}"))?;
|
||||
Ok(())
|
||||
}
|
||||
Err(download_err) if super::remote_stream::is_header_flood_error(&download_err) => {
|
||||
Err(download_err)
|
||||
if recovery_action(
|
||||
self.authority_origin(),
|
||||
super::remote_stream::is_header_flood_error(&download_err),
|
||||
) == StreamRecoveryAction::OwnerCmaf =>
|
||||
{
|
||||
log::warn!(
|
||||
"[QConnect] Full download hit the CDN header flood for track {track_id}: {download_err}. Last resort: CMAF."
|
||||
);
|
||||
self.play_via_cmaf(track_id, quality, start_position_secs)
|
||||
self.play_via_owner_cmaf(track_id, quality, start_position_secs)
|
||||
.await
|
||||
}
|
||||
Err(download_err) => Err(download_err),
|
||||
@@ -298,11 +657,11 @@ impl QconnectRendererEngine for DaemonRendererEngine {
|
||||
}
|
||||
|
||||
fn current_output_format(&self) -> Option<(u32, u32)> {
|
||||
if !self.is_current() {
|
||||
return None;
|
||||
}
|
||||
let player = self.core().player();
|
||||
Some((
|
||||
player.state.get_sample_rate(),
|
||||
player.state.get_bit_depth(),
|
||||
))
|
||||
Some((player.state.get_sample_rate(), player.state.get_bit_depth()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +705,52 @@ async fn download_remote_audio(url: &str) -> Result<Vec<u8>, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SIGNED_URL_MARKER: &str =
|
||||
"https://api.example.test/track/get?request_ts=42&request_sig=SIGNED-URL-SECRET";
|
||||
const REMOTE_BODY_MARKER: &str = "REMOTE-BODY\nAuthorization: Bearer jwt_api-secret";
|
||||
|
||||
#[test]
|
||||
fn owner_api_failure_categories_discard_signed_urls_and_remote_bodies() {
|
||||
let errors = [
|
||||
ApiError::AuthenticationError(SIGNED_URL_MARKER.to_string()),
|
||||
ApiError::Forbidden(format!(" : {REMOTE_BODY_MARKER}")),
|
||||
ApiError::ApiResponse(format!("{REMOTE_BODY_MARKER} {SIGNED_URL_MARKER}")),
|
||||
];
|
||||
|
||||
for error in errors {
|
||||
let diagnostic = classify_owner_api_failure(&error).to_string();
|
||||
assert!(!diagnostic.contains("SIGNED-URL-SECRET"));
|
||||
assert!(!diagnostic.contains("REMOTE-BODY"));
|
||||
assert!(!diagnostic.contains("Bearer"));
|
||||
assert!(!diagnostic.contains("jwt_api-secret"));
|
||||
assert!(!diagnostic.contains('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_core_and_opaque_playback_failures_are_payload_free() {
|
||||
let core_error = CoreError::Api(ApiError::ApiResponse(format!(
|
||||
"{SIGNED_URL_MARKER} {REMOTE_BODY_MARKER}"
|
||||
)));
|
||||
let core_diagnostic = classify_owner_core_failure(&core_error).to_string();
|
||||
assert_eq!(
|
||||
core_diagnostic,
|
||||
QconnectOwnerFailure::InvalidResponse.to_string()
|
||||
);
|
||||
assert!(!core_diagnostic.contains("SIGNED-URL-SECRET"));
|
||||
assert!(!core_diagnostic.contains("REMOTE-BODY"));
|
||||
|
||||
let opaque_error = format!("{SIGNED_URL_MARKER} {REMOTE_BODY_MARKER}");
|
||||
let playback_diagnostic =
|
||||
QconnectOwnerFailure::from_opaque_playback_error(&opaque_error).to_string();
|
||||
assert_eq!(
|
||||
playback_diagnostic,
|
||||
QconnectOwnerFailure::Playback.to_string()
|
||||
);
|
||||
assert!(!playback_diagnostic.contains("SIGNED-URL-SECRET"));
|
||||
assert!(!playback_diagnostic.contains("REMOTE-BODY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn software_mode_applies_and_reports_real() {
|
||||
// remote SetVolume 0.4 -> engine.set_volume(0.4); report reads real volume.
|
||||
@@ -396,4 +801,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegated_header_flood_fails_closed_without_owner_cmaf() {
|
||||
assert_eq!(
|
||||
recovery_action(RendererAuthorityOrigin::Delegated, true),
|
||||
StreamRecoveryAction::FailClosed
|
||||
);
|
||||
assert_ne!(
|
||||
recovery_action(RendererAuthorityOrigin::Delegated, true),
|
||||
StreamRecoveryAction::OwnerCmaf
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegated_non_header_failure_only_reuses_the_delegated_cdn_url() {
|
||||
assert_eq!(
|
||||
recovery_action(RendererAuthorityOrigin::Delegated, false),
|
||||
StreamRecoveryAction::FullDownload
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_retains_cmaf_recovery_for_the_known_header_limit() {
|
||||
assert_eq!(
|
||||
recovery_action(RendererAuthorityOrigin::Owner, true),
|
||||
StreamRecoveryAction::OwnerCmaf
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_origin_must_match_the_runtime_stamp_origin() {
|
||||
assert!(authority_origins_match(
|
||||
RendererAuthorityOrigin::Owner,
|
||||
AuthorityOrigin::Owner
|
||||
));
|
||||
assert!(authority_origins_match(
|
||||
RendererAuthorityOrigin::Delegated,
|
||||
AuthorityOrigin::Delegated { generation: 9 }
|
||||
));
|
||||
assert!(!authority_origins_match(
|
||||
RendererAuthorityOrigin::Owner,
|
||||
AuthorityOrigin::Delegated { generation: 9 }
|
||||
));
|
||||
assert!(!authority_origins_match(
|
||||
RendererAuthorityOrigin::Delegated,
|
||||
AuthorityOrigin::Owner
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
//! Thin qbzd adapter for the official QConnect LAN receiver surface.
|
||||
//!
|
||||
//! This module projects the daemon's existing QConnect identity into the LAN
|
||||
//! wire, owns the listener/mDNS lifetime, and drains the bounded latest-wins
|
||||
//! admission slot on one dedicated thread. Cloud validation and transactional
|
||||
//! authority switching deliberately remain outside this adapter.
|
||||
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use qbz_models::Quality;
|
||||
use qconnect_lan::{
|
||||
ConnectInfo, DeviceType, DisplayInfo, EndpointPolicy, HandoffCandidate, LanError,
|
||||
LanProjection, LanService, LanServiceConfig, MaxAudioQuality,
|
||||
};
|
||||
|
||||
use super::authority::{AuthorityCell, AuthorityOrigin, AuthorityStamp};
|
||||
use super::transport::default_qconnect_device_info;
|
||||
|
||||
const ADMISSION_WAIT: Duration = Duration::from_secs(60);
|
||||
const DAEMON_BRAND: &str = "QBZ";
|
||||
const DAEMON_MODEL: &str = "QBZ Daemon";
|
||||
const ADMISSION_THREAD_NAME: &str = "qbzd-qconnect-lan-admission";
|
||||
|
||||
pub type HandoffCallback = Arc<dyn Fn(HandoffCandidate) + Send + Sync + 'static>;
|
||||
|
||||
/// Stamp-aware bridge between runtime authority and the synchronous LAN
|
||||
/// projection read by the HTTP worker.
|
||||
///
|
||||
/// Every qbzd authority install/clear is serialized through this short-held
|
||||
/// mutex. An old runtime event can therefore never overwrite the session of a
|
||||
/// replacement runtime, and a prepared candidate is not exposed until the same
|
||||
/// critical section that installs its authority.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct DaemonLanProjectionSlot {
|
||||
inner: Arc<StdMutex<ProjectionSlotState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ProjectionSlotState {
|
||||
projection: Option<LanProjection>,
|
||||
installed: Option<InstalledSessionProjection>,
|
||||
}
|
||||
|
||||
struct InstalledSessionProjection {
|
||||
stamp: AuthorityStamp,
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl DaemonLanProjectionSlot {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, ProjectionSlotState> {
|
||||
self.inner.lock().unwrap_or_else(|poisoned| {
|
||||
log::error!("[QConnect LAN] projection slot recovered from a poisoned lock");
|
||||
let guard = poisoned.into_inner();
|
||||
self.inner.clear_poison();
|
||||
guard
|
||||
})
|
||||
}
|
||||
|
||||
fn write_projection(state: &ProjectionSlotState, session_id: Option<String>) {
|
||||
if let Some(projection) = state.projection.as_ref() {
|
||||
projection.set_current_session_id(session_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a runtime and its already-confirmed public session atomically
|
||||
/// with respect to every projection update. `session_id` is `None` for a
|
||||
/// fresh owner until its first cloud SESSION_STATE arrives.
|
||||
pub fn install_authority(
|
||||
&self,
|
||||
authority: &AuthorityCell,
|
||||
stamp: AuthorityStamp,
|
||||
session_id: Option<&str>,
|
||||
) -> bool {
|
||||
let mut state = self.lock();
|
||||
|
||||
// Fail closed during the tiny install boundary: the old session may no
|
||||
// longer be current, while exposing the candidate before install would
|
||||
// violate the handoff transaction.
|
||||
Self::write_projection(&state, None);
|
||||
if !authority.install(stamp) {
|
||||
let current = authority.current();
|
||||
let restore = state
|
||||
.installed
|
||||
.as_ref()
|
||||
.filter(|installed| Some(installed.stamp) == current)
|
||||
.and_then(|installed| installed.session_id.clone());
|
||||
if current != state.installed.as_ref().map(|installed| installed.stamp) {
|
||||
state.installed = None;
|
||||
}
|
||||
Self::write_projection(&state, restore);
|
||||
return false;
|
||||
}
|
||||
|
||||
let session_id = session_id.map(str::to_string);
|
||||
state.installed = Some(InstalledSessionProjection {
|
||||
stamp,
|
||||
session_id: session_id.clone(),
|
||||
});
|
||||
Self::write_projection(&state, session_id);
|
||||
true
|
||||
}
|
||||
|
||||
/// Publish a cloud-confirmed owner session from an event belonging to the
|
||||
/// exact installed runtime. Delegated session ids enter only through
|
||||
/// `install_authority` after their transactional commit.
|
||||
pub fn confirm_owner_session(
|
||||
&self,
|
||||
authority: &AuthorityCell,
|
||||
stamp: AuthorityStamp,
|
||||
session_id: &str,
|
||||
) -> bool {
|
||||
if stamp.origin() != AuthorityOrigin::Owner || session_id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut state = self.lock();
|
||||
if !authority.is_current(stamp)
|
||||
|| state.installed.as_ref().map(|installed| installed.stamp) != Some(stamp)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let session_id = session_id.to_string();
|
||||
if let Some(installed) = state.installed.as_mut() {
|
||||
installed.session_id = Some(session_id.clone());
|
||||
}
|
||||
Self::write_projection(&state, Some(session_id));
|
||||
true
|
||||
}
|
||||
|
||||
/// Snapshot the exact installed session for listener construction. This
|
||||
/// seeds GET connect-info before the HTTP worker can accept its first call.
|
||||
pub fn current_session_id(
|
||||
&self,
|
||||
authority: &AuthorityCell,
|
||||
expected: AuthorityStamp,
|
||||
) -> Option<String> {
|
||||
let state = self.lock();
|
||||
if !authority.is_current(expected) {
|
||||
return None;
|
||||
}
|
||||
state
|
||||
.installed
|
||||
.as_ref()
|
||||
.filter(|installed| installed.stamp == expected)
|
||||
.and_then(|installed| installed.session_id.clone())
|
||||
}
|
||||
|
||||
pub fn attach(&self, authority: &AuthorityCell, projection: LanProjection) {
|
||||
let mut state = self.lock();
|
||||
let current = authority.current();
|
||||
let session_id = state
|
||||
.installed
|
||||
.as_ref()
|
||||
.filter(|installed| Some(installed.stamp) == current)
|
||||
.and_then(|installed| installed.session_id.clone());
|
||||
projection.set_current_session_id(session_id);
|
||||
state.projection = Some(projection);
|
||||
}
|
||||
|
||||
pub fn detach(&self) {
|
||||
self.lock().projection.take();
|
||||
}
|
||||
|
||||
pub fn clear_authority(&self, authority: &AuthorityCell) -> Option<AuthorityStamp> {
|
||||
let mut state = self.lock();
|
||||
Self::write_projection(&state, None);
|
||||
let previous = authority.clear();
|
||||
state.installed = None;
|
||||
previous
|
||||
}
|
||||
|
||||
pub fn clear_if_current(&self, authority: &AuthorityCell, expected: AuthorityStamp) -> bool {
|
||||
let mut state = self.lock();
|
||||
if !authority.is_current(expected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Self::write_projection(&state, None);
|
||||
if authority.clear_if_current(expected) {
|
||||
state.installed = None;
|
||||
true
|
||||
} else {
|
||||
let restore = state
|
||||
.installed
|
||||
.as_ref()
|
||||
.filter(|installed| authority.is_current(installed.stamp))
|
||||
.and_then(|installed| installed.session_id.clone());
|
||||
Self::write_projection(&state, restore);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DaemonLanError {
|
||||
ExistingIdentityUnavailable,
|
||||
Service(LanError),
|
||||
AdmissionThread(std::io::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for DaemonLanError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::ExistingIdentityUnavailable => {
|
||||
formatter.write_str("qconnect LAN identity is unavailable")
|
||||
}
|
||||
Self::Service(error) => write!(formatter, "qconnect LAN service failed: {error}"),
|
||||
Self::AdmissionThread(_) => {
|
||||
formatter.write_str("qconnect LAN admission thread could not start")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for DaemonLanError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::Service(error) => Some(error),
|
||||
Self::AdmissionThread(error) => Some(error),
|
||||
Self::ExistingIdentityUnavailable => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LanError> for DaemonLanError {
|
||||
fn from(error: LanError) -> Self {
|
||||
Self::Service(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the daemon's effective Qobuz quality ceiling to the exact official LAN
|
||||
/// enum. `UltraHiRes` is QBZ's 24-bit/>96 kHz tier and tops out at the Qobuz
|
||||
/// 192 kHz format family; QBZ does not advertise the unused 384 kHz value.
|
||||
pub const fn max_audio_quality_for_quality(quality: Quality) -> MaxAudioQuality {
|
||||
match quality {
|
||||
Quality::Mp3 => MaxAudioQuality::MP3,
|
||||
Quality::Lossless => MaxAudioQuality::UpToCd,
|
||||
Quality::HiRes => MaxAudioQuality::UpToHires96,
|
||||
Quality::UltraHiRes => MaxAudioQuality::UpToHires192,
|
||||
}
|
||||
}
|
||||
|
||||
struct ExistingIdentity {
|
||||
device_uuid: String,
|
||||
friendly_name: String,
|
||||
software_version: String,
|
||||
}
|
||||
|
||||
impl ExistingIdentity {
|
||||
fn resolve() -> Result<Self, DaemonLanError> {
|
||||
let device_info = default_qconnect_device_info();
|
||||
let (Some(device_uuid), Some(friendly_name), Some(software_version)) = (
|
||||
device_info.device_uuid,
|
||||
device_info.friendly_name,
|
||||
device_info.software_version,
|
||||
) else {
|
||||
return Err(DaemonLanError::ExistingIdentityUnavailable);
|
||||
};
|
||||
if device_uuid.trim().is_empty()
|
||||
|| friendly_name.trim().is_empty()
|
||||
|| software_version.trim().is_empty()
|
||||
{
|
||||
return Err(DaemonLanError::ExistingIdentityUnavailable);
|
||||
}
|
||||
Ok(Self {
|
||||
device_uuid,
|
||||
friendly_name,
|
||||
software_version,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_projection(
|
||||
identity: &ExistingIdentity,
|
||||
app_id: String,
|
||||
quality: Quality,
|
||||
current_session_id: Option<String>,
|
||||
) -> LanProjection {
|
||||
LanProjection::new(
|
||||
DisplayInfo {
|
||||
friendly_name: identity.friendly_name.clone(),
|
||||
serial_number: identity.device_uuid.clone(),
|
||||
brand_display_name: DAEMON_BRAND.to_string(),
|
||||
model_display_name: DAEMON_MODEL.to_string(),
|
||||
max_audio_quality: max_audio_quality_for_quality(quality),
|
||||
device_type: DeviceType::Streamer,
|
||||
software_version: identity.software_version.clone(),
|
||||
},
|
||||
ConnectInfo {
|
||||
app_id,
|
||||
current_session_id,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Owns the LAN service and its blocking admission bridge.
|
||||
///
|
||||
/// The callback must enqueue or otherwise hand off the candidate promptly; it
|
||||
/// runs serially on the dedicated bridge thread so callback invocation order is
|
||||
/// the same order observed from the latest-wins admission slot.
|
||||
pub struct DaemonLanRuntime {
|
||||
service: Option<LanService>,
|
||||
projection: LanProjection,
|
||||
admission_bridge: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl DaemonLanRuntime {
|
||||
pub fn start(
|
||||
endpoint_policy: EndpointPolicy,
|
||||
app_id: String,
|
||||
quality: Quality,
|
||||
current_session_id: Option<String>,
|
||||
on_handoff: HandoffCallback,
|
||||
) -> Result<Self, DaemonLanError> {
|
||||
let identity = ExistingIdentity::resolve()?;
|
||||
let projection = build_projection(&identity, app_id, quality, current_session_id);
|
||||
let config =
|
||||
LanServiceConfig::new(projection.clone(), endpoint_policy, identity.device_uuid);
|
||||
let (mut service, inbox) = LanService::start(config)?;
|
||||
|
||||
let admission_bridge = match std::thread::Builder::new()
|
||||
.name(ADMISSION_THREAD_NAME.to_string())
|
||||
.spawn(move || loop {
|
||||
match inbox.take_timeout(ADMISSION_WAIT) {
|
||||
Some(candidate) => on_handoff(candidate),
|
||||
None if inbox.is_closed() => break,
|
||||
None => {}
|
||||
}
|
||||
}) {
|
||||
Ok(thread) => thread,
|
||||
Err(error) => {
|
||||
service.shutdown();
|
||||
return Err(DaemonLanError::AdmissionThread(error));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
service: Some(service),
|
||||
projection,
|
||||
admission_bridge: Some(admission_bridge),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn projection(&self) -> LanProjection {
|
||||
self.projection.clone()
|
||||
}
|
||||
|
||||
pub fn port(&self) -> Option<u16> {
|
||||
self.service.as_ref().map(LanService::port)
|
||||
}
|
||||
|
||||
/// Idempotent teardown in the normative order: withdraw discovery and stop
|
||||
/// HTTP admission first, then wait for the now-woken bridge to exit.
|
||||
pub fn shutdown(&mut self) {
|
||||
if let Some(mut service) = self.service.take() {
|
||||
service.shutdown();
|
||||
}
|
||||
if let Some(bridge) = self.admission_bridge.take() {
|
||||
let _ = bridge.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DaemonLanRuntime {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture_identity() -> ExistingIdentity {
|
||||
ExistingIdentity {
|
||||
device_uuid: "de0c0d70-0000-4000-8000-000000000001".to_string(),
|
||||
friendly_name: "Living room".to_string(),
|
||||
software_version: "qbz/2.1.0".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_mapping_matches_official_lan_families() {
|
||||
assert_eq!(
|
||||
max_audio_quality_for_quality(Quality::Mp3),
|
||||
MaxAudioQuality::MP3
|
||||
);
|
||||
assert_eq!(
|
||||
max_audio_quality_for_quality(Quality::Lossless),
|
||||
MaxAudioQuality::UpToCd
|
||||
);
|
||||
assert_eq!(
|
||||
max_audio_quality_for_quality(Quality::HiRes),
|
||||
MaxAudioQuality::UpToHires96
|
||||
);
|
||||
assert_eq!(
|
||||
max_audio_quality_for_quality(Quality::UltraHiRes),
|
||||
MaxAudioQuality::UpToHires192
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_projection_uses_existing_identity_and_official_role() {
|
||||
let projection = build_projection(
|
||||
&fixture_identity(),
|
||||
"app-id".to_string(),
|
||||
Quality::UltraHiRes,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
projection.display_info(),
|
||||
DisplayInfo {
|
||||
friendly_name: "Living room".to_string(),
|
||||
serial_number: "de0c0d70-0000-4000-8000-000000000001".to_string(),
|
||||
brand_display_name: "QBZ".to_string(),
|
||||
model_display_name: "QBZ Daemon".to_string(),
|
||||
max_audio_quality: MaxAudioQuality::UpToHires192,
|
||||
device_type: DeviceType::Streamer,
|
||||
software_version: "qbz/2.1.0".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
projection.connect_info(),
|
||||
ConnectInfo {
|
||||
app_id: "app-id".to_string(),
|
||||
current_session_id: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_session_id_changes_only_when_explicitly_updated() {
|
||||
let projection = build_projection(
|
||||
&fixture_identity(),
|
||||
"app-id".to_string(),
|
||||
Quality::Lossless,
|
||||
None,
|
||||
);
|
||||
assert_eq!(projection.connect_info().current_session_id, None);
|
||||
|
||||
projection.set_current_session_id(Some("delegated-session".to_string()));
|
||||
assert_eq!(
|
||||
projection.connect_info().current_session_id.as_deref(),
|
||||
Some("delegated-session")
|
||||
);
|
||||
projection.set_current_session_id(None);
|
||||
assert_eq!(projection.connect_info().current_session_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamped_projection_publishes_only_installed_authority() {
|
||||
let authority = AuthorityCell::new();
|
||||
let slot = DaemonLanProjectionSlot::default();
|
||||
let projection = build_projection(
|
||||
&fixture_identity(),
|
||||
"app-id".to_string(),
|
||||
Quality::Lossless,
|
||||
None,
|
||||
);
|
||||
slot.attach(&authority, projection.clone());
|
||||
|
||||
let owner = authority.reserve(AuthorityOrigin::Owner);
|
||||
assert!(slot.install_authority(&authority, owner, None));
|
||||
assert!(slot.confirm_owner_session(&authority, owner, "owner-session"));
|
||||
assert_eq!(
|
||||
projection.connect_info().current_session_id.as_deref(),
|
||||
Some("owner-session")
|
||||
);
|
||||
|
||||
// Merely reserving/preparing a guest cannot expose its candidate id.
|
||||
let guest = authority.reserve(AuthorityOrigin::Delegated { generation: 7 });
|
||||
assert_eq!(
|
||||
projection.connect_info().current_session_id.as_deref(),
|
||||
Some("owner-session")
|
||||
);
|
||||
|
||||
assert!(slot.install_authority(&authority, guest, Some("guest-session")));
|
||||
assert_eq!(
|
||||
projection.connect_info().current_session_id.as_deref(),
|
||||
Some("guest-session")
|
||||
);
|
||||
|
||||
// A late event from the retired owner cannot overwrite the guest.
|
||||
assert!(!slot.confirm_owner_session(&authority, owner, "stale-owner-session"));
|
||||
assert_eq!(
|
||||
projection.connect_info().current_session_id.as_deref(),
|
||||
Some("guest-session")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_session_events_update_and_stale_clear_cannot_win() {
|
||||
let authority = AuthorityCell::new();
|
||||
let slot = DaemonLanProjectionSlot::default();
|
||||
let projection = build_projection(
|
||||
&fixture_identity(),
|
||||
"app-id".to_string(),
|
||||
Quality::Lossless,
|
||||
None,
|
||||
);
|
||||
slot.attach(&authority, projection.clone());
|
||||
|
||||
let first_owner = authority.reserve(AuthorityOrigin::Owner);
|
||||
assert!(slot.install_authority(&authority, first_owner, None));
|
||||
assert!(slot.confirm_owner_session(&authority, first_owner, "owner-session-a"));
|
||||
assert!(slot.confirm_owner_session(&authority, first_owner, "owner-session-b"));
|
||||
assert_eq!(
|
||||
slot.current_session_id(&authority, first_owner).as_deref(),
|
||||
Some("owner-session-b")
|
||||
);
|
||||
|
||||
let next_owner = authority.reserve(AuthorityOrigin::Owner);
|
||||
assert!(slot.install_authority(&authority, next_owner, Some("owner-session-c")));
|
||||
assert!(!slot.clear_if_current(&authority, first_owner));
|
||||
assert_eq!(
|
||||
projection.connect_info().current_session_id.as_deref(),
|
||||
Some("owner-session-c")
|
||||
);
|
||||
}
|
||||
}
|
||||
+996
-132
File diff suppressed because it is too large.
Load diff
@@ -23,18 +23,19 @@
|
||||
//! (same pattern as `daemon.rs::spawn_queue_persist`), which ALSO covers queue
|
||||
//! edits while paused/stopped — a transition-only hook would miss those.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_models::CoreEvent;
|
||||
use qconnect_app::{is_local_renderer_active, QueueCommandType};
|
||||
use serde_json::json;
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::adapter::DaemonAdapter;
|
||||
use super::authority::{AuthorityCell, AuthorityStamp};
|
||||
use super::DaemonQconnectInner;
|
||||
use crate::adapter::DaemonAdapter;
|
||||
|
||||
/// Push the local core queue to the Connect session when it differs from the
|
||||
/// cloud's. No-op under any of the gates listed in the module docs. Echo-safe
|
||||
@@ -42,21 +43,34 @@ use super::DaemonQconnectInner;
|
||||
/// to the very queue it materialized locally, so a controller-pushed queue
|
||||
/// compares equal and is never bounced back.
|
||||
pub async fn publish_local_queue_if_changed(
|
||||
inner: &Arc<Mutex<DaemonQconnectInner>>,
|
||||
inner: &Arc<StdMutex<DaemonQconnectInner>>,
|
||||
runtime: &Arc<AppRuntime<DaemonAdapter>>,
|
||||
authority: &AuthorityCell,
|
||||
expected_stamp: AuthorityStamp,
|
||||
) {
|
||||
let (app, sync_state) = {
|
||||
let guard = inner.lock().await;
|
||||
let (app, sync_state, stamp) = {
|
||||
let guard = inner
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
match guard.runtime.as_ref() {
|
||||
Some(rt) => (Arc::clone(&rt.app), Arc::clone(&rt.sync_state)),
|
||||
Some(rt) if rt.stamp == expected_stamp => {
|
||||
(Arc::clone(&rt.app), Arc::clone(&rt.sync_state), rt.stamp)
|
||||
}
|
||||
None => return,
|
||||
Some(_) => return,
|
||||
}
|
||||
};
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only push while WE are the active renderer (the user is driving the
|
||||
// daemon). When a peer owns playback, the peer publishes its own queue.
|
||||
{
|
||||
let state = sync_state.lock().await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if !is_local_renderer_active(&state.session) {
|
||||
return;
|
||||
}
|
||||
@@ -71,6 +85,9 @@ pub async fn publish_local_queue_if_changed(
|
||||
}
|
||||
|
||||
let (tracks, current_index) = runtime.core().get_all_queue_tracks().await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -80,17 +97,31 @@ pub async fn publish_local_queue_if_changed(
|
||||
// inbound) so our own adoption / a remote queue change never bounces back.
|
||||
{
|
||||
let state = sync_state.lock().await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if let Some(applied) = &state.last_applied_queue_state {
|
||||
let applied_ids: Vec<u64> =
|
||||
applied.queue_items.iter().map(|item| item.track_id).collect();
|
||||
let applied_ids: Vec<u64> = applied
|
||||
.queue_items
|
||||
.iter()
|
||||
.map(|item| item.track_id)
|
||||
.collect();
|
||||
if applied_ids == ordered_ids {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ...and skip when we already pushed this exact queue (cloud echo pending).
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let guard = inner.lock().await;
|
||||
let guard = inner
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if !installed_runtime_matches(&guard, stamp) {
|
||||
return;
|
||||
}
|
||||
if guard.last_pushed_queue_ids.as_deref() == Some(ordered_ids.as_slice()) {
|
||||
return;
|
||||
}
|
||||
@@ -106,9 +137,20 @@ pub async fn publish_local_queue_if_changed(
|
||||
source != "local" && source != "plex" && track.id > 0
|
||||
});
|
||||
if !all_eligible {
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
log::info!("[QConnect] Local queue has non-Qobuz tracks; not casting to Connect");
|
||||
// Remember it so we don't re-log on every queue event within this queue.
|
||||
let mut guard = inner.lock().await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
let mut guard = inner
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if !installed_runtime_matches(&guard, stamp) {
|
||||
return;
|
||||
}
|
||||
guard.last_pushed_queue_ids = Some(ordered_ids);
|
||||
return;
|
||||
}
|
||||
@@ -128,18 +170,52 @@ pub async fn publish_local_queue_if_changed(
|
||||
let command = app
|
||||
.build_queue_command(QueueCommandType::CtrlSrvrQueueLoadTracks, payload)
|
||||
.await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
match app.send_queue_command(command).await {
|
||||
Ok(_) => {
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"[QConnect] Pushed local queue to Connect ({count} tracks, start={start_index})"
|
||||
);
|
||||
let mut guard = inner.lock().await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
let mut guard = inner
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if !installed_runtime_matches(&guard, stamp) {
|
||||
return;
|
||||
}
|
||||
guard.last_pushed_queue_ids = Some(ordered_ids);
|
||||
}
|
||||
Err(err) => log::warn!("[QConnect] Failed to push local queue: {err}"),
|
||||
Err(err) if authority.is_current(stamp) => {
|
||||
log::warn!("[QConnect] Failed to push local queue: {err}")
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_runtime_matches(inner: &DaemonQconnectInner, stamp: AuthorityStamp) -> bool {
|
||||
inner.runtime.as_ref().map(|runtime| runtime.stamp) == Some(stamp)
|
||||
}
|
||||
|
||||
fn capture_installed_stamp(
|
||||
inner: &StdMutex<DaemonQconnectInner>,
|
||||
authority: &AuthorityCell,
|
||||
) -> Option<AuthorityStamp> {
|
||||
let stamp = inner
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.runtime
|
||||
.as_ref()
|
||||
.map(|runtime| runtime.stamp)?;
|
||||
authority.is_current(stamp).then_some(stamp)
|
||||
}
|
||||
|
||||
/// The queue-publish subscriber: debounces `CoreEvent::QueueUpdated` bursts by
|
||||
/// 2 s (same ritual as `daemon.rs::spawn_queue_persist`), then runs
|
||||
/// [`publish_local_queue_if_changed`]. Non-queue events are drained WITHOUT
|
||||
@@ -148,8 +224,9 @@ pub async fn publish_local_queue_if_changed(
|
||||
/// aborted+joined in `QconnectHandle::shutdown` ahead of `drop(booted)` (the
|
||||
/// #521 ordering), exactly like the report scheduler.
|
||||
pub fn spawn_queue_cloud_publish(
|
||||
inner: Arc<Mutex<DaemonQconnectInner>>,
|
||||
inner: Arc<StdMutex<DaemonQconnectInner>>,
|
||||
runtime: Arc<AppRuntime<DaemonAdapter>>,
|
||||
authority: Arc<AuthorityCell>,
|
||||
mut rx: broadcast::Receiver<CoreEvent>,
|
||||
) -> JoinHandle<()> {
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
@@ -157,12 +234,17 @@ pub fn spawn_queue_cloud_publish(
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
// Block until the FIRST queue mutation of a burst.
|
||||
match rx.recv().await {
|
||||
Ok(CoreEvent::QueueUpdated { .. }) => {}
|
||||
let mut event_stamp = match rx.recv().await {
|
||||
Ok(CoreEvent::QueueUpdated { .. }) => {
|
||||
let Some(stamp) = capture_installed_stamp(&inner, &authority) else {
|
||||
continue;
|
||||
};
|
||||
stamp
|
||||
}
|
||||
Ok(_) => continue,
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => return,
|
||||
}
|
||||
};
|
||||
// Debounce: a fixed deadline that only a further QueueUpdated extends.
|
||||
let mut deadline = tokio::time::Instant::now() + DEBOUNCE;
|
||||
loop {
|
||||
@@ -170,6 +252,10 @@ pub fn spawn_queue_cloud_publish(
|
||||
_ = tokio::time::sleep_until(deadline) => break,
|
||||
r = rx.recv() => match r {
|
||||
Ok(CoreEvent::QueueUpdated { .. }) => {
|
||||
let Some(stamp) = capture_installed_stamp(&inner, &authority) else {
|
||||
break;
|
||||
};
|
||||
event_stamp = stamp;
|
||||
deadline = tokio::time::Instant::now() + DEBOUNCE;
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -178,7 +264,7 @@ pub fn spawn_queue_cloud_publish(
|
||||
}
|
||||
}
|
||||
}
|
||||
publish_local_queue_if_changed(&inner, &runtime).await;
|
||||
publish_local_queue_if_changed(&inner, &runtime, &authority, event_stamp).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -23,6 +23,31 @@ use std::time::Duration;
|
||||
|
||||
use qbz_player::{BufferWriter, Player};
|
||||
|
||||
/// Owned background feeder for one progressive remote stream.
|
||||
///
|
||||
/// Tokio normally detaches a task when its `JoinHandle` is dropped. This
|
||||
/// wrapper deliberately aborts instead: the renderer engine owns exactly one
|
||||
/// feeder, so replacing/stopping an authority cannot leave an old CDN request
|
||||
/// writing into a retired buffer.
|
||||
#[must_use = "dropping the feeder aborts the background CDN request"]
|
||||
pub struct RemoteStreamFeeder {
|
||||
task: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl RemoteStreamFeeder {
|
||||
fn new(task: tokio::task::JoinHandle<()>) -> Self {
|
||||
Self { task: Some(task) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RemoteStreamFeeder {
|
||||
fn drop(&mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format/size facts sniffed from a remote audio URL before streaming.
|
||||
pub struct RemoteStreamInfo {
|
||||
pub content_length: u64,
|
||||
@@ -45,8 +70,10 @@ pub async fn stream_remote_track_into_player(
|
||||
start_position_secs: u64,
|
||||
url: &str,
|
||||
log_tag: &str,
|
||||
) -> Result<(), String> {
|
||||
authority_check: impl FnOnce() -> Result<(), String>,
|
||||
) -> Result<RemoteStreamFeeder, String> {
|
||||
let stream_info = probe_remote_stream_info(url).await?;
|
||||
authority_check()?;
|
||||
log::info!(
|
||||
"[{}/STREAMING] Track {} - {:.2} MB, {}Hz, {} ch, {}-bit, {:.1} MB/s",
|
||||
log_tag,
|
||||
@@ -74,7 +101,7 @@ pub async fn stream_remote_track_into_player(
|
||||
let url = url.to_string();
|
||||
let content_length = stream_info.content_length;
|
||||
let log_tag = log_tag.to_string();
|
||||
tokio::spawn(async move {
|
||||
let task = tokio::spawn(async move {
|
||||
if let Err(err) =
|
||||
download_and_stream_remote_track(&url, writer, track_id, content_length, &log_tag).await
|
||||
{
|
||||
@@ -87,7 +114,7 @@ pub async fn stream_remote_track_into_player(
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
Ok(RemoteStreamFeeder::new(task))
|
||||
}
|
||||
|
||||
/// HEAD for content-length, then a small `Range: bytes=0-65535` GET to (a)
|
||||
@@ -101,14 +128,19 @@ pub async fn probe_remote_stream_info(url: &str) -> Result<RemoteStreamInfo, Str
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|err| format!("create stream probe client: {err}"))?;
|
||||
.map_err(|_| "create stream probe HTTP client failed".to_string())?;
|
||||
|
||||
let head_response = client
|
||||
.head(url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("probe HEAD request failed: {}", describe_reqwest_error(&err)))?;
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"probe HEAD request failed: {}",
|
||||
describe_reqwest_error(&err)
|
||||
)
|
||||
})?;
|
||||
|
||||
if !head_response.status().is_success() {
|
||||
return Err(format!(
|
||||
@@ -131,7 +163,12 @@ pub async fn probe_remote_stream_info(url: &str) -> Result<RemoteStreamInfo, Str
|
||||
.header("Range", "bytes=0-65535")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("probe range request failed: {}", describe_reqwest_error(&err)))?;
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"probe range request failed: {}",
|
||||
describe_reqwest_error(&err)
|
||||
)
|
||||
})?;
|
||||
|
||||
if !range_response.status().is_success() {
|
||||
return Err(format!(
|
||||
@@ -210,7 +247,7 @@ pub async fn download_and_stream_remote_track(
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|err| format!("create remote streaming client: {err}"))?;
|
||||
.map_err(|_| "create remote streaming HTTP client failed".to_string())?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
@@ -237,8 +274,12 @@ pub async fn download_and_stream_remote_track(
|
||||
let mut last_log_time = Instant::now();
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result
|
||||
.map_err(|err| format!("remote streaming chunk failed: {}", describe_reqwest_error(&err)))?;
|
||||
let chunk = chunk_result.map_err(|err| {
|
||||
format!(
|
||||
"remote streaming chunk failed: {}",
|
||||
describe_reqwest_error(&err)
|
||||
)
|
||||
})?;
|
||||
bytes_received += chunk.len() as u64;
|
||||
|
||||
if let Err(err) = writer.push_chunk(&chunk) {
|
||||
@@ -294,24 +335,63 @@ pub async fn download_and_stream_remote_track(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// reqwest's `Display` hides the source chain — which is exactly where the
|
||||
/// diagnosis lives (Akamai's >100-header small-object flood surfaces as hyper's
|
||||
/// "message head is too large" two levels down). Walk `source()` and join the
|
||||
/// chain so logs AND signature matching see the real cause.
|
||||
/// Return a bounded, URL-free diagnostic for a reqwest failure.
|
||||
///
|
||||
/// The raw error and its source chain can contain the signed CDN URL (including
|
||||
/// its query). We inspect the chain only to preserve the known header-limit
|
||||
/// classification and never copy an arbitrary cause into the returned string.
|
||||
pub fn describe_reqwest_error(err: &reqwest::Error) -> String {
|
||||
use std::error::Error as _;
|
||||
let mut out = err.to_string();
|
||||
let mut source = err.source();
|
||||
while let Some(cause) = source {
|
||||
out.push_str(": ");
|
||||
out.push_str(&cause.to_string());
|
||||
source = cause.source();
|
||||
if error_chain_has_header_limit(err) {
|
||||
return safe_transport_diagnostic(
|
||||
"message head is too large; signed URL and query are intentionally omitted",
|
||||
)
|
||||
.to_string();
|
||||
}
|
||||
|
||||
if err.is_timeout() {
|
||||
"HTTP transport timed out".to_string()
|
||||
} else if err.is_connect() {
|
||||
"HTTP transport connection failed".to_string()
|
||||
} else if err.is_body() {
|
||||
"HTTP response body failed".to_string()
|
||||
} else if err.is_decode() {
|
||||
"HTTP response decode failed".to_string()
|
||||
} else if err.is_status() {
|
||||
"HTTP status rejected".to_string()
|
||||
} else {
|
||||
"HTTP transport request failed".to_string()
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when an error message (already chain-expanded by
|
||||
/// [`describe_reqwest_error`]) shows hyper's hard-coded h1 100-header cap.
|
||||
fn error_chain_has_header_limit(err: &reqwest::Error) -> bool {
|
||||
use std::error::Error as _;
|
||||
|
||||
if is_header_flood_error(&err.to_string()) {
|
||||
return true;
|
||||
}
|
||||
let mut source = err.source();
|
||||
while let Some(cause) = source {
|
||||
if is_header_flood_error(&cause.to_string()) {
|
||||
return true;
|
||||
}
|
||||
source = cause.source();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Map arbitrary transport text to one of a few constant diagnostics. This is
|
||||
/// intentionally lossy: CDN URLs and their credential-bearing query strings
|
||||
/// are never valid log or API output.
|
||||
fn safe_transport_diagnostic(message: &str) -> &'static str {
|
||||
if is_header_flood_error(message) {
|
||||
"HTTP response header limit exceeded (message head is too large)"
|
||||
} else {
|
||||
"HTTP transport request failed"
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a sanitized diagnostic (or an internal raw cause inspected before
|
||||
/// logging) shows hyper's hard-coded h1 100-header cap.
|
||||
/// Akamai answers SMALL raw-url objects with ~106 headers (the `X-AK-GRN` /
|
||||
/// `X-AK-FWD-ERROR: ERR_POC_FWD_OBJ_TOO_SMALL` flood), so EVERY reqwest fetch
|
||||
/// of such an URL fails this way — streaming probe and full download alike.
|
||||
@@ -319,3 +399,26 @@ pub fn is_header_flood_error(message: &str) -> bool {
|
||||
let haystack = message.to_ascii_lowercase();
|
||||
haystack.contains("message head is too large") || haystack.contains("too many headers")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_header_flood_error, safe_transport_diagnostic};
|
||||
|
||||
#[test]
|
||||
fn cdn_diagnostic_never_echoes_url_or_query() {
|
||||
let raw = "request failed for https://cdn.example/audio.flac?jwt=super-secret";
|
||||
let diagnostic = safe_transport_diagnostic(raw);
|
||||
assert_eq!(diagnostic, "HTTP transport request failed");
|
||||
assert!(!diagnostic.contains("cdn.example"));
|
||||
assert!(!diagnostic.contains("super-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_header_limit_remains_machine_classifiable() {
|
||||
let raw = "https://cdn.example/audio?token=secret: message head is too large";
|
||||
let diagnostic = safe_transport_diagnostic(raw);
|
||||
assert!(is_header_flood_error(diagnostic));
|
||||
assert!(!diagnostic.contains("cdn.example"));
|
||||
assert!(!diagnostic.contains("secret"));
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
//! `duration_ms` are MILLISECONDS (the QConnect protocol unit).
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_player::player::PlaybackBufferState;
|
||||
@@ -25,8 +25,9 @@ use qconnect_app::{
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::adapter::DaemonAdapter;
|
||||
use super::authority::{AuthorityCell, AuthorityStamp};
|
||||
use super::sink::DaemonQconnectApp;
|
||||
use crate::adapter::DaemonAdapter;
|
||||
|
||||
pub const QCONNECT_RENDERER_CHANNELS: i32 = 2;
|
||||
const AUDIO_QUALITY_UNKNOWN: i32 = 0;
|
||||
@@ -45,25 +46,39 @@ pub async fn report_playback_state(
|
||||
app: &Arc<DaemonQconnectApp>,
|
||||
sync_state: &Arc<Mutex<QconnectRemoteSyncState>>,
|
||||
runtime: &Arc<AppRuntime<DaemonAdapter>>,
|
||||
authority: &AuthorityCell,
|
||||
stamp: AuthorityStamp,
|
||||
playing_state: i32,
|
||||
position_ms: i64,
|
||||
duration_ms: i64,
|
||||
track_id: u64,
|
||||
buffer_state: PlaybackBufferState,
|
||||
) {
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
// Only report when WE are the active renderer. When a peer renderer owns
|
||||
// playback (the daemon is acting as a controller) the renderer reports come
|
||||
// from the peer, not us.
|
||||
{
|
||||
let state = sync_state.lock().await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if !is_local_renderer_active(&state.session) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let (current_qid, next_qid) =
|
||||
resolve_queue_item_ids_by_track_id(app, sync_state, track_id).await;
|
||||
resolve_queue_item_ids_by_track_id(app, sync_state, authority, stamp, track_id).await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
let queue_version = app.queue_state_snapshot().await.version;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
let report = build_renderer_playback_report(
|
||||
Uuid::new_v4().to_string(),
|
||||
@@ -77,13 +92,22 @@ pub async fn report_playback_state(
|
||||
next_queue_item_id: next_qid,
|
||||
},
|
||||
);
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = app.send_renderer_report_command(report).await {
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
log::warn!("[QConnect] Failed to report playback state: {err}");
|
||||
}
|
||||
|
||||
if position_ms >= 0 {
|
||||
if position_ms >= 0 && authority.is_current(stamp) {
|
||||
app.update_renderer_position(position_ms as u64).await;
|
||||
}
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Report the live output format so the controller shows the correct quality
|
||||
// badge (CD / Hi-Res). Reads the player's current output (sample_rate/
|
||||
@@ -95,12 +119,21 @@ pub async fn report_playback_state(
|
||||
if let Some(snapshot) =
|
||||
build_file_audio_quality_snapshot(sample_rate, bit_depth, QCONNECT_RENDERER_CHANNELS)
|
||||
{
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = app
|
||||
.report_file_audio_quality_if_changed(queue_version, snapshot)
|
||||
.await
|
||||
{
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
log::warn!("[QConnect] Failed to report file audio quality: {err}");
|
||||
}
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = app
|
||||
.report_device_audio_quality_if_changed(
|
||||
queue_version,
|
||||
@@ -110,6 +143,9 @@ pub async fn report_playback_state(
|
||||
)
|
||||
.await
|
||||
{
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
log::warn!("[QConnect] Failed to report device audio quality: {err}");
|
||||
}
|
||||
}
|
||||
@@ -158,14 +194,25 @@ fn build_file_audio_quality_snapshot(
|
||||
async fn resolve_queue_item_ids_by_track_id(
|
||||
app: &Arc<DaemonQconnectApp>,
|
||||
sync_state: &Arc<Mutex<QconnectRemoteSyncState>>,
|
||||
authority: &AuthorityCell,
|
||||
stamp: AuthorityStamp,
|
||||
track_id: u64,
|
||||
) -> (Option<u64>, Option<u64>) {
|
||||
if !authority.is_current(stamp) {
|
||||
return (None, None);
|
||||
}
|
||||
let queue = app.queue_state_snapshot().await;
|
||||
if !authority.is_current(stamp) {
|
||||
return (None, None);
|
||||
}
|
||||
let (current_qid, next_qid, next_track_id) =
|
||||
qconnect_app::queue_resolution::resolve_queue_item_ids_from_queue_state(&queue, track_id);
|
||||
|
||||
if let Some(current_qid) = current_qid {
|
||||
let mut state = sync_state.lock().await;
|
||||
if !authority.is_current(stamp) {
|
||||
return (None, None);
|
||||
}
|
||||
state.last_renderer_queue_item_id = Some(current_qid);
|
||||
state.last_renderer_next_queue_item_id = next_qid;
|
||||
state.last_renderer_track_id = Some(track_id);
|
||||
@@ -195,8 +242,9 @@ async fn resolve_queue_item_ids_by_track_id(
|
||||
// `is_playing`, so a paused/stopped renderer stays silent like the desktop.
|
||||
pub async fn run_report_scheduler(
|
||||
notify: Arc<tokio::sync::Notify>,
|
||||
inner: Arc<Mutex<super::DaemonQconnectInner>>,
|
||||
inner: Arc<StdMutex<super::DaemonQconnectInner>>,
|
||||
runtime: Arc<AppRuntime<DaemonAdapter>>,
|
||||
authority: Arc<AuthorityCell>,
|
||||
) {
|
||||
use qconnect_app::renderer::{PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING};
|
||||
|
||||
@@ -211,8 +259,27 @@ pub async fn run_report_scheduler(
|
||||
// Reset so the periodic floor only fires after 2 s of edge silence.
|
||||
interval.reset();
|
||||
|
||||
// Capture one exact installed runtime before reading player state. If a
|
||||
// handoff lands while the snapshot is read, the stamp check below drops
|
||||
// the mixed-authority sample.
|
||||
let (app, sync_state, stamp) = {
|
||||
let guard = inner
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
match guard.runtime.as_ref() {
|
||||
Some(rt) => (Arc::clone(&rt.app), Arc::clone(&rt.sync_state), rt.stamp),
|
||||
_ => continue,
|
||||
}
|
||||
};
|
||||
if !authority.is_current(stamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read the live player state. Nothing loaded -> nothing to report.
|
||||
let ev = runtime.core().player().get_playback_event();
|
||||
if !authority.is_current(stamp) {
|
||||
continue;
|
||||
}
|
||||
let report_track_id = qconnect_report_track_id(&ev);
|
||||
if report_track_id == 0 {
|
||||
continue;
|
||||
@@ -229,16 +296,6 @@ pub async fn run_report_scheduler(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the LIVE session (app + the shared sync accumulator). No runtime
|
||||
// means QConnect is not connected -> a no-op this tick.
|
||||
let (app, sync_state) = {
|
||||
let guard = inner.lock().await;
|
||||
match guard.runtime.as_ref() {
|
||||
Some(rt) => (Arc::clone(&rt.app), Arc::clone(&rt.sync_state)),
|
||||
None => continue,
|
||||
}
|
||||
};
|
||||
|
||||
let playing_state = if ev.is_playing {
|
||||
PLAYING_STATE_PLAYING
|
||||
} else {
|
||||
@@ -251,6 +308,8 @@ pub async fn run_report_scheduler(
|
||||
&app,
|
||||
&sync_state,
|
||||
&runtime,
|
||||
&authority,
|
||||
stamp,
|
||||
playing_state,
|
||||
position_ms,
|
||||
duration_ms,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
//!
|
||||
//! The desktop's D5 offline-mode parks are dropped: qbzd has no offline MODE.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qconnect_app::renderer::PLAYING_STATE_STOPPED;
|
||||
@@ -32,15 +32,17 @@ use serde_json::json;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::adapter::DaemonAdapter;
|
||||
use crate::state::DaemonShared;
|
||||
use super::authority::{AuthorityCell, AuthorityStamp};
|
||||
use super::engine::VolumeMode; // T10 (OD4): join-time volume report honors the mode
|
||||
use super::lan::DaemonLanProjectionSlot;
|
||||
use super::sink::{DaemonEventSink, DaemonQconnectApp};
|
||||
use super::transport::{
|
||||
default_qconnect_device_info, default_qconnect_device_info_with_name, resolve_transport_config,
|
||||
QconnectJoinSessionRequest, AUDIO_QUALITY_HIRES_LEVEL2,
|
||||
};
|
||||
use super::{update_lifecycle_state_if_running, DaemonQconnectInner};
|
||||
use crate::adapter::DaemonAdapter;
|
||||
use crate::state::DaemonShared;
|
||||
|
||||
type Runtime = Arc<AppRuntime<DaemonAdapter>>;
|
||||
|
||||
@@ -52,22 +54,39 @@ type Runtime = Arc<AppRuntime<DaemonAdapter>>;
|
||||
pub struct DaemonSessionLoopHost {
|
||||
pub app: Arc<DaemonQconnectApp>,
|
||||
pub sync_state: Arc<Mutex<QconnectRemoteSyncState>>,
|
||||
pub inner: Arc<Mutex<DaemonQconnectInner>>,
|
||||
pub inner: Arc<StdMutex<DaemonQconnectInner>>,
|
||||
pub authority: Arc<AuthorityCell>,
|
||||
pub stamp: AuthorityStamp,
|
||||
pub sink: Arc<DaemonEventSink>,
|
||||
pub runtime: Runtime,
|
||||
pub shared: Arc<std::sync::Mutex<DaemonShared>>,
|
||||
/// T10 (OD4): resolved volume policy — the deferred renderer join reports 100
|
||||
/// in `Locked` mode, the real player volume in `Software`.
|
||||
pub volume_mode: VolumeMode,
|
||||
pub(crate) projection: DaemonLanProjectionSlot,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SessionLoopHost for DaemonSessionLoopHost {
|
||||
async fn update_lifecycle(&self, state: QconnectLifecycleState) {
|
||||
update_lifecycle_state_if_running(&self.inner, &self.sink, &self.shared, state).await;
|
||||
if !self.authority.is_current(self.stamp) {
|
||||
return;
|
||||
}
|
||||
update_lifecycle_state_if_running(
|
||||
&self.inner,
|
||||
&self.sink,
|
||||
&self.shared,
|
||||
&self.authority,
|
||||
self.stamp,
|
||||
state,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn bootstrap_after_reconnect(&self) {
|
||||
if !self.authority.is_current(self.stamp) {
|
||||
return;
|
||||
}
|
||||
// FIX (b) (IV3, daemon-copy only): the WS transport reuses `last_config`
|
||||
// on in-loop reconnects and never re-resolves `/qws/createToken`, so a
|
||||
// reconnect after the JWT expires keeps failing. Re-resolve the transport
|
||||
@@ -76,17 +95,46 @@ impl SessionLoopHost for DaemonSessionLoopHost {
|
||||
// desktop copy does NOT do this.
|
||||
match resolve_transport_config(&self.runtime).await {
|
||||
Ok(fresh) => {
|
||||
let mut guard = self.inner.lock().await;
|
||||
if let Some(rt) = guard.runtime.as_mut() {
|
||||
rt.config = fresh;
|
||||
if !self.authority.is_current(self.stamp) {
|
||||
return;
|
||||
}
|
||||
let latched = {
|
||||
let mut guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(rt) = guard.runtime.as_mut() {
|
||||
if rt.stamp == self.stamp {
|
||||
rt.config = fresh;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if !latched || !self.authority.is_current(self.stamp) {
|
||||
return;
|
||||
}
|
||||
log::info!("[QConnect] reconnect: re-resolved transport credentials (fresh JWT)");
|
||||
}
|
||||
Err(err) => {
|
||||
if !self.authority.is_current(self.stamp) {
|
||||
return;
|
||||
}
|
||||
log::warn!("[QConnect] reconnect: credential re-resolve failed: {err}");
|
||||
}
|
||||
}
|
||||
if let Err(err) = bootstrap_remote_presence(&self.app, None).await {
|
||||
if !self.authority.is_current(self.stamp) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) =
|
||||
bootstrap_remote_presence(&self.app, None, &self.authority, self.stamp).await
|
||||
{
|
||||
if !self.authority.is_current(self.stamp) {
|
||||
return;
|
||||
}
|
||||
log::error!("[QConnect] Re-bootstrap after reconnect failed: {err}");
|
||||
}
|
||||
}
|
||||
@@ -99,6 +147,8 @@ impl SessionLoopHost for DaemonSessionLoopHost {
|
||||
self.volume_mode, // T10 (OD4): 100 in Locked, real in Software
|
||||
&session_uuid,
|
||||
reason,
|
||||
&self.authority,
|
||||
self.stamp,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -109,20 +159,40 @@ impl SessionLoopHost for DaemonSessionLoopHost {
|
||||
last_reason: String,
|
||||
idle_retry_active: bool,
|
||||
) -> bool {
|
||||
{
|
||||
let mut guard = self.inner.lock().await;
|
||||
guard.lifecycle_state = QconnectLifecycleState::Exhausted;
|
||||
guard.last_error = Some(format!(
|
||||
"Reconnect attempts exhausted ({attempts}): {last_reason}"
|
||||
));
|
||||
if !idle_retry_active {
|
||||
// Legacy terminate path: drop the runtime so a fresh connect()
|
||||
// succeeds. Dropping it detaches this task's own JoinHandle (fine
|
||||
// — the loop breaks right after).
|
||||
guard.runtime = None;
|
||||
if !self.authority.is_current(self.stamp) {
|
||||
return true;
|
||||
}
|
||||
let (applied, retired) = {
|
||||
let mut guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if guard.runtime.as_ref().map(|rt| rt.stamp) != Some(self.stamp) {
|
||||
(false, None)
|
||||
} else {
|
||||
guard.lifecycle_state = QconnectLifecycleState::Exhausted;
|
||||
guard.last_error = Some(format!(
|
||||
"Reconnect attempts exhausted ({attempts}): {last_reason}"
|
||||
));
|
||||
if !idle_retry_active {
|
||||
// Legacy terminate path: drop the runtime so a fresh connect()
|
||||
// succeeds. Dropping it detaches this task's own JoinHandle
|
||||
// (fine — the loop breaks right after).
|
||||
(true, guard.runtime.take())
|
||||
} else {
|
||||
(true, None)
|
||||
}
|
||||
}
|
||||
};
|
||||
// Never drop a retired runtime while holding the synchronous inner lock.
|
||||
drop(retired);
|
||||
if !applied || !self.authority.is_current(self.stamp) {
|
||||
return true;
|
||||
}
|
||||
if let Ok(mut s) = self.shared.lock() {
|
||||
if !self.authority.is_current(self.stamp) {
|
||||
return true;
|
||||
}
|
||||
s.qconnect.state = "exhausted".to_string();
|
||||
s.qconnect.session_active = false;
|
||||
// 01 §9.3: reconnect-exhausted is a real network-class failure —
|
||||
@@ -132,6 +202,12 @@ impl SessionLoopHost for DaemonSessionLoopHost {
|
||||
s.emit_qconnect_session_changed();
|
||||
}
|
||||
log::warn!("[QConnect] Reconnect exhausted ({attempts}): {last_reason}");
|
||||
if !idle_retry_active {
|
||||
// Invalidate this sink/host after publishing the terminal status.
|
||||
// `clear_if_current` cannot clear a replacement installed in between.
|
||||
self.projection
|
||||
.clear_if_current(&self.authority, self.stamp);
|
||||
}
|
||||
// Daemon has no offline MODE, so the desktop's idle-retry offline park is
|
||||
// dropped: keep idling (the transport's internal 60s idle rearm keeps
|
||||
// trying) unless idle-retry is off, in which case break + drop.
|
||||
@@ -139,6 +215,9 @@ impl SessionLoopHost for DaemonSessionLoopHost {
|
||||
}
|
||||
|
||||
async fn on_loop_error(&self, message: String) {
|
||||
if !self.authority.is_current(self.stamp) {
|
||||
return;
|
||||
}
|
||||
// Slint copy surfaced a toast here — daemon logs it.
|
||||
log::error!("[QConnect] session loop error: {message}");
|
||||
}
|
||||
@@ -151,7 +230,34 @@ impl SessionLoopHost for DaemonSessionLoopHost {
|
||||
pub async fn bootstrap_remote_presence(
|
||||
app: &Arc<DaemonQconnectApp>,
|
||||
custom_device_name: Option<String>,
|
||||
authority: &AuthorityCell,
|
||||
stamp: AuthorityStamp,
|
||||
) -> Result<(), String> {
|
||||
bootstrap_remote_presence_with_gate(app, custom_device_name, || authority.is_current(stamp))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Complete the owner bootstrap on an isolated, authenticated/subscribed QWS
|
||||
/// runtime before its authority commit. The receiver is already subscribed, so
|
||||
/// resulting session events remain buffered for the loop installed at commit.
|
||||
pub(crate) async fn bootstrap_prepared_owner_presence(
|
||||
app: &Arc<DaemonQconnectApp>,
|
||||
custom_device_name: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
bootstrap_remote_presence_with_gate(app, custom_device_name, || true).await
|
||||
}
|
||||
|
||||
async fn bootstrap_remote_presence_with_gate<F>(
|
||||
app: &Arc<DaemonQconnectApp>,
|
||||
custom_device_name: Option<String>,
|
||||
is_current: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn() -> bool,
|
||||
{
|
||||
if !is_current() {
|
||||
return Ok(());
|
||||
}
|
||||
let device_info = default_qconnect_device_info_with_name(custom_device_name.as_deref());
|
||||
|
||||
let join_payload = serde_json::to_value(QconnectJoinSessionRequest {
|
||||
@@ -163,22 +269,40 @@ pub async fn bootstrap_remote_presence(
|
||||
let join_command = app
|
||||
.build_queue_command(QueueCommandType::CtrlSrvrJoinSession, join_payload)
|
||||
.await;
|
||||
let join_action_uuid = app
|
||||
.send_queue_command(join_command)
|
||||
.await
|
||||
.map_err(|err| format!("send bootstrap ctrl_srvr_join_session failed: {err}"))?;
|
||||
if !is_current() {
|
||||
return Ok(());
|
||||
}
|
||||
let join_action_uuid = match app.send_queue_command(join_command).await {
|
||||
Ok(action_uuid) => action_uuid,
|
||||
Err(_) if !is_current() => return Ok(()),
|
||||
Err(err) => {
|
||||
return Err(format!(
|
||||
"send bootstrap ctrl_srvr_join_session failed: {err}"
|
||||
))
|
||||
}
|
||||
};
|
||||
// JoinSession responds with session/renderer controller events not part of
|
||||
// queue reducer correlation. Drop the pending slot so queue ops aren't blocked.
|
||||
app.clear_pending_if_matches(&join_action_uuid).await;
|
||||
if !is_current() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ask_queue_command = app
|
||||
.build_queue_command(QueueCommandType::CtrlSrvrAskForQueueState, json!({}))
|
||||
.await;
|
||||
let ask_action_uuid = app
|
||||
.send_queue_command(ask_queue_command)
|
||||
.await
|
||||
.map_err(|err| format!("send bootstrap ask_for_queue_state failed: {err}"))?;
|
||||
if !is_current() {
|
||||
return Ok(());
|
||||
}
|
||||
let ask_action_uuid = match app.send_queue_command(ask_queue_command).await {
|
||||
Ok(action_uuid) => action_uuid,
|
||||
Err(_) if !is_current() => return Ok(()),
|
||||
Err(err) => return Err(format!("send bootstrap ask_for_queue_state failed: {err}")),
|
||||
};
|
||||
app.clear_pending_if_matches(&ask_action_uuid).await;
|
||||
if !is_current() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[QConnect] Bootstrap complete: controller joined, queue state requested. Renderer join deferred until session_uuid received."
|
||||
@@ -198,16 +322,28 @@ pub async fn deferred_renderer_join(
|
||||
volume_mode: VolumeMode, // T10 (OD4): join-time volume report policy
|
||||
session_uuid: &str,
|
||||
join_reason: i32,
|
||||
authority: &AuthorityCell,
|
||||
stamp: AuthorityStamp,
|
||||
) {
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
let already_joined = {
|
||||
let st = sync_state.lock().await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
st.last_joined_session_uuid.as_deref() == Some(session_uuid)
|
||||
};
|
||||
if already_joined {
|
||||
log::info!(
|
||||
"[QConnect] Deferred join skipped (already joined session_uuid={session_uuid}); re-asking renderer state"
|
||||
);
|
||||
log::info!("[QConnect] Deferred join skipped (session already joined)");
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = app.ask_for_active_renderer_state().await {
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
log::warn!("[QConnect] Idempotent-join AskForRendererState failed: {err}");
|
||||
}
|
||||
return;
|
||||
@@ -215,8 +351,11 @@ pub async fn deferred_renderer_join(
|
||||
|
||||
let device_info = default_qconnect_device_info();
|
||||
let queue_version_ref = app.queue_state_snapshot().await.version;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("[QConnect] Deferred renderer join with session_uuid={session_uuid}");
|
||||
log::info!("[QConnect] Starting deferred renderer join");
|
||||
|
||||
// 1. Renderer JoinSession with session_uuid.
|
||||
// Do NOT auto-steal the render on a fresh connect: join as an AVAILABLE
|
||||
@@ -246,7 +385,13 @@ pub async fn deferred_renderer_join(
|
||||
queue_version_ref,
|
||||
renderer_join_payload,
|
||||
);
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = app.send_renderer_report_command(renderer_join_report).await {
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
log::error!("[QConnect] Deferred renderer join failed: {err}");
|
||||
return;
|
||||
}
|
||||
@@ -255,7 +400,13 @@ pub async fn deferred_renderer_join(
|
||||
// we may already have a current track, so resolve the real duration + current/
|
||||
// next queue_item_ids instead of hardcoding nulls.
|
||||
let renderer = app.renderer_state_snapshot().await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
let queue = app.queue_state_snapshot().await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
let current_track_id = renderer.current_track.as_ref().map(|item| item.track_id);
|
||||
let (current_qid, next_qid, _) = current_track_id
|
||||
.map(|tid| {
|
||||
@@ -271,6 +422,9 @@ pub async fn deferred_renderer_join(
|
||||
.unwrap_or(0),
|
||||
None => 0,
|
||||
};
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
let mut state_report_payload = json!({
|
||||
"playing_state": PLAYING_STATE_STOPPED,
|
||||
"buffer_state": RendererBufferState::Ok.as_i32(),
|
||||
@@ -293,7 +447,13 @@ pub async fn deferred_renderer_join(
|
||||
queue_version_ref,
|
||||
state_report_payload,
|
||||
);
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = app.send_renderer_report_command(state_report).await {
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
log::error!("[QConnect] Deferred renderer state report failed: {err}");
|
||||
}
|
||||
|
||||
@@ -310,7 +470,13 @@ pub async fn deferred_renderer_join(
|
||||
queue_version_ref,
|
||||
json!({ "volume": volume_pct }),
|
||||
);
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = app.send_renderer_report_command(volume_report).await {
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
log::error!("[QConnect] Deferred renderer volume report failed: {err}");
|
||||
}
|
||||
|
||||
@@ -320,7 +486,13 @@ pub async fn deferred_renderer_join(
|
||||
queue_version_ref,
|
||||
json!({ "max_audio_quality": AUDIO_QUALITY_HIRES_LEVEL2 }),
|
||||
);
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = app.send_renderer_report_command(max_quality_report).await {
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
log::error!("[QConnect] Deferred renderer max quality report failed: {err}");
|
||||
}
|
||||
|
||||
@@ -329,17 +501,32 @@ pub async fn deferred_renderer_join(
|
||||
// Re-request session state so the server sends an updated renderer list
|
||||
// (including ourselves).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
let refresh_command = app
|
||||
.build_queue_command(QueueCommandType::CtrlSrvrAskForQueueState, json!({}))
|
||||
.await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if let Ok(action_uuid) = app.send_queue_command(refresh_command).await {
|
||||
app.clear_pending_if_matches(&action_uuid).await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
log::info!("[QConnect] Re-requested session state after renderer join");
|
||||
}
|
||||
|
||||
// Resync the active renderer's full state too, so a reconnect rejoin restores
|
||||
// renderer state.
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = app.ask_for_active_renderer_state().await {
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
log::warn!("[QConnect] Post-join AskForRendererState failed: {err}");
|
||||
}
|
||||
|
||||
@@ -347,6 +534,9 @@ pub async fn deferred_renderer_join(
|
||||
// takes the idempotent fast-path above.
|
||||
{
|
||||
let mut st = sync_state.lock().await;
|
||||
if !authority.is_current(stamp) {
|
||||
return;
|
||||
}
|
||||
st.last_joined_session_uuid = Some(session_uuid.to_string());
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,9 @@ use serde_json::Value;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::authority::{AuthorityCell, AuthorityStamp};
|
||||
use super::engine::DaemonRendererEngine;
|
||||
use super::lan::DaemonLanProjectionSlot;
|
||||
use super::transport::resolve_local_identity;
|
||||
|
||||
/// Concrete `QconnectApp` type used by the daemon adapter.
|
||||
@@ -42,6 +44,13 @@ pub struct DaemonEventSink {
|
||||
engine: DaemonRendererEngine,
|
||||
/// THE shared remote-sync accumulator (one Mutex, shared with `QconnectApp`).
|
||||
sync_state: Arc<Mutex<QconnectRemoteSyncState>>,
|
||||
/// Exact runtime authority. The sink is constructed while a runtime is
|
||||
/// prepared, and remains dormant until this stamp is installed.
|
||||
authority: Arc<AuthorityCell>,
|
||||
stamp: AuthorityStamp,
|
||||
/// Public LAN session projection. Updates are serialized with authority
|
||||
/// installs so late SESSION_STATE events cannot overwrite a replacement.
|
||||
projection: DaemonLanProjectionSlot,
|
||||
/// Late-bound weak handle to the owning app, wired via `set_app` after the
|
||||
/// app is built FROM this sink. Used to emit renderer reports (e.g.
|
||||
/// is_active=true after SetActive(true)) and to drive the session-apply +
|
||||
@@ -61,10 +70,16 @@ impl DaemonEventSink {
|
||||
pub fn new(
|
||||
engine: DaemonRendererEngine,
|
||||
sync_state: Arc<Mutex<QconnectRemoteSyncState>>,
|
||||
authority: Arc<AuthorityCell>,
|
||||
stamp: AuthorityStamp,
|
||||
projection: DaemonLanProjectionSlot,
|
||||
) -> Self {
|
||||
Self {
|
||||
engine,
|
||||
sync_state,
|
||||
authority,
|
||||
stamp,
|
||||
projection,
|
||||
app: Arc::new(OnceLock::new()),
|
||||
last_peer_active: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
@@ -75,13 +90,23 @@ impl DaemonEventSink {
|
||||
let _ = self.app.set(Arc::downgrade(app));
|
||||
}
|
||||
|
||||
fn is_current(&self) -> bool {
|
||||
self.authority.is_current(self.stamp)
|
||||
}
|
||||
|
||||
/// Emit a StateUpdated report announcing this renderer is now active. Sent
|
||||
/// after SetActive(true) is applied so the controller learns we are ready.
|
||||
async fn report_active_renderer_ready(&self) {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
let Some(app) = self.app.get().and_then(Weak::upgrade) else {
|
||||
return;
|
||||
};
|
||||
let queue_version = app.queue_state_snapshot().await.version;
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
let report = RendererReport::new(
|
||||
RendererReportType::RndrSrvrStateUpdated,
|
||||
Uuid::new_v4().to_string(),
|
||||
@@ -95,7 +120,13 @@ impl DaemonEventSink {
|
||||
}
|
||||
}),
|
||||
);
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = app.send_renderer_report_command(report).await {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
log::warn!("[QConnect] Failed to report active-renderer-ready: {err}");
|
||||
}
|
||||
}
|
||||
@@ -106,6 +137,9 @@ impl DaemonEventSink {
|
||||
/// `apply_session_management_event`; the post-lock ordering (loop mode ->
|
||||
/// local-playback handoff -> projection -> freeze -> watchdog) is identical.
|
||||
async fn apply_session_management_event(&self, message_type: &str, payload: &Value) {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
let Some(app) = self.app.get().and_then(Weak::upgrade) else {
|
||||
return;
|
||||
};
|
||||
@@ -113,11 +147,34 @@ impl DaemonEventSink {
|
||||
let outcome = app
|
||||
.apply_session_management_event(message_type, payload, &identity)
|
||||
.await;
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
|
||||
if message_type == "MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE" {
|
||||
let session_id = {
|
||||
let state = self.sync_state.lock().await;
|
||||
state.session.session_uuid.clone()
|
||||
};
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
if let Some(session_id) = session_id {
|
||||
self.projection
|
||||
.confirm_owner_session(&self.authority, self.stamp, &session_id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(loop_mode) = outcome.apply_loop_mode {
|
||||
if let Err(err) = qconnect_app::renderer::apply_remote_loop_mode(&self.engine, loop_mode)
|
||||
.await
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
if let Err(err) =
|
||||
qconnect_app::renderer::apply_remote_loop_mode(&self.engine, loop_mode).await
|
||||
{
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
log::warn!("[QConnect] Failed to apply remote loop mode: {err}");
|
||||
}
|
||||
}
|
||||
@@ -131,14 +188,23 @@ impl DaemonEventSink {
|
||||
}
|
||||
|
||||
if let Some(renderer_id) = outcome.disconnected_renderer_id {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
app.freeze_active_renderer_projection(
|
||||
renderer_id,
|
||||
QconnectAppEvent::RendererDisconnected { renderer_id },
|
||||
)
|
||||
.await;
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((renderer_id, generation)) = outcome.watchdog_arm {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
app.arm_renderer_watchdog(renderer_id, generation);
|
||||
}
|
||||
|
||||
@@ -150,13 +216,22 @@ impl DaemonEventSink {
|
||||
// align + projection resolve the real current track now.
|
||||
let peer_active_now = {
|
||||
let state = self.sync_state.lock().await;
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
is_peer_renderer_active(&state.session)
|
||||
};
|
||||
let was_peer_active = self
|
||||
.last_peer_active
|
||||
.swap(peer_active_now, std::sync::atomic::Ordering::Relaxed);
|
||||
if peer_active_now && !was_peer_active {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = app.ask_for_active_renderer_state().await {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
log::warn!(
|
||||
"[QConnect] controller entry: ask_for_active_renderer_state failed: {err}"
|
||||
);
|
||||
@@ -168,8 +243,14 @@ impl DaemonEventSink {
|
||||
/// the two don't double-play. Mirrors the Tauri helper, with the engine seam
|
||||
/// in place of `CoreBridge`.
|
||||
async fn sync_local_playback_for_renderer_ownership(&self) {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
let peer_renderer_active = {
|
||||
let state = self.sync_state.lock().await;
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
is_peer_renderer_active(&state.session)
|
||||
};
|
||||
if !peer_renderer_active {
|
||||
@@ -180,6 +261,9 @@ impl DaemonEventSink {
|
||||
if playback_state.track_id == 0 {
|
||||
return;
|
||||
}
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[QConnect] Stopping local playback because active renderer is a peer (track_id={})",
|
||||
@@ -194,8 +278,14 @@ impl DaemonEventSink {
|
||||
/// playback, align the local queue cursor to the peer's current track (so a
|
||||
/// later takeover lands on the right track). Mirrors the Tauri helper.
|
||||
async fn sync_active_renderer_projection(&self, renderer_id: i32) {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
let (queue_state, renderer_state, session_loop_mode, should_align_engine) = {
|
||||
let state = self.sync_state.lock().await;
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
let Some(active_renderer_id) = state.session.active_renderer_id else {
|
||||
return;
|
||||
};
|
||||
@@ -220,8 +310,14 @@ impl DaemonEventSink {
|
||||
|
||||
let renderer_snapshot =
|
||||
build_session_renderer_snapshot(&queue_state, Some(&renderer_state), session_loop_mode);
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut state = self.sync_state.lock().await;
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
cache_renderer_snapshot(&mut state, &renderer_snapshot);
|
||||
}
|
||||
|
||||
@@ -232,28 +328,47 @@ impl DaemonEventSink {
|
||||
let Some(current_track) = renderer_snapshot.current_track.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) =
|
||||
qconnect_app::renderer::align_queue_cursor(&self.engine, current_track.track_id).await
|
||||
{
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
log::warn!("[QConnect] Failed to sync peer renderer cursor into engine: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn renderer_command_name(command: &RendererCommand) -> &'static str {
|
||||
match command {
|
||||
RendererCommand::SetState { .. } => "set_state",
|
||||
RendererCommand::SetVolume { .. } => "set_volume",
|
||||
RendererCommand::SetActive { .. } => "set_active",
|
||||
RendererCommand::SetMaxAudioQuality { .. } => "set_max_audio_quality",
|
||||
RendererCommand::SetLoopMode { .. } => "set_loop_mode",
|
||||
RendererCommand::SetShuffleMode { .. } => "set_shuffle_mode",
|
||||
RendererCommand::MuteVolume { .. } => "mute_volume",
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl QconnectEventSink for DaemonEventSink {
|
||||
async fn on_event(&self, event: QconnectAppEvent) {
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
match &event {
|
||||
QconnectAppEvent::SessionManagementEvent {
|
||||
message_type,
|
||||
payload,
|
||||
} => {
|
||||
log::info!(
|
||||
"[QConnect] Session management: {} payload={}",
|
||||
message_type,
|
||||
serde_json::to_string(payload).unwrap_or_else(|_| "?".to_string())
|
||||
);
|
||||
// Payloads can contain session identifiers and controller
|
||||
// topology. Log only the protocol message type.
|
||||
log::info!("[QConnect] Session management: {message_type}");
|
||||
self.apply_session_management_event(message_type, payload)
|
||||
.await;
|
||||
}
|
||||
@@ -265,6 +380,9 @@ impl QconnectEventSink for DaemonEventSink {
|
||||
renderer_state.current_position_ms,
|
||||
);
|
||||
let mut sync_state = self.sync_state.lock().await;
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
cache_renderer_snapshot(&mut sync_state, renderer_state);
|
||||
}
|
||||
QconnectAppEvent::QueueUpdated(queue_state) => {
|
||||
@@ -277,8 +395,14 @@ impl QconnectEventSink for DaemonEventSink {
|
||||
);
|
||||
{
|
||||
let mut sync_state = self.sync_state.lock().await;
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
sync_state.last_remote_queue_state = Some(queue_state.clone());
|
||||
}
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = qconnect_app::renderer::materialize_remote_queue(
|
||||
&self.engine,
|
||||
&self.sync_state,
|
||||
@@ -286,13 +410,21 @@ impl QconnectEventSink for DaemonEventSink {
|
||||
)
|
||||
.await
|
||||
{
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
log::warn!("[QConnect] Failed to materialize remote queue: {err}");
|
||||
}
|
||||
}
|
||||
QconnectAppEvent::RendererCommandApplied { command, state } => {
|
||||
log::info!("[QConnect] Renderer command applied: {:?}", command);
|
||||
let became_active =
|
||||
matches!(command, RendererCommand::SetActive { active: true });
|
||||
log::info!(
|
||||
"[QConnect] Renderer command applied: {}",
|
||||
renderer_command_name(command)
|
||||
);
|
||||
let became_active = matches!(command, RendererCommand::SetActive { active: true });
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = qconnect_app::renderer::apply_renderer_command(
|
||||
&self.engine,
|
||||
&self.sync_state,
|
||||
@@ -301,6 +433,9 @@ impl QconnectEventSink for DaemonEventSink {
|
||||
)
|
||||
.await
|
||||
{
|
||||
if !self.is_current() {
|
||||
return;
|
||||
}
|
||||
log::warn!("[QConnect] Failed to apply renderer command: {err}");
|
||||
} else if became_active {
|
||||
self.report_active_renderer_ready().await;
|
||||
@@ -330,9 +465,7 @@ impl QconnectEventSink for DaemonEventSink {
|
||||
QconnectAppEvent::LifecycleChanged { state } => {
|
||||
log::info!("[QConnect] Lifecycle -> {state:?}");
|
||||
}
|
||||
QconnectAppEvent::Diagnostic {
|
||||
channel, level, ..
|
||||
} => {
|
||||
QconnectAppEvent::Diagnostic { channel, level, .. } => {
|
||||
log::debug!("[QConnect] diagnostic {channel} [{level}]");
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -18,6 +18,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_qobuz::ApiError;
|
||||
use qconnect_transport_ws::WsTransportConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -30,6 +31,61 @@ const DEFAULT_QCONNECT_SOFTWARE_PREFIX: &str = "qbz";
|
||||
|
||||
type Runtime = Arc<AppRuntime<DaemonAdapter>>;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum QconnectCredentialFailure {
|
||||
ApiClientUnavailable,
|
||||
Network,
|
||||
Authentication,
|
||||
Authorization,
|
||||
Offline,
|
||||
RateLimited,
|
||||
Server,
|
||||
InvalidResponse,
|
||||
ServiceResponse,
|
||||
Client,
|
||||
MissingEndpoint,
|
||||
}
|
||||
|
||||
impl QconnectCredentialFailure {
|
||||
const fn category(self) -> &'static str {
|
||||
match self {
|
||||
Self::ApiClientUnavailable => "api-client-unavailable",
|
||||
Self::Network => "network",
|
||||
Self::Authentication => "authentication",
|
||||
Self::Authorization => "authorization",
|
||||
Self::Offline => "offline",
|
||||
Self::RateLimited => "rate-limited",
|
||||
Self::Server => "server",
|
||||
Self::InvalidResponse => "invalid-response",
|
||||
Self::ServiceResponse => "service-response",
|
||||
Self::Client => "client",
|
||||
Self::MissingEndpoint => "missing-endpoint",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_qconnect_credential_error(error: &ApiError) -> QconnectCredentialFailure {
|
||||
match error {
|
||||
ApiError::NetworkError(_) => QconnectCredentialFailure::Network,
|
||||
ApiError::AuthenticationError(_)
|
||||
| ApiError::InvalidAppId
|
||||
| ApiError::InvalidAppSecret
|
||||
| ApiError::BundleExtractionError(_) => QconnectCredentialFailure::Authentication,
|
||||
ApiError::IneligibleUser | ApiError::Forbidden(_) | ApiError::ForbiddenCircuitOpen(_) => {
|
||||
QconnectCredentialFailure::Authorization
|
||||
}
|
||||
ApiError::OfflineMode => QconnectCredentialFailure::Offline,
|
||||
ApiError::RateLimited(_) => QconnectCredentialFailure::RateLimited,
|
||||
ApiError::ServerError(_) => QconnectCredentialFailure::Server,
|
||||
ApiError::ParseError(_) => QconnectCredentialFailure::InvalidResponse,
|
||||
ApiError::ApiResponse(_) => QconnectCredentialFailure::ServiceResponse,
|
||||
ApiError::NonStreamable
|
||||
| ApiError::InvalidQuality(_)
|
||||
| ApiError::NoQualityAvailable
|
||||
| ApiError::TrackUnavailable(_) => QconnectCredentialFailure::Client,
|
||||
}
|
||||
}
|
||||
|
||||
/// The daemon-root `qconnect_settings.db` path, set once by `qconnect::start`
|
||||
/// before any connect. Daemon adaptation: the desktop delegates the path to the
|
||||
/// GLOBAL `qbz_app::qconnect_identity`; the daemon points every device-identity
|
||||
@@ -464,14 +520,18 @@ pub async fn resolve_transport_config(runtime: &Runtime) -> Result<WsTransportCo
|
||||
endpoint_url = endpoint_url.or(discovered_endpoint);
|
||||
jwt_qws = jwt_qws.or(discovered_jwt_qws);
|
||||
}
|
||||
Err(err) if endpoint_url.is_some() => {
|
||||
Err(failure) if endpoint_url.is_some() => {
|
||||
log::warn!(
|
||||
"[QConnect] qws/createToken auto-discovery failed, using provided endpoint: {err}"
|
||||
"[QConnect] qws/createToken auto-discovery failed, using provided endpoint \
|
||||
(category={})",
|
||||
failure.category()
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
Err(failure) => {
|
||||
return Err(format!(
|
||||
"QConnect endpoint_url is required (or QBZ_QCONNECT_WS_ENDPOINT). Auto-discovery via qws/createToken failed: {err}"
|
||||
"QConnect endpoint_url is required (or QBZ_QCONNECT_WS_ENDPOINT). \
|
||||
Auto-discovery via qws/createToken failed (category={})",
|
||||
failure.category()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -505,7 +565,7 @@ pub async fn resolve_transport_config(runtime: &Runtime) -> Result<WsTransportCo
|
||||
|
||||
async fn fetch_qconnect_transport_credentials(
|
||||
runtime: &Runtime,
|
||||
) -> Result<(Option<String>, Option<String>), String> {
|
||||
) -> Result<(Option<String>, Option<String>), QconnectCredentialFailure> {
|
||||
// The client is Option-wrapped (None before API init).
|
||||
let client = runtime
|
||||
.core()
|
||||
@@ -513,18 +573,18 @@ async fn fetch_qconnect_transport_credentials(
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| "qws/createToken requires an initialized API client".to_string())?;
|
||||
.ok_or(QconnectCredentialFailure::ApiClientUnavailable)?;
|
||||
|
||||
let (endpoint, _expires_at, jwt) = client
|
||||
.create_qconnect_token()
|
||||
.await
|
||||
.map_err(|err| format!("qws/createToken failed: {err}"))?
|
||||
.map_err(|error| classify_qconnect_credential_error(&error))?
|
||||
.into_parts();
|
||||
let endpoint_url = normalize_opt_string(endpoint);
|
||||
let jwt_qws = normalize_opt_string(Some(jwt));
|
||||
|
||||
if endpoint_url.is_none() {
|
||||
return Err("qws/createToken response missing jwt_qws.endpoint".to_string());
|
||||
return Err(QconnectCredentialFailure::MissingEndpoint);
|
||||
}
|
||||
|
||||
Ok((endpoint_url, jwt_qws))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// crates/qbzd/src/scrobble_engine.rs — scrobble-on-play (CONSOLE ext).
|
||||
//
|
||||
// A daemon background task subscribing the DaemonAdapter CoreEvent bus. On
|
||||
// `TrackStarted` it sends "now playing" to every ACTIVE provider; when the
|
||||
// A daemon background task subscribing the authority-stamped playback-event
|
||||
// bus. On `TrackStarted` it sends "now playing" to every ACTIVE provider; when the
|
||||
// track crosses the scrobble threshold (`qbz_app::scrobble_timing::
|
||||
// scrobble_delay_secs` — Last.fm's played-half-or-4-min rule) it scrobbles
|
||||
// ONCE. Credentials are re-read from the canonical `ScrobblerSettingsStore` on
|
||||
@@ -20,6 +20,7 @@
|
||||
// Connection is never held across an await: it is opened inside a
|
||||
// `spawn_blocking` for each queue/drain op (mirrors `qbz::scrobble`).
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use qbz_app::settings::scrobblers::{ScrobblerSettings, ScrobblerSettingsStore};
|
||||
@@ -30,7 +31,9 @@ use qbz_models::{CoreEvent, QueueTrack};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::events_bridge::AuthorityStampedEvent;
|
||||
use crate::paths::ProfileRoots;
|
||||
use crate::qconnect::authority::{AuthorityCell, OwnerAuthorityToken};
|
||||
|
||||
/// How often the ListenBrainz offline queue is retried (plus once at task
|
||||
/// start). Live now-playing/scrobble submits are unaffected — the drain only
|
||||
@@ -40,6 +43,9 @@ const DRAIN_INTERVAL: Duration = Duration::from_secs(120);
|
||||
/// The track currently being timed for a scrobble.
|
||||
struct Playing {
|
||||
track: QueueTrack,
|
||||
/// Exact owner observation that admitted this track start. A position from
|
||||
/// any later owner generation must not finish this pending scrobble.
|
||||
owner_token: OwnerAuthorityToken,
|
||||
/// Unix seconds when it started — Last.fm's scrobble timestamp.
|
||||
started_at: u64,
|
||||
/// Seconds into the track at which it becomes scrobble-eligible; `None`
|
||||
@@ -51,7 +57,11 @@ struct Playing {
|
||||
/// Spawn the scrobble-on-play task. Holds NO `Arc<AppRuntime>` (only the roots,
|
||||
/// its own store, and the bus receiver), so it is outside the §8.2 audio
|
||||
/// clock-release ordering — the caller aborts it for a clean shutdown.
|
||||
pub fn spawn(roots: ProfileRoots, mut rx: broadcast::Receiver<CoreEvent>) -> JoinHandle<()> {
|
||||
pub fn spawn(
|
||||
roots: ProfileRoots,
|
||||
mut rx: broadcast::Receiver<AuthorityStampedEvent>,
|
||||
authority: Arc<AuthorityCell>,
|
||||
) -> JoinHandle<()> {
|
||||
use broadcast::error::RecvError;
|
||||
tokio::spawn(async move {
|
||||
let store = match ScrobblerSettingsStore::new_at(&roots.data) {
|
||||
@@ -71,31 +81,62 @@ pub fn spawn(roots: ProfileRoots, mut rx: broadcast::Receiver<CoreEvent>) -> Joi
|
||||
// `biased`: live bus events take priority over the drain tick.
|
||||
tokio::select! {
|
||||
biased;
|
||||
ev = rx.recv() => match ev {
|
||||
Ok(CoreEvent::TrackStarted { track, .. }) => {
|
||||
let settings = store.get_settings().unwrap_or_default();
|
||||
if !settings.enabled {
|
||||
received = rx.recv() => match received {
|
||||
Ok(stamped) => {
|
||||
let Some(owner_token) = stamped.owner_token else {
|
||||
playing = None;
|
||||
continue;
|
||||
};
|
||||
let Some(permit) = authority
|
||||
.wait_for_exact_owner_action_permit(owner_token)
|
||||
.await
|
||||
else {
|
||||
playing = None;
|
||||
continue;
|
||||
};
|
||||
if playing
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.owner_token != owner_token)
|
||||
{
|
||||
playing = None;
|
||||
}
|
||||
now_playing(&settings, &track).await;
|
||||
playing = Some(Playing {
|
||||
threshold: qbz_app::scrobble_timing::scrobble_delay_secs(track.duration_secs),
|
||||
started_at: now_unix(),
|
||||
track,
|
||||
scrobbled: false,
|
||||
});
|
||||
}
|
||||
Ok(CoreEvent::PositionUpdated { position_secs, .. }) => {
|
||||
if let Some(p) = playing.as_mut() {
|
||||
if due(position_secs, p.threshold, p.scrobbled) {
|
||||
|
||||
match stamped.event {
|
||||
CoreEvent::TrackStarted { track, .. } => {
|
||||
let settings = store.get_settings().unwrap_or_default();
|
||||
scrobble(&settings, &p.track, p.started_at, &roots).await;
|
||||
p.scrobbled = true;
|
||||
if !settings.enabled {
|
||||
playing = None;
|
||||
continue;
|
||||
}
|
||||
now_playing(&settings, &track).await;
|
||||
playing = Some(Playing {
|
||||
threshold: qbz_app::scrobble_timing::scrobble_delay_secs(track.duration_secs),
|
||||
started_at: now_unix(),
|
||||
track,
|
||||
owner_token,
|
||||
scrobbled: false,
|
||||
});
|
||||
}
|
||||
CoreEvent::PositionUpdated { position_secs, .. } => {
|
||||
if let Some(p) = playing.as_mut() {
|
||||
if due(position_secs, p.threshold, p.scrobbled) {
|
||||
let settings = store.get_settings().unwrap_or_default();
|
||||
p.scrobbled = scrobble(
|
||||
&settings,
|
||||
&p.track,
|
||||
p.started_at,
|
||||
&roots,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Keep the exact admission alive through every provider
|
||||
// await and any failed-listen persistence above.
|
||||
drop(permit);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => return,
|
||||
},
|
||||
@@ -134,7 +175,12 @@ async fn now_playing(s: &ScrobblerSettings, t: &QueueTrack) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn scrobble(s: &ScrobblerSettings, t: &QueueTrack, started_at: u64, roots: &ProfileRoots) {
|
||||
async fn scrobble(
|
||||
s: &ScrobblerSettings,
|
||||
t: &QueueTrack,
|
||||
started_at: u64,
|
||||
roots: &ProfileRoots,
|
||||
) -> bool {
|
||||
let album = album_opt(t);
|
||||
if s.lastfm_active() {
|
||||
let c = LastFmClient::with_session_key(s.lastfm_session_key.clone());
|
||||
@@ -145,8 +191,15 @@ async fn scrobble(s: &ScrobblerSettings, t: &QueueTrack, started_at: u64, roots:
|
||||
}
|
||||
if s.listenbrainz_active() {
|
||||
let c = lb_client(s);
|
||||
match c.submit_listen(&t.artist, &t.title, album, started_at as i64, None).await {
|
||||
Ok(()) => log::info!("[scrobbler] listenbrainz submitted: {} — {}", t.artist, t.title),
|
||||
match c
|
||||
.submit_listen(&t.artist, &t.title, album, started_at as i64, None)
|
||||
.await
|
||||
{
|
||||
Ok(()) => log::info!(
|
||||
"[scrobbler] listenbrainz submitted: {} — {}",
|
||||
t.artist,
|
||||
t.title
|
||||
),
|
||||
Err(e) => {
|
||||
// Persist to the shared offline queue; the periodic drain retries it.
|
||||
log::warn!("[scrobbler] listenbrainz submit failed, queueing: {e}");
|
||||
@@ -154,6 +207,7 @@ async fn scrobble(s: &ScrobblerSettings, t: &QueueTrack, started_at: u64, roots:
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Persist a failed listen into the SHARED `ListenBrainzCache.listen_queue`
|
||||
@@ -275,11 +329,16 @@ fn album_opt(t: &QueueTrack) -> Option<&str> {
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::qconnect::authority::AuthorityOrigin;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -320,8 +379,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn album_opt_drops_empty_and_unknown() {
|
||||
assert_eq!(album_opt(&qt("Light as a Feather")), Some("Light as a Feather"));
|
||||
assert_eq!(
|
||||
album_opt(&qt("Light as a Feather")),
|
||||
Some("Light as a Feather")
|
||||
);
|
||||
assert_eq!(album_opt(&qt("")), None);
|
||||
assert_eq!(album_opt(&qt("Unknown Album")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_owner_generation_cannot_finish_pending_scrobble() {
|
||||
let authority = Arc::new(AuthorityCell::new());
|
||||
let owner = authority.reserve(AuthorityOrigin::Owner);
|
||||
assert!(authority.install(owner));
|
||||
let (old_token, permit) = authority
|
||||
.try_owner_action_permit_observed()
|
||||
.expect("owner observation");
|
||||
drop(permit);
|
||||
|
||||
let guest = authority.reserve(AuthorityOrigin::Delegated { generation: 4 });
|
||||
assert!(authority.install(guest));
|
||||
let restored_owner = authority.reserve(AuthorityOrigin::Owner);
|
||||
assert!(authority.install(restored_owner));
|
||||
|
||||
assert!(authority
|
||||
.try_owner_action_permit_exact(old_token)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,8 @@ impl DaemonShared {
|
||||
/// `Mutex<DaemonShared>` guard as every other field here, not a
|
||||
/// synchronization primitive of its own.
|
||||
pub fn network_online(&self) -> bool {
|
||||
self.network_online.load(std::sync::atomic::Ordering::Relaxed)
|
||||
self.network_online
|
||||
.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Latch the network-reachability signal. See the field doc for exactly
|
||||
@@ -94,13 +95,41 @@ pub fn token_fingerprint(token: &str) -> u64 {
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct QconnectStatus {
|
||||
pub enabled: bool,
|
||||
pub state: String, // "off"|"connecting"|"connected"|"retrying"|"exhausted"
|
||||
pub session_active: bool,
|
||||
pub device_name: String,
|
||||
pub last_transport_reconnect: Option<String>,
|
||||
/// LAN receiver lifecycle. Contract values are
|
||||
/// off|binding|listening|validating|delegated|restoring|error.
|
||||
pub lan_state: String,
|
||||
/// Bound receiver port, published only while the LAN service is live.
|
||||
pub lan_port: Option<u16>,
|
||||
/// Credential authority currently driving QConnect: owner|delegated.
|
||||
pub credential_origin: String,
|
||||
/// Latest candidate under validation/commit, never a session identifier.
|
||||
pub candidate_generation: Option<u64>,
|
||||
/// Sanitized LAN failure code. Never contains an endpoint, JWT, body, or IP.
|
||||
pub last_lan_error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for QconnectStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
state: String::new(),
|
||||
session_active: false,
|
||||
device_name: String::new(),
|
||||
last_transport_reconnect: None,
|
||||
lan_state: "off".to_string(),
|
||||
lan_port: None,
|
||||
credential_origin: "owner".to_string(),
|
||||
candidate_generation: None,
|
||||
last_lan_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -123,6 +152,28 @@ mod tests {
|
||||
assert!(!q.session_active);
|
||||
assert_eq!(q.device_name, "");
|
||||
assert!(q.last_transport_reconnect.is_none());
|
||||
assert_eq!(q.lan_state, "off");
|
||||
assert!(q.lan_port.is_none());
|
||||
assert_eq!(q.credential_origin, "owner");
|
||||
assert!(q.candidate_generation.is_none());
|
||||
assert!(q.last_lan_error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qconnect_lan_observability_serializes_with_contract_field_names() {
|
||||
let mut q = QconnectStatus::default();
|
||||
q.lan_state = "validating".to_string();
|
||||
q.lan_port = Some(49_152);
|
||||
q.credential_origin = "delegated".to_string();
|
||||
q.candidate_generation = Some(7);
|
||||
q.last_lan_error = Some("qws_auth_rejected".to_string());
|
||||
|
||||
let value = serde_json::to_value(q).expect("serialize QConnect status");
|
||||
assert_eq!(value["lan_state"], "validating");
|
||||
assert_eq!(value["lan_port"], 49_152);
|
||||
assert_eq!(value["credential_origin"], "delegated");
|
||||
assert_eq!(value["candidate_generation"], 7);
|
||||
assert_eq!(value["last_lan_error"], "qws_auth_rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user