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:
172
decode_burp.py
Normal file
172
decode_burp.py
Normal file
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decode QConnect WebSocket messages from Burp capture."""
|
||||
import json, sys
|
||||
|
||||
def decode_varint(data, pos):
|
||||
val = 0; shift = 0
|
||||
while pos < len(data):
|
||||
b = data[pos]; pos += 1
|
||||
val |= (b & 0x7F) << shift
|
||||
if not (b & 0x80): return val, pos
|
||||
shift += 7
|
||||
return val, pos
|
||||
|
||||
def parse_fields(data):
|
||||
fields = []; pos = 0
|
||||
while pos < len(data):
|
||||
if pos >= len(data): break
|
||||
tag, pos = decode_varint(data, pos)
|
||||
fnum = tag >> 3; wt = tag & 7
|
||||
if wt == 0:
|
||||
val, pos = decode_varint(data, pos)
|
||||
fields.append((fnum, wt, val.to_bytes(8, 'little')))
|
||||
elif wt == 1:
|
||||
fields.append((fnum, wt, data[pos:pos+8])); pos += 8
|
||||
elif wt == 2:
|
||||
ln, pos = decode_varint(data, pos)
|
||||
fields.append((fnum, wt, data[pos:pos+ln])); pos += ln
|
||||
elif wt == 5:
|
||||
fields.append((fnum, wt, data[pos:pos+4])); pos += 4
|
||||
else: break
|
||||
return fields
|
||||
|
||||
def get_varint(fields, num):
|
||||
for f, w, d in fields:
|
||||
if f == num and w == 0:
|
||||
return int.from_bytes(d[:8], 'little')
|
||||
return None
|
||||
|
||||
def get_bytes(fields, num):
|
||||
for f, w, d in fields:
|
||||
if f == num and w == 2: return d
|
||||
return None
|
||||
|
||||
def get_fixed32(fields, num):
|
||||
for f, w, d in fields:
|
||||
if f == num and w == 5: return int.from_bytes(d[:4], 'little')
|
||||
return None
|
||||
|
||||
def get_fixed64(fields, num):
|
||||
for f, w, d in fields:
|
||||
if f == num and w == 1: return int.from_bytes(d[:8], 'little')
|
||||
return None
|
||||
|
||||
MSG_NAMES = {
|
||||
1: "ERROR", 21: "RNDR_JOIN", 23: "STATE_UPDATED", 25: "VOLUME_CHANGED",
|
||||
28: "QUALITY_CHANGED", 29: "VOLUME_MUTED", 41: "SET_STATE", 42: "SET_VOLUME",
|
||||
43: "SET_ACTIVE", 44: "SET_QUALITY", 45: "SET_LOOP", 46: "SET_SHUFFLE",
|
||||
47: "MUTE_VOLUME", 61: "CTRL_JOIN", 66: "QUEUE_REQ", 67: "QUEUE_REQ2",
|
||||
75: "QUEUE_UPDATE", 76: "SESSION_SETUP", 77: "ACK_JOIN", 79: "TRACK_LIST",
|
||||
82: "SRV_STATE_ECHO", 83: "DEVICE_LIST", 86: "SRV_VOL_ECHO",
|
||||
87: "SRV_VOL_ECHO2", 90: "SRV_QUEUE_INFO", 91: "SRV_QUEUE_RESP",
|
||||
97: "SRV_ACK", 98: "SRV_ACK2", 99: "SRV_QUALITY_ECHO",
|
||||
100: "SRV_QUALITY_INFO", 103: "SRV_TRACKLIST_RESP"
|
||||
}
|
||||
|
||||
def decode_renderer_state(data):
|
||||
"""Decode RendererState proto"""
|
||||
f = parse_fields(data)
|
||||
ps = get_varint(f, 1)
|
||||
bs = get_varint(f, 2)
|
||||
pos_data = get_bytes(f, 3)
|
||||
dur = get_varint(f, 4)
|
||||
qi = get_varint(f, 6)
|
||||
nqi = get_varint(f, 7)
|
||||
|
||||
pos_ms = None; ts = None
|
||||
if pos_data:
|
||||
pf = parse_fields(pos_data)
|
||||
ts = get_fixed64(pf, 1)
|
||||
pos_ms = get_varint(pf, 2)
|
||||
|
||||
return f"playing={ps} buffer={bs} pos={pos_ms}ms dur={dur}ms qi={qi} nqi={nqi}"
|
||||
|
||||
def decode_set_state(data):
|
||||
"""Decode SET_STATE (type 41) payload"""
|
||||
f = parse_fields(data)
|
||||
ps = get_varint(f, 1)
|
||||
pos = get_varint(f, 2)
|
||||
qv = get_bytes(f, 3)
|
||||
ct = get_bytes(f, 4)
|
||||
nt = get_bytes(f, 5)
|
||||
|
||||
qv_str = ""
|
||||
if qv:
|
||||
qvf = parse_fields(qv)
|
||||
qv_str = f" qver={get_varint(qvf, 1)}.{get_varint(qvf, 2)}"
|
||||
|
||||
ct_str = ""
|
||||
if ct:
|
||||
ctf = parse_fields(ct)
|
||||
ct_str = f" cur_track(qi={get_varint(ctf, 1)} tid={get_fixed32(ctf, 2)})"
|
||||
|
||||
nt_str = ""
|
||||
if nt:
|
||||
ntf = parse_fields(nt)
|
||||
nt_str = f" next_track(qi={get_varint(ntf, 1)} tid={get_fixed32(ntf, 2)})"
|
||||
|
||||
return f"playing_state={'None' if ps is None else ps} pos={pos}{qv_str}{ct_str}{nt_str}"
|
||||
|
||||
def process_message(payload_str, direction):
|
||||
data = payload_str.encode('latin-1')
|
||||
if len(data) < 2: return
|
||||
|
||||
# Decode frame layer
|
||||
pos = 0
|
||||
while pos < len(data):
|
||||
if pos >= len(data): break
|
||||
ft = data[pos]; pos += 1
|
||||
flen, pos = decode_varint(data, pos)
|
||||
if pos + flen > len(data): break
|
||||
fbody = data[pos:pos+flen]; pos += flen
|
||||
|
||||
if ft != 6: continue # only data frames
|
||||
|
||||
# Parse frame body fields
|
||||
ff = parse_fields(fbody)
|
||||
f7 = get_bytes(ff, 7)
|
||||
if not f7: continue
|
||||
|
||||
# Inside f7, field 3 = QConnect message
|
||||
cf = parse_fields(f7)
|
||||
for cfnum, cwt, cdata in cf:
|
||||
if cfnum != 3 or cwt != 2: continue
|
||||
mf = parse_fields(cdata)
|
||||
mt = get_varint(mf, 1)
|
||||
if mt is None: continue
|
||||
|
||||
name = MSG_NAMES.get(mt, f"TYPE_{mt}")
|
||||
payload = get_bytes(mf, mt)
|
||||
|
||||
extra = ""
|
||||
if mt == 23 and payload: # STATE_UPDATED
|
||||
# Unwrap field 1 (RendererState wrapper)
|
||||
sf = parse_fields(payload)
|
||||
state_data = get_bytes(sf, 1)
|
||||
if state_data:
|
||||
extra = " " + decode_renderer_state(state_data)
|
||||
else:
|
||||
extra = " " + decode_renderer_state(payload)
|
||||
elif mt == 41 and payload: # SET_STATE
|
||||
extra = " " + decode_set_state(payload)
|
||||
elif mt == 82 and payload: # SRV_STATE_ECHO
|
||||
sf = parse_fields(payload)
|
||||
state_data = get_bytes(sf, 1)
|
||||
if state_data:
|
||||
extra = " " + decode_renderer_state(state_data)
|
||||
|
||||
arrow = ">>>" if "CLIENT" in direction else "<<<"
|
||||
print(f" {arrow} {name}({mt}){extra}")
|
||||
|
||||
# Read from burp JSON
|
||||
import subprocess
|
||||
# Just process messages passed on stdin
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line: continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
d = msg.get('direction', '')
|
||||
p = msg.get('payload', '')
|
||||
process_message(p, d)
|
||||
except: pass
|
||||
Reference in New Issue
Block a user