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:
18 files changed
+371
-35
No files matched your search
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1027,6 +1027,21 @@ fn v2_resolve_local_artwork(url: &str) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Shared blocking HTTP client for notification artwork downloads. Reused across
|
||||
/// track changes so a long listening session doesn't leak a file descriptor per
|
||||
/// track via a fresh `reqwest::blocking::Client::new()` (EMFILE — see the image
|
||||
/// cache for the same fix on the Discover grid).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn notification_http_client() -> &'static reqwest::blocking::Client {
|
||||
static CLIENT: std::sync::OnceLock<reqwest::blocking::Client> = std::sync::OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
reqwest::blocking::Client::builder()
|
||||
.pool_max_idle_per_host(2)
|
||||
.build()
|
||||
.expect("failed to build shared notification HTTP client")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub fn v2_cache_notification_artwork(url: &str) -> Result<PathBuf, String> {
|
||||
if let Some(local_path) = v2_resolve_local_artwork(url) {
|
||||
@@ -1044,7 +1059,7 @@ pub fn v2_cache_notification_artwork(url: &str) -> Result<PathBuf, String> {
|
||||
return Ok(cache_path);
|
||||
}
|
||||
|
||||
let response = reqwest::blocking::Client::new()
|
||||
let response = notification_http_client()
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
use std::sync::OnceLock;
|
||||
use tauri::State;
|
||||
|
||||
/// Shared blocking HTTP client for image downloads.
|
||||
///
|
||||
/// Discover (and any grid view) loads dozens of images at once, each through
|
||||
/// `v2_get_cached_image`. Building a fresh `reqwest::blocking::Client` per image
|
||||
/// opens a new connection pool + file descriptors every time and exhausts the
|
||||
/// process fd limit — the `Os { code: 24, "Too many open files" }` (EMFILE)
|
||||
/// panic seen in `Client::new()`. One reused client fixes the leak and enables
|
||||
/// connection keep-alive across downloads.
|
||||
fn image_http_client() -> &'static reqwest::blocking::Client {
|
||||
static CLIENT: OnceLock<reqwest::blocking::Client> = OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
reqwest::blocking::Client::builder()
|
||||
.pool_max_idle_per_host(4)
|
||||
.build()
|
||||
.expect("failed to build shared image HTTP client")
|
||||
})
|
||||
}
|
||||
|
||||
/// Download an image via reqwest (rustls) and write to a temp file.
|
||||
/// Returns a file:// URL that WebKit can load without needing system TLS.
|
||||
/// Used as fallback when the image cache service is unavailable.
|
||||
async fn download_image_to_temp(url: &str) -> Result<String, String> {
|
||||
let url_owned = url.to_string();
|
||||
let bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
let response = reqwest::blocking::Client::new()
|
||||
let response = image_http_client()
|
||||
.get(&url_owned)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
@@ -110,7 +129,7 @@ pub async fn v2_get_cached_image(
|
||||
// Download the image via reqwest (uses rustls — own CA bundle)
|
||||
let url_clone = url.clone();
|
||||
let bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
let response = reqwest::blocking::Client::new()
|
||||
let response = image_http_client()
|
||||
.get(&url_clone)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
Reference in new issue
Block a user