fix(qobuz): 403 circuit breaker + prefetch backoff; header tooltips

Issue #637: a Qobuz outage + forced re-login left a user's account
transiently 403'd, and the no-backoff prefetch retry storm escalated it
into a per-IP edge/WAF block (the "error decoding response body" lines are
that non-JSON WAF body hitting a bare .json()). The bundle bump b019->b034
is exonerated: extraction yields byte-identical app_id/secret/private_key.

- ForbiddenBreaker: after repeated 403s the hot streaming/favorites paths
  short-circuit without touching the network, with an exponential cooldown
  and a half-open probe; success resets it.
- get_stream_url: typed 401/403 + body preview; abort the quality fallback
  loop on 403/401/breaker-open instead of firing all five qualities.
- CMAF session/start + file/url: feed the breaker, map 403.
- get_favorites + qws/createToken: check status before .json() so a 403
  surfaces as a 403, not a misleading "error decoding response body".
- Prefetch: per-track failure cooldown (negative cache) so a failed track
  is not re-hammered every queue tick.
- Audible path: a 403/back-off no longer counts as an "unavailable" skip;
  stop cleanly with a toast.
- QBZ_FORCE_403 test hook to smoke the back-off path on a healthy account.
- Header action-button tooltips for AlbumView/ArtistView/PlaylistView,
  translated across all 8 locales.

cc
This commit is contained in:
vicrodh committed 2026-07-19 10:01:37 -06:00
1 parent e81ed911f8
commit 075582920a
19 files changed
+770 -17

No files matched your search

+38
View File
@@ -5,6 +5,7 @@
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::PlaybackCache;
@@ -26,6 +27,11 @@ struct CacheState {
current_size: usize,
/// Track IDs currently being fetched
fetching: HashSet<u64>,
/// Track IDs whose last prefetch failed, with when it failed. Lets the
/// prefetch scheduler back off a track that is currently un-fetchable
/// (e.g. the account is being 403'd) instead of re-hammering it every
/// queue tick and feeding a request storm (issue #637).
failed: HashMap<u64, Instant>,
}
/// Audio cache manager with LRU eviction and optional disk spillover
@@ -56,6 +62,7 @@ impl AudioCache {
access_order: Vec::new(),
current_size: 0,
fetching: HashSet::new(),
failed: HashMap::new(),
}),
max_size_bytes,
playback_cache: None,
@@ -70,6 +77,7 @@ impl AudioCache {
access_order: Vec::new(),
current_size: 0,
fetching: HashSet::new(),
failed: HashMap::new(),
}),
max_size_bytes,
playback_cache: Some(playback_cache),
@@ -124,6 +132,35 @@ impl AudioCache {
self.state.lock().unwrap().fetching.remove(&track_id);
}
/// Record that a prefetch for this track failed (starts a back-off window).
pub fn mark_failed(&self, track_id: u64) {
self.state
.lock()
.unwrap()
.failed
.insert(track_id, Instant::now());
}
/// True if the track failed to prefetch within `cooldown` — the scheduler
/// uses this to skip re-hammering a currently un-fetchable track (issue
/// #637). Expired entries are cleaned up on read.
pub fn recently_failed(&self, track_id: u64, cooldown: Duration) -> bool {
let mut state = self.state.lock().unwrap();
match state.failed.get(&track_id) {
Some(when) if when.elapsed() < cooldown => true,
Some(_) => {
state.failed.remove(&track_id);
false
}
None => false,
}
}
/// Clear a track's failure marker (e.g. once it is successfully cached).
pub fn clear_failed(&self, track_id: u64) {
self.state.lock().unwrap().failed.remove(&track_id);
}
/// Insert a track into cache, evicting old entries to disk if needed
pub fn insert(&self, track_id: u64, data: Vec<u8>) {
let size = data.len();
@@ -204,6 +241,7 @@ impl AudioCache {
state.access_order.clear();
state.current_size = 0;
state.fetching.clear();
state.failed.clear();
log::info!("L1 memory cache cleared");
// Also clear L2 disk cache if present