merge: bring v1.2.15 Tauri fixes (shared HTTP client / fd exhaustion, qobuz bundle-token cache + bounded fetch) into slint-mvp

cc
This commit is contained in:
vicrodh committed 2026-06-05 15:11:48 -06:00
commit 1072195740
18 files changed
+371 -35

No files matched your search

+3
View File
@@ -27,6 +27,9 @@ base64 = { workspace = true }
# Time
chrono = { workspace = true }
# Filesystem (bundle-token cache location)
dirs = "6"
# Regex
regex = { workspace = true }
+190 -11
View File
@@ -5,12 +5,22 @@
use regex::Regex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use super::error::{ApiError, Result};
const LOGIN_PAGE_URL: &str = "https://play.qobuz.com/login";
const BUNDLE_BASE_URL: &str = "https://play.qobuz.com";
/// Per-request ceiling for the bundle fetch. The login page is tiny but the
/// bundle.js is ~7 MB and served from a CDN that is sometimes very slow; without
/// this, a stalled download blocks the entire app startup indefinitely.
const BUNDLE_FETCH_TIMEOUT: Duration = Duration::from_secs(45);
/// Extra attempts after the first on a failed/timed-out extraction.
const BUNDLE_EXTRACTION_RETRIES: usize = 2;
/// Extracted bundle tokens
#[derive(Debug, Clone)]
pub struct BundleTokens {
@@ -21,16 +31,114 @@ pub struct BundleTokens {
pub private_key: Option<String>,
}
/// Extract app_id, secrets, and OAuth private_key from Qobuz bundle
pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
// Step 1: Get login page to find bundle URL
let login_page = client.get(LOGIN_PAGE_URL).send().await?.text().await?;
/// On-disk cache of the extracted tokens, keyed by the Qobuz bundle version
/// (e.g. `8.1.0-b019`) so we can detect when Qobuz rotates the bundle and the
/// secrets change. Lives in the regenerable cache dir, never in precious data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedBundle {
pub bundle_version: String,
pub app_id: String,
pub secrets: Vec<String>,
#[serde(default)]
pub private_key: Option<String>,
/// Unix seconds when these tokens were fetched (freshness only; not a TTL).
pub fetched_at: i64,
}
impl From<CachedBundle> for BundleTokens {
fn from(c: CachedBundle) -> Self {
BundleTokens {
app_id: c.app_id,
secrets: c.secrets,
private_key: c.private_key,
}
}
}
fn cache_path() -> Option<PathBuf> {
Some(dirs::cache_dir()?.join("qbz").join("bundle_tokens.json"))
}
/// Load cached tokens if a valid cache file exists. Returns `None` on any error
/// (missing file, malformed JSON, empty fields) so the caller falls back to a
/// live fetch.
pub fn load_cached_bundle() -> Option<CachedBundle> {
let path = cache_path()?;
let data = std::fs::read(&path).ok()?;
match serde_json::from_slice::<CachedBundle>(&data) {
Ok(c) if !c.app_id.is_empty() && !c.secrets.is_empty() => Some(c),
Ok(_) => {
log::warn!("[Bundle] Cached tokens missing app_id/secrets, ignoring");
None
}
Err(e) => {
log::warn!("[Bundle] Failed to parse token cache: {}", e);
None
}
}
}
fn save_cached_bundle(c: &CachedBundle) {
let Some(path) = cache_path() else {
log::warn!("[Bundle] No cache dir available, skipping token cache write");
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match serde_json::to_vec_pretty(c) {
Ok(bytes) => match std::fs::write(&path, bytes) {
Ok(_) => log::info!("[Bundle] Cached tokens (version {})", c.bundle_version),
Err(e) => log::warn!("[Bundle] Failed to write token cache: {}", e),
},
Err(e) => log::warn!("[Bundle] Failed to serialize token cache: {}", e),
}
}
fn now_unix() -> i64 {
chrono::Utc::now().timestamp()
}
/// Parse the bundle version out of the `/resources/<version>/bundle.js` path,
/// e.g. `/resources/8.1.0-b019/bundle.js` -> `8.1.0-b019`.
fn bundle_version_from_url(bundle_url: &str) -> String {
bundle_url
.trim_start_matches("/resources/")
.trim_end_matches("/bundle.js")
.to_string()
}
/// Fetch the login page and return the current bundle URL + parsed version.
/// Cheap (~small page); used both by the full extraction and the background
/// version check.
async fn fetch_bundle_url(client: &Client) -> Result<(String, String)> {
let login_page = client
.get(LOGIN_PAGE_URL)
.timeout(BUNDLE_FETCH_TIMEOUT)
.send()
.await?
.text()
.await?;
let bundle_url = extract_bundle_url(&login_page)?;
let version = bundle_version_from_url(&bundle_url);
Ok((bundle_url, version))
}
/// Single network extraction attempt: fetch login page -> bundle.js -> parse.
/// Returns the tokens together with the bundle version they came from.
async fn extract_bundle_tokens_once(client: &Client) -> Result<(BundleTokens, String)> {
// Step 1: Get login page to find bundle URL + version
let (bundle_url, version) = fetch_bundle_url(client).await?;
let full_bundle_url = format!("{}{}", BUNDLE_BASE_URL, bundle_url);
// Step 2: Fetch the bundle
let bundle_content = client.get(&full_bundle_url).send().await?.text().await?;
// Step 2: Fetch the bundle (large; bounded by BUNDLE_FETCH_TIMEOUT)
let bundle_content = client
.get(&full_bundle_url)
.timeout(BUNDLE_FETCH_TIMEOUT)
.send()
.await?
.text()
.await?;
// Step 3: Extract app_id
let app_id = extract_app_id(&bundle_content)?;
@@ -52,11 +160,82 @@ pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
log::debug!("OAuth private_key not found in bundle (older bundle version)");
}
Ok(BundleTokens {
app_id,
secrets,
private_key,
})
Ok((
BundleTokens {
app_id,
secrets,
private_key,
},
version,
))
}
/// Extract app_id, secrets, and OAuth private_key from the live Qobuz bundle,
/// with a small retry loop, and persist the result to the on-disk cache.
///
/// This is the network ("cold") path. Prefer [`load_cached_bundle`] +
/// [`refresh_bundle_if_changed`] on warm starts so the UI never blocks on the
/// 7 MB download.
pub async fn extract_and_cache_bundle_tokens(client: &Client) -> Result<BundleTokens> {
let mut last_err: Option<ApiError> = None;
let attempts = BUNDLE_EXTRACTION_RETRIES + 1;
for attempt in 1..=attempts {
match extract_bundle_tokens_once(client).await {
Ok((tokens, version)) => {
save_cached_bundle(&CachedBundle {
bundle_version: version,
app_id: tokens.app_id.clone(),
secrets: tokens.secrets.clone(),
private_key: tokens.private_key.clone(),
fetched_at: now_unix(),
});
return Ok(tokens);
}
Err(e) => {
log::warn!(
"[Bundle] Extraction attempt {}/{} failed: {}",
attempt,
attempts,
e
);
last_err = Some(e);
}
}
}
Err(last_err
.unwrap_or_else(|| ApiError::BundleExtractionError("bundle extraction failed".into())))
}
/// Background refresh: cheaply re-check the current bundle version. If Qobuz
/// rotated the bundle, re-extract (and re-cache) the new secrets and return
/// them; if unchanged, just bump the cache freshness timestamp and return
/// `None`. Never blocks the UI — call from a spawned task.
pub async fn refresh_bundle_if_changed(
client: &Client,
cached_version: &str,
) -> Option<BundleTokens> {
let (_, version) = fetch_bundle_url(client).await.ok()?;
if version == cached_version {
if let Some(mut c) = load_cached_bundle() {
c.fetched_at = now_unix();
save_cached_bundle(&c);
}
log::debug!("[Bundle] Background check: version {} unchanged", version);
return None;
}
log::info!(
"[Bundle] Background check: version changed {} -> {}, re-extracting",
cached_version,
version
);
extract_and_cache_bundle_tokens(client).await.ok()
}
/// Backwards-compatible one-shot extraction (no caching). Retained for callers
/// that just want a live fetch; the app startup path uses
/// [`extract_and_cache_bundle_tokens`] instead.
pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
extract_bundle_tokens_once(client).await.map(|(t, _)| t)
}
fn extract_bundle_url(html: &str) -> Result<String> {
+37 -5
View File
@@ -9,7 +9,7 @@ use super::auth::{
get_timestamp, parse_login_response, sign_file_url, sign_get_favorites, sign_get_file_url,
sign_request, sign_search, sign_session_start,
};
use super::bundle::{extract_bundle_tokens, BundleTokens};
use super::bundle::{self, BundleTokens};
use super::endpoints::{self, paths};
use super::error::{ApiError, Result};
use qbz_models::*;
@@ -52,6 +52,10 @@ impl QobuzClient {
let http = Client::builder()
.user_agent(USER_AGENT)
.cookie_store(true)
// Bound the TCP connect phase so a dead route (e.g. a stale CDN
// address) can't hang startup. Does not affect body-read time, so
// long streaming reads are unaffected.
.connect_timeout(std::time::Duration::from_secs(10))
.build()?;
Ok(Self {
@@ -64,11 +68,39 @@ impl QobuzClient {
})
}
/// Initialize client by extracting bundle tokens
pub async fn init(&self) -> Result<()> {
let tokens = extract_bundle_tokens(&self.http).await?;
/// Initialize client by extracting bundle tokens.
///
/// Warm start: if cached tokens exist, use them immediately so the UI never
/// blocks on Qobuz's (sometimes very slow) ~7 MB bundle download, then
/// refresh in the background — re-downloading only if Qobuz rotated the
/// bundle version. Cold start (first run or after a cache wipe): fetch now,
/// bounded by a per-request timeout + a small retry so a slow/dead CDN can't
/// hang forever.
///
/// Returns `true` if it served cached tokens (warm), `false` if it had to do
/// a live extraction (cold) — callers can use this to drive a "connecting"
/// UI only when it actually matters.
pub async fn init(&self) -> Result<bool> {
if let Some(cached) = bundle::load_cached_bundle() {
let version = cached.bundle_version.clone();
log::info!("[Bundle] Using cached tokens (version {})", version);
*self.tokens.write().await = Some(cached.into());
let client = self.http.clone();
let tokens_arc = Arc::clone(&self.tokens);
tokio::spawn(async move {
if let Some(fresh) = bundle::refresh_bundle_if_changed(&client, &version).await {
*tokens_arc.write().await = Some(fresh);
log::info!("[Bundle] Background refresh applied rotated tokens");
}
});
return Ok(true);
}
log::info!("[Bundle] No cached tokens, extracting from Qobuz...");
let tokens = bundle::extract_and_cache_bundle_tokens(&self.http).await?;
*self.tokens.write().await = Some(tokens);
Ok(())
Ok(false)
}
/// Set the locale for API requests