diff --git a/crates/Cargo.lock b/crates/Cargo.lock index d5c832ab6..93b8ecbb2 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -2584,6 +2584,7 @@ dependencies = [ "anyhow", "base64", "chrono", + "dirs", "futures-util", "log", "md-5", diff --git a/crates/qbz-app/src/runtime.rs b/crates/qbz-app/src/runtime.rs index de408a296..728869ca3 100644 --- a/crates/qbz-app/src/runtime.rs +++ b/crates/qbz-app/src/runtime.rs @@ -360,6 +360,11 @@ impl Default for RuntimeManager { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "event", content = "data")] pub enum RuntimeEvent { + /// A live (cold) bundle-token extraction has started — no cache was + /// available, so we must download Qobuz's ~7 MB bundle before the UI can + /// proceed. The frontend shows a "connecting to Qobuz" state. Only emitted + /// on a cold start (first run or after a cache wipe); warm starts skip it. + BundleFetchStarted, /// Runtime initialized (client ready) RuntimeInitialized, /// Authentication state changed diff --git a/crates/qbz-qobuz/Cargo.toml b/crates/qbz-qobuz/Cargo.toml index 7a2b1b4f4..9412a9c57 100644 --- a/crates/qbz-qobuz/Cargo.toml +++ b/crates/qbz-qobuz/Cargo.toml @@ -27,6 +27,9 @@ base64 = { workspace = true } # Time chrono = { workspace = true } +# Filesystem (bundle-token cache location) +dirs = "6" + # Regex regex = { workspace = true } diff --git a/crates/qbz-qobuz/src/bundle.rs b/crates/qbz-qobuz/src/bundle.rs index 94e45ad42..946b3f0d4 100644 --- a/crates/qbz-qobuz/src/bundle.rs +++ b/crates/qbz-qobuz/src/bundle.rs @@ -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, } -/// Extract app_id, secrets, and OAuth private_key from Qobuz bundle -pub async fn extract_bundle_tokens(client: &Client) -> Result { - // 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, + #[serde(default)] + pub private_key: Option, + /// Unix seconds when these tokens were fetched (freshness only; not a TTL). + pub fetched_at: i64, +} +impl From 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 { + 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 { + let path = cache_path()?; + let data = std::fs::read(&path).ok()?; + match serde_json::from_slice::(&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//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 { 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 { + let mut last_err: Option = 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 { + 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 { + extract_bundle_tokens_once(client).await.map(|(t, _)| t) } fn extract_bundle_url(html: &str) -> Result { diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index e3f04d69e..014168db0 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -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 { + 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 diff --git a/src-tauri/src/api/client.rs b/src-tauri/src/api/client.rs index 2a2d4c0d5..a6589947b 100644 --- a/src-tauri/src/api/client.rs +++ b/src-tauri/src/api/client.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use tokio::sync::RwLock; use super::auth::{get_timestamp, parse_login_response, sign_get_favorites, sign_get_file_url}; -use super::bundle::{extract_bundle_tokens, BundleTokens}; +use super::bundle::BundleTokens; use super::endpoints::{self, paths}; use super::error::{ApiError, Result}; use super::models::*; @@ -40,6 +40,9 @@ impl QobuzClient { let http = Client::builder() .user_agent(USER_AGENT) .cookie_store(true) + // Bound the TCP connect phase so a dead route can't hang startup. + // Body-read time is unaffected, so streaming reads are fine. + .connect_timeout(std::time::Duration::from_secs(10)) .build()?; Ok(Self { @@ -51,10 +54,50 @@ impl QobuzClient { }) } - /// Initialize client by extracting bundle tokens + /// Initialize client by extracting bundle tokens. + /// + /// Shares the on-disk token cache with the qbz_qobuz core client (same + /// `~/.cache/qbz/bundle_tokens.json`), so a cold start downloads Qobuz's + /// ~7 MB bundle at most once across both clients, and warm starts never + /// block the UI on it. On a warm start the cached tokens are applied + /// immediately and refreshed in the background (re-downloading only if + /// Qobuz rotated the bundle version). pub async fn init(&self) -> Result<()> { - let tokens = extract_bundle_tokens(&self.http).await?; - *self.tokens.write().await = Some(tokens); + if let Some(cached) = qbz_qobuz::bundle::load_cached_bundle() { + let version = cached.bundle_version.clone(); + log::info!("[Bundle] (api) Using cached tokens (version {})", version); + *self.tokens.write().await = Some(BundleTokens { + app_id: cached.app_id, + secrets: cached.secrets, + private_key: cached.private_key, + }); + + let http = self.http.clone(); + let tokens_arc = Arc::clone(&self.tokens); + tokio::spawn(async move { + if let Some(fresh) = + qbz_qobuz::bundle::refresh_bundle_if_changed(&http, &version).await + { + *tokens_arc.write().await = Some(BundleTokens { + app_id: fresh.app_id, + secrets: fresh.secrets, + private_key: fresh.private_key, + }); + log::info!("[Bundle] (api) Background refresh applied rotated tokens"); + } + }); + return Ok(()); + } + + log::info!("[Bundle] (api) No cached tokens, extracting from Qobuz..."); + let fresh = qbz_qobuz::bundle::extract_and_cache_bundle_tokens(&self.http) + .await + .map_err(|e| ApiError::BundleExtractionError(e.to_string()))?; + *self.tokens.write().await = Some(BundleTokens { + app_id: fresh.app_id, + secrets: fresh.secrets, + private_key: fresh.private_key, + }); Ok(()) } diff --git a/src-tauri/src/commands_v2/runtime.rs b/src-tauri/src/commands_v2/runtime.rs index bb574bed5..8b21cd84d 100644 --- a/src-tauri/src/commands_v2/runtime.rs +++ b/src-tauri/src/commands_v2/runtime.rs @@ -53,8 +53,14 @@ pub async fn runtime_bootstrap( log::info!("[Runtime] Bootstrap starting..."); - // Step 1: Initialize API client (bundle tokens) + // Step 1: Initialize API client (bundle tokens). + // On a cold start (no cached tokens) this must download Qobuz's ~7 MB bundle, + // which can take many seconds when their CDN is slow — tell the frontend so it + // shows a "connecting to Qobuz" state instead of a frozen splash. { + if qbz_qobuz::bundle::load_cached_bundle().is_none() { + let _ = app.emit("runtime:event", RuntimeEvent::BundleFetchStarted); + } let client = app_state.client.read().await; match client.init().await { Ok(_) => {