diff --git a/crates/qbz-media-controls/src/notify.rs b/crates/qbz-media-controls/src/notify.rs index 708e34dd3..1ed532723 100644 --- a/crates/qbz-media-controls/src/notify.rs +++ b/crates/qbz-media-controls/src/notify.rs @@ -126,8 +126,11 @@ fn http_client() -> &'static reqwest::blocking::Client { /// Resolve `url` to a local image file: a `file://`/`asset://` URL maps /// straight through, an http(s) URL is downloaded and cached by md5(url). +/// `offline` = local paths + md5 cache hits only, never the HTTP download — +/// the verdict is injected by the caller so this crate stays frontend-agnostic +/// (no dependency on the app's offline-mode engine). #[cfg(any(target_os = "linux", target_os = "macos"))] -fn cache_artwork(url: &str) -> Result { +fn cache_artwork(url: &str, offline: bool) -> Result { use md5::{Digest, Md5}; use std::io::Write; @@ -145,6 +148,10 @@ fn cache_artwork(url: &str) -> Result { return Ok(cache_path); } + if offline { + return Err("offline: artwork not cached locally".to_string()); + } + let response = http_client() .get(url) .header("User-Agent", "Mozilla/5.0") @@ -215,8 +222,9 @@ fn prepare_icon_bytes(path: &std::path::Path) -> Result, String> { /// Show a track-change notification. Fire-and-forget: every failure is logged, /// none propagated. Must be called from within a tokio runtime (it uses -/// `spawn_blocking` for the HTTP/image work). -pub async fn show_track_notification(meta: NotificationMeta) { +/// `spawn_blocking` for the HTTP/image work). `offline` skips the artwork +/// HTTP download (local paths / disk-cache hits still render an icon). +pub async fn show_track_notification(meta: NotificationMeta, offline: bool) { let body = build_body(&meta); log::info!( "[notify] track notification: {} by {}", @@ -234,7 +242,7 @@ pub async fn show_track_notification(meta: NotificationMeta) { if let Some(url) = meta.art_url.clone() { let prepared = tokio::task::spawn_blocking(move || -> Result, String> { - let path = cache_artwork(&url)?; + let path = cache_artwork(&url, offline)?; prepare_icon_bytes(&path) }) .await; @@ -265,7 +273,7 @@ pub async fn show_track_notification(meta: NotificationMeta) { { let _ = tokio::task::spawn_blocking(move || { let _ = notify_rust::set_application("com.blitzfc.qbz"); - let artwork_path = meta.art_url.as_deref().and_then(|url| match cache_artwork(url) { + let artwork_path = meta.art_url.as_deref().and_then(|url| match cache_artwork(url, offline) { Ok(path) => Some(path), Err(e) => { log::debug!("[notify] could not cache artwork: {e}"); @@ -287,6 +295,7 @@ pub async fn show_track_notification(meta: NotificationMeta) { #[cfg(not(any(target_os = "linux", target_os = "macos")))] { let _ = body; + let _ = offline; log::info!("[notify] desktop notifications not implemented on this platform"); } } diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index d3addd37d..5740bf0ac 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -207,6 +207,10 @@ pub fn open_cache() -> ImageCache { /// resolve cover art. Set once at startup. static SHARED_CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); +/// Total-request timeout: without one, a half-up network (DNS blackhole, +/// captive portal) pins a `MAX_CONCURRENT` semaphore permit indefinitely. +const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + /// Process-wide async HTTP client for artwork downloads. A single client pools /// connections / reuses keep-alive across all concurrent artwork jobs /// (`MAX_CONCURRENT` = 16), instead of `reqwest::get` building a fresh client + @@ -214,8 +218,12 @@ static SHARED_CACHE: std::sync::OnceLock = std::sync::OnceLock::new( /// rustls per the workspace default; if the builder fails, fall back to the /// default client (equivalent to `Client::new()`), keeping this infallible — /// matching the silent-fallback style of `open_cache`. -static HTTP: std::sync::LazyLock = - std::sync::LazyLock::new(|| reqwest::Client::builder().build().unwrap_or_default()); +static HTTP: std::sync::LazyLock = std::sync::LazyLock::new(|| { + reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .unwrap_or_default() +}); /// Publish the image cache for `shared_cache()` consumers. Call once. pub fn set_shared_cache(cache: ImageCache) { @@ -227,6 +235,21 @@ pub fn shared_cache() -> Option { SHARED_CACHE.get().cloned() } +/// Disk-cache lookup for a remote artwork URL: the cached file's path, or +/// `None` on miss / unopened cache. Never touches the network — offline +/// consumers (MPRIS art, artwork save-as) use this instead of downloading. +pub fn cached_path_for(url: &str) -> Option { + let cache = shared_cache()?; + let guard = cache.lock().ok()?; + guard.as_ref()?.get(url) +} + +/// `file://` form of [`cached_path_for`], for the MPRIS `artUrl` property. +pub fn cached_file_url_for(url: &str) -> Option { + let path = cached_path_for(url)?; + ArtworkRef::LocalFile(path.to_string_lossy().into_owned()).to_mpris_url() +} + /// Build a Slint image from decoded RGBA8 pixels. Returns an empty image /// if the buffer length does not match the dimensions. pub fn pixels_to_image(pixels: &[u8], width: u32, height: u32) -> slint::Image { @@ -365,6 +388,10 @@ async fn fetch_cached_http(url: &str, cache: &ImageCache) -> Option> { match cached_path { Some(path) => tokio::fs::read(&path).await.ok(), + // Offline: a miss must not burn a network attempt (or pin a semaphore + // permit) — fail soft to the placeholder; nothing negative is cached, + // so the cover retries naturally once back online. + None if crate::offline_mode::engine().is_offline() => None, None => { let downloaded = HTTP.get(url).send().await.ok()?.bytes().await.ok()?.to_vec(); if let Ok(guard) = cache.lock() { diff --git a/crates/qbz-slint/src/playback.rs b/crates/qbz-slint/src/playback.rs index 8d93355d8..8f9158881 100644 --- a/crates/qbz-slint/src/playback.rs +++ b/crates/qbz-slint/src/playback.rs @@ -1023,6 +1023,23 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We t.set_track(title.clone(), artist.clone(), album.clone()); } + // Offline: desktop widgets can't fetch an https artUrl. Swap remote covers + // for the shared disk-cache copy (file://) when present; local/Plex art + // keeps its normal URL (already file:// / LAN Plex). On a cache miss MPRIS + // gets no art, while the notification keeps the remote URL so its own md5 + // disk cache can still serve it (the offline flag below blocks the + // download). + let offline = crate::offline_mode::engine().is_offline(); + let mut mpris_art = artwork.to_mpris_url(); + let mut notify_art = mpris_art.clone(); + if offline { + if let qbz_models::ArtworkRef::Remote(url) = &artwork { + let cached = crate::artwork::cached_file_url_for(url); + notify_art = cached.clone().or(notify_art); + mpris_art = cached; + } + } + // Push to the OS media controls (MPRIS / SMTC / MediaRemote). The app icon // GNOME shows comes from the MPRIS DesktopEntry; `art_url` is the album art // (`mpris:artUrl`) — remote Qobuz covers pass through, local covers become a @@ -1033,7 +1050,7 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We artist: artist.clone(), album: album.clone(), duration: (duration > 0).then(|| std::time::Duration::from_secs(duration as u64)), - art_url: artwork.to_mpris_url(), + art_url: mpris_art, }); mc.set_playback( qbz_media_controls::PlaybackStatus::Playing, @@ -1051,7 +1068,7 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We album: album.clone(), bit_depth: track.bit_depth, sample_rate: track.sample_rate, - art_url: artwork.to_mpris_url(), + art_url: notify_art, }; tokio::spawn(async move { if let Some(svc) = crate::qconnect_service::service() { @@ -1059,7 +1076,7 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We return; } } - qbz_media_controls::show_track_notification(notify_meta).await; + qbz_media_controls::show_track_notification(notify_meta, offline).await; }); }