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:
3 files changed
+63
-10
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user