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 reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Duration;
use super::error::{ApiError, Result};
@@ -55,15 +55,24 @@ impl From<CachedBundle> for BundleTokens {
}
}
fn cache_path() -> Option<PathBuf> {
Some(dirs::cache_dir()?.join("qbz").join("bundle_tokens.json"))
/// Where the token cache lives.
///
/// `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
/// (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()?;
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()?;
match serde_json::from_slice::<CachedBundle>(&data) {
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) {
let Some(path) = cache_path() else {
fn save_cached_bundle(c: &CachedBundle, cache_dir: Option<&Path>) {
let Some(path) = cache_path(cache_dir) else {
log::warn!("[Bundle] No cache dir available, skipping token cache write");
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
/// 7 MB download.
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 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(),
});
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(),
},
cache_dir,
);
return Ok(tokens);
}
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(
client: &Client,
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> {
let (_, version) = fetch_bundle_url(client).await.ok()?;
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();
save_cached_bundle(&c);
save_cached_bundle(&c, cache_dir);
}
log::debug!("[Bundle] Background check: version {} unchanged", version);
return None;
@@ -236,7 +263,9 @@ pub async fn refresh_bundle_if_changed(
cached_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
@@ -403,6 +432,26 @@ mod tests {
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]
fn test_extract_app_id() {
let bundle = r#"production:{api:{appId:"123456789",appSecret:"abc"}"#;
+32 -5
View File
@@ -2,6 +2,7 @@
use reqwest::{Client, StatusCode};
use serde_json::Value;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
@@ -54,6 +55,9 @@ pub struct QobuzClient {
validated_secret: Arc<RwLock<Option<String>>>,
locale: Arc<RwLock<String>>,
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
/// post-outage account hiccup can't be escalated into a per-IP edge block
/// by the no-backoff prefetch scheduler (issue #637).
@@ -69,14 +73,27 @@ impl Clone for QobuzClient {
validated_secret: Arc::clone(&self.validated_secret),
locale: Arc::clone(&self.locale),
cmaf_session: Arc::clone(&self.cmaf_session),
bundle_cache_dir: self.bundle_cache_dir.clone(),
forbidden_breaker: Arc::clone(&self.forbidden_breaker),
}
}
}
impl QobuzClient {
/// Create a new client
/// Create a new client, caching bundle tokens under the platform cache
/// directory.
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()
.user_agent(USER_AGENT)
.cookie_store(true)
@@ -93,6 +110,7 @@ impl QobuzClient {
validated_secret: Arc::new(RwLock::new(None)),
locale: Arc::new(RwLock::new("en".to_string())),
cmaf_session: Arc::new(RwLock::new(None)),
bundle_cache_dir,
forbidden_breaker: Arc::new(ForbiddenBreaker::new()),
})
}
@@ -147,7 +165,7 @@ impl QobuzClient {
/// 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() {
if let Some(cached) = bundle::load_cached_bundle_from(self.bundle_cache_dir.as_deref()) {
let version = cached.bundle_version.clone();
log::info!("[Bundle] Using cached tokens (version {})", version);
*self.tokens.write().await = Some(cached.into());
@@ -160,9 +178,14 @@ impl QobuzClient {
Ok(client) => {
let client = client.clone();
let tokens_arc = Arc::clone(&self.tokens);
let cache_dir = self.bundle_cache_dir.clone();
tokio::spawn(async move {
if let Some(fresh) =
bundle::refresh_bundle_if_changed(&client, &version).await
if let Some(fresh) = bundle::refresh_bundle_if_changed_in(
&client,
&version,
cache_dir.as_deref(),
)
.await
{
*tokens_arc.write().await = Some(fresh);
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
// purpose so an offline cold start fails fast instead of waiting out
// 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);
Ok(false)
}