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:
14 files changed
+378
-269
No files matched your search
Generated
+1
@@ -5310,6 +5310,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"log",
|
||||
"prost",
|
||||
"qbz-models",
|
||||
"qconnect-core",
|
||||
"qconnect-protocol",
|
||||
"qconnect-transport-ws",
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "UI-agnostic Qobuz Connect application adapter"
|
||||
qconnect-core = { path = "../qconnect-core" }
|
||||
qconnect-protocol = { path = "../qconnect-protocol" }
|
||||
qconnect-transport-ws = { path = "../qconnect-transport-ws" }
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -6,6 +6,7 @@ mod app;
|
||||
mod error;
|
||||
mod events;
|
||||
mod feature_flags;
|
||||
pub mod session;
|
||||
mod state;
|
||||
pub mod startup;
|
||||
|
||||
@@ -13,13 +14,20 @@ pub use app::QconnectApp;
|
||||
pub use startup::{compute_effective_startup, QconnectStartupMode};
|
||||
pub use error::QconnectAppError;
|
||||
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::{
|
||||
QBZ_QCONNECT_PANEL_SWITCH, QBZ_QCONNECT_QUEUE_MODEL, QBZ_QCONNECT_STRICT_DOMAIN_ISOLATION,
|
||||
QBZ_QCONNECT_TRANSPORT,
|
||||
};
|
||||
pub use qconnect_core::{
|
||||
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, HandoffIntent,
|
||||
QConnectQueueState, QConnectRendererState, QueueVersion, RendererCommand, TrackOrigin,
|
||||
evaluate_remote_queue_admission, resolve_handoff_intent, validate_track_origins_for_admission,
|
||||
AdmissionDecision, HandoffIntent, QConnectQueueState, QConnectRendererState, QueueVersion,
|
||||
RendererCommand, TrackOrigin,
|
||||
};
|
||||
pub use qconnect_protocol::{QueueCommandType, RendererReport, RendererReportType};
|
||||
pub use state::QconnectRuntimeState;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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]
|
||||
fn admission_matches_cast_predicate() {
|
||||
let pairs = [
|
||||
|
||||
@@ -10,8 +10,8 @@ pub mod renderer;
|
||||
pub mod telemetry;
|
||||
|
||||
pub use admission::{
|
||||
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, HandoffIntent,
|
||||
TrackOrigin,
|
||||
evaluate_remote_queue_admission, resolve_handoff_intent, validate_track_origins_for_admission,
|
||||
AdmissionDecision, HandoffIntent, TrackOrigin,
|
||||
};
|
||||
pub use pending::{PendingActionError, PendingActionSlot, PendingCorrelation, PendingQueueAction};
|
||||
pub use queue::{QConnectQueueState, QueueEvent, QueueItem, QueueVersion};
|
||||
|
||||
Generated
+1
@@ -5380,6 +5380,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"log",
|
||||
"qbz-models",
|
||||
"qconnect-core",
|
||||
"qconnect-protocol",
|
||||
"qconnect-transport-ws",
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
//! to `QconnectServiceState` after a `runtime.check_requirements` gate.
|
||||
|
||||
use qconnect_app::{
|
||||
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, QConnectQueueState,
|
||||
QConnectRendererState, QconnectStartupMode, QueueCommandType, RendererReport,
|
||||
RendererReportType,
|
||||
evaluate_remote_queue_admission, resolve_handoff_intent,
|
||||
validate_track_origins_for_admission as validate_core_track_origins_for_admission,
|
||||
AdmissionDecision, QConnectQueueState, QConnectRendererState, QconnectStartupMode,
|
||||
QueueCommandType, RendererReport, RendererReportType,
|
||||
};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
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
|
||||
/// command-level `origin`. A bare `track_id` (a `u64`) 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.
|
||||
/// Thin Tauri-side wrapper over the frontend-agnostic
|
||||
/// `qconnect_core::validate_track_origins_for_admission`: maps the wire
|
||||
/// `QconnectTrackOrigin`s to core `TrackOrigin`s and delegates. The pure
|
||||
/// per-track backstop logic (empty-list block, any-non-Qobuz block) lives in
|
||||
/// `qconnect-core` next to `evaluate_remote_queue_admission`.
|
||||
pub(super) fn validate_track_origins_for_admission(
|
||||
origins: &[QconnectTrackOrigin],
|
||||
) -> AdmissionDecision {
|
||||
if origins.is_empty() {
|
||||
return AdmissionDecision::block("empty_track_origins_blocked");
|
||||
}
|
||||
for origin in origins {
|
||||
let decision = evaluate_remote_queue_admission(origin.into_core_origin());
|
||||
if !decision.accepted {
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
AdmissionDecision::allow("all_track_origins_qobuz")
|
||||
let core_origins: Vec<_> = origins
|
||||
.iter()
|
||||
.map(|origin| origin.into_core_origin())
|
||||
.collect();
|
||||
validate_core_track_origins_for_admission(&core_origins)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -38,27 +38,12 @@ pub(super) const PLAYING_STATE_PLAYING: i32 = 2;
|
||||
pub(super) const PLAYING_STATE_PAUSED: i32 = 3;
|
||||
pub(super) const BUFFER_STATE_OK: 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(super) const JOIN_SESSION_REASON_CONTROLLER_REQUEST: i32 = 1;
|
||||
pub(super) 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(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)
|
||||
}
|
||||
/// The renderer-liveness silence budget and the pure watchdog-arming predicate
|
||||
/// now live in the frontend-agnostic `qconnect_app::session` module. Re-exported
|
||||
/// here so existing `super::…` references inside this module compile unchanged.
|
||||
/// (The JoinSession `reason` consts also moved there; they are consumed only by
|
||||
/// `deferred_join_reason`, which moved too, so they are not re-exported here.)
|
||||
pub(super) use qconnect_app::{should_arm_renderer_watchdog, QCONNECT_RENDERER_LOST_TIMEOUT_MS};
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -41,32 +41,15 @@ use super::{
|
||||
QconnectLifecycleState, QconnectMuteVolumeRequest, QconnectQueueVersionPayload,
|
||||
QconnectRemoteSyncState, QconnectSessionState, QconnectSetPlayerStateQueueItemPayload,
|
||||
QconnectSetPlayerStateRequest, QconnectSetVolumeRequest, QconnectVisibleQueueProjection,
|
||||
AUDIO_QUALITY_HIRES_LEVEL2, BUFFER_STATE_OK, JOIN_SESSION_REASON_CONTROLLER_REQUEST,
|
||||
JOIN_SESSION_REASON_RECONNECTION, PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING,
|
||||
AUDIO_QUALITY_HIRES_LEVEL2, BUFFER_STATE_OK, PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING,
|
||||
PLAYING_STATE_STOPPED,
|
||||
};
|
||||
|
||||
/// 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(super) 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(super) fn should_reask_queue_state(
|
||||
session_uuid_known: bool,
|
||||
attempts: u32,
|
||||
max_attempts: u32,
|
||||
) -> bool {
|
||||
!session_uuid_known && attempts < max_attempts
|
||||
}
|
||||
/// `deferred_join_reason` and `should_reask_queue_state` now live in the
|
||||
/// frontend-agnostic `qconnect_app::session` module. Re-exported here so the
|
||||
/// existing `deferred_join_reason`/`should_reask_queue_state` call sites in this
|
||||
/// module compile unchanged.
|
||||
pub(super) use qconnect_app::{deferred_join_reason, should_reask_queue_state};
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -18,69 +18,12 @@ use super::{
|
||||
QconnectQueueVersionPayload, QconnectRemoteSyncState, QconnectVisibleQueueProjection,
|
||||
};
|
||||
|
||||
/// 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(super) 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(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,
|
||||
},
|
||||
}
|
||||
}
|
||||
/// `ServerActiveState`, `ConnectionDecision`, and `compute_connection_state`
|
||||
/// now live in the frontend-agnostic `qconnect_app::session` module. Re-exported
|
||||
/// here so existing `super::session::…` references compile unchanged.
|
||||
/// (`ConnectionDecision` is the return type — consumed via field access, so it
|
||||
/// is not named directly on the Tauri side; it lives in `qconnect_app`.)
|
||||
pub(super) use qconnect_app::{compute_connection_state, ServerActiveState};
|
||||
|
||||
pub(super) fn queue_item_snapshot_for_cursor(
|
||||
queue: &QConnectQueueState,
|
||||
|
||||
+14
-101
@@ -49,50 +49,10 @@ fn normalizes_renderer_volume() {
|
||||
assert!((normalize_volume_to_fraction(125) - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_join_reason_is_reconnection_only_after_a_drop() {
|
||||
use super::service::deferred_join_reason;
|
||||
use super::{JOIN_SESSION_REASON_CONTROLLER_REQUEST, JOIN_SESSION_REASON_RECONNECTION};
|
||||
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
|
||||
);
|
||||
}
|
||||
// `deferred_join_reason`, `should_reask_queue_state`, and
|
||||
// `compute_connection_state` are now pure functions in
|
||||
// `qconnect_app::session`; their unit tests live in that crate's `session`
|
||||
// test module.
|
||||
|
||||
#[test]
|
||||
fn maps_outbound_command_type_to_protocol_command_type() {
|
||||
@@ -145,16 +105,11 @@ fn maps_qconnect_track_origin_to_core_origin_and_handoff() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_command_when_any_track_origin_is_non_qobuz() {
|
||||
use super::commands::validate_track_origins_for_admission;
|
||||
use super::QconnectTrackOrigin::*;
|
||||
assert!(validate_track_origins_for_admission(&[QobuzOnline, QobuzOfflineCache]).accepted);
|
||||
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
|
||||
}
|
||||
// The pure per-track admission backstop now lives in
|
||||
// `qconnect_core::validate_track_origins_for_admission`; its unit test lives in
|
||||
// that crate's `admission` test module. The thin Tauri wrapper
|
||||
// `commands::validate_track_origins_for_admission` only maps wire origins to
|
||||
// core origins.
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_status_from_wire_maps_known_values() {
|
||||
use super::RendererStatus;
|
||||
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));
|
||||
}
|
||||
// `RendererStatus::from_wire` and `should_arm_renderer_watchdog` are now in
|
||||
// `qconnect_app::session`; their unit tests live in that crate's `session` test
|
||||
// module.
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_from_max_audio_quality_maps_levels() {
|
||||
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);
|
||||
}
|
||||
// `quality_from_max_audio_quality` is now in `qconnect_app::session`; its unit
|
||||
// test lives in that crate's `session` test module.
|
||||
|
||||
#[test]
|
||||
fn renderer_allows_remote_volume_defaults_allow_when_absent() {
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use qbz_models::Quality;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
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;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// 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(
|
||||
bridge: &CoreBridge,
|
||||
track_id: u64,
|
||||
|
||||
@@ -279,26 +279,6 @@ pub struct QconnectAskForRendererStateRequest {
|
||||
}
|
||||
|
||||
/// 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, 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// now lives in the frontend-agnostic `qconnect_app::session` module. Re-exported
|
||||
/// here so the command surface (`pub use types::*`) keeps exposing it unchanged.
|
||||
pub use qconnect_app::RendererStatus;
|
||||
Reference in new issue
Block a user