feat(qobuz): read-only member-login probe and raw OAuth helpers

Qobuz answers 200 for an account without an active subscription, but the
login parser rejects it (IneligibleUser) before anything can be captured.
Add `exchange_oauth_code` (OAuth step 1, extracted from
`login_with_oauth_code` unchanged), `login_with_token_raw` and
`get_stream_url_raw`, which return status and body verbatim without
parsing or storing a session, and a probe example that signs in through
the same system-browser OAuth flow as the app (or a pasted token), then
prints the redacted login payload, favorites totals, playlists and the
getFileUrl responses at CD and Hi-Res quality. Nothing is written to
Qobuz or to disk.

cc
This commit is contained in:
vicrodh committed 2026-09-02 07:23:06 -06:00
1 parent 87f32bc5e5
commit 89efde792a
2 files changed
+454 -8

No files matched your search

@@ -0,0 +1,369 @@
//! Read-only live probe: what does Qobuz return for an account WITHOUT an
//! active subscription ("Qobuz member")?
//!
//! Answers Q2 of `qbz-nix-docs/offline-mode/tauri-review-2026-06-09/
//! 10-subscription-trial-offline-gating.md` §5.6 against the LIVE API and
//! prints REDACTED JSON so the shapes can be captured into
//! `qbz-nix-docs/qobuz-api/` before the member-mode parser is written:
//!
//! 1. `POST /user/login` raw payload after the OAuth exchange (does it
//! 200? what is in `credential.parameters` / `user.subscription`?)
//! 2. `favorite/getUserFavorites` totals + `getUserPlaylists` (reads?)
//! 3. `/track/getFileUrl` for a catalog track at CD + Hi-Res quality
//! (`sample`? `restrictions`? which format comes back?)
//! 4. optionally the same for a PURCHASED track (`--purchased=<track_id>`)
//!
//! Sign-in is the same system-browser OAuth the app uses (Qobuz retired the
//! email+password login in 2026): the probe binds a loopback listener,
//! prints the sign-in URL (and tries to open it), captures the redirect,
//! exchanges the code. Alternatively paste an existing `user_auth_token`
//! (e.g. the web player's `X-User-Auth-Token` header) to skip the browser:
//!
//! cargo run -p qbz-qobuz --example member_login_probe [--purchased=ID] [track_id...]
//! QBZ_PROBE_TOKEN=... cargo run -p qbz-qobuz --example member_login_probe
//!
//! Nothing is written to disk and nothing is written to Qobuz: login + GETs.
use qbz_models::{Quality, UserSession};
use qbz_qobuz::QobuzClient;
use serde_json::Value;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
struct StderrLogger;
impl log::Log for StderrLogger {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= log::Level::Info
}
fn log(&self, record: &log::Record) {
if self.enabled(record.metadata()) {
eprintln!("[{}] {}", record.level(), record.args());
}
}
fn flush(&self) {}
}
static LOGGER: StderrLogger = StderrLogger;
const OAUTH_TIMEOUT: Duration = Duration::from_secs(180);
/// Devin Townsend - Vampira (Retinal Circus): a plain catalog track that the
/// lyrics probe already used, so the two captures line up.
const DEFAULT_TRACK_IDS: &[u64] = &[29006863];
/// Keys whose string values identify the account or are secrets. Redacted
/// in place before printing; structure and every other field stay verbatim.
const REDACT_KEYS: &[&str] = &[
"user_auth_token",
"email",
"login",
"firstname",
"lastname",
"display_name",
"avatar",
"publicId",
"public_id",
"session_id",
];
fn redact(value: &mut Value) {
match value {
Value::Object(map) => {
for (key, child) in map.iter_mut() {
if REDACT_KEYS.contains(&key.as_str()) {
if let Value::String(s) = child {
if !s.is_empty() {
*s = format!("<redacted:{}>", s.len());
}
}
} else if key == "url" {
// Signed CDN URL: keep scheme + host + path, drop the
// query (it carries the per-request token).
if let Value::String(s) = child {
if let Some((base, _)) = s.split_once('?') {
*s = format!("{base}?<redacted-query>");
}
}
} else {
redact(child);
}
}
}
Value::Array(items) => items.iter_mut().for_each(redact),
_ => {}
}
}
fn print_json(label: &str, status: u16, body: &str) {
println!("\n=== {label} (HTTP {status}) ===");
match serde_json::from_str::<Value>(body) {
Ok(mut json) => {
redact(&mut json);
println!(
"{}",
serde_json::to_string_pretty(&json).unwrap_or_default()
);
}
Err(_) => println!("<non-JSON body, {} bytes>", body.len()),
}
}
/// 48 hex chars from a splitmix64 stream seeded by wall-clock nanos + pid.
/// A probe run by hand does not need a CSPRNG; it needs a path the callback
/// must echo back so a stray request on the port is ignored.
fn gen_nonce() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E37_79B9_7F4A_7C15);
let mut state = nanos ^ ((std::process::id() as u64) << 32);
let mut out = String::with_capacity(48);
for _ in 0..3 {
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
out.push_str(&format!("{z:016x}"));
}
out
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(v);
i += 3;
continue;
}
}
out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] });
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn query_param(query: &str, key: &str) -> Option<String> {
query
.split('&')
.filter_map(|pair| pair.split_once('='))
.find(|(k, _)| *k == key)
.map(|(_, v)| percent_decode(v))
}
/// The authorization code ONLY when the request path carries the nonce
/// (`GET /<nonce>?...`); `code_autorisation` wins over `code`, as in the app.
fn parse_callback(request_line: &str, expected_nonce: &str) -> Option<String> {
let target = request_line.split_whitespace().nth(1)?;
let (path, query) = target.split_once('?')?;
if path.trim_matches('/') != expected_nonce {
return None;
}
query_param(query, "code_autorisation").or_else(|| query_param(query, "code"))
}
async fn capture_oauth_code(listener: TcpListener, expected_nonce: &str) -> Option<String> {
loop {
let (mut stream, _) = listener.accept().await.ok()?;
let mut buf = [0u8; 8192];
let n = stream.read(&mut buf).await.ok()?;
let request = String::from_utf8_lossy(&buf[..n]);
let code = parse_callback(request.lines().next().unwrap_or(""), expected_nonce);
let body = if code.is_some() {
"<html><body style=\"font-family:system-ui;text-align:center;padding:64px\">\
<h2>Probe signed in</h2><p>You can close this tab.</p></body></html>"
} else {
"<html><body style=\"font-family:system-ui;text-align:center;padding:64px\">\
<h2>Waiting for Qobuz...</h2></body></html>"
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
if code.is_some() {
return code;
}
}
}
/// System-browser OAuth, the app's flow: loopback listener + nonce path,
/// sign-in URL, redirect capture, code → token exchange.
async fn oauth_token(client: &QobuzClient) -> Result<String, Box<dyn std::error::Error>> {
let app_id = client.app_id().await?;
let listener = TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
let nonce = gen_nonce();
let redirect = format!("http%3A%2F%2Flocalhost%3A{port}%2F{nonce}");
let url =
format!("https://www.qobuz.com/signin/oauth?ext_app_id={app_id}&redirect_url={redirect}");
eprintln!("[probe] sign in with the account to probe:\n{url}\n");
// Best-effort: open it; if that fails the URL above is the fallback.
#[cfg(target_os = "macos")]
let opener = "open";
#[cfg(not(target_os = "macos"))]
let opener = "xdg-open";
let _ = std::process::Command::new(opener)
.arg(&url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
let code = tokio::time::timeout(OAUTH_TIMEOUT, capture_oauth_code(listener, &nonce))
.await
.map_err(|_| "OAuth login timed out (180 s)")?
.ok_or("OAuth login cancelled or no code received")?;
eprintln!("[probe] code captured, exchanging for a token");
Ok(client.exchange_oauth_code(&code).await?)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Info));
let mut purchased: Option<u64> = None;
let mut ids: Vec<u64> = Vec::new();
for arg in std::env::args().skip(1) {
if let Some(id) = arg.strip_prefix("--purchased=") {
purchased = Some(id.parse()?);
} else if let Ok(id) = arg.parse() {
ids.push(id);
}
}
let track_ids: Vec<u64> = if ids.is_empty() {
DEFAULT_TRACK_IDS.to_vec()
} else {
ids
};
let client = QobuzClient::new()?;
let warm = client.init().await?;
eprintln!("[probe] init done (warm bundle cache: {warm})");
// 1. Token: pasted, or the app's OAuth flow. Then the raw session
// fetch, captured BEFORE the parser gets a chance to reject it.
let token = match std::env::var("QBZ_PROBE_TOKEN") {
Ok(t) if !t.trim().is_empty() => {
eprintln!("[probe] using QBZ_PROBE_TOKEN");
t.trim().to_string()
}
_ => oauth_token(&client).await?,
};
let (status, body) = client.login_with_token_raw(&token).await?;
print_json("POST /user/login (X-User-Auth-Token)", status, &body);
if status != 200 {
eprintln!("[probe] login did not return 200; nothing else to probe");
return Ok(());
}
let json: Value = serde_json::from_str(&body)?;
let user = json.get("user").ok_or("no `user` in login payload")?;
let user_id = user
.get("id")
.and_then(Value::as_u64)
.ok_or("no `user.id` in login payload")?;
let session_token = json
.get("user_auth_token")
.and_then(Value::as_str)
.unwrap_or(&token)
.to_string();
let parameters = user.get("credential").and_then(|c| c.get("parameters"));
let has_parameters = parameters
.and_then(Value::as_object)
.map(|o| !o.is_empty())
.unwrap_or(false);
println!(
"\n[verdict] user_id={user_id} credential.parameters present+non-empty: {has_parameters} \
-> today's parser would say: {}",
if has_parameters {
"eligible"
} else {
"IneligibleUser"
}
);
println!(
"[verdict] user.subscription = {}",
user.get("subscription")
.map(|s| s.to_string())
.unwrap_or_else(|| "<absent>".into())
);
// Hand-built session so the authenticated GETs below can run even when
// the parser would have refused. In-memory only.
client
.set_session(UserSession {
user_auth_token: session_token,
user_id,
email: String::new(),
display_name: String::new(),
subscription_label: String::new(),
subscription_valid_until: None,
country_code: None,
language_code: None,
})
.await;
// 2. Reads: favorites totals per type + playlists.
for fav_type in ["albums", "tracks", "artists", "labels", "awards"] {
match client.get_favorites(fav_type, 1, 0).await {
Ok(v) => println!(
"[favorites] {fav_type}: total={}",
v.get(fav_type)
.and_then(|t| t.get("total"))
.map(|t| t.to_string())
.unwrap_or_else(|| "?".into())
),
Err(e) => println!("[favorites] {fav_type}: ERROR {e}"),
}
}
match client.get_user_playlists().await {
Ok(p) => println!("[playlists] getUserPlaylists: {} entries", p.len()),
Err(e) => println!("[playlists] getUserPlaylists: ERROR {e}"),
}
// 3. Streaming: catalog track(s) at CD and Hi-Res.
for track_id in &track_ids {
for quality in [Quality::Lossless, Quality::UltraHiRes] {
match client.get_stream_url_raw(*track_id, quality).await {
Ok((status, body)) => print_json(
&format!("getFileUrl track={track_id} format_id={}", quality.id()),
status,
&body,
),
Err(e) => println!(
"\n=== getFileUrl track={track_id} format_id={} === ERROR {e}",
quality.id()
),
}
}
}
// 4. Streaming a purchased track, if the caller named one.
if let Some(track_id) = purchased {
for quality in [Quality::Lossless, Quality::UltraHiRes] {
match client.get_stream_url_raw(track_id, quality).await {
Ok((status, body)) => print_json(
&format!(
"getFileUrl PURCHASED track={track_id} format_id={}",
quality.id()
),
status,
&body,
),
Err(e) => println!(
"\n=== getFileUrl PURCHASED track={track_id} format_id={} === ERROR {e}",
quality.id()
),
}
}
}
eprintln!("[probe] done; nothing was written to Qobuz or to disk");
Ok(())
}
+85 -8
View File
@@ -425,6 +425,39 @@ impl QobuzClient {
}
}
/// Raw `POST /user/login` with an OAuth `user_auth_token`: HTTP status
/// and body, verbatim. Same request as [`Self::login_with_token`], but it
/// never parses the payload and never stores a session, so an account
/// the parser rejects today (`IneligibleUser`) can still be captured for
/// the redacted fixtures in `qbz-nix-docs/qobuz-api/`. Probe support
/// only (`examples/member_login_probe.rs`).
pub async fn login_with_token_raw(&self, token: &str) -> Result<(u16, String)> {
use reqwest::header::{HeaderMap, HeaderValue};
let app_id = self.app_id().await?;
let mut headers = HeaderMap::new();
headers.insert(
"X-App-Id",
HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?,
);
headers.insert(
"X-User-Auth-Token",
HeaderValue::from_str(token)
.map_err(|_| ApiError::AuthenticationError("Invalid token format".into()))?,
);
let response = self
.http
.post(endpoints::build_url(endpoints::paths::USER_LOGIN))
.headers(headers)
.header("Content-Type", "text/plain;charset=UTF-8")
.body("extra=partner")
.send()
.await?;
let status = response.status().as_u16();
let body = response.text().await?;
Ok((status, body))
}
/// Check if logged in
pub async fn is_logged_in(&self) -> bool {
self.session.read().await.is_some()
@@ -455,9 +488,11 @@ impl QobuzClient {
/// Exchange an OAuth code for a full user session.
///
/// This implements the new Qobuz OAuth flow:
/// 1. GET /oauth/callback?code=CODE&private_key=KEY → { token }
/// 2. POST /user/login with X-User-Auth-Token: token, body=extra=partner → UserSession
pub async fn login_with_oauth_code(&self, code: &str) -> Result<UserSession> {
/// OAuth step 1 on its own: `GET /oauth/callback?code&private_key&app_id`
/// → the `user_auth_token`. Nothing is stored; `login_with_oauth_code`
/// is this plus the `/user/login` session fetch, and the member-mode
/// probe uses it alone so it can capture that second response raw.
pub async fn exchange_oauth_code(&self, code: &str) -> Result<String> {
use reqwest::header::{HeaderMap, HeaderValue};
let tokens = self.tokens.read().await;
@@ -468,9 +503,8 @@ impl QobuzClient {
let private_key = tokens.private_key.clone().ok_or_else(|| {
ApiError::BundleExtractionError("OAuth private key not available in bundle".to_string())
})?;
let _ = tokens; // drop read lock
drop(tokens);
// Step 1: Exchange code for token
let callback_url = endpoints::build_url(endpoints::paths::OAUTH_CALLBACK);
let mut headers = HeaderMap::new();
headers.insert(
@@ -501,12 +535,23 @@ impl QobuzClient {
}
let callback_json: serde_json::Value = callback_response.json().await?;
let token = callback_json["token"]
callback_json["token"]
.as_str()
.map(str::to_string)
.ok_or_else(|| {
ApiError::ApiResponse("OAuth callback: no token in response".to_string())
})?
.to_string();
})
}
/// 1. GET /oauth/callback?code=CODE&private_key=KEY → { token }
/// 2. POST /user/login with X-User-Auth-Token: token, body=extra=partner → UserSession
pub async fn login_with_oauth_code(&self, code: &str) -> Result<UserSession> {
use reqwest::header::{HeaderMap, HeaderValue};
let app_id = self.app_id().await?;
// Step 1: Exchange code for token
let token = self.exchange_oauth_code(code).await?;
log::info!("[OAuth] Got token, fetching user session via /user/login");
@@ -2462,6 +2507,38 @@ impl QobuzClient {
// === Authenticated endpoints ===
/// Get stream URL for a track (requires auth + signature)
/// Raw `/track/getFileUrl` (`intent=stream`): HTTP status and body,
/// verbatim, no parsing. Probe support only — the typed reader is
/// [`Self::get_stream_url`]. Same signing, same headers, same breaker.
pub async fn get_stream_url_raw(
&self,
track_id: u64,
quality: Quality,
) -> Result<(u16, String)> {
self.forbidden_guard()?;
let url = endpoints::build_url(paths::TRACK_GET_FILE_URL);
let timestamp = get_timestamp();
let secret = self.secret().await?;
let signature = sign_get_file_url(track_id, quality.id(), timestamp, &secret);
let response = self
.http()?
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&[
("track_id", track_id.to_string()),
("format_id", quality.id().to_string()),
("intent", "stream".to_string()),
("request_ts", timestamp.to_string()),
("request_sig", signature),
])
.send()
.await?;
let status = response.status();
self.note_forbidden_status(status);
let body = response.text().await?;
Ok((status.as_u16(), body))
}
pub async fn get_stream_url(&self, track_id: u64, quality: Quality) -> Result<StreamUrl> {
// Back off before the network if the 403 breaker is open (issue #637).
self.forbidden_guard()?;