refactor(qconnect): relocate pure session/liveness fns to shared crates (slice 0)

Move the frontend-agnostic pure functions and enums out of the Tauri
qconnect adapter into the shared crates, with zero behavior change. The
Tauri side keeps thin re-exports so all existing call sites compile
unchanged.

Into qconnect-app (new `session` module):
  - should_arm_renderer_watchdog
  - compute_connection_state + ServerActiveState + ConnectionDecision
  - deferred_join_reason, should_reask_queue_state
  - RendererStatus + from_wire
  - quality_from_max_audio_quality
  - QCONNECT_RENDERER_LOST_TIMEOUT_MS + JoinSession reason consts

Into qconnect-core (next to evaluate_remote_queue_admission):
  - validate_track_origins_for_admission (pure over TrackOrigin); the
    Tauri command wrapper stays thin and only maps wire origins to core
    origins.

The 6 pure-fn unit tests move into the destination crates' test
modules. R5 honored: compute_connection_state matrix cells are relocated
verbatim, not changed.

cc
This commit is contained in:
vicrodh committed 2026-06-01 08:55:04 -06:00
1 parent f3d37dac42
commit f100cf4d75
14 files changed
+378 -269

No files matched your search

+1
View File
@@ -5310,6 +5310,7 @@ dependencies = [
"async-trait", "async-trait",
"log", "log",
"prost", "prost",
"qbz-models",
"qconnect-core", "qconnect-core",
"qconnect-protocol", "qconnect-protocol",
"qconnect-transport-ws", "qconnect-transport-ws",
+1
View File
@@ -9,6 +9,7 @@ description = "UI-agnostic Qobuz Connect application adapter"
qconnect-core = { path = "../qconnect-core" } qconnect-core = { path = "../qconnect-core" }
qconnect-protocol = { path = "../qconnect-protocol" } qconnect-protocol = { path = "../qconnect-protocol" }
qconnect-transport-ws = { path = "../qconnect-transport-ws" } qconnect-transport-ws = { path = "../qconnect-transport-ws" }
qbz-models = { path = "../qbz-models" }
async-trait = { workspace = true } async-trait = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
+10 -2
View File
@@ -6,6 +6,7 @@ mod app;
mod error; mod error;
mod events; mod events;
mod feature_flags; mod feature_flags;
pub mod session;
mod state; mod state;
pub mod startup; pub mod startup;
@@ -13,13 +14,20 @@ pub use app::QconnectApp;
pub use startup::{compute_effective_startup, QconnectStartupMode}; pub use startup::{compute_effective_startup, QconnectStartupMode};
pub use error::QconnectAppError; pub use error::QconnectAppError;
pub use events::{NoOpEventSink, QconnectAppEvent, QconnectEventSink}; pub use events::{NoOpEventSink, QconnectAppEvent, QconnectEventSink};
pub use session::{
compute_connection_state, deferred_join_reason, quality_from_max_audio_quality,
should_arm_renderer_watchdog, should_reask_queue_state, ConnectionDecision, RendererStatus,
ServerActiveState, JOIN_SESSION_REASON_CONTROLLER_REQUEST, JOIN_SESSION_REASON_RECONNECTION,
QCONNECT_RENDERER_LOST_TIMEOUT_MS,
};
pub use feature_flags::{ pub use feature_flags::{
QBZ_QCONNECT_PANEL_SWITCH, QBZ_QCONNECT_QUEUE_MODEL, QBZ_QCONNECT_STRICT_DOMAIN_ISOLATION, QBZ_QCONNECT_PANEL_SWITCH, QBZ_QCONNECT_QUEUE_MODEL, QBZ_QCONNECT_STRICT_DOMAIN_ISOLATION,
QBZ_QCONNECT_TRANSPORT, QBZ_QCONNECT_TRANSPORT,
}; };
pub use qconnect_core::{ pub use qconnect_core::{
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, HandoffIntent, evaluate_remote_queue_admission, resolve_handoff_intent, validate_track_origins_for_admission,
QConnectQueueState, QConnectRendererState, QueueVersion, RendererCommand, TrackOrigin, AdmissionDecision, HandoffIntent, QConnectQueueState, QConnectRendererState, QueueVersion,
RendererCommand, TrackOrigin,
}; };
pub use qconnect_protocol::{QueueCommandType, RendererReport, RendererReportType}; pub use qconnect_protocol::{QueueCommandType, RendererReport, RendererReportType};
pub use state::QconnectRuntimeState; pub use state::QconnectRuntimeState;
+270
View File
@@ -0,0 +1,270 @@
//! Frontend-agnostic session/liveness primitives for Qobuz Connect.
//!
//! These are pure functions and pure data enums relocated out of the Tauri
//! adapter so that both the shipping Tauri adapter and a future Slint adapter
//! can share the exact same session-arbitration / liveness logic. Behavior is
//! byte-for-byte identical to the prior Tauri-side definitions.
use qbz_models::Quality;
/// Playing-state wire value for PLAYING. Mirrors the Tauri adapter's
/// `PLAYING_STATE_PLAYING` (the renderer reports playing_state == 2 while
/// actively playing). Kept private to the moved liveness predicate.
const PLAYING_STATE_PLAYING: i32 = 2;
/// JoinSession `reason` wire values (proto tag 3): a first join from a fresh
/// runtime is a controller request, a join after a transport drop carries the
/// reconnection reason so the server treats it as session continuity rather
/// than a brand-new controller (P1-2).
pub const JOIN_SESSION_REASON_CONTROLLER_REQUEST: i32 = 1;
pub const JOIN_SESSION_REASON_RECONNECTION: i32 = 2;
/// Official "renderer LOST" silence budget. A *playing* active peer renderer
/// that sends no RENDERER_STATE_UPDATED for this long is considered
/// unreachable (webplayer arms setTimeout(...,12e3) on onPlayerStateUpdated
/// while playingState==PLAY). See 05-sync-status-queue.md §1.
pub const QCONNECT_RENDERER_LOST_TIMEOUT_MS: u64 = 12_000;
/// Pure arming predicate for the renderer-liveness watchdog: arm only while the
/// active renderer is a peer AND its reported playing_state is PLAYING.
pub fn should_arm_renderer_watchdog(playing_state: Option<i32>, is_active_peer: bool) -> bool {
is_active_peer && playing_state == Some(PLAYING_STATE_PLAYING)
}
/// The server's view of who currently owns the active-renderer slot in a
/// SESSION_STATE frame, classified relative to us (P1-3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServerActiveState {
/// No renderer is active in the session.
None,
/// We are the active renderer.
Me,
/// Another renderer is active and reports PLAYING.
OtherPlaying,
/// Another renderer is active and reports a non-playing state.
OtherPaused,
}
/// Outcome of takeover arbitration on a SESSION_STATE frame (P1-3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ConnectionDecision {
/// We should consider ourselves the active renderer.
pub should_be_active: bool,
/// We should emit CtrlSrvrSetActiveRenderer to claim the slot.
pub should_set_active_renderer: bool,
/// We should push our queue + state to the session.
pub should_set_queue: bool,
/// We should ask the renderer for its current state.
pub should_ask_queue: bool,
}
/// Pure takeover arbitration ported from the web controller's
/// `computeConnectionState`. This is a REDUCED matrix: it captures the
/// shape and named outputs faithfully from the appendix prose, but the
/// exact web truth-table cell values were not dumped verbatim. The
/// `OtherPaused` vs `OtherPlaying` divergence and repeat-mode
/// reconciliation are pending a decompiled-bundle cross-check; repeat-mode
/// reconciliation is intentionally OUT of P1-3 scope (it overlaps the
/// existing `session_loop_mode` handling in `event_sink.rs`).
pub fn compute_connection_state(
was_active: bool,
was_playing: bool,
server: ServerActiveState,
queue_equal: bool,
) -> ConnectionDecision {
use ServerActiveState::*;
match server {
None => ConnectionDecision {
should_be_active: was_active,
should_set_active_renderer: was_active,
should_set_queue: was_active,
should_ask_queue: false,
},
Me => ConnectionDecision {
should_be_active: true,
should_set_active_renderer: false,
should_set_queue: !queue_equal && (was_active || was_playing),
should_ask_queue: queue_equal,
},
OtherPlaying | OtherPaused => ConnectionDecision {
should_be_active: false,
should_set_active_renderer: false,
should_set_queue: false,
should_ask_queue: true,
},
}
}
/// Pure selector for the JoinSession `reason`: a post-drop rejoin carries
/// RECONNECTION, the first join from a fresh runtime carries CONTROLLER_REQUEST
/// (P1-2).
pub fn deferred_join_reason(has_disconnected: bool) -> i32 {
if has_disconnected {
JOIN_SESSION_REASON_RECONNECTION
} else {
JOIN_SESSION_REASON_CONTROLLER_REQUEST
}
}
/// Pure predicate (P1-8): keep re-asking for queue state after a Lagged
/// broadcast drop until the session_uuid is confirmed or the attempt budget is
/// spent. Stops immediately once the session_uuid is known.
pub fn should_reask_queue_state(
session_uuid_known: bool,
attempts: u32,
max_attempts: u32,
) -> bool {
!session_uuid_known && attempts < max_attempts
}
/// Wire renderer status from `MESSAGE_TYPE_SRVR_CTRL_RENDERER_STATE_UPDATED`
/// (`status` field, decoded at qconnect-protocol decoder.rs:801).
/// Wire enum: UNKNOWN=0, ACTIVE_CONNECTED=1, ACTIVE_DISCONNECTED=2, INACTIVE=3.
/// Per the official client's `Ya` collapse, UNKNOWN and any UNRECOGNIZED value
/// map to INACTIVE.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RendererStatus {
ActiveConnected,
ActiveDisconnected,
Inactive,
}
impl RendererStatus {
pub fn from_wire(value: Option<i64>) -> Self {
match value {
Some(1) => Self::ActiveConnected,
Some(2) => Self::ActiveDisconnected,
// 0 (UNKNOWN), 3 (INACTIVE), any UNRECOGNIZED value, and a missing
// field all collapse to INACTIVE.
_ => Self::Inactive,
}
}
}
/// Map a QConnect `max_audio_quality` level to a qbz `Quality`.
/// QConnect levels: 0/1 ~ MP3, 2 ~ CD/Lossless, 3 ~ Hi-Res (<=96kHz),
/// 4 ~ Hi-Res (>96kHz), 5/None ~ uncapped. The qbz `Quality` enum only has
/// four variants (Mp3, Lossless, HiRes, UltraHiRes), so 4 and uncapped both
/// resolve to UltraHiRes.
pub fn quality_from_max_audio_quality(level: Option<i32>) -> Quality {
match level {
Some(l) if l <= 1 => Quality::Mp3,
Some(2) => Quality::Lossless,
Some(3) => Quality::HiRes,
Some(4) => Quality::UltraHiRes,
_ => Quality::UltraHiRes,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deferred_join_reason_is_reconnection_only_after_a_drop() {
assert_eq!(
deferred_join_reason(false),
JOIN_SESSION_REASON_CONTROLLER_REQUEST
);
assert_eq!(deferred_join_reason(true), JOIN_SESSION_REASON_RECONNECTION);
}
#[test]
fn reask_queue_state_stops_once_session_uuid_known_or_budget_spent() {
assert!(should_reask_queue_state(false, 0, 5));
assert!(should_reask_queue_state(false, 4, 5));
assert!(!should_reask_queue_state(false, 5, 5));
assert!(!should_reask_queue_state(true, 0, 5));
}
#[test]
fn compute_connection_state_matrix() {
use ServerActiveState::*;
let d = compute_connection_state(true, true, None, false);
assert!(
d.should_be_active
&& d.should_set_active_renderer
&& d.should_set_queue
&& !d.should_ask_queue
);
let d = compute_connection_state(true, false, Me, false);
assert!(d.should_be_active && !d.should_set_active_renderer && d.should_set_queue);
let d = compute_connection_state(false, false, Me, true);
assert!(d.should_ask_queue && !d.should_set_queue && !d.should_set_active_renderer);
let d = compute_connection_state(false, false, OtherPlaying, false);
assert!(!d.should_be_active && !d.should_set_active_renderer && d.should_ask_queue);
let d = compute_connection_state(false, false, None, false);
assert!(
!d.should_be_active
&& !d.should_set_active_renderer
&& !d.should_set_queue
&& !d.should_ask_queue
);
}
#[test]
fn renderer_status_from_wire_maps_known_values() {
assert_eq!(RendererStatus::from_wire(Some(0)), RendererStatus::Inactive); // UNKNOWN collapses
assert_eq!(
RendererStatus::from_wire(Some(1)),
RendererStatus::ActiveConnected
);
assert_eq!(
RendererStatus::from_wire(Some(2)),
RendererStatus::ActiveDisconnected
);
assert_eq!(RendererStatus::from_wire(Some(3)), RendererStatus::Inactive);
}
#[test]
fn renderer_status_from_wire_collapses_unknown_and_missing_to_inactive() {
assert_eq!(RendererStatus::from_wire(Some(99)), RendererStatus::Inactive); // UNRECOGNIZED
assert_eq!(RendererStatus::from_wire(None), RendererStatus::Inactive); // absent field
}
#[test]
fn watchdog_arms_only_for_playing_active_peer() {
const PLAYING_STATE_UNKNOWN: i32 = 0;
const PLAYING_STATE_STOPPED: i32 = 1;
const PLAYING_STATE_PAUSED: i32 = 3;
// Arm: playing AND active peer.
assert!(should_arm_renderer_watchdog(
Some(PLAYING_STATE_PLAYING),
true
));
// Do not arm when paused/stopped/unknown even if active peer.
assert!(!should_arm_renderer_watchdog(
Some(PLAYING_STATE_PAUSED),
true
));
assert!(!should_arm_renderer_watchdog(
Some(PLAYING_STATE_STOPPED),
true
));
assert!(!should_arm_renderer_watchdog(
Some(PLAYING_STATE_UNKNOWN),
true
));
assert!(!should_arm_renderer_watchdog(None, true));
// Do not arm when not an active peer (e.g. local renderer is active).
assert!(!should_arm_renderer_watchdog(
Some(PLAYING_STATE_PLAYING),
false
));
}
#[test]
fn quality_from_max_audio_quality_maps_levels() {
// qbz Quality has four variants: Mp3, Lossless (CD), HiRes (<=96kHz),
// UltraHiRes (>96kHz). QConnect levels collapse onto these.
assert_eq!(quality_from_max_audio_quality(Some(0)), Quality::Mp3);
assert_eq!(quality_from_max_audio_quality(Some(1)), Quality::Mp3);
assert_eq!(quality_from_max_audio_quality(Some(2)), Quality::Lossless);
assert_eq!(quality_from_max_audio_quality(Some(3)), Quality::HiRes);
assert_eq!(quality_from_max_audio_quality(Some(4)), Quality::UltraHiRes);
assert_eq!(quality_from_max_audio_quality(Some(5)), Quality::UltraHiRes);
assert_eq!(quality_from_max_audio_quality(None), Quality::UltraHiRes);
assert_eq!(quality_from_max_audio_quality(Some(99)), Quality::UltraHiRes);
}
}
+39
View File
@@ -62,11 +62,50 @@ pub fn resolve_handoff_intent(origin: TrackOrigin) -> HandoffIntent {
} }
} }
/// Server-side backstop: re-evaluate EVERY track's origin, independent of any
/// command-level `origin`. A bare `track_id` cannot prove "Qobuz vs local/Plex"
/// on its own, so the frontend ships per-track origins and the gate re-validates
/// each one here. An empty list is blocked: we cannot prove the queue is
/// all-Qobuz, so we refuse rather than trust the command-level origin.
pub fn validate_track_origins_for_admission(origins: &[TrackOrigin]) -> AdmissionDecision {
if origins.is_empty() {
return AdmissionDecision::block("empty_track_origins_blocked");
}
for origin in origins {
let decision = evaluate_remote_queue_admission(*origin);
if !decision.accepted {
return decision;
}
}
AdmissionDecision::allow("all_track_origins_qobuz")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use qbz_models::PlaybackSource; use qbz_models::PlaybackSource;
#[test]
fn blocks_command_when_any_track_origin_is_non_qobuz() {
assert!(
validate_track_origins_for_admission(&[
TrackOrigin::QobuzOnline,
TrackOrigin::QobuzOfflineCache
])
.accepted
);
assert!(
!validate_track_origins_for_admission(&[
TrackOrigin::QobuzOnline,
TrackOrigin::LocalLibrary
])
.accepted
);
assert!(!validate_track_origins_for_admission(&[TrackOrigin::Plex]).accepted);
assert!(!validate_track_origins_for_admission(&[TrackOrigin::ExternalUnknown]).accepted);
assert!(!validate_track_origins_for_admission(&[]).accepted); // empty -> blocked
}
#[test] #[test]
fn admission_matches_cast_predicate() { fn admission_matches_cast_predicate() {
let pairs = [ let pairs = [
+2 -2
View File
@@ -10,8 +10,8 @@ pub mod renderer;
pub mod telemetry; pub mod telemetry;
pub use admission::{ pub use admission::{
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, HandoffIntent, evaluate_remote_queue_admission, resolve_handoff_intent, validate_track_origins_for_admission,
TrackOrigin, AdmissionDecision, HandoffIntent, TrackOrigin,
}; };
pub use pending::{PendingActionError, PendingActionSlot, PendingCorrelation, PendingQueueAction}; pub use pending::{PendingActionError, PendingActionSlot, PendingCorrelation, PendingQueueAction};
pub use queue::{QConnectQueueState, QueueEvent, QueueItem, QueueVersion}; pub use queue::{QConnectQueueState, QueueEvent, QueueItem, QueueVersion};
+1
View File
@@ -5380,6 +5380,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"log", "log",
"qbz-models",
"qconnect-core", "qconnect-core",
"qconnect-protocol", "qconnect-protocol",
"qconnect-transport-ws", "qconnect-transport-ws",
+14 -18
View File
@@ -5,9 +5,10 @@
//! to `QconnectServiceState` after a `runtime.check_requirements` gate. //! to `QconnectServiceState` after a `runtime.check_requirements` gate.
use qconnect_app::{ use qconnect_app::{
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, QConnectQueueState, evaluate_remote_queue_admission, resolve_handoff_intent,
QConnectRendererState, QconnectStartupMode, QueueCommandType, RendererReport, validate_track_origins_for_admission as validate_core_track_origins_for_admission,
RendererReportType, AdmissionDecision, QConnectQueueState, QConnectRendererState, QconnectStartupMode,
QueueCommandType, RendererReport, RendererReportType,
}; };
use tauri::{AppHandle, Emitter, Manager, State}; use tauri::{AppHandle, Emitter, Manager, State};
use uuid::Uuid; use uuid::Uuid;
@@ -182,24 +183,19 @@ pub async fn v2_qconnect_evaluate_queue_admission(
}) })
} }
/// Server-side backstop: re-evaluate EVERY track's origin, independent of the /// Thin Tauri-side wrapper over the frontend-agnostic
/// command-level `origin`. A bare `track_id` (a `u64`) cannot prove "Qobuz vs /// `qconnect_core::validate_track_origins_for_admission`: maps the wire
/// local/Plex" on its own, so the frontend ships per-track origins and the gate /// `QconnectTrackOrigin`s to core `TrackOrigin`s and delegates. The pure
/// re-validates each one here. An empty list is blocked: we cannot prove the /// per-track backstop logic (empty-list block, any-non-Qobuz block) lives in
/// queue is all-Qobuz, so we refuse rather than trust the command-level origin. /// `qconnect-core` next to `evaluate_remote_queue_admission`.
pub(super) fn validate_track_origins_for_admission( pub(super) fn validate_track_origins_for_admission(
origins: &[QconnectTrackOrigin], origins: &[QconnectTrackOrigin],
) -> AdmissionDecision { ) -> AdmissionDecision {
if origins.is_empty() { let core_origins: Vec<_> = origins
return AdmissionDecision::block("empty_track_origins_blocked"); .iter()
} .map(|origin| origin.into_core_origin())
for origin in origins { .collect();
let decision = evaluate_remote_queue_admission(origin.into_core_origin()); validate_core_track_origins_for_admission(&core_origins)
if !decision.accepted {
return decision;
}
}
AdmissionDecision::allow("all_track_origins_qobuz")
} }
#[tauri::command] #[tauri::command]
+6 -21
View File
@@ -38,27 +38,12 @@ pub(super) const PLAYING_STATE_PLAYING: i32 = 2;
pub(super) const PLAYING_STATE_PAUSED: i32 = 3; pub(super) const PLAYING_STATE_PAUSED: i32 = 3;
pub(super) const BUFFER_STATE_OK: i32 = 2; pub(super) const BUFFER_STATE_OK: i32 = 2;
/// JoinSession `reason` wire values (proto tag 3): a first join from a fresh /// The renderer-liveness silence budget and the pure watchdog-arming predicate
/// runtime is a controller request, a join after a transport drop carries the /// now live in the frontend-agnostic `qconnect_app::session` module. Re-exported
/// reconnection reason so the server treats it as session continuity rather /// here so existing `super::…` references inside this module compile unchanged.
/// than a brand-new controller (P1-2). /// (The JoinSession `reason` consts also moved there; they are consumed only by
pub(super) const JOIN_SESSION_REASON_CONTROLLER_REQUEST: i32 = 1; /// `deferred_join_reason`, which moved too, so they are not re-exported here.)
pub(super) const JOIN_SESSION_REASON_RECONNECTION: i32 = 2; pub(super) use qconnect_app::{should_arm_renderer_watchdog, QCONNECT_RENDERER_LOST_TIMEOUT_MS};
/// Official "renderer LOST" silence budget. A *playing* active peer renderer
/// that sends no RENDERER_STATE_UPDATED for this long is considered
/// unreachable (webplayer arms setTimeout(...,12e3) on onPlayerStateUpdated
/// while playingState==PLAY). See 05-sync-status-queue.md §1.
pub(super) const QCONNECT_RENDERER_LOST_TIMEOUT_MS: u64 = 12_000;
/// Pure arming predicate for the renderer-liveness watchdog: arm only while the
/// active renderer is a peer AND its reported playing_state is PLAYING.
pub(super) fn should_arm_renderer_watchdog(
playing_state: Option<i32>,
is_active_peer: bool,
) -> bool {
is_active_peer && playing_state == Some(PLAYING_STATE_PLAYING)
}
// AudioQuality enum: 0=unknown, 1=mp3, 2=cd, 3=hires_l1, 4=hires_l2(192k), 5=hires_l3(384k) // AudioQuality enum: 0=unknown, 1=mp3, 2=cd, 3=hires_l1, 4=hires_l2(192k), 5=hires_l3(384k)
pub(super) const AUDIO_QUALITY_UNKNOWN: i32 = 0; pub(super) const AUDIO_QUALITY_UNKNOWN: i32 = 0;
+6 -23
View File
@@ -41,32 +41,15 @@ use super::{
QconnectLifecycleState, QconnectMuteVolumeRequest, QconnectQueueVersionPayload, QconnectLifecycleState, QconnectMuteVolumeRequest, QconnectQueueVersionPayload,
QconnectRemoteSyncState, QconnectSessionState, QconnectSetPlayerStateQueueItemPayload, QconnectRemoteSyncState, QconnectSessionState, QconnectSetPlayerStateQueueItemPayload,
QconnectSetPlayerStateRequest, QconnectSetVolumeRequest, QconnectVisibleQueueProjection, QconnectSetPlayerStateRequest, QconnectSetVolumeRequest, QconnectVisibleQueueProjection,
AUDIO_QUALITY_HIRES_LEVEL2, BUFFER_STATE_OK, JOIN_SESSION_REASON_CONTROLLER_REQUEST, AUDIO_QUALITY_HIRES_LEVEL2, BUFFER_STATE_OK, PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING,
JOIN_SESSION_REASON_RECONNECTION, PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING,
PLAYING_STATE_STOPPED, PLAYING_STATE_STOPPED,
}; };
/// Pure selector for the JoinSession `reason`: a post-drop rejoin carries /// `deferred_join_reason` and `should_reask_queue_state` now live in the
/// RECONNECTION, the first join from a fresh runtime carries CONTROLLER_REQUEST /// frontend-agnostic `qconnect_app::session` module. Re-exported here so the
/// (P1-2). /// existing `deferred_join_reason`/`should_reask_queue_state` call sites in this
pub(super) fn deferred_join_reason(has_disconnected: bool) -> i32 { /// module compile unchanged.
if has_disconnected { pub(super) use qconnect_app::{deferred_join_reason, should_reask_queue_state};
JOIN_SESSION_REASON_RECONNECTION
} else {
JOIN_SESSION_REASON_CONTROLLER_REQUEST
}
}
/// Pure predicate (P1-8): keep re-asking for queue state after a Lagged
/// broadcast drop until the session_uuid is confirmed or the attempt budget is
/// spent. Stops immediately once the session_uuid is known.
pub(super) fn should_reask_queue_state(
session_uuid_known: bool,
attempts: u32,
max_attempts: u32,
) -> bool {
!session_uuid_known && attempts < max_attempts
}
/// Prior-state snapshot captured from `sync_state` BEFORE a SESSION_STATE frame /// Prior-state snapshot captured from `sync_state` BEFORE a SESSION_STATE frame
/// is applied by `event_sink`, plus the server's classified active state derived /// is applied by `event_sink`, plus the server's classified active state derived
+6 -63
View File
@@ -18,69 +18,12 @@ use super::{
QconnectQueueVersionPayload, QconnectRemoteSyncState, QconnectVisibleQueueProjection, QconnectQueueVersionPayload, QconnectRemoteSyncState, QconnectVisibleQueueProjection,
}; };
/// The server's view of who currently owns the active-renderer slot in a /// `ServerActiveState`, `ConnectionDecision`, and `compute_connection_state`
/// SESSION_STATE frame, classified relative to us (P1-3). /// now live in the frontend-agnostic `qconnect_app::session` module. Re-exported
#[derive(Debug, Clone, Copy, PartialEq, Eq)] /// here so existing `super::session::…` references compile unchanged.
pub(super) enum ServerActiveState { /// (`ConnectionDecision` is the return type — consumed via field access, so it
/// No renderer is active in the session. /// is not named directly on the Tauri side; it lives in `qconnect_app`.)
None, pub(super) use qconnect_app::{compute_connection_state, ServerActiveState};
/// We are the active renderer.
Me,
/// Another renderer is active and reports PLAYING.
OtherPlaying,
/// Another renderer is active and reports a non-playing state.
OtherPaused,
}
/// Outcome of takeover arbitration on a SESSION_STATE frame (P1-3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ConnectionDecision {
/// We should consider ourselves the active renderer.
pub should_be_active: bool,
/// We should emit CtrlSrvrSetActiveRenderer to claim the slot.
pub should_set_active_renderer: bool,
/// We should push our queue + state to the session.
pub should_set_queue: bool,
/// We should ask the renderer for its current state.
pub should_ask_queue: bool,
}
/// Pure takeover arbitration ported from the web controller's
/// `computeConnectionState`. This is a REDUCED matrix: it captures the
/// shape and named outputs faithfully from the appendix prose, but the
/// exact web truth-table cell values were not dumped verbatim. The
/// `OtherPaused` vs `OtherPlaying` divergence and repeat-mode
/// reconciliation are pending a decompiled-bundle cross-check; repeat-mode
/// reconciliation is intentionally OUT of P1-3 scope (it overlaps the
/// existing `session_loop_mode` handling in `event_sink.rs`).
pub(super) fn compute_connection_state(
was_active: bool,
was_playing: bool,
server: ServerActiveState,
queue_equal: bool,
) -> ConnectionDecision {
use ServerActiveState::*;
match server {
None => ConnectionDecision {
should_be_active: was_active,
should_set_active_renderer: was_active,
should_set_queue: was_active,
should_ask_queue: false,
},
Me => ConnectionDecision {
should_be_active: true,
should_set_active_renderer: false,
should_set_queue: !queue_equal && (was_active || was_playing),
should_ask_queue: queue_equal,
},
OtherPlaying | OtherPaused => ConnectionDecision {
should_be_active: false,
should_set_active_renderer: false,
should_set_queue: false,
should_ask_queue: true,
},
}
}
pub(super) fn queue_item_snapshot_for_cursor( pub(super) fn queue_item_snapshot_for_cursor(
queue: &QConnectQueueState, queue: &QConnectQueueState,
+14 -101
View File
@@ -49,50 +49,10 @@ fn normalizes_renderer_volume() {
assert!((normalize_volume_to_fraction(125) - 1.0).abs() < f32::EPSILON); assert!((normalize_volume_to_fraction(125) - 1.0).abs() < f32::EPSILON);
} }
#[test] // `deferred_join_reason`, `should_reask_queue_state`, and
fn deferred_join_reason_is_reconnection_only_after_a_drop() { // `compute_connection_state` are now pure functions in
use super::service::deferred_join_reason; // `qconnect_app::session`; their unit tests live in that crate's `session`
use super::{JOIN_SESSION_REASON_CONTROLLER_REQUEST, JOIN_SESSION_REASON_RECONNECTION}; // test module.
assert_eq!(
deferred_join_reason(false),
JOIN_SESSION_REASON_CONTROLLER_REQUEST
);
assert_eq!(
deferred_join_reason(true),
JOIN_SESSION_REASON_RECONNECTION
);
}
#[test]
fn reask_queue_state_stops_once_session_uuid_known_or_budget_spent() {
use super::service::should_reask_queue_state;
assert!(should_reask_queue_state(false, 0, 5));
assert!(should_reask_queue_state(false, 4, 5));
assert!(!should_reask_queue_state(false, 5, 5));
assert!(!should_reask_queue_state(true, 0, 5));
}
#[test]
fn compute_connection_state_matrix() {
use super::session::{compute_connection_state, ServerActiveState::*};
let d = compute_connection_state(true, true, None, false);
assert!(
d.should_be_active && d.should_set_active_renderer && d.should_set_queue && !d.should_ask_queue
);
let d = compute_connection_state(true, false, Me, false);
assert!(d.should_be_active && !d.should_set_active_renderer && d.should_set_queue);
let d = compute_connection_state(false, false, Me, true);
assert!(d.should_ask_queue && !d.should_set_queue && !d.should_set_active_renderer);
let d = compute_connection_state(false, false, OtherPlaying, false);
assert!(!d.should_be_active && !d.should_set_active_renderer && d.should_ask_queue);
let d = compute_connection_state(false, false, None, false);
assert!(
!d.should_be_active
&& !d.should_set_active_renderer
&& !d.should_set_queue
&& !d.should_ask_queue
);
}
#[test] #[test]
fn maps_outbound_command_type_to_protocol_command_type() { fn maps_outbound_command_type_to_protocol_command_type() {
@@ -145,16 +105,11 @@ fn maps_qconnect_track_origin_to_core_origin_and_handoff() {
); );
} }
#[test] // The pure per-track admission backstop now lives in
fn blocks_command_when_any_track_origin_is_non_qobuz() { // `qconnect_core::validate_track_origins_for_admission`; its unit test lives in
use super::commands::validate_track_origins_for_admission; // that crate's `admission` test module. The thin Tauri wrapper
use super::QconnectTrackOrigin::*; // `commands::validate_track_origins_for_admission` only maps wire origins to
assert!(validate_track_origins_for_admission(&[QobuzOnline, QobuzOfflineCache]).accepted); // core origins.
assert!(!validate_track_origins_for_admission(&[QobuzOnline, LocalLibrary]).accepted);
assert!(!validate_track_origins_for_admission(&[Plex]).accepted);
assert!(!validate_track_origins_for_admission(&[ExternalUnknown]).accepted);
assert!(!validate_track_origins_for_admission(&[]).accepted); // empty -> blocked
}
#[test] #[test]
fn refreshes_local_renderer_id_from_exact_device_uuid_match() { fn refreshes_local_renderer_id_from_exact_device_uuid_match() {
@@ -1134,38 +1089,9 @@ fn device_uuid_persists_and_is_reused_across_calls() {
let _ = std::fs::remove_file(&tmp); let _ = std::fs::remove_file(&tmp);
} }
#[test] // `RendererStatus::from_wire` and `should_arm_renderer_watchdog` are now in
fn renderer_status_from_wire_maps_known_values() { // `qconnect_app::session`; their unit tests live in that crate's `session` test
use super::RendererStatus; // module.
assert_eq!(RendererStatus::from_wire(Some(0)), RendererStatus::Inactive); // UNKNOWN collapses
assert_eq!(RendererStatus::from_wire(Some(1)), RendererStatus::ActiveConnected);
assert_eq!(RendererStatus::from_wire(Some(2)), RendererStatus::ActiveDisconnected);
assert_eq!(RendererStatus::from_wire(Some(3)), RendererStatus::Inactive);
}
#[test]
fn renderer_status_from_wire_collapses_unknown_and_missing_to_inactive() {
use super::RendererStatus;
assert_eq!(RendererStatus::from_wire(Some(99)), RendererStatus::Inactive); // UNRECOGNIZED
assert_eq!(RendererStatus::from_wire(None), RendererStatus::Inactive); // absent field
}
#[test]
fn watchdog_arms_only_for_playing_active_peer() {
use super::{
should_arm_renderer_watchdog, PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING,
PLAYING_STATE_STOPPED, PLAYING_STATE_UNKNOWN,
};
// Arm: playing AND active peer.
assert!(should_arm_renderer_watchdog(Some(PLAYING_STATE_PLAYING), true));
// Do not arm when paused/stopped/unknown even if active peer.
assert!(!should_arm_renderer_watchdog(Some(PLAYING_STATE_PAUSED), true));
assert!(!should_arm_renderer_watchdog(Some(PLAYING_STATE_STOPPED), true));
assert!(!should_arm_renderer_watchdog(Some(PLAYING_STATE_UNKNOWN), true));
assert!(!should_arm_renderer_watchdog(None, true));
// Do not arm when not an active peer (e.g. local renderer is active).
assert!(!should_arm_renderer_watchdog(Some(PLAYING_STATE_PLAYING), false));
}
#[test] #[test]
fn build_set_position_request_carries_position_and_queue_item_without_changing_play_state() { fn build_set_position_request_carries_position_and_queue_item_without_changing_play_state() {
@@ -1204,21 +1130,8 @@ fn startup_retry_schedule_is_bounded_and_monotonic() {
assert_eq!(*s.last().unwrap(), 30_000); assert_eq!(*s.last().unwrap(), 30_000);
} }
#[test] // `quality_from_max_audio_quality` is now in `qconnect_app::session`; its unit
fn quality_from_max_audio_quality_maps_levels() { // test lives in that crate's `session` test module.
use super::track_loading::quality_from_max_audio_quality;
use qbz_models::Quality;
// qbz Quality has four variants: Mp3, Lossless (CD), HiRes (<=96kHz),
// UltraHiRes (>96kHz). QConnect levels collapse onto these.
assert_eq!(quality_from_max_audio_quality(Some(0)), Quality::Mp3);
assert_eq!(quality_from_max_audio_quality(Some(1)), Quality::Mp3);
assert_eq!(quality_from_max_audio_quality(Some(2)), Quality::Lossless);
assert_eq!(quality_from_max_audio_quality(Some(3)), Quality::HiRes);
assert_eq!(quality_from_max_audio_quality(Some(4)), Quality::UltraHiRes);
assert_eq!(quality_from_max_audio_quality(Some(5)), Quality::UltraHiRes);
assert_eq!(quality_from_max_audio_quality(None), Quality::UltraHiRes);
assert_eq!(quality_from_max_audio_quality(Some(99)), Quality::UltraHiRes);
}
#[test] #[test]
fn renderer_allows_remote_volume_defaults_allow_when_absent() { fn renderer_allows_remote_volume_defaults_allow_when_absent() {
+5 -16
View File
@@ -6,11 +6,15 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use qbz_models::Quality;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::core_bridge::CoreBridge; use crate::core_bridge::CoreBridge;
/// `quality_from_max_audio_quality` now lives in the frontend-agnostic
/// `qconnect_app::session` module. Re-exported here so the existing call sites in
/// this module compile unchanged.
use qconnect_app::quality_from_max_audio_quality;
use super::QconnectRemoteSyncState; use super::QconnectRemoteSyncState;
const LOAD_ATTEMPT_DEDUP_WINDOW: Duration = Duration::from_secs(5); const LOAD_ATTEMPT_DEDUP_WINDOW: Duration = Duration::from_secs(5);
@@ -29,21 +33,6 @@ pub(super) fn should_reload_remote_track(
playback_state.track_id != track_id playback_state.track_id != track_id
} }
/// Map a QConnect `max_audio_quality` level to a qbz `Quality`.
/// QConnect levels: 0/1 ~ MP3, 2 ~ CD/Lossless, 3 ~ Hi-Res (<=96kHz),
/// 4 ~ Hi-Res (>96kHz), 5/None ~ uncapped. The qbz `Quality` enum only has
/// four variants (Mp3, Lossless, HiRes, UltraHiRes), so 4 and uncapped both
/// resolve to UltraHiRes.
pub(super) fn quality_from_max_audio_quality(level: Option<i32>) -> Quality {
match level {
Some(l) if l <= 1 => Quality::Mp3,
Some(2) => Quality::Lossless,
Some(3) => Quality::HiRes,
Some(4) => Quality::UltraHiRes,
_ => Quality::UltraHiRes,
}
}
async fn load_remote_track_into_player( async fn load_remote_track_into_player(
bridge: &CoreBridge, bridge: &CoreBridge,
track_id: u64, track_id: u64,
+3 -23
View File
@@ -279,26 +279,6 @@ pub struct QconnectAskForRendererStateRequest {
} }
/// Wire renderer status from `MESSAGE_TYPE_SRVR_CTRL_RENDERER_STATE_UPDATED` /// Wire renderer status from `MESSAGE_TYPE_SRVR_CTRL_RENDERER_STATE_UPDATED`
/// (`status` field, decoded at qconnect-protocol decoder.rs:801). /// now lives in the frontend-agnostic `qconnect_app::session` module. Re-exported
/// Wire enum: UNKNOWN=0, ACTIVE_CONNECTED=1, ACTIVE_DISCONNECTED=2, INACTIVE=3. /// here so the command surface (`pub use types::*`) keeps exposing it unchanged.
/// Per the official client's `Ya` collapse, UNKNOWN and any UNRECOGNIZED value pub use qconnect_app::RendererStatus;
/// map to INACTIVE.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RendererStatus {
ActiveConnected,
ActiveDisconnected,
Inactive,
}
impl RendererStatus {
pub(super) fn from_wire(value: Option<i64>) -> Self {
match value {
Some(1) => Self::ActiveConnected,
Some(2) => Self::ActiveDisconnected,
// 0 (UNKNOWN), 3 (INACTIVE), any UNRECOGNIZED value, and a missing
// field all collapse to INACTIVE.
_ => Self::Inactive,
}
}
}