feat(qobuz): integrate configurable bundle cache directory (#708)

cx
This commit is contained in:
vicrodh committed 2026-08-26 17:42:23 -06:00
commit 9aeee886db
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)
}