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:
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(())
|
||||
}
|
||||
@@ -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()?;
|
||||
|
||||
Reference in new issue
Block a user