feat(qconnect): surface server queue_hash onto queue state

Decoder now reads field #100 (previously decoded-then-dropped) on QueueState
and carries it through to QConnectQueueState.last_server_queue_hash via the
QueueStateReplaced reducer path (full-state replace already clones it).

cc
This commit is contained in:
vicrodh committed 2026-06-01 03:16:16 -06:00
1 parent 8256cc3122
commit 3ac68e788a
3 files changed
+56 -1

No files matched your search

+46
View File
@@ -637,6 +637,12 @@ fn map_server_event(event: &QueueServerEvent, current: &QConnectQueueState) -> Q
next.shuffle_order = None;
}
// Surface the server queue_hash (field #100). Only overwrite when the
// payload actually carries it, so an omitted hash leaves prior state.
if let Some(server_hash) = parse_bytes(&event.payload, "server_queue_hash") {
next.last_server_queue_hash = Some(server_hash);
}
QueueEvent::QueueStateReplaced {
action_uuid: event.action_uuid.clone(),
state: next,
@@ -827,6 +833,21 @@ fn parse_u64(payload: &Value, field: &str) -> Option<u64> {
payload.get(field).and_then(value_as_u64)
}
/// Parse a JSON byte array (e.g. the server `queue_hash` field #100) into bytes.
/// Returns `None` when absent/null so an omitted hash does not clobber state.
fn parse_bytes(payload: &Value, field: &str) -> Option<Vec<u8>> {
payload
.get(field)
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(value_as_u64)
.filter_map(|value| u8::try_from(value).ok())
.collect()
})
}
fn parse_bool(payload: &Value, field: &str, default: bool) -> bool {
payload
.get(field)
@@ -1165,6 +1186,31 @@ mod tests {
);
}
#[tokio::test]
async fn queue_state_event_surfaces_server_queue_hash() {
let (app, _sink, _transport, _events_rx) = build_connected_app().await;
app.apply_server_event(QueueServerEvent {
event_type: QueueEventType::SrvrCtrlQueueState,
action_uuid: None,
queue_version: Some(QueueVersion::new(1, 0)),
payload: json!({
"tracks": [],
"shuffle_mode": false,
"autoplay_tracks": [],
"server_queue_hash": [1, 2, 3, 4]
}),
})
.await
.expect("apply queue-state event");
let snap = app.queue_state_snapshot().await;
assert_eq!(
snap.last_server_queue_hash.as_deref(),
Some(&[1u8, 2, 3, 4][..])
);
}
#[tokio::test]
async fn autoplay_growth_triggers_queue_state_resync() {
let (app, sink, transport, _events_rx) = build_connected_app().await;
+6
View File
@@ -36,6 +36,11 @@ pub struct QConnectQueueState {
pub autoplay_loading: bool,
pub autoplay_items: Vec<QueueItem>,
pub updated_at_ms: u64,
/// Last `queue_hash` (field #100) reported by the server on a queue-state
/// confirmation. Surfaced for divergence detection; see the algorithm-agnostic
/// `queue_hashes_diverge` seam in qconnect-app (algorithm is a BLOCKING unknown).
#[serde(default)]
pub last_server_queue_hash: Option<Vec<u8>>,
}
impl Default for QConnectQueueState {
@@ -49,6 +54,7 @@ impl Default for QConnectQueueState {
autoplay_loading: false,
autoplay_items: Vec::new(),
updated_at_ms: 0,
last_server_queue_hash: None,
}
}
}
+4 -1
View File
@@ -544,7 +544,10 @@ fn map_queue_state(payload: QueueStateMessage) -> Result<QueueServerEvent, Proto
"shuffled_track_indexes": shuffled_indexes,
"autoplay_mode": payload.autoplay_mode.unwrap_or(false),
"autoplay_loading": payload.autoplay_loading.unwrap_or(false),
"autoplay_tracks": autoplay_tracks
"autoplay_tracks": autoplay_tracks,
// Field #100: previously decoded-then-dropped. Surfaced for the
// queue_hash divergence seam (algorithm is a BLOCKING unknown).
"server_queue_hash": payload.queue_hash
}),
})
}