feat(qbzd): harden qconnect ownership lifecycle

cx
This commit is contained in:
vicrodh committed 2026-08-30 22:22:39 -06:00
1 parent d3cf7ae53d
commit c927eb9c9c
22 files changed
+5560 -401

No files matched your search

+108 -1
View File
@@ -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"));
}
}
+16 -1
View File
@@ -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
View File
@@ -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
}
+39 -1
View File
@@ -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}));
+4 -1
View File
@@ -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
View File
@@ -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) => {
+97 -11
View File
@@ -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);
}
+207 -17
View File
@@ -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
View File
@@ -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"
);
}
+9
View File
@@ -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
+510 -57
View File
@@ -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
));
}
}
+526
View File
@@ -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")
);
}
}
File diff suppressed because it is too large. Load diff
+104 -18
View File
@@ -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;
}
})
}
+126 -23
View File
@@ -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"));
}
}
+74 -15
View File
@@ -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,
+222 -32
View File
@@ -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());
}
}
+146 -13
View File
@@ -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}]");
}
_ => {}
+68 -8
View File
@@ -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))
+108 -26
View File
@@ -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());
}
}
+53 -2
View File
@@ -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]