Compare commits
6 Commits
feat/persi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aadc62d5ae | ||
| 4af07dbb32 | |||
| 233045510a | |||
|
|
5bf77d513b | ||
| ac76c36f32 | |||
|
|
44a2494c73 |
34
Cargo.lock
generated
34
Cargo.lock
generated
@@ -1602,6 +1602,29 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qobuzd"
|
||||
version = "0.1.0"
|
||||
@@ -1622,6 +1645,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"keyring",
|
||||
"md-5",
|
||||
"prost",
|
||||
"rand 0.8.5",
|
||||
"rand_chacha 0.3.1",
|
||||
"reqwest",
|
||||
@@ -1932,7 +1956,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1993,9 +2017,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -2379,10 +2403,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -33,6 +33,7 @@ sha1 = "0.10"
|
||||
hex = "0.4"
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls", "native-tls-vendored"] }
|
||||
futures-util = "0.3"
|
||||
prost = "0.13"
|
||||
symphonia = { version = "0.5", features = ["flac", "pcm", "mp3", "aac", "ogg", "wav"] }
|
||||
cpal = "0.15"
|
||||
rubato = "0.15"
|
||||
|
||||
172
decode_burp.py
172
decode_burp.py
@@ -1,172 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,145 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package qobuz.connect;
|
||||
|
||||
message DeviceInfo {
|
||||
string device_id = 1;
|
||||
string device_name = 2;
|
||||
string device_type = 3;
|
||||
string firmware_version = 4;
|
||||
string ip_address = 5;
|
||||
int32 port = 6;
|
||||
Capabilities capabilities = 7;
|
||||
}
|
||||
|
||||
message Capabilities {
|
||||
bool supports_video = 1;
|
||||
bool supports_audio = 2;
|
||||
bool supports_image = 3;
|
||||
repeated string supported_formats = 4;
|
||||
}
|
||||
|
||||
message ControlMessage {
|
||||
string message_id = 1;
|
||||
MessageType type = 2;
|
||||
oneof payload {
|
||||
PlayRequest play = 10;
|
||||
PauseRequest pause = 11;
|
||||
StopRequest stop = 12;
|
||||
SeekRequest seek = 13;
|
||||
VolumeRequest volume = 14;
|
||||
GetStatusRequest get_status = 15;
|
||||
LoadRequest load = 16;
|
||||
}
|
||||
}
|
||||
|
||||
enum MessageType {
|
||||
UNKNOWN = 0;
|
||||
PLAY = 1;
|
||||
PAUSE = 2;
|
||||
STOP = 3;
|
||||
SEEK = 4;
|
||||
VOLUME = 5;
|
||||
GET_STATUS = 6;
|
||||
LOAD = 7;
|
||||
STATUS = 100;
|
||||
ERROR = 101;
|
||||
CONNECTED = 102;
|
||||
DISCONNECTED = 103;
|
||||
}
|
||||
|
||||
message PlayRequest {
|
||||
string track_url = 1;
|
||||
int64 position_ms = 2;
|
||||
}
|
||||
|
||||
message PauseRequest {}
|
||||
|
||||
message StopRequest {}
|
||||
|
||||
message SeekRequest {
|
||||
int64 position_ms = 1;
|
||||
}
|
||||
|
||||
message VolumeRequest {
|
||||
int32 volume = 1; // 0-100
|
||||
bool mute = 2;
|
||||
}
|
||||
|
||||
message GetStatusRequest {}
|
||||
|
||||
message LoadRequest {
|
||||
string track_id = 1;
|
||||
string album_id = 2;
|
||||
int32 format_id = 3;
|
||||
int64 position_ms = 4;
|
||||
}
|
||||
|
||||
message ControlResponse {
|
||||
string message_id = 1;
|
||||
MessageType type = 2;
|
||||
bool success = 3;
|
||||
string error_message = 4;
|
||||
oneof payload {
|
||||
StatusResponse status = 10;
|
||||
}
|
||||
}
|
||||
|
||||
message StatusResponse {
|
||||
PlaybackState state = 1;
|
||||
string track_id = 2;
|
||||
string album_id = 3;
|
||||
string track_url = 4;
|
||||
int64 position_ms = 5;
|
||||
int64 duration_ms = 6;
|
||||
int32 volume = 7;
|
||||
bool muted = 8;
|
||||
TrackInfo track_info = 9;
|
||||
}
|
||||
|
||||
enum PlaybackState {
|
||||
IDLE = 0;
|
||||
LOADING = 1;
|
||||
PLAYING = 2;
|
||||
PAUSED = 3;
|
||||
BUFFERING = 4;
|
||||
ERROR = 5;
|
||||
}
|
||||
|
||||
message TrackInfo {
|
||||
string title = 1;
|
||||
string artist = 2;
|
||||
string album = 3;
|
||||
string album_artist = 4;
|
||||
int32 track_number = 5;
|
||||
int32 disc_number = 6;
|
||||
int64 duration_ms = 7;
|
||||
string artwork_url = 8;
|
||||
string format = 9;
|
||||
int32 bit_depth = 10;
|
||||
int32 sample_rate = 11;
|
||||
}
|
||||
|
||||
message LinkRequest {
|
||||
string device_id = 1;
|
||||
string device_name = 2;
|
||||
string device_type = 3;
|
||||
}
|
||||
|
||||
message LinkResponse {
|
||||
bool success = 1;
|
||||
string link_token = 2;
|
||||
string error_message = 3;
|
||||
}
|
||||
|
||||
message StreamRequest {
|
||||
string track_id = 1;
|
||||
int32 format_id = 2;
|
||||
int64 position_ms = 3;
|
||||
}
|
||||
|
||||
message StreamResponse {
|
||||
bool success = 1;
|
||||
string stream_url = 2;
|
||||
string error_message = 3;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod error;
|
||||
pub mod player;
|
||||
pub mod proto;
|
||||
pub mod qconnect;
|
||||
pub mod token;
|
||||
pub mod types;
|
||||
|
||||
113
src/main.rs
113
src/main.rs
@@ -2,10 +2,10 @@ use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info, Level};
|
||||
use tracing::{error, Level};
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
use qobuzd::api::{QobuzApi, TrackStream};
|
||||
use qobuzd::api::QobuzApi;
|
||||
use qobuzd::auth::QobuzAuth;
|
||||
use qobuzd::config::Config;
|
||||
use qobuzd::qconnect::QConnect;
|
||||
@@ -35,22 +35,6 @@ enum Commands {
|
||||
Logout,
|
||||
Status,
|
||||
User,
|
||||
Search {
|
||||
#[arg(short, long)]
|
||||
query: String,
|
||||
#[arg(short, long, default_value = "albums")]
|
||||
search_type: String,
|
||||
},
|
||||
Album {
|
||||
#[arg(short, long)]
|
||||
album_id: String,
|
||||
},
|
||||
Stream {
|
||||
#[arg(short, long)]
|
||||
track_id: String,
|
||||
#[arg(short, long, default_value = "5")]
|
||||
format_id: u32,
|
||||
},
|
||||
Serve,
|
||||
}
|
||||
|
||||
@@ -132,86 +116,6 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Search { query, search_type } => {
|
||||
let guard = auth.lock().await;
|
||||
let token = guard.get_valid_token().await?;
|
||||
drop(guard);
|
||||
let api = QobuzApi::new(&config);
|
||||
match api.search(&token, &query, &search_type, 10, 0).await {
|
||||
Ok(results) => {
|
||||
println!("{}", serde_json::to_string_pretty(&results)?);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Search failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Album { album_id } => {
|
||||
let guard = auth.lock().await;
|
||||
let token = guard.get_valid_token().await?;
|
||||
drop(guard);
|
||||
let api = QobuzApi::new(&config);
|
||||
match api.get_album(&token, &album_id).await {
|
||||
Ok(album) => {
|
||||
println!("Album: {}", album.title);
|
||||
if let Some(artists) = &album.artists {
|
||||
if let Some(a) = artists.first() {
|
||||
println!("Artist: {}", a.name);
|
||||
}
|
||||
}
|
||||
println!("Tracks: {}", album.track_count.unwrap_or(0));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Stream {
|
||||
track_id,
|
||||
format_id,
|
||||
} => {
|
||||
let guard = auth.lock().await;
|
||||
let token = guard.get_valid_token().await?;
|
||||
drop(guard);
|
||||
let api = QobuzApi::new(&config);
|
||||
match api.get_track_stream(&token, &track_id, format_id).await {
|
||||
Ok(TrackStream::DirectUrl { url, .. }) => println!("Stream URL: {}", url),
|
||||
Ok(TrackStream::Segmented {
|
||||
url_template,
|
||||
n_segments,
|
||||
encryption_key_hex,
|
||||
sampling_rate_hz,
|
||||
bit_depth,
|
||||
..
|
||||
}) => {
|
||||
println!("Segmented stream template: {}", url_template);
|
||||
println!("Segments: {}", n_segments);
|
||||
println!(
|
||||
"Encrypted: {}",
|
||||
if encryption_key_hex.is_some() {
|
||||
"yes"
|
||||
} else {
|
||||
"no"
|
||||
}
|
||||
);
|
||||
if let Some(sr) = sampling_rate_hz {
|
||||
println!("Sampling rate: {} Hz", sr);
|
||||
}
|
||||
if let Some(bits) = bit_depth {
|
||||
println!("Bit depth: {}", bits);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Serve => {
|
||||
let guard = auth.lock().await;
|
||||
let token = match guard.get_valid_token().await {
|
||||
@@ -229,20 +133,13 @@ async fn main() -> Result<()> {
|
||||
|
||||
println!("Starting QobuzD as '{}'...", device_name);
|
||||
|
||||
let mut qconnect = QConnect::start(token, device_id, device_name);
|
||||
let qconnect = QConnect::start(token, device_id, device_name);
|
||||
|
||||
println!("QobuzD is running. Select it in the Qobuz app to play music.");
|
||||
println!("Press Ctrl+C to stop.");
|
||||
|
||||
// Just forward commands to stdout for visibility
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if let Some(cmd) = qconnect.poll_command() {
|
||||
info!("Command received: {:?}", cmd);
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
});
|
||||
// QConnect handles commands internally; nothing to poll.
|
||||
let _ = qconnect;
|
||||
|
||||
tokio::signal::ctrl_c().await?;
|
||||
println!("\nStopped.");
|
||||
|
||||
1009
src/proto.rs
Normal file
1009
src/proto.rs
Normal file
File diff suppressed because it is too large
Load Diff
2377
src/qconnect.rs
2377
src/qconnect.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user