feat(qobuz): let an embedder choose the bundle cache directory

`cache_path` asks `dirs::cache_dir()` where the token cache belongs, so the
location comes from process-global ambient state and a host that already
knows where its cache goes has no way to say so.

That is fine on the desktop. It is not fine everywhere `dirs` resolves: on
Android it takes the XDG implementation, so the path is `$XDG_CACHE_HOME`
or `$HOME/.cache`, and an app process has neither. The cache then either has
nowhere to go at all, taking the existing "No cache dir available" branch,
or names somewhere the process cannot write, so the write fails and is
logged. Either way the tokens never persist and every launch pays a full
live extraction of a ~7 MB bundle.js.

`QobuzClient::with_cache_dir` takes the root instead. `QobuzClient::new`,
`load_cached_bundle`, `extract_and_cache_bundle_tokens` and
`refresh_bundle_if_changed` all keep their signatures and their behaviour:
passing no directory still resolves through `dirs`. The `qbz/` namespace is
kept inside whatever root is supplied, so the file has the same name and
shape wherever it lands.
This commit is contained in:
Afonso Jorge Ramos committed 2026-08-21 00:58:08 +02:00
1 parent 82fea6257c
commit 008bb31579
2 files changed
+97 -21

No files matched your search

+65 -16
View File
@@ -6,7 +6,7 @@
use regex::Regex; use regex::Regex;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use super::error::{ApiError, Result}; use super::error::{ApiError, Result};
@@ -55,15 +55,24 @@ impl From<CachedBundle> for BundleTokens {
} }
} }
fn cache_path() -> Option<PathBuf> { /// Where the token cache lives.
Some(dirs::cache_dir()?.join("qbz").join("bundle_tokens.json")) ///
/// `cache_dir` is what an embedder supplied; without one this falls back to the
/// platform cache directory, which is what the desktop has always used.
fn cache_path(cache_dir: Option<&Path>) -> Option<PathBuf> {
let root = cache_dir.map(Path::to_path_buf).or_else(dirs::cache_dir)?;
Some(root.join("qbz").join("bundle_tokens.json"))
} }
/// Load cached tokens if a valid cache file exists. Returns `None` on any error /// 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 /// (missing file, malformed JSON, empty fields) so the caller falls back to a
/// live fetch. /// live fetch.
pub fn load_cached_bundle() -> Option<CachedBundle> { pub fn load_cached_bundle() -> Option<CachedBundle> {
let path = cache_path()?; load_cached_bundle_from(None)
}
pub(crate) fn load_cached_bundle_from(cache_dir: Option<&Path>) -> Option<CachedBundle> {
let path = cache_path(cache_dir)?;
let data = std::fs::read(&path).ok()?; let data = std::fs::read(&path).ok()?;
match serde_json::from_slice::<CachedBundle>(&data) { match serde_json::from_slice::<CachedBundle>(&data) {
Ok(c) if !c.app_id.is_empty() && !c.secrets.is_empty() => Some(c), Ok(c) if !c.app_id.is_empty() && !c.secrets.is_empty() => Some(c),
@@ -78,8 +87,8 @@ pub fn load_cached_bundle() -> Option<CachedBundle> {
} }
} }
fn save_cached_bundle(c: &CachedBundle) { fn save_cached_bundle(c: &CachedBundle, cache_dir: Option<&Path>) {
let Some(path) = cache_path() else { let Some(path) = cache_path(cache_dir) else {
log::warn!("[Bundle] No cache dir available, skipping token cache write"); log::warn!("[Bundle] No cache dir available, skipping token cache write");
return; return;
}; };
@@ -177,18 +186,28 @@ async fn extract_bundle_tokens_once(client: &Client) -> Result<(BundleTokens, St
/// [`refresh_bundle_if_changed`] on warm starts so the UI never blocks on the /// [`refresh_bundle_if_changed`] on warm starts so the UI never blocks on the
/// 7 MB download. /// 7 MB download.
pub async fn extract_and_cache_bundle_tokens(client: &Client) -> Result<BundleTokens> { pub async fn extract_and_cache_bundle_tokens(client: &Client) -> Result<BundleTokens> {
extract_and_cache_bundle_tokens_in(client, None).await
}
pub(crate) async fn extract_and_cache_bundle_tokens_in(
client: &Client,
cache_dir: Option<&Path>,
) -> Result<BundleTokens> {
let mut last_err: Option<ApiError> = None; let mut last_err: Option<ApiError> = None;
let attempts = BUNDLE_EXTRACTION_RETRIES + 1; let attempts = BUNDLE_EXTRACTION_RETRIES + 1;
for attempt in 1..=attempts { for attempt in 1..=attempts {
match extract_bundle_tokens_once(client).await { match extract_bundle_tokens_once(client).await {
Ok((tokens, version)) => { Ok((tokens, version)) => {
save_cached_bundle(&CachedBundle { save_cached_bundle(
bundle_version: version, &CachedBundle {
app_id: tokens.app_id.clone(), bundle_version: version,
secrets: tokens.secrets.clone(), app_id: tokens.app_id.clone(),
private_key: tokens.private_key.clone(), secrets: tokens.secrets.clone(),
fetched_at: now_unix(), private_key: tokens.private_key.clone(),
}); fetched_at: now_unix(),
},
cache_dir,
);
return Ok(tokens); return Ok(tokens);
} }
Err(e) => { Err(e) => {
@@ -221,12 +240,20 @@ pub async fn extract_and_cache_bundle_tokens(client: &Client) -> Result<BundleTo
pub async fn refresh_bundle_if_changed( pub async fn refresh_bundle_if_changed(
client: &Client, client: &Client,
cached_version: &str, cached_version: &str,
) -> Option<BundleTokens> {
refresh_bundle_if_changed_in(client, cached_version, None).await
}
pub(crate) async fn refresh_bundle_if_changed_in(
client: &Client,
cached_version: &str,
cache_dir: Option<&Path>,
) -> Option<BundleTokens> { ) -> Option<BundleTokens> {
let (_, version) = fetch_bundle_url(client).await.ok()?; let (_, version) = fetch_bundle_url(client).await.ok()?;
if version == cached_version { if version == cached_version {
if let Some(mut c) = load_cached_bundle() { if let Some(mut c) = load_cached_bundle_from(cache_dir) {
c.fetched_at = now_unix(); c.fetched_at = now_unix();
save_cached_bundle(&c); save_cached_bundle(&c, cache_dir);
} }
log::debug!("[Bundle] Background check: version {} unchanged", version); log::debug!("[Bundle] Background check: version {} unchanged", version);
return None; return None;
@@ -236,7 +263,9 @@ pub async fn refresh_bundle_if_changed(
cached_version, cached_version,
version version
); );
extract_and_cache_bundle_tokens(client).await.ok() extract_and_cache_bundle_tokens_in(client, cache_dir)
.await
.ok()
} }
/// Backwards-compatible one-shot extraction (no caching). Retained for callers /// Backwards-compatible one-shot extraction (no caching). Retained for callers
@@ -403,6 +432,26 @@ mod tests {
assert_eq!(result.unwrap(), "/resources/7.0.1-b001/bundle.js"); assert_eq!(result.unwrap(), "/resources/7.0.1-b001/bundle.js");
} }
/// An embedder's root is used verbatim, and the `qbz/` namespace inside it
/// is kept, so the cache has the same name and shape wherever it lands.
#[test]
fn an_explicit_cache_root_keeps_the_qbz_namespace() {
let root = Path::new("/app/cache");
assert_eq!(
cache_path(Some(root)),
Some(root.join("qbz").join("bundle_tokens.json"))
);
}
/// Without one, nothing about the desktop's path changes.
#[test]
fn no_explicit_root_falls_back_to_the_platform_cache_dir() {
assert_eq!(
cache_path(None),
dirs::cache_dir().map(|d| d.join("qbz").join("bundle_tokens.json"))
);
}
#[test] #[test]
fn test_extract_app_id() { fn test_extract_app_id() {
let bundle = r#"production:{api:{appId:"123456789",appSecret:"abc"}"#; let bundle = r#"production:{api:{appId:"123456789",appSecret:"abc"}"#;
+32 -5
View File
@@ -2,6 +2,7 @@
use reqwest::{Client, StatusCode}; use reqwest::{Client, StatusCode};
use serde_json::Value; use serde_json::Value;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -54,6 +55,9 @@ pub struct QobuzClient {
validated_secret: Arc<RwLock<Option<String>>>, validated_secret: Arc<RwLock<Option<String>>>,
locale: Arc<RwLock<String>>, locale: Arc<RwLock<String>>,
cmaf_session: Arc<RwLock<Option<CmafSession>>>, cmaf_session: Arc<RwLock<Option<CmafSession>>>,
/// Where the regenerable bundle-token cache goes, when the embedder rather
/// than the platform decides. `None` keeps `dirs::cache_dir()`.
bundle_cache_dir: Option<PathBuf>,
/// Backs off the hot streaming/favorites paths after repeated 403s so a /// Backs off the hot streaming/favorites paths after repeated 403s so a
/// post-outage account hiccup can't be escalated into a per-IP edge block /// post-outage account hiccup can't be escalated into a per-IP edge block
/// by the no-backoff prefetch scheduler (issue #637). /// by the no-backoff prefetch scheduler (issue #637).
@@ -69,14 +73,27 @@ impl Clone for QobuzClient {
validated_secret: Arc::clone(&self.validated_secret), validated_secret: Arc::clone(&self.validated_secret),
locale: Arc::clone(&self.locale), locale: Arc::clone(&self.locale),
cmaf_session: Arc::clone(&self.cmaf_session), cmaf_session: Arc::clone(&self.cmaf_session),
bundle_cache_dir: self.bundle_cache_dir.clone(),
forbidden_breaker: Arc::clone(&self.forbidden_breaker), forbidden_breaker: Arc::clone(&self.forbidden_breaker),
} }
} }
} }
impl QobuzClient { impl QobuzClient {
/// Create a new client /// Create a new client, caching bundle tokens under the platform cache
/// directory.
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
Self::build(None)
}
/// Create a client whose regenerable bundle-token cache lives beneath
/// `cache_dir`, for hosts that are handed their cache location rather than
/// deriving it from the environment.
pub fn with_cache_dir(cache_dir: PathBuf) -> Result<Self> {
Self::build(Some(cache_dir))
}
fn build(bundle_cache_dir: Option<PathBuf>) -> Result<Self> {
let http = Client::builder() let http = Client::builder()
.user_agent(USER_AGENT) .user_agent(USER_AGENT)
.cookie_store(true) .cookie_store(true)
@@ -93,6 +110,7 @@ impl QobuzClient {
validated_secret: Arc::new(RwLock::new(None)), validated_secret: Arc::new(RwLock::new(None)),
locale: Arc::new(RwLock::new("en".to_string())), locale: Arc::new(RwLock::new("en".to_string())),
cmaf_session: Arc::new(RwLock::new(None)), cmaf_session: Arc::new(RwLock::new(None)),
bundle_cache_dir,
forbidden_breaker: Arc::new(ForbiddenBreaker::new()), forbidden_breaker: Arc::new(ForbiddenBreaker::new()),
}) })
} }
@@ -147,7 +165,7 @@ impl QobuzClient {
/// a live extraction (cold) — callers can use this to drive a "connecting" /// a live extraction (cold) — callers can use this to drive a "connecting"
/// UI only when it actually matters. /// UI only when it actually matters.
pub async fn init(&self) -> Result<bool> { pub async fn init(&self) -> Result<bool> {
if let Some(cached) = bundle::load_cached_bundle() { if let Some(cached) = bundle::load_cached_bundle_from(self.bundle_cache_dir.as_deref()) {
let version = cached.bundle_version.clone(); let version = cached.bundle_version.clone();
log::info!("[Bundle] Using cached tokens (version {})", version); log::info!("[Bundle] Using cached tokens (version {})", version);
*self.tokens.write().await = Some(cached.into()); *self.tokens.write().await = Some(cached.into());
@@ -160,9 +178,14 @@ impl QobuzClient {
Ok(client) => { Ok(client) => {
let client = client.clone(); let client = client.clone();
let tokens_arc = Arc::clone(&self.tokens); let tokens_arc = Arc::clone(&self.tokens);
let cache_dir = self.bundle_cache_dir.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Some(fresh) = if let Some(fresh) = bundle::refresh_bundle_if_changed_in(
bundle::refresh_bundle_if_changed(&client, &version).await &client,
&version,
cache_dir.as_deref(),
)
.await
{ {
*tokens_arc.write().await = Some(fresh); *tokens_arc.write().await = Some(fresh);
log::info!("[Bundle] Background refresh applied rotated tokens"); log::info!("[Bundle] Background refresh applied rotated tokens");
@@ -180,7 +203,11 @@ impl QobuzClient {
// Cold start: a live bundle fetch is a network request — gated on // Cold start: a live bundle fetch is a network request — gated on
// purpose so an offline cold start fails fast instead of waiting out // purpose so an offline cold start fails fast instead of waiting out
// the network timeouts. // the network timeouts.
let tokens = bundle::extract_and_cache_bundle_tokens(self.http()?).await?; let tokens = bundle::extract_and_cache_bundle_tokens_in(
self.http()?,
self.bundle_cache_dir.as_deref(),
)
.await?;
*self.tokens.write().await = Some(tokens); *self.tokens.write().await = Some(tokens);
Ok(false) Ok(false)
} }