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:
19 files changed
+770
-17
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user