diff --git a/crates/qconnect-protocol/src/command.rs b/crates/qconnect-protocol/src/command.rs index bf7897e17..c5fc1c35c 100644 --- a/crates/qconnect-protocol/src/command.rs +++ b/crates/qconnect-protocol/src/command.rs @@ -4,6 +4,14 @@ use serde_json::Value; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum QueueCommandType { + CtrlSrvrJoinSession, + CtrlSrvrSetPlayerState, + CtrlSrvrSetActiveRenderer, + CtrlSrvrSetVolume, + CtrlSrvrSetLoopMode, + CtrlSrvrMuteVolume, + CtrlSrvrSetMaxAudioQuality, + CtrlSrvrAskForRendererState, CtrlSrvrQueueAddTracks, CtrlSrvrQueueLoadTracks, CtrlSrvrQueueInsertTracks, @@ -21,6 +29,14 @@ pub enum QueueCommandType { impl QueueCommandType { pub const fn as_message_type(self) -> &'static str { match self { + Self::CtrlSrvrJoinSession => "MESSAGE_TYPE_CTRL_SRVR_JOIN_SESSION", + Self::CtrlSrvrSetPlayerState => "MESSAGE_TYPE_CTRL_SRVR_SET_PLAYER_STATE", + Self::CtrlSrvrSetActiveRenderer => "MESSAGE_TYPE_CTRL_SRVR_SET_ACTIVE_RENDERER", + Self::CtrlSrvrSetVolume => "MESSAGE_TYPE_CTRL_SRVR_SET_VOLUME", + Self::CtrlSrvrSetLoopMode => "MESSAGE_TYPE_CTRL_SRVR_SET_LOOP_MODE", + Self::CtrlSrvrMuteVolume => "MESSAGE_TYPE_CTRL_SRVR_MUTE_VOLUME", + Self::CtrlSrvrSetMaxAudioQuality => "MESSAGE_TYPE_CTRL_SRVR_SET_MAX_AUDIO_QUALITY", + Self::CtrlSrvrAskForRendererState => "MESSAGE_TYPE_CTRL_SRVR_ASK_FOR_RENDERER_STATE", Self::CtrlSrvrQueueAddTracks => "MESSAGE_TYPE_CTRL_SRVR_QUEUE_ADD_TRACKS", Self::CtrlSrvrQueueLoadTracks => "MESSAGE_TYPE_CTRL_SRVR_QUEUE_LOAD_TRACKS", Self::CtrlSrvrQueueInsertTracks => "MESSAGE_TYPE_CTRL_SRVR_QUEUE_INSERT_TRACKS", diff --git a/crates/qconnect-protocol/src/mapper.rs b/crates/qconnect-protocol/src/mapper.rs index f0c8451f4..4b44f5425 100644 --- a/crates/qconnect-protocol/src/mapper.rs +++ b/crates/qconnect-protocol/src/mapper.rs @@ -10,14 +10,17 @@ use uuid::Uuid; use crate::{ build_outbound_envelope, queue_command_proto::{ - AskForQueueStateMessage, AutoplayLoadTracksMessage, AutoplayRemoveTracksMessage, - ClearQueueMessage, PlaybackPositionMessage, QConnectMessage, QConnectMessageType, + AskForQueueStateMessage, AskForRendererStateMessage, AutoplayLoadTracksMessage, + AutoplayRemoveTracksMessage, ClearQueueMessage, DeviceInfoMessage, JoinSessionMessage, + MuteVolumeMessage, PlaybackPositionMessage, QConnectMessage, QConnectMessageType, QConnectMessages, QueueAddTracksMessage, QueueInsertTracksMessage, QueueLoadTracksMessage, QueueRemoveTracksMessage, QueueReorderTracksMessage, QueueVersionRef, RendererFileAudioQualityChangedMessage, RendererMaxAudioQualityChangedMessage, RendererStateMessage, RendererStateUpdatedMessage, RendererVolumeChangedMessage, - RendererVolumeMutedMessage, SetAutoplayModeMessage, SetQueueStateMessage, - SetQueueTrackWithContext, SetShuffleModeMessage, + RendererVolumeMutedMessage, SetActiveRendererMessage, SetAutoplayModeMessage, + SetLoopModeMessage, SetMaxAudioQualityMessage, SetPlayerStateMessage, + SetPlayerStateQueueItemMessage, SetQueueStateMessage, SetQueueTrackWithContext, + SetShuffleModeMessage, SetVolumeMessage, }, OutboundEnvelope, ProtocolError, QueueCommand, QueueCommandType, RendererReport, RendererReportType, @@ -75,6 +78,81 @@ fn map_queue_command(command: &QueueCommand) -> Result Ok(QConnectMessage { + message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrJoinSession as i32), + ctrl_srvr_join_session: Some(JoinSessionMessage { + session_uuid: optional_uuid_bytes( + &command.payload, + &["session_uuid", "sessionUuid"], + )?, + device_info: parse_device_info(&command.payload)?, + }), + ..Default::default() + }), + QueueCommandType::CtrlSrvrSetPlayerState => Ok(QConnectMessage { + message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrSetPlayerState as i32), + ctrl_srvr_set_player_state: Some(SetPlayerStateMessage { + playing_state: optional_i32_any( + &command.payload, + &["playing_state", "playingState"], + )?, + current_position: optional_i32_any( + &command.payload, + &["current_position", "currentPosition"], + )?, + current_queue_item: parse_set_player_state_queue_item(&command.payload)?, + }), + ..Default::default() + }), + QueueCommandType::CtrlSrvrSetActiveRenderer => Ok(QConnectMessage { + message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrSetActiveRenderer as i32), + ctrl_srvr_set_active_renderer: Some(SetActiveRendererMessage { + renderer_id: optional_i32_any(&command.payload, &["renderer_id", "rendererId"])?, + }), + ..Default::default() + }), + QueueCommandType::CtrlSrvrSetVolume => Ok(QConnectMessage { + message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrSetVolume as i32), + ctrl_srvr_set_volume: Some(SetVolumeMessage { + renderer_id: optional_i32_any(&command.payload, &["renderer_id", "rendererId"])?, + volume: optional_i32_any(&command.payload, &["volume"])?, + volume_delta: optional_i32_any(&command.payload, &["volume_delta", "volumeDelta"])?, + }), + ..Default::default() + }), + QueueCommandType::CtrlSrvrSetLoopMode => Ok(QConnectMessage { + message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrSetLoopMode as i32), + ctrl_srvr_set_loop_mode: Some(SetLoopModeMessage { + loop_mode: optional_i32_any(&command.payload, &["loop_mode", "loopMode"])?, + }), + ..Default::default() + }), + QueueCommandType::CtrlSrvrMuteVolume => Ok(QConnectMessage { + message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrMuteVolume as i32), + ctrl_srvr_mute_volume: Some(MuteVolumeMessage { + renderer_id: optional_i32_any(&command.payload, &["renderer_id", "rendererId"])?, + value: Some(optional_bool_any(&command.payload, &["value"], false)), + }), + ..Default::default() + }), + QueueCommandType::CtrlSrvrSetMaxAudioQuality => Ok(QConnectMessage { + message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrSetMaxAudioQuality as i32), + ctrl_srvr_set_max_audio_quality: Some(SetMaxAudioQualityMessage { + renderer_id: optional_i32_any(&command.payload, &["renderer_id", "rendererId"])?, + max_audio_quality: optional_i32_any( + &command.payload, + &["max_audio_quality", "maxAudioQuality"], + )?, + }), + ..Default::default() + }), + QueueCommandType::CtrlSrvrAskForRendererState => Ok(QConnectMessage { + message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrAskForRendererState as i32), + ctrl_srvr_ask_for_renderer_state: Some(AskForRendererStateMessage { + renderer_id: optional_i32_any(&command.payload, &["renderer_id", "rendererId"])?, + }), + ..Default::default() + }), QueueCommandType::CtrlSrvrQueueAddTracks => { let track_ids = required_i32_list(&command.payload, "track_ids")?; let shuffle_seed = optional_i32(&command.payload, "shuffle_seed")?; @@ -530,10 +608,136 @@ fn optional_i32(payload: &Value, key: &str) -> Result, ProtocolError } } +fn optional_i32_any(payload: &Value, keys: &[&str]) -> Result, ProtocolError> { + for key in keys { + if payload.get(*key).is_some() { + return optional_i32(payload, key); + } + } + + Ok(None) +} + fn optional_bool(payload: &Value, key: &str, default: bool) -> bool { payload.get(key).and_then(Value::as_bool).unwrap_or(default) } +fn optional_bool_any(payload: &Value, keys: &[&str], default: bool) -> bool { + for key in keys { + let Some(value) = payload.get(*key) else { + continue; + }; + + if let Some(as_bool) = value.as_bool() { + return as_bool; + } + if let Some(as_int) = value.as_i64() { + return as_int != 0; + } + if let Some(as_uint) = value.as_u64() { + return as_uint != 0; + } + } + + default +} + +fn optional_string_any(payload: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| payload.get(*key).and_then(Value::as_str)) + .map(ToString::to_string) +} + +fn parse_queue_version_ref(payload: &Value, key: &str) -> Option { + let version = payload.get(key)?; + let major = version + .get("major") + .and_then(Value::as_u64) + .and_then(|value| i32::try_from(value).ok())?; + let minor = version + .get("minor") + .and_then(Value::as_u64) + .and_then(|value| i32::try_from(value).ok())?; + + Some(QueueVersionRef { + major: Some(major), + minor: Some(minor), + }) +} + +fn parse_device_info(payload: &Value) -> Result, ProtocolError> { + let nested = payload + .get("device_info") + .or_else(|| payload.get("deviceInfo")); + let source = match nested { + Some(value) => value, + None => payload, + }; + + if !source.is_object() { + return Err(ProtocolError::InvalidPayload( + "field 'device_info' must be an object".to_string(), + )); + } + + let device_uuid = optional_uuid_bytes(source, &["device_uuid", "deviceUuid", "uuid"])?; + let friendly_name = optional_string_any(source, &["friendly_name", "friendlyName"]); + let brand = optional_string_any(source, &["brand"]); + let model = optional_string_any(source, &["model"]); + let serial_number = optional_string_any(source, &["serial_number", "serialNumber"]); + let device_type = optional_i32_any(source, &["device_type", "deviceType"])?; + let software_version = optional_string_any(source, &["software_version", "softwareVersion"]); + + if device_uuid.is_none() + && friendly_name.is_none() + && brand.is_none() + && model.is_none() + && serial_number.is_none() + && device_type.is_none() + && software_version.is_none() + { + return Ok(None); + } + + Ok(Some(DeviceInfoMessage { + device_uuid, + friendly_name, + brand, + model, + serial_number, + device_type, + software_version, + })) +} + +fn parse_set_player_state_queue_item( + payload: &Value, +) -> Result, ProtocolError> { + let nested = payload + .get("current_queue_item") + .or_else(|| payload.get("currentQueueItem")); + let source = match nested { + Some(value) => value, + None => payload, + }; + + if !source.is_object() { + return Err(ProtocolError::InvalidPayload( + "field 'current_queue_item' must be an object".to_string(), + )); + } + + let queue_version = parse_queue_version_ref(source, "queue_version") + .or_else(|| parse_queue_version_ref(source, "queueVersion")); + let id = optional_i32_any(source, &["id", "queue_item_id", "queueItemId"])?; + + if queue_version.is_none() && id.is_none() { + return Ok(None); + } + + Ok(Some(SetPlayerStateQueueItemMessage { queue_version, id })) +} + fn parse_uuid(value: &str) -> Result, ProtocolError> { let parsed = Uuid::parse_str(value) .map_err(|err| ProtocolError::InvalidUuid(format!("'{value}' parse error: {err}")))?; @@ -615,6 +819,94 @@ mod tests { assert!(!payload.is_empty()); } + #[test] + fn encodes_join_session_message_with_device_info() { + let command = QueueCommand::new( + QueueCommandType::CtrlSrvrJoinSession, + "a043f34c-15f5-44bb-a5ad-06ce4e0c009d", + QueueVersion::new(1, 0), + json!({ + "session_uuid": "a3f7be0f-3854-44c6-9194-c93cedec322a", + "device_info": { + "device_uuid": "3c2da4ec-f4b9-4600-9177-7f8a44ce69f4", + "friendly_name": "QBZ Desktop", + "brand": "QBZ", + "model": "Linux", + "serial_number": "qbz-dev-1", + "device_type": 6, + "software_version": "0.1.0" + } + }), + ); + + let payload = encode_queue_command_batch(&command).expect("batch payload"); + let decoded = QConnectMessages::decode(payload.as_slice()).expect("decode batch"); + assert_eq!(decoded.messages.len(), 1); + + let message = &decoded.messages[0]; + assert_eq!( + message.message_type, + Some(QConnectMessageType::MessageTypeCtrlSrvrJoinSession as i32) + ); + let join = message + .ctrl_srvr_join_session + .as_ref() + .expect("join payload"); + assert!(join.session_uuid.is_some()); + let device = join.device_info.as_ref().expect("device_info payload"); + assert_eq!(device.friendly_name.as_deref(), Some("QBZ Desktop")); + assert_eq!(device.device_type, Some(6)); + } + + #[test] + fn encodes_set_player_state_with_current_queue_item() { + let command = QueueCommand::new( + QueueCommandType::CtrlSrvrSetPlayerState, + "132db70b-d293-4576-bfd6-747c40ab764f", + QueueVersion::new(5, 3), + json!({ + "playing_state": 2, + "current_position": 42123, + "current_queue_item": { + "queue_version": { + "major": 5, + "minor": 3 + }, + "id": 901 + } + }), + ); + + let payload = encode_queue_command_batch(&command).expect("batch payload"); + let decoded = QConnectMessages::decode(payload.as_slice()).expect("decode batch"); + assert_eq!(decoded.messages.len(), 1); + + let message = &decoded.messages[0]; + assert_eq!( + message.message_type, + Some(QConnectMessageType::MessageTypeCtrlSrvrSetPlayerState as i32) + ); + let player_state = message + .ctrl_srvr_set_player_state + .as_ref() + .expect("player state payload"); + assert_eq!(player_state.playing_state, Some(2)); + assert_eq!(player_state.current_position, Some(42_123)); + let queue_item = player_state + .current_queue_item + .as_ref() + .expect("current queue item payload"); + assert_eq!(queue_item.id, Some(901)); + assert_eq!( + queue_item.queue_version.as_ref().and_then(|v| v.major), + Some(5) + ); + assert_eq!( + queue_item.queue_version.as_ref().and_then(|v| v.minor), + Some(3) + ); + } + #[test] fn encodes_renderer_state_updated_report_with_queue_version() { let report = RendererReport::new( diff --git a/crates/qconnect-protocol/src/queue_command_proto.rs b/crates/qconnect-protocol/src/queue_command_proto.rs index 8f8d72e96..a4675c7b0 100644 --- a/crates/qconnect-protocol/src/queue_command_proto.rs +++ b/crates/qconnect-protocol/src/queue_command_proto.rs @@ -17,6 +17,10 @@ pub enum QConnectMessageType { MessageTypeSrvrRndrSetLoopMode = 45, MessageTypeSrvrRndrSetShuffleMode = 46, MessageTypeSrvrRndrMuteVolume = 47, + MessageTypeCtrlSrvrJoinSession = 61, + MessageTypeCtrlSrvrSetPlayerState = 62, + MessageTypeCtrlSrvrSetActiveRenderer = 63, + MessageTypeCtrlSrvrSetVolume = 64, MessageTypeCtrlSrvrClearQueue = 65, MessageTypeCtrlSrvrQueueLoadTracks = 66, MessageTypeCtrlSrvrQueueInsertTracks = 67, @@ -24,8 +28,12 @@ pub enum QConnectMessageType { MessageTypeCtrlSrvrQueueRemoveTracks = 69, MessageTypeCtrlSrvrQueueReorderTracks = 70, MessageTypeCtrlSrvrSetShuffleMode = 71, + MessageTypeCtrlSrvrSetLoopMode = 72, + MessageTypeCtrlSrvrMuteVolume = 73, + MessageTypeCtrlSrvrSetMaxAudioQuality = 74, MessageTypeCtrlSrvrSetQueueState = 75, MessageTypeCtrlSrvrAskForQueueState = 76, + MessageTypeCtrlSrvrAskForRendererState = 77, MessageTypeCtrlSrvrSetAutoplayMode = 78, MessageTypeCtrlSrvrAutoplayLoadTracks = 79, MessageTypeCtrlSrvrAutoplayRemoveTracks = 80, @@ -86,6 +94,94 @@ pub struct SetQueueTrackWithContext { pub context_uuid: Option>, } +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DeviceInfoMessage { + #[prost(bytes = "vec", optional, tag = "1")] + pub device_uuid: Option>, + #[prost(string, optional, tag = "2")] + pub friendly_name: Option, + #[prost(string, optional, tag = "3")] + pub brand: Option, + #[prost(string, optional, tag = "4")] + pub model: Option, + #[prost(string, optional, tag = "5")] + pub serial_number: Option, + #[prost(int32, optional, tag = "6")] + pub device_type: Option, + #[prost(string, optional, tag = "8")] + pub software_version: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct JoinSessionMessage { + #[prost(bytes = "vec", optional, tag = "1")] + pub session_uuid: Option>, + #[prost(message, optional, tag = "2")] + pub device_info: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetPlayerStateQueueItemMessage { + #[prost(message, optional, tag = "1")] + pub queue_version: Option, + #[prost(int32, optional, tag = "2")] + pub id: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetPlayerStateMessage { + #[prost(int32, optional, tag = "1")] + pub playing_state: Option, + #[prost(int32, optional, tag = "2")] + pub current_position: Option, + #[prost(message, optional, tag = "3")] + pub current_queue_item: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetActiveRendererMessage { + #[prost(int32, optional, tag = "1")] + pub renderer_id: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetVolumeMessage { + #[prost(int32, optional, tag = "1")] + pub renderer_id: Option, + #[prost(int32, optional, tag = "2")] + pub volume: Option, + #[prost(int32, optional, tag = "3")] + pub volume_delta: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetLoopModeMessage { + #[prost(int32, optional, tag = "1")] + pub loop_mode: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct MuteVolumeMessage { + #[prost(int32, optional, tag = "1")] + pub renderer_id: Option, + #[prost(bool, optional, tag = "2")] + pub value: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetMaxAudioQualityMessage { + #[prost(int32, optional, tag = "1")] + pub renderer_id: Option, + #[prost(int32, optional, tag = "2")] + pub max_audio_quality: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AskForRendererStateMessage { + #[prost(int32, optional, tag = "1")] + pub renderer_id: Option, +} + #[derive(Clone, PartialEq, ::prost::Message)] pub struct ClearQueueMessage { #[prost(message, optional, tag = "1")] @@ -630,6 +726,14 @@ pub struct QConnectMessage { pub srvr_rndr_set_shuffle_mode: Option, #[prost(message, optional, tag = "47")] pub srvr_rndr_mute_volume: Option, + #[prost(message, optional, tag = "61")] + pub ctrl_srvr_join_session: Option, + #[prost(message, optional, tag = "62")] + pub ctrl_srvr_set_player_state: Option, + #[prost(message, optional, tag = "63")] + pub ctrl_srvr_set_active_renderer: Option, + #[prost(message, optional, tag = "64")] + pub ctrl_srvr_set_volume: Option, #[prost(message, optional, tag = "65")] pub ctrl_srvr_clear_queue: Option, #[prost(message, optional, tag = "66")] @@ -644,10 +748,18 @@ pub struct QConnectMessage { pub ctrl_srvr_queue_reorder_tracks: Option, #[prost(message, optional, tag = "71")] pub ctrl_srvr_set_shuffle_mode: Option, + #[prost(message, optional, tag = "72")] + pub ctrl_srvr_set_loop_mode: Option, + #[prost(message, optional, tag = "73")] + pub ctrl_srvr_mute_volume: Option, + #[prost(message, optional, tag = "74")] + pub ctrl_srvr_set_max_audio_quality: Option, #[prost(message, optional, tag = "75")] pub ctrl_srvr_set_queue_state: Option, #[prost(message, optional, tag = "76")] pub ctrl_srvr_ask_for_queue_state: Option, + #[prost(message, optional, tag = "77")] + pub ctrl_srvr_ask_for_renderer_state: Option, #[prost(message, optional, tag = "78")] pub ctrl_srvr_set_autoplay_mode: Option, #[prost(message, optional, tag = "79")]