Initial commit: QobuzD - Qobuz Connect renderer for Linux

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>
This commit is contained in:
joren
2026-03-31 20:38:54 +02:00
commit 3a0d6e0240
18 changed files with 7168 additions and 0 deletions

174
PROTOCOL.md Normal file
View File

@@ -0,0 +1,174 @@
# 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