feat(slint): offline artwork policy and local media-controls art

Shared artwork HTTP client gains the missing 10s total timeout (half-up
networks could pin the 16-permit semaphore). fetch_cached_http short-
circuits while offline: disk hits serve, misses return the placeholder
immediately with zero network and no negative caching. Now-playing meta
resolves MPRIS art to a cached file:// URL (or none) instead of an
unfetchable https URL while offline; system notifications skip their
blocking artwork download offline via an injected flag, keeping
qbz-media-controls frontend-agnostic.

Part of offline-mode slice 3; main.rs wiring rides the slice-3 wiring
commit.

cc
This commit is contained in:
vicrodh committed 2026-06-09 21:00:33 -06:00
1 parent b4caa5a309
commit dee85faadb
3 files changed
+63 -10

No files matched your search

+14 -5
View File
@@ -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<PathBuf, String> {
fn cache_artwork(url: &str, offline: bool) -> Result<PathBuf, String> {
use md5::{Digest, Md5};
use std::io::Write;
@@ -145,6 +148,10 @@ fn cache_artwork(url: &str) -> Result<PathBuf, String> {
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<Vec<u8>, 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<Vec<u8>, 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");
}
}
+29 -2
View File
@@ -207,6 +207,10 @@ pub fn open_cache() -> ImageCache {
/// resolve cover art. Set once at startup.
static SHARED_CACHE: std::sync::OnceLock<ImageCache> = 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<ImageCache> = 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<reqwest::Client> =
std::sync::LazyLock::new(|| reqwest::Client::builder().build().unwrap_or_default());
static HTTP: std::sync::LazyLock<reqwest::Client> = 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<ImageCache> {
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<std::path::PathBuf> {
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<String> {
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<Vec<u8>> {
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() {
+20 -3
View File
@@ -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;
});
}