Compare commits
16 Commits
fix/seek-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aadc62d5ae | ||
| 4af07dbb32 | |||
| 233045510a | |||
|
|
5bf77d513b | ||
| ac76c36f32 | |||
|
|
44a2494c73 | ||
|
|
778f5fc69e | ||
|
|
a26db5cf96 | ||
|
|
20d5ecf231 | ||
|
|
c3cad15719 | ||
|
|
7b882a727a | ||
|
|
bacb40af58 | ||
|
|
4c19691b75 | ||
|
|
749b0c1aaf | ||
|
|
bb362686b4 | ||
|
|
6296acc6dd |
66
Cargo.lock
generated
66
Cargo.lock
generated
@@ -196,6 +196,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-padding"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.2"
|
||||||
@@ -220,6 +229,15 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbc"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.57"
|
version = "1.2.57"
|
||||||
@@ -750,6 +768,15 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hkdf"
|
||||||
|
version = "0.12.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||||
|
dependencies = [
|
||||||
|
"hmac",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hmac"
|
name = "hmac"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -1009,6 +1036,7 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"block-padding",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1574,22 +1602,50 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "qobuzd"
|
name = "qobuzd"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
"cbc",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"cpal",
|
"cpal",
|
||||||
|
"ctr",
|
||||||
"directories",
|
"directories",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"hkdf",
|
||||||
"hmac",
|
"hmac",
|
||||||
"keyring",
|
"keyring",
|
||||||
"md-5",
|
"md-5",
|
||||||
|
"prost",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"rand_chacha 0.3.1",
|
"rand_chacha 0.3.1",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -1900,7 +1956,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1961,9 +2017,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.28"
|
version = "0.1.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -2347,10 +2403,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -33,9 +33,14 @@ sha1 = "0.10"
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
tokio-tungstenite = { version = "0.24", features = ["native-tls", "native-tls-vendored"] }
|
tokio-tungstenite = { version = "0.24", features = ["native-tls", "native-tls-vendored"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
prost = "0.13"
|
||||||
symphonia = { version = "0.5", features = ["flac", "pcm", "mp3", "aac", "ogg", "wav"] }
|
symphonia = { version = "0.5", features = ["flac", "pcm", "mp3", "aac", "ogg", "wav"] }
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
rubato = "0.15"
|
rubato = "0.15"
|
||||||
|
aes = "0.8"
|
||||||
|
cbc = "0.1"
|
||||||
|
ctr = "0.9"
|
||||||
|
hkdf = "0.12"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
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
|
|
||||||
249
src/api.rs
249
src/api.rs
@@ -2,8 +2,98 @@ use crate::config::Config;
|
|||||||
use crate::crypto;
|
use crate::crypto;
|
||||||
use crate::error::{QobuzError, Result};
|
use crate::error::{QobuzError, Result};
|
||||||
use crate::types::*;
|
use crate::types::*;
|
||||||
|
use aes::Aes128;
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
use cbc::cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit};
|
||||||
|
use hkdf::Hkdf;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
use sha2::Sha256;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
type Aes128CbcDec = cbc::Decryptor<Aes128>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TrackStream {
|
||||||
|
DirectUrl {
|
||||||
|
url: String,
|
||||||
|
sampling_rate_hz: Option<u32>,
|
||||||
|
bit_depth: Option<u32>,
|
||||||
|
channels: Option<u32>,
|
||||||
|
},
|
||||||
|
Segmented {
|
||||||
|
url_template: String,
|
||||||
|
n_segments: u32,
|
||||||
|
encryption_key_hex: Option<String>,
|
||||||
|
sampling_rate_hz: Option<u32>,
|
||||||
|
bit_depth: Option<u32>,
|
||||||
|
channels: Option<u32>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn b64url_decode(s: &str) -> Result<Vec<u8>> {
|
||||||
|
URL_SAFE_NO_PAD
|
||||||
|
.decode(s.trim_end_matches('='))
|
||||||
|
.map_err(|e| QobuzError::CryptoError(format!("base64url decode failed: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_track_key_hex(
|
||||||
|
session_infos: &str,
|
||||||
|
app_secret_hex: &str,
|
||||||
|
key_field: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let infos_parts: Vec<&str> = session_infos.splitn(2, '.').collect();
|
||||||
|
if infos_parts.len() != 2 {
|
||||||
|
return Err(QobuzError::CryptoError(format!(
|
||||||
|
"invalid session infos format: {}",
|
||||||
|
session_infos
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let salt = b64url_decode(infos_parts[0])?;
|
||||||
|
let info = b64url_decode(infos_parts[1])?;
|
||||||
|
let ikm = hex::decode(app_secret_hex)
|
||||||
|
.map_err(|e| QobuzError::CryptoError(format!("invalid app secret hex: {}", e)))?;
|
||||||
|
|
||||||
|
let hk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
|
||||||
|
let mut kek = [0u8; 16];
|
||||||
|
hk.expand(&info, &mut kek)
|
||||||
|
.map_err(|e| QobuzError::CryptoError(format!("HKDF expand failed: {e:?}")))?;
|
||||||
|
|
||||||
|
let key_parts: Vec<&str> = key_field.splitn(3, '.').collect();
|
||||||
|
if key_parts.len() != 3 || key_parts[0] != "qbz-1" {
|
||||||
|
return Err(QobuzError::CryptoError(format!(
|
||||||
|
"unexpected key field format: {}",
|
||||||
|
key_field
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ciphertext = b64url_decode(key_parts[1])?;
|
||||||
|
let iv_bytes = b64url_decode(key_parts[2])?;
|
||||||
|
if ciphertext.len() < 16 || iv_bytes.len() < 16 {
|
||||||
|
return Err(QobuzError::CryptoError(format!(
|
||||||
|
"invalid key field lengths: ciphertext={} iv={}",
|
||||||
|
ciphertext.len(),
|
||||||
|
iv_bytes.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buf = ciphertext;
|
||||||
|
let iv: [u8; 16] = iv_bytes[..16]
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| QobuzError::CryptoError("invalid IV length".to_string()))?;
|
||||||
|
let decrypted = Aes128CbcDec::new(&kek.into(), &iv.into())
|
||||||
|
.decrypt_padded_mut::<NoPadding>(&mut buf)
|
||||||
|
.map_err(|e| QobuzError::CryptoError(format!("AES-CBC decrypt failed: {e:?}")))?;
|
||||||
|
|
||||||
|
if decrypted.len() < 16 {
|
||||||
|
return Err(QobuzError::CryptoError(
|
||||||
|
"decrypted track key too short".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(hex::encode(&decrypted[..16]))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct QobuzApi {
|
pub struct QobuzApi {
|
||||||
@@ -311,7 +401,118 @@ impl QobuzApi {
|
|||||||
Ok(album)
|
Ok(album)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_track_url(
|
async fn start_playback_session(&self, access_token: &str) -> Result<PlaybackSession> {
|
||||||
|
let timestamp = self.get_timestamp();
|
||||||
|
let signature =
|
||||||
|
crypto::generate_request_signature("session/start", &[("profile", "qbz-1")], timestamp);
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api.json/0.2/session/start?app_id={}&request_ts={}&request_sig={}",
|
||||||
|
self.base_url, self.app_id, timestamp, signature
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.headers(self.build_auth_headers(Some(access_token)))
|
||||||
|
.form(&[("profile", "qbz-1")])
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(QobuzError::ApiError(format!(
|
||||||
|
"Failed to start playback session: {} - {}",
|
||||||
|
status, body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let session: PlaybackSession = response.json().await?;
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_track_stream_mobile(
|
||||||
|
&self,
|
||||||
|
access_token: &str,
|
||||||
|
track_id: &str,
|
||||||
|
format_id: u32,
|
||||||
|
) -> Result<TrackStream> {
|
||||||
|
let session = self.start_playback_session(access_token).await?;
|
||||||
|
|
||||||
|
let timestamp = self.get_timestamp();
|
||||||
|
let format_id_str = format_id.to_string();
|
||||||
|
let signature = crypto::generate_request_signature(
|
||||||
|
"file/url",
|
||||||
|
&[
|
||||||
|
("format_id", &format_id_str),
|
||||||
|
("intent", "stream"),
|
||||||
|
("track_id", track_id),
|
||||||
|
],
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api.json/0.2/file/url?app_id={}&track_id={}&format_id={}&intent=stream&request_ts={}&request_sig={}",
|
||||||
|
self.base_url, self.app_id, track_id, format_id, timestamp, signature
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut headers = self.build_auth_headers(Some(access_token));
|
||||||
|
headers.insert(
|
||||||
|
"X-Session-Id",
|
||||||
|
session.session_id.parse().map_err(|e| {
|
||||||
|
QobuzError::ApiError(format!("Invalid X-Session-Id header value: {}", e))
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self.client.get(&url).headers(headers).send().await?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(QobuzError::ApiError(format!(
|
||||||
|
"Failed to get mobile file URL: {} - {}",
|
||||||
|
status, body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file: FileUrlResponse = response.json().await?;
|
||||||
|
|
||||||
|
if let (Some(key_field), Some(infos)) = (file.key.clone(), session.infos.as_deref()) {
|
||||||
|
match derive_track_key_hex(infos, &crypto::APP_SECRET, &key_field) {
|
||||||
|
Ok(unwrapped) => file.key = Some(unwrapped),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to unwrap track key for {}: {}", track_id, e);
|
||||||
|
file.key = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url) = file.url {
|
||||||
|
return Ok(TrackStream::DirectUrl {
|
||||||
|
url,
|
||||||
|
sampling_rate_hz: file.sampling_rate.map(|v| v.round() as u32),
|
||||||
|
bit_depth: file.bit_depth.map(|v| v as u32),
|
||||||
|
channels: Some(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(url_template), Some(n_segments)) = (file.url_template, file.n_segments) {
|
||||||
|
return Ok(TrackStream::Segmented {
|
||||||
|
url_template,
|
||||||
|
n_segments,
|
||||||
|
encryption_key_hex: file.key,
|
||||||
|
sampling_rate_hz: file.sampling_rate.map(|v| v.round() as u32),
|
||||||
|
bit_depth: file.bit_depth.map(|v| v as u32),
|
||||||
|
channels: Some(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(QobuzError::ApiError(
|
||||||
|
"Mobile file/url response did not contain url or url_template".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_track_url_legacy(
|
||||||
&self,
|
&self,
|
||||||
access_token: &str,
|
access_token: &str,
|
||||||
track_id: &str,
|
track_id: &str,
|
||||||
@@ -360,6 +561,52 @@ impl QobuzApi {
|
|||||||
Ok(url_response.url)
|
Ok(url_response.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_track_stream(
|
||||||
|
&self,
|
||||||
|
access_token: &str,
|
||||||
|
track_id: &str,
|
||||||
|
format_id: u32,
|
||||||
|
) -> Result<TrackStream> {
|
||||||
|
match self
|
||||||
|
.get_track_stream_mobile(access_token, track_id, format_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(stream) => Ok(stream),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"Mobile file/url failed for track {} (format {}), falling back to legacy endpoint: {}",
|
||||||
|
track_id, format_id, e
|
||||||
|
);
|
||||||
|
let url = self
|
||||||
|
.get_track_url_legacy(access_token, track_id, format_id)
|
||||||
|
.await?;
|
||||||
|
Ok(TrackStream::DirectUrl {
|
||||||
|
url,
|
||||||
|
sampling_rate_hz: None,
|
||||||
|
bit_depth: None,
|
||||||
|
channels: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_track_url(
|
||||||
|
&self,
|
||||||
|
access_token: &str,
|
||||||
|
track_id: &str,
|
||||||
|
format_id: u32,
|
||||||
|
) -> Result<String> {
|
||||||
|
match self
|
||||||
|
.get_track_stream(access_token, track_id, format_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
TrackStream::DirectUrl { url, .. } => Ok(url),
|
||||||
|
TrackStream::Segmented { .. } => Err(QobuzError::ApiError(
|
||||||
|
"Track uses segmented stream; use get_track_stream instead".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_track(&self, access_token: &str, track_id: &str) -> Result<Track> {
|
pub async fn get_track(&self, access_token: &str, track_id: &str) -> Result<Track> {
|
||||||
let timestamp = self.get_timestamp();
|
let timestamp = self.get_timestamp();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use md5::{Digest, Md5};
|
use md5::{Digest, Md5};
|
||||||
use sha1::Sha1;
|
use sha1::Sha1;
|
||||||
|
|
||||||
const APP_SECRET: &str = "e79f8b9be485692b0e5f9dd895826368";
|
pub const APP_SECRET: &str = "e79f8b9be485692b0e5f9dd895826368";
|
||||||
|
|
||||||
pub fn md5_hash(input: &str) -> String {
|
pub fn md5_hash(input: &str) -> String {
|
||||||
let mut hasher = Md5::new();
|
let mut hasher = Md5::new();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod config;
|
|||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
|
pub mod proto;
|
||||||
pub mod qconnect;
|
pub mod qconnect;
|
||||||
pub mod token;
|
pub mod token;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
86
src/main.rs
86
src/main.rs
@@ -2,7 +2,7 @@ use anyhow::Result;
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{error, info, Level};
|
use tracing::{error, Level};
|
||||||
use tracing_subscriber::FmtSubscriber;
|
use tracing_subscriber::FmtSubscriber;
|
||||||
|
|
||||||
use qobuzd::api::QobuzApi;
|
use qobuzd::api::QobuzApi;
|
||||||
@@ -35,22 +35,6 @@ enum Commands {
|
|||||||
Logout,
|
Logout,
|
||||||
Status,
|
Status,
|
||||||
User,
|
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,
|
Serve,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,61 +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_url(&token, &track_id, format_id).await {
|
|
||||||
Ok(url) => println!("Stream URL: {}", url),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed: {}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Commands::Serve => {
|
Commands::Serve => {
|
||||||
let guard = auth.lock().await;
|
let guard = auth.lock().await;
|
||||||
let token = match guard.get_valid_token().await {
|
let token = match guard.get_valid_token().await {
|
||||||
@@ -204,20 +133,13 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
println!("Starting QobuzD as '{}'...", device_name);
|
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!("QobuzD is running. Select it in the Qobuz app to play music.");
|
||||||
println!("Press Ctrl+C to stop.");
|
println!("Press Ctrl+C to stop.");
|
||||||
|
|
||||||
// Just forward commands to stdout for visibility
|
// QConnect handles commands internally; nothing to poll.
|
||||||
tokio::spawn(async move {
|
let _ = qconnect;
|
||||||
loop {
|
|
||||||
if let Some(cmd) = qconnect.poll_command() {
|
|
||||||
info!("Command received: {:?}", cmd);
|
|
||||||
}
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tokio::signal::ctrl_c().await?;
|
tokio::signal::ctrl_c().await?;
|
||||||
println!("\nStopped.");
|
println!("\nStopped.");
|
||||||
|
|||||||
1056
src/player.rs
1056
src/player.rs
File diff suppressed because it is too large
Load Diff
1009
src/proto.rs
Normal file
1009
src/proto.rs
Normal file
File diff suppressed because it is too large
Load Diff
2211
src/qconnect.rs
2211
src/qconnect.rs
File diff suppressed because it is too large
Load Diff
21
src/types.rs
21
src/types.rs
@@ -251,6 +251,27 @@ pub struct Track {
|
|||||||
pub rights: Option<Rights>,
|
pub rights: Option<Rights>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PlaybackSession {
|
||||||
|
pub session_id: String,
|
||||||
|
pub expires_at: Option<u64>,
|
||||||
|
pub infos: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FileUrlResponse {
|
||||||
|
pub track_id: Option<i64>,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub url_template: Option<String>,
|
||||||
|
pub n_segments: Option<u32>,
|
||||||
|
pub format_id: Option<i32>,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub sampling_rate: Option<f64>,
|
||||||
|
pub bit_depth: Option<i32>,
|
||||||
|
pub key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Playlist {
|
pub struct Playlist {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
|
|||||||
Reference in New Issue
Block a user