Rust-based QConnect renderer with: - QConnect WebSocket protocol (hand-rolled protobuf) - Audio playback via Symphonia + cpal - Play, pause, resume, volume, skip support - Correct BufferState/PlayingState enum values per proto spec - Server-driven queue management (no local queue) - Periodic position reporting for track-end detection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
175 lines
5.2 KiB
Markdown
175 lines
5.2 KiB
Markdown
# QConnect Protocol Reference
|
|
|
|
Reverse-engineered from Qobuz Android app (JADX decompilation) and Burp traffic analysis.
|
|
|
|
## Transport Layer
|
|
|
|
WebSocket binary frames with custom framing:
|
|
- Frame = `type_byte` + `varint(body_length)` + `body`
|
|
- Frame type 1 = Auth (field 1=msg_id, field 3=jwt)
|
|
- Frame type 2 = Subscribe (field 1=msg_id, field 3=1)
|
|
- Frame type 6 = Data payload (field 1=msg_id, field 2=timestamp, field 3=1, field 5=\x02, field 7=qconnect_container)
|
|
|
|
## QConnect Container (inside frame body field 7)
|
|
|
|
- Field 3 = serialized QConnect Message
|
|
|
|
## QConnect Message
|
|
|
|
```protobuf
|
|
message Message {
|
|
MessageType message_type = 1; // field number of payload == message_type value
|
|
oneof payload {
|
|
// Renderer -> Server
|
|
Renderer.JoinSessionMessage rndr_srvr_join_session = 21;
|
|
Renderer.StateUpdatedMessage rndr_srvr_state_updated = 23;
|
|
Renderer.VolumeChangedMessage rndr_srvr_volume_changed = 25;
|
|
|
|
// Server -> Renderer
|
|
Renderer.SetStateMessage srvr_rndr_set_state = 41;
|
|
Renderer.SetVolumeMessage srvr_rndr_set_volume = 42;
|
|
Renderer.SetActiveMessage srvr_rndr_set_active = 43;
|
|
Renderer.SetMaxAudioQualityMsg srvr_rndr_set_max_quality = 44;
|
|
Renderer.SetLoopModeMessage srvr_rndr_set_loop_mode = 45;
|
|
Renderer.SetShuffleModeMessage srvr_rndr_set_shuffle_mode = 46;
|
|
Renderer.MuteVolumeMessage srvr_rndr_mute_volume = 47;
|
|
|
|
// Controller -> Server
|
|
Controller.JoinSessionMessage ctrl_srvr_join_session = 61;
|
|
Controller.SetPlayerStateMsg ctrl_srvr_set_player_state = 62;
|
|
Controller.QueueLoadTracksMsg ctrl_srvr_queue_load_tracks= 66;
|
|
|
|
// Server -> Controller
|
|
// SessionStateMessage = 81;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Message Definitions
|
|
|
|
### Renderer.SetStateMessage (41 - SRVR_RNDR_SET_STATE)
|
|
```protobuf
|
|
message SetStateMessage {
|
|
PlayingState playing_state = 1; // enum: 0=UNKNOWN, 1=STOPPED, 2=PLAYING, 3=PAUSED
|
|
int32 current_position = 2; // playback position (seconds)
|
|
QueueVersion queue_version = 3; // sub-message (field 1 = major version)
|
|
QueueTrackWithContext current_track = 4;
|
|
QueueTrackWithContext next_track = 5;
|
|
}
|
|
```
|
|
|
|
### Renderer.SetVolumeMessage (42 - SRVR_RNDR_SET_VOLUME)
|
|
```protobuf
|
|
message SetVolumeMessage {
|
|
int32 volume = 1; // absolute volume (0-100)
|
|
int32 volume_delta = 2; // relative change
|
|
}
|
|
```
|
|
|
|
### Renderer.SetActiveMessage (43 - SRVR_RNDR_SET_ACTIVE)
|
|
```protobuf
|
|
message SetActiveMessage {
|
|
bool active = 1;
|
|
}
|
|
```
|
|
|
|
### Common.QueueTrackWithContext
|
|
```protobuf
|
|
message QueueTrackWithContext {
|
|
int32 queue_item_id = 1;
|
|
int32 track_id = 2;
|
|
bytes context_uuid = 3; // optional
|
|
}
|
|
```
|
|
|
|
### Common.PlaybackPosition
|
|
```protobuf
|
|
message PlaybackPosition {
|
|
fixed64 timestamp = 1; // epoch millis
|
|
int32 value = 2; // position in seconds
|
|
}
|
|
```
|
|
|
|
### RendererState (sent by renderer to report its state)
|
|
```protobuf
|
|
message RendererState {
|
|
PlayingState playing_state = 1;
|
|
BufferState buffer_state = 2; // enum: 0=UNKNOWN, 1=BUFFERING, 2=OK, 3=ERROR, 4=UNDERRUN
|
|
PlaybackPosition current_position = 3;
|
|
int32 duration = 4; // seconds
|
|
QueueVersion queue_version = 5; // sub-message (field 1 = major)
|
|
int32 current_queue_item_id = 6;
|
|
int32 next_queue_item_id = 7;
|
|
}
|
|
```
|
|
|
|
### Renderer.JoinSessionMessage (21 - RNDR_SRVR_JOIN_SESSION)
|
|
```protobuf
|
|
message JoinSessionMessage {
|
|
bytes session_uuid = 1;
|
|
DeviceInfo device_info = 2;
|
|
RendererState initial_state = 4;
|
|
bool is_active = 5;
|
|
}
|
|
```
|
|
|
|
### Renderer.StateUpdatedMessage (23 - RNDR_SRVR_STATE_UPDATED)
|
|
```protobuf
|
|
message StateUpdatedMessage {
|
|
RendererState state = 1;
|
|
}
|
|
```
|
|
|
|
### Renderer.VolumeChangedMessage (25 - RNDR_SRVR_VOLUME_CHANGED)
|
|
```protobuf
|
|
message VolumeChangedMessage {
|
|
int32 volume = 1;
|
|
}
|
|
```
|
|
|
|
### Controller.JoinSessionMessage (61 - CTRL_SRVR_JOIN_SESSION)
|
|
```protobuf
|
|
message JoinSessionMessage {
|
|
DeviceInfo device_info = 2;
|
|
}
|
|
```
|
|
|
|
### Common.DeviceInfo
|
|
```protobuf
|
|
message DeviceInfo {
|
|
bytes device_uuid = 1;
|
|
string friendly_name = 2;
|
|
string brand = 3;
|
|
string model = 4;
|
|
string serial_number = 5;
|
|
DeviceType type = 6; // enum: 5=COMPUTER
|
|
Capabilities capabilities = 7;
|
|
string software_version = 8;
|
|
}
|
|
```
|
|
|
|
### Common.Capabilities
|
|
```protobuf
|
|
message Capabilities {
|
|
int32 audio_quality = 1;
|
|
int32 field_2 = 2; // observed value: 4
|
|
int32 field_3 = 3; // observed value: 2
|
|
}
|
|
```
|
|
|
|
## PlayingState Enum
|
|
- 0 = UNKNOWN (idle sync, ignore)
|
|
- 1 = STOPPED
|
|
- 2 = PLAYING
|
|
- 3 = PAUSED
|
|
|
|
## Connection Flow
|
|
1. Get JWT + WS endpoint via `GET /api.json/0.2/qws/getToken`
|
|
2. Open ctrl WebSocket, send Auth (frame type 1) + Subscribe (frame type 2) + CTRL_SRVR_JOIN_SESSION (61)
|
|
3. Receive SRVR_CTRL_SESSION_STATE (81) containing session_uuid
|
|
4. Open renderer WebSocket, send Auth + Subscribe + RNDR_SRVR_JOIN_SESSION (21) with session_uuid
|
|
5. Receive SET_ACTIVE (43) confirming join
|
|
6. Enter main loop: handle SET_STATE (41), SET_VOLUME (42), etc.
|
|
7. Report state changes via STATE_UPDATED (23) and VOLUME_CHANGED (25)
|
|
8. Send heartbeat state updates every 5 seconds
|