From 075582920a91d4023a0b7759bf67291fae2df583 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:01:37 -0600 Subject: [PATCH] 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 --- crates/qbz-cache/src/audio_cache.rs | 38 +++++ crates/qbz-player/src/player/mod.rs | 15 ++ crates/qbz-qobuz/src/client.rs | 141 +++++++++++++++- crates/qbz-qobuz/src/error.rs | 14 ++ crates/qbz-qobuz/src/forbidden_breaker.rs | 159 ++++++++++++++++++ crates/qbz-qobuz/src/lib.rs | 1 + .../translations/de/LC_MESSAGES/qbz-ui.po | 41 +++++ .../translations/en/LC_MESSAGES/qbz-ui.po | 56 ++++++ .../translations/es/LC_MESSAGES/qbz-ui.po | 41 +++++ .../translations/fr/LC_MESSAGES/qbz-ui.po | 41 +++++ .../translations/ja/LC_MESSAGES/qbz-ui.po | 41 +++++ .../translations/nl/LC_MESSAGES/qbz-ui.po | 41 +++++ .../translations/pt/LC_MESSAGES/qbz-ui.po | 41 +++++ .../translations/ru/LC_MESSAGES/qbz-ui.po | 41 +++++ crates/qbz-ui/ui/album/AlbumPageView.slint | 11 ++ crates/qbz-ui/ui/artist/ArtistPageView.slint | 6 + crates/qbz-ui/ui/playlist/PlaylistView.slint | 9 + crates/qbz/src/playback.rs | 31 +++- crates/qbzd/src/qconnect/transport.rs | 19 ++- 19 files changed, 770 insertions(+), 17 deletions(-) create mode 100644 crates/qbz-qobuz/src/forbidden_breaker.rs diff --git a/crates/qbz-cache/src/audio_cache.rs b/crates/qbz-cache/src/audio_cache.rs index 75313b1b7..1992f7086 100644 --- a/crates/qbz-cache/src/audio_cache.rs +++ b/crates/qbz-cache/src/audio_cache.rs @@ -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, + /// 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, } /// 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) { 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 diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index 8e5853da5..90a1fe769 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -4292,6 +4292,19 @@ impl Player { log::debug!("[PREFETCH] Track {track_id} already being fetched"); return Ok(()); } + // Back off a track whose last prefetch failed recently (e.g. the account + // is being 403'd post-outage) instead of re-hammering it on every queue + // tick — that no-backoff loop is what escalated into an edge/IP block in + // issue #637. The client-side 403 breaker is the primary guard; this + // just keeps us from even spinning up the task/log spam. + const PREFETCH_FAIL_COOLDOWN: Duration = Duration::from_secs(20); + if self + .audio_cache + .recently_failed(track_id, PREFETCH_FAIL_COOLDOWN) + { + log::debug!("[PREFETCH] Track {track_id} recently failed — backing off"); + return Ok(()); + } self.audio_cache.mark_fetching(track_id); log::info!("[PREFETCH] Prefetching track {track_id} at {quality:?}"); @@ -4319,11 +4332,13 @@ impl Player { let len = data.len(); self.audio_cache.insert(track_id, data); self.audio_cache.unmark_fetching(track_id); + self.audio_cache.clear_failed(track_id); log::info!("[PREFETCH] Complete for track {track_id} ({len} bytes)"); Ok(()) } Err(e) => { self.audio_cache.unmark_fetching(track_id); + self.audio_cache.mark_failed(track_id); log::warn!("[PREFETCH] Failed for track {track_id}: {e}"); Err(e) } diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index 2f3af9cb6..17f47989f 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -12,11 +12,31 @@ use super::auth::{ use super::bundle::{self, BundleTokens}; use super::endpoints::{self, paths}; use super::error::{ApiError, Result}; +use super::forbidden_breaker::ForbiddenBreaker; use super::lyrics::{QobuzLyricsDocument, QobuzLyricsUrls}; use qbz_models::*; const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"; +/// Read a short, log-safe preview of a response body — for diagnosing an +/// unexpected non-2xx (e.g. distinguishing an edge/WAF HTML 403 from the API's +/// JSON error envelope, issue #637). Bounded so a large/HTML body can't bloat +/// the log; prefixed with " : " so it reads well appended to an error message. +async fn body_preview(response: reqwest::Response) -> String { + match response.text().await { + Ok(body) => { + let trimmed = body.trim(); + if trimmed.is_empty() { + " : ".to_string() + } else { + let preview: String = trimmed.chars().take(200).collect(); + format!(" : {preview}") + } + } + Err(_) => String::new(), + } +} + /// CMAF session state (session/start + infos for key derivation) struct CmafSession { session_id: String, @@ -32,6 +52,10 @@ pub struct QobuzClient { validated_secret: Arc>>, locale: Arc>, cmaf_session: Arc>>, + /// 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). + forbidden_breaker: Arc, } impl Clone for QobuzClient { @@ -43,6 +67,7 @@ impl Clone for QobuzClient { validated_secret: Arc::clone(&self.validated_secret), locale: Arc::clone(&self.locale), cmaf_session: Arc::clone(&self.cmaf_session), + forbidden_breaker: Arc::clone(&self.forbidden_breaker), } } } @@ -66,9 +91,47 @@ impl QobuzClient { validated_secret: Arc::new(RwLock::new(None)), locale: Arc::new(RwLock::new("en".to_string())), cmaf_session: Arc::new(RwLock::new(None)), + forbidden_breaker: Arc::new(ForbiddenBreaker::new()), }) } + // === 403 circuit breaker (issue #637) === + + /// Short-circuit an authenticated request when the 403 breaker is open. + /// Returns `Err(ForbiddenCircuitOpen)` — no network is touched — so a + /// post-outage 403 storm cannot get the user's IP edge-blocked. Callers put + /// this at the top of the hot streaming/favorites paths. + fn forbidden_guard(&self) -> Result<()> { + // Test hook: `QBZ_FORCE_403=1` forces the breaker open so the whole 403 + // back-off path (no-network short-circuit + abort-fallback + the audible + // "backing off" toast) can be smoke-tested on a HEALTHY account, which + // otherwise can't reproduce the incident. Off by default; issue #637. + if std::env::var_os("QBZ_FORCE_403").is_some() { + return Err(ApiError::ForbiddenCircuitOpen(30)); + } + if let Some(remaining) = self.forbidden_breaker.blocked_for() { + return Err(ApiError::ForbiddenCircuitOpen(remaining.as_secs())); + } + Ok(()) + } + + /// Feed an authenticated response's status to the breaker: a 403 counts + /// toward opening it; any success resets it. Other statuses are neutral + /// (they have their own handling and must not open the breaker). + fn note_forbidden_status(&self, status: StatusCode) { + if status == StatusCode::FORBIDDEN { + if let Some(cooldown) = self.forbidden_breaker.record_forbidden() { + log::warn!( + "[403-breaker] Repeated 403s from Qobuz — backing off for {}s (no network) \ + to avoid an edge/IP block. See issue #637.", + cooldown.as_secs() + ); + } + } else if status.is_success() { + self.forbidden_breaker.record_success(); + } + } + /// Initialize client by extracting bundle tokens. /// /// Warm start: if cached tokens exist, use them immediately so the UI never @@ -2149,6 +2212,8 @@ impl QobuzClient { /// Get stream URL for a track (requires auth + signature) pub async fn get_stream_url(&self, track_id: u64, quality: Quality) -> Result { + // Back off before the network if the 403 breaker is open (issue #637). + self.forbidden_guard()?; log::info!( "Getting stream URL for track {} with quality {:?}", track_id, @@ -2176,8 +2241,11 @@ impl QobuzClient { .send() .await?; - log::info!("Stream URL response status: {}", response.status()); - match response.status() { + let status = response.status(); + log::info!("Stream URL response status: {}", status); + // Feed the breaker: a 403 counts toward opening it; a 200 resets it. + self.note_forbidden_status(status); + match status { StatusCode::OK => { let json: Value = response.json().await?; log::debug!( @@ -2216,6 +2284,14 @@ impl QobuzClient { }) } StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret), + StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError( + "stream URL 401 — user auth token invalid or expired".to_string(), + )), + StatusCode::FORBIDDEN => { + let preview = body_preview(response).await; + log::warn!("Stream URL 403 for track {}{}", track_id, preview); + Err(ApiError::Forbidden(preview)) + } status => Err(ApiError::ApiResponse(format!( "Unexpected status: {}", status @@ -2257,6 +2333,16 @@ impl QobuzClient { log::error!("Invalid app secret"); return Err(ApiError::InvalidAppSecret); } + // A 403 (or an open breaker, or a 401) is NOT a per-quality + // restriction — every quality would 403 the same way. Abort the + // whole fallback loop immediately instead of firing 5 more + // requests per track and feeding the storm (issue #637). + Err(e @ (ApiError::Forbidden(_) + | ApiError::ForbiddenCircuitOpen(_) + | ApiError::AuthenticationError(_))) => { + log::warn!("Stream URL aborting quality fallback: {}", e); + return Err(e); + } Err(ApiError::TrackUnavailable(_)) => { // Track is completely unavailable on Qobuz track_unavailable = true; @@ -2281,6 +2367,8 @@ impl QobuzClient { /// Get user favorites (requires auth + signature) pub async fn get_favorites(&self, fav_type: &str, limit: u32, offset: u32) -> Result { + // Back off before the network if the 403 breaker is open (issue #637). + self.forbidden_guard()?; let url = endpoints::build_url(paths::FAVORITE_GET_USER_FAVORITES); let timestamp = get_timestamp(); let secret = self.secret().await?; @@ -2299,11 +2387,24 @@ impl QobuzClient { ]) .send() .await?; - log::debug!( - "[API] get_favorites({}) status={}", - fav_type, - http_response.status() - ); + let status = http_response.status(); + log::debug!("[API] get_favorites({}) status={}", fav_type, status); + // Feed the breaker AND check status BEFORE decoding: a 403's body is not + // our JSON envelope (it's an edge/WAF HTML/empty body), so a bare + // `.json()` surfaced it as a misleading "error decoding response body" + // instead of the real 403 (issue #637). + self.note_forbidden_status(status); + if !status.is_success() { + let preview = body_preview(http_response).await; + if status == StatusCode::FORBIDDEN { + log::warn!("get_favorites({}) 403{}", fav_type, preview); + return Err(ApiError::Forbidden(preview)); + } + return Err(ApiError::ApiResponse(format!( + "get_favorites failed with status {}{}", + status, preview + ))); + } let response: Value = http_response.json().await?; Ok(response) @@ -2682,7 +2783,10 @@ impl QobuzClient { } } - // We're the one task that actually starts a session. + // We're the one task that actually starts a session. Back off before + // the network if the 403 breaker is open (issue #637) — a cached + // session above is still served; only the network POST is gated. + self.forbidden_guard()?; log::info!("[CMAF] Starting new session"); let timestamp = get_timestamp(); let sig = sign_session_start(timestamp); @@ -2701,7 +2805,14 @@ impl QobuzClient { .await?; let status = response.status(); + // Feed the breaker: a 403 here counts toward opening it; success resets. + self.note_forbidden_status(status); if !status.is_success() { + if status == StatusCode::FORBIDDEN { + let preview = body_preview(response).await; + log::warn!("[CMAF] session/start 403{}", preview); + return Err(ApiError::Forbidden(preview)); + } return Err(ApiError::ApiResponse(format!( "session/start failed with status {}", status @@ -2740,6 +2851,9 @@ impl QobuzClient { let format_id = quality.id(); let url = endpoints::build_url(paths::FILE_URL); + // Back off before the network if the 403 breaker is open (issue #637). + self.forbidden_guard()?; + // Retry transient failures (5xx / 429 / network blips) with backoff so // a momentary hiccup on the next track's file/url is not turned into a // queue skip. A real 404 → TrackUnavailable is terminal and returns @@ -2787,9 +2901,20 @@ impl QobuzClient { format_id, status ); + // Feed the breaker: 403 counts toward opening it; 2xx resets. + self.note_forbidden_status(status); if !status.is_success() { let code = status.as_u16(); + if status == reqwest::StatusCode::FORBIDDEN { + let preview = body_preview(response).await; + log::warn!( + "[CMAF] file/url 403 for track {}{}", + track_id, + preview + ); + return Err(ApiError::Forbidden(preview)); + } return Err(if code == 404 { ApiError::TrackUnavailable(track_id) } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS { diff --git a/crates/qbz-qobuz/src/error.rs b/crates/qbz-qobuz/src/error.rs index b019fd9da..5dd1ecd41 100644 --- a/crates/qbz-qobuz/src/error.rs +++ b/crates/qbz-qobuz/src/error.rs @@ -31,6 +31,20 @@ pub enum ApiError { #[error("Track {0} is no longer available on Qobuz")] TrackUnavailable(u64), + /// Qobuz answered 403 Forbidden on an authenticated request. The account is + /// authenticated but not currently allowed to perform the action (entitlement + /// not restored after an outage, geo/concurrency limit, or an edge/WAF block). + /// The string is a short body preview for diagnostics. Terminal, never a + /// per-quality restriction — abort the fallback loop instead of retrying. + #[error("Access forbidden by Qobuz (HTTP 403){0}")] + Forbidden(String), + + /// The 403 circuit breaker is open after repeated forbidden responses; the + /// request was short-circuited WITHOUT touching the network so we don't get + /// the IP edge-blocked. Clears itself after a cooldown. See [`crate::forbidden_breaker`]. + #[error("Temporarily backing off after repeated 403s ({0}s remaining)")] + ForbiddenCircuitOpen(u64), + #[error("Offline mode is active - Qobuz services are disabled")] OfflineMode, diff --git a/crates/qbz-qobuz/src/forbidden_breaker.rs b/crates/qbz-qobuz/src/forbidden_breaker.rs new file mode 100644 index 000000000..1505cc0ab --- /dev/null +++ b/crates/qbz-qobuz/src/forbidden_breaker.rs @@ -0,0 +1,159 @@ +//! Forbidden (HTTP 403) circuit breaker. +//! +//! Issue #637: after a Qobuz outage + forced re-login, a user's account can be +//! transiently rejected (entitlement not yet restored, a wedged session, etc.). +//! Our prefetch scheduler re-drives `get_stream_url` / CMAF `session/start` +//! with no backoff, so a handful of legitimate 403s escalated into a sustained +//! ~2-3 req/s storm — which trips Qobuz's edge/WAF and turns the transient 403 +//! into a persistent per-IP block (the "error decoding response body" lines in +//! the report are that HTML/empty WAF body hitting a `.json()` call). +//! +//! This breaker converts "hammer until IP-banned" into "back off and recover": +//! once a small number of 403s land in quick succession it opens for an +//! exponential cooldown, during which the hot streaming/favorites paths +//! short-circuit WITHOUT touching the network. After the cooldown a single +//! probe is allowed through; a success closes the breaker, another 403 re-opens +//! it immediately with a longer cooldown. +//! +//! Only genuine 403s feed it — 5xx/429/404/transport errors have their own +//! handling and must not open it. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// Consecutive 403s (across the streaming/favorites paths) that open the breaker. +const OPEN_THRESHOLD: u32 = 3; +/// First cooldown once the breaker opens. +const BASE_COOLDOWN: Duration = Duration::from_secs(30); +/// Cooldown ceiling — doubling stops here. +const MAX_COOLDOWN: Duration = Duration::from_secs(120); + +struct Inner { + /// Consecutive 403s not yet cleared by a success. Not reset when the breaker + /// opens, so the post-cooldown probe re-opens on a single further 403. + consecutive: u32, + /// When set and still in the future, the breaker is open until this instant. + open_until: Option, + /// Cooldown applied on the next open (grows exponentially, capped). + next_cooldown: Duration, +} + +/// A shared, cheaply-clonable 403 circuit breaker. Uses a plain `Mutex` (no +/// `.await` held) so it is trivially callable from any async path. +pub struct ForbiddenBreaker { + inner: Mutex, +} + +impl Default for ForbiddenBreaker { + fn default() -> Self { + Self::new() + } +} + +impl ForbiddenBreaker { + pub fn new() -> Self { + Self { + inner: Mutex::new(Inner { + consecutive: 0, + open_until: None, + next_cooldown: BASE_COOLDOWN, + }), + } + } + + /// If the breaker is open, returns the remaining cooldown so the caller can + /// short-circuit (and log how long it is backing off). Returns `None` when + /// closed OR when the cooldown has just elapsed — in the latter case the + /// open state is cleared so exactly the next call is a half-open probe. + pub fn blocked_for(&self) -> Option { + let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(until) = g.open_until { + let now = Instant::now(); + if now < until { + return Some(until - now); + } + // Cooldown elapsed: half-open. Let the next call probe the network. + g.open_until = None; + } + None + } + + /// Record a successful authenticated response: fully resets the breaker. + pub fn record_success(&self) { + let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + g.consecutive = 0; + g.open_until = None; + g.next_cooldown = BASE_COOLDOWN; + } + + /// Record a 403. Opens (or re-opens) the breaker once the threshold is hit. + /// Returns the cooldown it opened with, if it opened on this call — for + /// logging. + pub fn record_forbidden(&self) -> Option { + let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + g.consecutive = g.consecutive.saturating_add(1); + if g.consecutive >= OPEN_THRESHOLD { + let cooldown = g.next_cooldown; + g.open_until = Some(Instant::now() + cooldown); + g.next_cooldown = (g.next_cooldown * 2).min(MAX_COOLDOWN); + Some(cooldown) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stays_closed_below_threshold() { + let b = ForbiddenBreaker::new(); + assert!(b.record_forbidden().is_none()); + assert!(b.record_forbidden().is_none()); + assert!(b.blocked_for().is_none(), "2 < threshold, still closed"); + } + + #[test] + fn opens_at_threshold_and_blocks() { + let b = ForbiddenBreaker::new(); + b.record_forbidden(); + b.record_forbidden(); + let opened = b.record_forbidden(); + assert_eq!(opened, Some(BASE_COOLDOWN), "3rd 403 opens with base cooldown"); + let remaining = b.blocked_for().expect("breaker is open"); + assert!(remaining <= BASE_COOLDOWN && remaining > Duration::ZERO); + } + + #[test] + fn success_resets_everything() { + let b = ForbiddenBreaker::new(); + b.record_forbidden(); + b.record_forbidden(); + b.record_success(); + // Counter cleared: it takes another full threshold to open again. + assert!(b.record_forbidden().is_none()); + assert!(b.record_forbidden().is_none()); + assert!(b.blocked_for().is_none()); + } + + #[test] + fn cooldown_grows_exponentially_and_caps() { + let b = ForbiddenBreaker::new(); + // First open: BASE. + b.record_forbidden(); + b.record_forbidden(); + assert_eq!(b.record_forbidden(), Some(BASE_COOLDOWN)); + // Consecutive is not reset on open, so a single further 403 re-opens + // with the doubled cooldown. + assert_eq!(b.record_forbidden(), Some(BASE_COOLDOWN * 2)); + assert_eq!(b.record_forbidden(), Some((BASE_COOLDOWN * 4).min(MAX_COOLDOWN))); + // Keep re-opening; cooldown must never exceed the cap. + for _ in 0..8 { + let c = b.record_forbidden().expect("re-opens each time past threshold"); + assert!(c <= MAX_COOLDOWN); + } + assert_eq!(b.record_forbidden(), Some(MAX_COOLDOWN), "cooldown pinned at cap"); + } +} diff --git a/crates/qbz-qobuz/src/lib.rs b/crates/qbz-qobuz/src/lib.rs index 6fedce572..c2ac08ea6 100644 --- a/crates/qbz-qobuz/src/lib.rs +++ b/crates/qbz-qobuz/src/lib.rs @@ -36,6 +36,7 @@ pub mod client; pub mod cmaf; pub mod endpoints; pub mod error; +pub mod forbidden_breaker; pub mod link_resolver; pub mod lyrics; pub mod offline_gate; diff --git a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po index b79644138..21c78c3c3 100644 --- a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po @@ -4619,3 +4619,44 @@ msgstr "Aus dem Katalog" msgid "In library" msgstr "In der Bibliothek" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "Zu Favoriten hinzufügen" + +msgid "Remove from favorites" +msgstr "Aus Favoriten entfernen" + +msgid "Start radio" +msgstr "Radio starten" + +msgid "Booklet" +msgstr "Booklet" + +msgid "Add to Mixtape" +msgstr "Zu Mixtape hinzufügen" + +msgid "Album info" +msgstr "Album-Infos" + +msgid "More options" +msgstr "Weitere Optionen" + +msgid "Follow artist" +msgstr "Künstler folgen" + +msgid "Unfollow" +msgstr "Nicht mehr folgen" + +msgid "Artist network" +msgstr "Künstler-Netzwerk" + +msgid "Upload to Qobuz" +msgstr "Zu Qobuz hochladen" + +msgid "Select tracks" +msgstr "Titel auswählen" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuz weist Anfragen vorübergehend ab – kurze Pause. Bitte gleich erneut versuchen." + diff --git a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po index ca8ff9dd9..14635d90a 100644 --- a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po @@ -397,3 +397,59 @@ msgstr "From catalog" msgid "In library" msgstr "In library" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Play" +msgstr "Play" + +msgid "Shuffle" +msgstr "Shuffle" + +msgid "Edit metadata" +msgstr "Edit metadata" + +msgid "Add to favorites" +msgstr "Add to favorites" + +msgid "Remove from favorites" +msgstr "Remove from favorites" + +msgid "Start radio" +msgstr "Start radio" + +msgid "Booklet" +msgstr "Booklet" + +msgid "Add to Mixtape" +msgstr "Add to Mixtape" + +msgid "Album info" +msgstr "Album info" + +msgid "More options" +msgstr "More options" + +msgid "Follow artist" +msgstr "Follow artist" + +msgid "Unfollow" +msgstr "Unfollow" + +msgid "Artist network" +msgstr "Artist network" + +msgid "Make available offline" +msgstr "Make available offline" + +msgid "Upload to Qobuz" +msgstr "Upload to Qobuz" + +msgid "Edit playlist" +msgstr "Edit playlist" + +msgid "Select tracks" +msgstr "Select tracks" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuz is temporarily refusing requests — backing off. Try again shortly." + diff --git a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po index 21ccdf9da..edf6ac78b 100644 --- a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po @@ -4649,3 +4649,44 @@ msgstr "Del catálogo" msgid "In library" msgstr "En la biblioteca" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "Agregar a favoritos" + +msgid "Remove from favorites" +msgstr "Quitar de favoritos" + +msgid "Start radio" +msgstr "Iniciar radio" + +msgid "Booklet" +msgstr "Libreto" + +msgid "Add to Mixtape" +msgstr "Agregar a Mixtape" + +msgid "Album info" +msgstr "Información del álbum" + +msgid "More options" +msgstr "Más opciones" + +msgid "Follow artist" +msgstr "Seguir artista" + +msgid "Unfollow" +msgstr "Dejar de seguir" + +msgid "Artist network" +msgstr "Red del artista" + +msgid "Upload to Qobuz" +msgstr "Subir a Qobuz" + +msgid "Select tracks" +msgstr "Seleccionar pistas" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuz está rechazando solicitudes temporalmente; esperando un momento. Intenta de nuevo en breve." + diff --git a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po index 74e6e1431..3e88a18b1 100644 --- a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po @@ -4619,3 +4619,44 @@ msgstr "Du catalogue" msgid "In library" msgstr "Dans la bibliothèque" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "Ajouter aux favoris" + +msgid "Remove from favorites" +msgstr "Retirer des favoris" + +msgid "Start radio" +msgstr "Démarrer la radio" + +msgid "Booklet" +msgstr "Livret" + +msgid "Add to Mixtape" +msgstr "Ajouter à la Mixtape" + +msgid "Album info" +msgstr "Infos de l'album" + +msgid "More options" +msgstr "Plus d'options" + +msgid "Follow artist" +msgstr "Suivre l'artiste" + +msgid "Unfollow" +msgstr "Ne plus suivre" + +msgid "Artist network" +msgstr "Réseau de l'artiste" + +msgid "Upload to Qobuz" +msgstr "Envoyer vers Qobuz" + +msgid "Select tracks" +msgstr "Sélectionner des pistes" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuz refuse temporairement les requêtes — pause en cours. Réessayez dans un instant." + diff --git a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po index 29284beb5..fa170a424 100644 --- a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po @@ -4607,3 +4607,44 @@ msgstr "カタログから" msgid "In library" msgstr "ライブラリ内" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "お気に入りに追加" + +msgid "Remove from favorites" +msgstr "お気に入りから削除" + +msgid "Start radio" +msgstr "ラジオを開始" + +msgid "Booklet" +msgstr "ブックレット" + +msgid "Add to Mixtape" +msgstr "ミックステープに追加" + +msgid "Album info" +msgstr "アルバム情報" + +msgid "More options" +msgstr "その他のオプション" + +msgid "Follow artist" +msgstr "アーティストをフォロー" + +msgid "Unfollow" +msgstr "フォロー解除" + +msgid "Artist network" +msgstr "アーティストネットワーク" + +msgid "Upload to Qobuz" +msgstr "Qobuzにアップロード" + +msgid "Select tracks" +msgstr "トラックを選択" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuzが一時的にリクエストを拒否しています。少し待って再試行してください。" + diff --git a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po index 299ff966f..67f6fecf7 100644 --- a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po @@ -4631,3 +4631,44 @@ msgstr "Uit catalogus" msgid "In library" msgstr "In bibliotheek" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "Aan favorieten toevoegen" + +msgid "Remove from favorites" +msgstr "Uit favorieten verwijderen" + +msgid "Start radio" +msgstr "Radio starten" + +msgid "Booklet" +msgstr "Boekje" + +msgid "Add to Mixtape" +msgstr "Aan Mixtape toevoegen" + +msgid "Album info" +msgstr "Albuminfo" + +msgid "More options" +msgstr "Meer opties" + +msgid "Follow artist" +msgstr "Artiest volgen" + +msgid "Unfollow" +msgstr "Ontvolgen" + +msgid "Artist network" +msgstr "Artiestennetwerk" + +msgid "Upload to Qobuz" +msgstr "Uploaden naar Qobuz" + +msgid "Select tracks" +msgstr "Nummers selecteren" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuz weigert tijdelijk verzoeken — even pauzeren. Probeer het zo opnieuw." + diff --git a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po index 02f84f7f1..b082cc2a9 100644 --- a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po @@ -4619,3 +4619,44 @@ msgstr "Do catálogo" msgid "In library" msgstr "Na biblioteca" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "Adicionar aos favoritos" + +msgid "Remove from favorites" +msgstr "Remover dos favoritos" + +msgid "Start radio" +msgstr "Iniciar rádio" + +msgid "Booklet" +msgstr "Livreto" + +msgid "Add to Mixtape" +msgstr "Adicionar à Mixtape" + +msgid "Album info" +msgstr "Informações do álbum" + +msgid "More options" +msgstr "Mais opções" + +msgid "Follow artist" +msgstr "Seguir artista" + +msgid "Unfollow" +msgstr "Deixar de seguir" + +msgid "Artist network" +msgstr "Rede do artista" + +msgid "Upload to Qobuz" +msgstr "Enviar para o Qobuz" + +msgid "Select tracks" +msgstr "Selecionar faixas" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "O Qobuz está recusando solicitações temporariamente — aguardando. Tente novamente em breve." + diff --git a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po index f558ab5f3..f6d1d4af3 100644 --- a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po @@ -4633,3 +4633,44 @@ msgstr "Из каталога" msgid "In library" msgstr "В библиотеке" + +# Issue #637: header action-button tooltips + 403 back-off toast +msgid "Add to favorites" +msgstr "Добавить в избранное" + +msgid "Remove from favorites" +msgstr "Удалить из избранного" + +msgid "Start radio" +msgstr "Запустить радио" + +msgid "Booklet" +msgstr "Буклет" + +msgid "Add to Mixtape" +msgstr "Добавить в Mixtape" + +msgid "Album info" +msgstr "Об альбоме" + +msgid "More options" +msgstr "Ещё" + +msgid "Follow artist" +msgstr "Подписаться на исполнителя" + +msgid "Unfollow" +msgstr "Отписаться" + +msgid "Artist network" +msgstr "Сеть исполнителя" + +msgid "Upload to Qobuz" +msgstr "Загрузить в Qobuz" + +msgid "Select tracks" +msgstr "Выбрать треки" + +msgid "Qobuz is temporarily refusing requests — backing off. Try again shortly." +msgstr "Qobuz временно отклоняет запросы — пауза. Повторите попытку чуть позже." + diff --git a/crates/qbz-ui/ui/album/AlbumPageView.slint b/crates/qbz-ui/ui/album/AlbumPageView.slint index dbb0bbf68..3b22f8efc 100644 --- a/crates/qbz-ui/ui/album/AlbumPageView.slint +++ b/crates/qbz-ui/ui/album/AlbumPageView.slint @@ -508,6 +508,7 @@ export component AlbumPageView inherits Rectangle { icon: @image-url("../assets/icons/play-fill.svg"); primary: true; on-surface: root.hdr-on-surface; + tooltip: @tr("Play"); // Spin only while THIS album's first track is fetching. loading: NowPlayingState.loading && NowPlayingState.album-id == AlbumState.id; @@ -520,6 +521,7 @@ export component AlbumPageView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/shuffle.svg"); on-surface: root.hdr-on-surface; + tooltip: @tr("Shuffle"); clicked => { root.media-action("album", AlbumState.id, "shuffle"); } @@ -532,6 +534,7 @@ export component AlbumPageView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/pencil.svg"); on-surface: root.hdr-on-surface; + tooltip: @tr("Edit metadata"); clicked => { root.media-action("album", AlbumState.id, "edit"); } @@ -548,6 +551,9 @@ export component AlbumPageView inherits Rectangle { ? @image-url("../assets/icons/heart-filled.svg") : @image-url("../assets/icons/heart.svg"); on-surface: root.hdr-on-surface; + tooltip: AlbumState.is-favorite + ? @tr("Remove from favorites") + : @tr("Add to favorites"); active: AlbumState.is-favorite; loading: AlbumState.favorite-loading; clicked => { @@ -560,6 +566,7 @@ export component AlbumPageView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/radio.svg"); on-surface: root.hdr-on-surface; + tooltip: @tr("Start radio"); clicked => { root.media-action("album", AlbumState.id, "radio"); } @@ -570,6 +577,7 @@ export component AlbumPageView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/book-open.svg"); on-surface: root.hdr-on-surface; + tooltip: @tr("Booklet"); clicked => { root.media-action("album", AlbumState.id, "booklet"); } @@ -580,6 +588,7 @@ export component AlbumPageView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/cassette-tape.svg"); on-surface: root.hdr-on-surface; + tooltip: @tr("Add to Mixtape"); clicked => { root.media-action("album", AlbumState.id, "add-to-mixtape"); } @@ -593,6 +602,7 @@ export component AlbumPageView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/info.svg"); on-surface: root.hdr-on-surface; + tooltip: @tr("Album info"); clicked => { root.media-action("album", AlbumState.id, "info"); } @@ -606,6 +616,7 @@ export component AlbumPageView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/ellipsis.svg"); on-surface: root.hdr-on-surface; + tooltip: @tr("More options"); clicked => { album-menu.show(); } diff --git a/crates/qbz-ui/ui/artist/ArtistPageView.slint b/crates/qbz-ui/ui/artist/ArtistPageView.slint index fa2690620..3d4caee83 100644 --- a/crates/qbz-ui/ui/artist/ArtistPageView.slint +++ b/crates/qbz-ui/ui/artist/ArtistPageView.slint @@ -415,6 +415,9 @@ export component ArtistPageView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/heart.svg"); on-surface: root.hdr-on-surface; + tooltip: ArtistState.is-following + ? @tr("Unfollow") + : @tr("Follow artist"); active: ArtistState.is-following; clicked => { root.media-action("artist", ArtistState.id, "follow"); @@ -429,6 +432,7 @@ export component ArtistPageView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/radio.svg"); on-surface: root.hdr-on-surface; + tooltip: @tr("Start radio"); clicked => { radio-popup.show(); } @@ -462,6 +466,7 @@ export component ArtistPageView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/element-connect.svg"); on-surface: root.hdr-on-surface; + tooltip: @tr("Artist network"); active: NetworkSidebarState.open; clicked => { NetworkSidebarState.open = !NetworkSidebarState.open; @@ -478,6 +483,7 @@ export component ArtistPageView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/ellipsis.svg"); on-surface: root.hdr-on-surface; + tooltip: @tr("More options"); clicked => { artist-overflow-menu.show(); } diff --git a/crates/qbz-ui/ui/playlist/PlaylistView.slint b/crates/qbz-ui/ui/playlist/PlaylistView.slint index bf41e7bd4..02a4fee39 100644 --- a/crates/qbz-ui/ui/playlist/PlaylistView.slint +++ b/crates/qbz-ui/ui/playlist/PlaylistView.slint @@ -643,6 +643,7 @@ export component PlaylistView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/play-fill.svg"); primary: true; + tooltip: @tr("Play"); // Playlists have no header gradient — these sit on // the plain page background, so use the theme-aware // variant (reads on light themes). @@ -658,6 +659,7 @@ export component PlaylistView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/shuffle.svg"); on-surface: true; + tooltip: @tr("Shuffle"); enabled: PlaylistState.tracks.length > 0; clicked => { root.shuffle(); @@ -673,6 +675,9 @@ export component PlaylistView inherits Rectangle { icon: @image-url("../assets/icons/heart.svg"); active: PlaylistState.is-favorite; on-surface: true; + tooltip: PlaylistState.is-favorite + ? @tr("Remove from favorites") + : @tr("Add to favorites"); clicked => { root.toggle-favorite(); } @@ -742,6 +747,7 @@ export component PlaylistView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/cloud-download.svg"); on-surface: true; + tooltip: @tr("Make available offline"); enabled: PlaylistState.tracks.length > 0; clicked => { root.media-action("playlist", PlaylistState.id, "cache"); @@ -758,6 +764,7 @@ export component PlaylistView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/cloud-upload.svg"); on-surface: true; + tooltip: @tr("Upload to Qobuz"); enabled: PlaylistState.tracks.length > 0; clicked => { root.show-upload-confirm = true; @@ -771,6 +778,7 @@ export component PlaylistView inherits Rectangle { CircleAction { icon: @image-url("../assets/icons/pen-line.svg"); on-surface: true; + tooltip: @tr("Edit playlist"); clicked => { root.edit(); } @@ -787,6 +795,7 @@ export component PlaylistView inherits Rectangle { icon: @image-url("../assets/icons/square-check-big.svg"); active: PlaylistState.multi-select-mode; on-surface: true; + tooltip: @tr("Select tracks"); clicked => { root.media-action("playlist", "", "select-toggle"); } diff --git a/crates/qbz/src/playback.rs b/crates/qbz/src/playback.rs index df302985b..91072abaa 100644 --- a/crates/qbz/src/playback.rs +++ b/crates/qbz/src/playback.rs @@ -276,6 +276,18 @@ fn is_terminal_unavailable(e: &str) -> bool { || e.contains("No valid quality available") // ApiError::NoQualityAvailable } +/// True when a play error is a Qobuz 403 / the client-side 403 back-off (issue +/// #637). This is NOT the track's fault (every track 403s the same way), so it +/// must NOT count as an "unavailable" skip — skipping good tracks helps +/// nothing and, pre-fix, burned through 5 of them showing a misleading "no +/// longer available". Instead we stop cleanly and tell the user. Matches the +/// `ApiError::Forbidden` / `ApiError::ForbiddenCircuitOpen` Display texts that +/// survive the `Result<(), String>` flattening. +fn is_forbidden_backoff(e: &str) -> bool { + e.contains("Access forbidden by Qobuz") // ApiError::Forbidden + || e.contains("backing off after repeated 403s") // ApiError::ForbiddenCircuitOpen +} + /// Offline playability verdict for one queue track (offline-MODE slice 3d). #[derive(PartialEq)] enum OfflinePlayability { @@ -656,7 +668,24 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak, track_id // bounded, issue #467). Without this the queue cursor parks on the // dead track and playback stops. Terminal errors only: transient // failures were already retried by the client and should not skip. - if still_current && is_terminal_unavailable(&e) { + if still_current && is_forbidden_backoff(&e) { + // Qobuz is 403'ing (or the client breaker is backing off). + // Don't skip — it's not the track. Stop cleanly and surface it. + log::warn!( + "[qbz-slint] playback: Qobuz 403 / backing off — not skipping (account or edge issue, see #637)" + ); + crate::toast::show_weak( + weak, + qbz_i18n::t( + "Qobuz is temporarily refusing requests — backing off. Try again shortly.", + ), + crate::ToastKind::Error, + ); + set_viz_paused(runtime, true); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_playing(false); + }); + } else if still_current && is_terminal_unavailable(&e) { auto_skip_unavailable(runtime, weak, track_id).await; } } diff --git a/crates/qbzd/src/qconnect/transport.rs b/crates/qbzd/src/qconnect/transport.rs index c0ae7be05..a203c1ab9 100644 --- a/crates/qbzd/src/qconnect/transport.rs +++ b/crates/qbzd/src/qconnect/transport.rs @@ -546,20 +546,23 @@ async fn fetch_qconnect_transport_credentials( .map_err(|err| format!("qws/createToken HTTP request failed: {err}"))?; let status = response.status(); - let payload: Value = response - .json() + // Read the raw body and check status BEFORE decoding: a 403's body is an + // edge/WAF HTML or empty page, not our JSON envelope, so a bare `.json()` + // surfaced it as a misleading "response decode failed" instead of the real + // status (issue #637). + let body = response + .text() .await - .map_err(|err| format!("qws/createToken response decode failed: {err}"))?; + .map_err(|err| format!("qws/createToken response read failed: {err}"))?; if !status.is_success() { - let preview = serde_json::to_string(&payload) - .unwrap_or_else(|_| "".to_string()) - .chars() - .take(300) - .collect::(); + let preview = body.trim().chars().take(300).collect::(); return Err(format!("qws/createToken status {status}: {preview}")); } + let payload: Value = serde_json::from_str(&body) + .map_err(|err| format!("qws/createToken response decode failed: {err}"))?; + let jwt_qws_payload = payload .get("jwt_qws") .ok_or_else(|| "qws/createToken response missing jwt_qws payload".to_string())?;