feat(auth): member accounts are valid sessions; typed entitlements

Qobuz answers `/user/login` with 200 for an account without an active
subscription and the web player signs it in as a "Qobuz member" with its
favorites, playlists, purchases and 30-second previews. QBZ threw that
session away: `parse_login_response` returned `IneligibleUser` whenever
`credential.parameters` was empty (verified 2026-09-02 with a lapsed
account). The gate was ours, not Qobuz's.

The parser now reads the entitlement flags (`lossy/lossless/hires/
hires_purchases/offline/mobile_streaming`) into a typed `Entitlements`
on `UserSession`, labels a member "Member", and keeps `subscription.
end_date`/`offer` as information (the embedded Qobuz fixture has a past
end_date with every flag true: the flags are the verdict, the date is
not). `StreamUrl` gains `sample`, the flag Qobuz sets when it serves a
preview instead of the track. `IneligibleUser` stays in the enum for its
existing matchers; nothing emits it from this path any more. Persisted
pre-member sessions still load (`serde(default)`).

cc
This commit is contained in:
vicrodh committed 2026-09-02 07:27:38 -06:00
1 parent 89efde792a
commit 2b7d804f09
8 files changed
+243 -36

No files matched your search

+1
View File
@@ -148,5 +148,6 @@ pub use types::{
TrackFileUrl,
TrackToAnalyse,
TracksContainer,
Entitlements,
UserSession,
};
+53 -1
View File
@@ -117,8 +117,40 @@ pub enum QualityLimit {
// ============ User Session ============
/// What the account is entitled to, straight from
/// `user.credential.parameters` of the `/user/login` payload (wire names,
/// verified against Qobuz's own embedded fixture — see
/// `qbz-nix-docs/offline-mode/tauri-review-2026-06-09/10-subscription-trial-offline-gating.md`
/// §1.2). An account without an active subscription ("Qobuz member") has
/// no `parameters` at all and therefore every flag `false`; Qobuz still
/// serves it favorites, playlists, purchases and 30-second previews.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Entitlements {
#[serde(default)]
pub lossy_streaming: bool,
#[serde(default)]
pub lossless_streaming: bool,
#[serde(default)]
pub hires_streaming: bool,
#[serde(default)]
pub hires_purchases_streaming: bool,
#[serde(default)]
pub offline_streaming: bool,
#[serde(default)]
pub mobile_streaming: bool,
}
impl Entitlements {
/// No streaming entitlement of any kind: a Qobuz member without a
/// subscription. Purchases and previews still work; full-length
/// catalog playback and offline do not.
pub fn is_member_only(&self) -> bool {
!(self.lossy_streaming || self.lossless_streaming || self.hires_streaming)
}
}
/// User credentials and session info
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UserSession {
pub user_auth_token: String,
pub user_id: u64,
@@ -127,6 +159,20 @@ pub struct UserSession {
pub subscription_label: String,
#[serde(default)]
pub subscription_valid_until: Option<String>,
/// Entitlement flags from `credential.parameters`. `serde(default)`
/// keeps persisted pre-member-mode sessions loadable (all `false`,
/// which the next real login corrects).
#[serde(default)]
pub entitlements: Entitlements,
/// `user.subscription.end_date` (`YYYY-MM-DD`), when Qobuz sends the
/// `subscription` object at all. Informational: the entitlement flags
/// are the server's verdict, not this date (the embedded fixture has a
/// past `end_date` with every flag still `true`).
#[serde(default)]
pub subscription_end_date: Option<String>,
/// `user.subscription.offer` (e.g. "studio", "sublime"), when present.
#[serde(default)]
pub subscription_offer: Option<String>,
/// Account territory (ISO 3166-1 alpha-2, e.g. "FR") from the login
/// response. `serde(default)` keeps pre-v10 persisted sessions loadable.
#[serde(default)]
@@ -149,6 +195,12 @@ pub struct StreamUrl {
pub bit_depth: Option<u32>,
pub track_id: u64,
pub restrictions: Vec<StreamRestriction>,
/// `true` when Qobuz served a 30-second preview instead of the track
/// (an account without the streaming entitlement, or a track that is
/// not streamable in the territory). The URL still plays; the player
/// must say so and must not count it as a listen.
#[serde(default)]
pub sample: bool,
}
impl StreamUrl {
@@ -300,12 +300,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.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,
..UserSession::default()
})
.await;
+165 -16
View File
@@ -5,7 +5,7 @@ use md5::{Digest, Md5};
use std::time::{SystemTime, UNIX_EPOCH};
use super::error::{ApiError, Result};
use qbz_models::UserSession;
use qbz_models::{Entitlements, UserSession};
/// Generate MD5 signature for protected API endpoints
///
@@ -129,14 +129,48 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
.unwrap_or("")
.to_string();
// Check subscription
// Subscription / entitlements. `credential.parameters` is the server's
// verdict: a populated object for a subscriber, `null`/absent/empty
// for a Qobuz member without a subscription. The member is a valid
// session (member mode, 2026-09-02): favorites, playlists, purchases
// and previews all work; the flags below say what does not.
let credential = user.get("credential");
let subscription_label = credential
let parameters = credential
.and_then(|c| c.get("parameters"))
.and_then(|p| p.get("short_label"))
.and_then(|p| p.as_object())
.filter(|o| !o.is_empty());
let entitlements = parameters
.map(|p| Entitlements {
lossy_streaming: flag(p, "lossy_streaming"),
lossless_streaming: flag(p, "lossless_streaming"),
hires_streaming: flag(p, "hires_streaming"),
hires_purchases_streaming: flag(p, "hires_purchases_streaming"),
offline_streaming: flag(p, "offline_streaming"),
mobile_streaming: flag(p, "mobile_streaming"),
})
.unwrap_or_default();
let subscription_label = match parameters {
Some(p) => p
.get("short_label")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string(),
// msgid; the UI translates it (the web player says "Qobuz member").
None => "Member".to_string(),
};
let subscription = user.get("subscription");
let subscription_end_date = subscription
.and_then(|s| s.get("end_date"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let subscription_offer = subscription
.and_then(|s| s.get("offer"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
fn parse_subscription_valid_until(parameters: &serde_json::Value) -> Option<String> {
// Try common string fields first.
@@ -191,16 +225,6 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
.and_then(|v| v.as_str())
.map(str::to_string);
// Check if user has valid subscription
let has_subscription = credential
.and_then(|c| c.get("parameters"))
.map(|p| !p.is_null() && p.as_object().map(|o| !o.is_empty()).unwrap_or(false))
.unwrap_or(false);
if !has_subscription {
return Err(ApiError::IneligibleUser);
}
Ok(UserSession {
user_auth_token,
user_id,
@@ -208,11 +232,22 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
display_name,
subscription_label,
subscription_valid_until,
entitlements,
subscription_end_date,
subscription_offer,
country_code,
language_code,
})
}
/// A boolean entitlement flag; anything but literal `true` is `false`.
fn flag(parameters: &serde_json::Map<String, serde_json::Value>, key: &str) -> bool {
parameters
.get(key)
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// App seed for CMAF request signing and key derivation.
/// Public value extracted from the webplayer JS bundle.
pub const CMAF_SEED: &str = "abb21364945c0583309667d13ca3d93a";
@@ -302,4 +337,118 @@ mod tests {
assert_eq!(session.country_code, None);
assert_eq!(session.language_code, None);
}
/// Qobuz's own embedded `/user/login` fixture (webpack module 58981 of
/// the web player bundle): an internal Studio account with a PAST
/// `end_date` and `is_canceled: true`, yet every entitlement `true`.
/// The flags are the verdict; the date is not.
fn studio_fixture() -> serde_json::Value {
serde_json::json!({
"user_auth_token": "CmJsOF3qsokN12dd",
"user": {
"id": 1705826,
"email": "account-test+FRfavorites@qobuz.com",
"login": "test-FR-Studio",
"display_name": "test FR Studio",
"country_code": "FR",
"language_code": "fr",
"subscription": {
"offer": "studio",
"periodicity": "annual",
"end_date": "2022-06-19",
"is_canceled": true
},
"credential": {
"id": 1489142,
"label": "streaming-studio",
"description": "Abonné Qobuz Studio",
"parameters": {
"lossy_streaming": true,
"lossless_streaming": true,
"hires_streaming": true,
"hires_purchases_streaming": true,
"mobile_streaming": true,
"offline_streaming": true,
"hfp_purchase": false,
"included_format_group_ids": [1, 2, 3, 4],
"label": "Qobuz Studio",
"short_label": "Studio",
"source": "internal"
}
}
}
})
}
#[test]
fn subscriber_carries_entitlements_offer_and_end_date() {
let session = parse_login_response(&studio_fixture()).expect("subscriber parses");
assert_eq!(session.subscription_label, "Studio");
assert!(session.entitlements.offline_streaming);
assert!(session.entitlements.hires_streaming);
assert!(session.entitlements.hires_purchases_streaming);
assert!(!session.entitlements.is_member_only());
assert_eq!(session.subscription_offer.as_deref(), Some("studio"));
assert_eq!(session.subscription_end_date.as_deref(), Some("2022-06-19"));
}
/// A Qobuz member without a subscription: Qobuz answers 200 and the
/// session is real; QBZ used to throw it away (`IneligibleUser`). The
/// web player shows the same account as "Qobuz member" with its
/// favorites and playlists (verified 2026-09-02 with a lapsed account).
#[test]
fn member_without_parameters_is_a_valid_session() {
for credential in [
serde_json::json!({"parameters": null}),
serde_json::json!({"parameters": {}}),
serde_json::json!({}),
] {
let response = login_response(serde_json::json!({ "credential": credential }));
let session = parse_login_response(&response)
.unwrap_or_else(|e| panic!("member must parse, got {e} for {credential}"));
assert_eq!(session.user_id, 1705826);
assert_eq!(session.subscription_label, "Member");
assert_eq!(session.entitlements, Entitlements::default());
assert!(session.entitlements.is_member_only());
assert_eq!(session.subscription_end_date, None);
}
// No `credential` object at all: same verdict.
let mut response = login_response(serde_json::json!({}));
response["user"]
.as_object_mut()
.unwrap()
.remove("credential");
let session = parse_login_response(&response).expect("member without credential");
assert_eq!(session.subscription_label, "Member");
assert!(session.entitlements.is_member_only());
}
#[test]
fn flags_default_false_when_absent_or_not_boolean() {
let response = login_response(serde_json::json!({
"credential": {"parameters": {
"short_label": "Solo",
"lossless_streaming": true,
"hires_streaming": "true",
"offline_streaming": 1
}}
}));
let session = parse_login_response(&response).expect("parses");
assert_eq!(session.subscription_label, "Solo");
assert!(session.entitlements.lossless_streaming);
assert!(!session.entitlements.hires_streaming);
assert!(!session.entitlements.offline_streaming);
assert!(!session.entitlements.is_member_only());
}
#[test]
fn persisted_session_without_new_fields_still_loads() {
// A session snapshot written before member mode: no entitlements,
// no subscription fields. Must deserialize with everything false.
let json = r#"{"user_auth_token":"t","user_id":1,"email":"","display_name":"",
"subscription_label":"Studio"}"#;
let session: UserSession = serde_json::from_str(json).expect("old snapshot loads");
assert_eq!(session.entitlements, Entitlements::default());
assert_eq!(session.subscription_offer, None);
}
}
+14 -10
View File
@@ -495,15 +495,18 @@ impl QobuzClient {
pub async fn exchange_oauth_code(&self, code: &str) -> Result<String> {
use reqwest::header::{HeaderMap, HeaderValue};
let tokens = self.tokens.read().await;
let tokens = tokens
.as_ref()
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?;
let app_id = tokens.app_id.clone();
let private_key = tokens.private_key.clone().ok_or_else(|| {
ApiError::BundleExtractionError("OAuth private key not available in bundle".to_string())
})?;
drop(tokens);
let (app_id, private_key) = {
let guard = self.tokens.read().await;
let tokens = guard.as_ref().ok_or_else(|| {
ApiError::BundleExtractionError("Client not initialized".to_string())
})?;
let private_key = tokens.private_key.clone().ok_or_else(|| {
ApiError::BundleExtractionError(
"OAuth private key not available in bundle".to_string(),
)
})?;
(tokens.app_id.clone(), private_key)
};
let callback_url = endpoints::build_url(endpoints::paths::OAUTH_CALLBACK);
let mut headers = HeaderMap::new();
@@ -2609,6 +2612,7 @@ impl QobuzClient {
bit_depth: json["bit_depth"].as_u64().map(|v| v as u32),
track_id,
restrictions,
sample: json["sample"].as_bool().unwrap_or(false),
})
}
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret),
@@ -3396,9 +3400,9 @@ mod tests {
email: "owner@example.test".to_string(),
display_name: "Owner".to_string(),
subscription_label: "Studio".to_string(),
subscription_valid_until: None,
country_code: Some("MX".to_string()),
language_code: Some("es".to_string()),
..UserSession::default()
});
let credentials = client.delegated_app_credentials().await.unwrap();
+4
View File
@@ -415,6 +415,10 @@ impl DelegatedQobuzClient {
.map(|depth| depth as u32),
track_id,
restrictions,
sample: value
.get("sample")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
+4
View File
@@ -527,6 +527,10 @@ impl QobuzClient {
.map(|v| v as u32),
track_id,
restrictions,
sample: json
.get("sample")
.and_then(|v| v.as_bool())
.unwrap_or(false),
})
}
StatusCode::BAD_REQUEST => match intent {
+1 -3
View File
@@ -1269,9 +1269,7 @@ mod tests {
email: "a@b.c".into(),
display_name: "Tester".into(),
subscription_label: "studio".into(),
subscription_valid_until: None,
country_code: None,
language_code: None,
..UserSession::default()
};
set_logged_in(&shared, &session);
let s = shared.lock().unwrap();