From 89efde792ae0a9da647abc927ca050ff3deca789 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:23:06 -0600 Subject: [PATCH] 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 --- .../qbz-qobuz/examples/member_login_probe.rs | 369 ++++++++++++++++++ crates/qbz-qobuz/src/client.rs | 93 ++++- 2 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 crates/qbz-qobuz/examples/member_login_probe.rs diff --git a/crates/qbz-qobuz/examples/member_login_probe.rs b/crates/qbz-qobuz/examples/member_login_probe.rs new file mode 100644 index 000000000..cf6e33bca --- /dev/null +++ b/crates/qbz-qobuz/examples/member_login_probe.rs @@ -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=`) +//! +//! 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!("", 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}?"); + } + } + } 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::(body) { + Ok(mut json) => { + redact(&mut json); + println!( + "{}", + serde_json::to_string_pretty(&json).unwrap_or_default() + ); + } + Err(_) => println!("", 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 { + 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 /?...`); `code_autorisation` wins over `code`, as in the app. +fn parse_callback(request_line: &str, expected_nonce: &str) -> Option { + 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 { + 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() { + "\ +

Probe signed in

You can close this tab.

" + } else { + "\ +

Waiting for Qobuz...

" + }; + 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> { + 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> { + let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Info)); + + let mut purchased: Option = None; + let mut ids: Vec = 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 = 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(|| "".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(()) +} diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index d7b543102..aec29ba7e 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -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 { + /// 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 { 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 { + 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 { // Back off before the network if the 403 breaker is open (issue #637). self.forbidden_guard()?;