diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 824061e40..a73e48c03 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -5310,6 +5310,7 @@ dependencies = [ "async-trait", "log", "prost", + "qbz-models", "qconnect-core", "qconnect-protocol", "qconnect-transport-ws", diff --git a/crates/qconnect-app/Cargo.toml b/crates/qconnect-app/Cargo.toml index 306b14876..452e898f7 100644 --- a/crates/qconnect-app/Cargo.toml +++ b/crates/qconnect-app/Cargo.toml @@ -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 } diff --git a/crates/qconnect-app/src/lib.rs b/crates/qconnect-app/src/lib.rs index 3092ec8c2..6996379d2 100644 --- a/crates/qconnect-app/src/lib.rs +++ b/crates/qconnect-app/src/lib.rs @@ -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; diff --git a/crates/qconnect-app/src/session.rs b/crates/qconnect-app/src/session.rs new file mode 100644 index 000000000..51c83d005 --- /dev/null +++ b/crates/qconnect-app/src/session.rs @@ -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, 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) -> 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) -> 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); + } +} diff --git a/crates/qconnect-core/src/admission.rs b/crates/qconnect-core/src/admission.rs index fe19755ea..d6bdedb3c 100644 --- a/crates/qconnect-core/src/admission.rs +++ b/crates/qconnect-core/src/admission.rs @@ -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 = [ diff --git a/crates/qconnect-core/src/lib.rs b/crates/qconnect-core/src/lib.rs index 7038c4033..85f774518 100644 --- a/crates/qconnect-core/src/lib.rs +++ b/crates/qconnect-core/src/lib.rs @@ -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}; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a156da61c..0e44c6fc2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5380,6 +5380,7 @@ version = "0.1.0" dependencies = [ "async-trait", "log", + "qbz-models", "qconnect-core", "qconnect-protocol", "qconnect-transport-ws", diff --git a/src-tauri/src/qconnect/commands.rs b/src-tauri/src/qconnect/commands.rs index ab9a204a1..7585aa73f 100644 --- a/src-tauri/src/qconnect/commands.rs +++ b/src-tauri/src/qconnect/commands.rs @@ -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] diff --git a/src-tauri/src/qconnect/mod.rs b/src-tauri/src/qconnect/mod.rs index 238fd34bb..baf249a65 100644 --- a/src-tauri/src/qconnect/mod.rs +++ b/src-tauri/src/qconnect/mod.rs @@ -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, - 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; diff --git a/src-tauri/src/qconnect/service.rs b/src-tauri/src/qconnect/service.rs index ea5c4a0cc..a04d6d4bd 100644 --- a/src-tauri/src/qconnect/service.rs +++ b/src-tauri/src/qconnect/service.rs @@ -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 diff --git a/src-tauri/src/qconnect/session.rs b/src-tauri/src/qconnect/session.rs index 006cfc7ab..aedc9768d 100644 --- a/src-tauri/src/qconnect/session.rs +++ b/src-tauri/src/qconnect/session.rs @@ -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, diff --git a/src-tauri/src/qconnect/tests.rs b/src-tauri/src/qconnect/tests.rs index af4c3e07d..f8dd49054 100644 --- a/src-tauri/src/qconnect/tests.rs +++ b/src-tauri/src/qconnect/tests.rs @@ -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() { diff --git a/src-tauri/src/qconnect/track_loading.rs b/src-tauri/src/qconnect/track_loading.rs index 7312f73ca..461093476 100644 --- a/src-tauri/src/qconnect/track_loading.rs +++ b/src-tauri/src/qconnect/track_loading.rs @@ -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) -> 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, diff --git a/src-tauri/src/qconnect/types.rs b/src-tauri/src/qconnect/types.rs index f7d229b13..916ed0c27 100644 --- a/src-tauri/src/qconnect/types.rs +++ b/src-tauri/src/qconnect/types.rs @@ -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) -> 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;