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:
8 files changed
+243
-36
No files matched your search
@@ -148,5 +148,6 @@ pub use types::{
|
||||
TrackFileUrl,
|
||||
TrackToAnalyse,
|
||||
TracksContainer,
|
||||
Entitlements,
|
||||
UserSession,
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user