feat(playback): source-aware artwork resolution (ArtworkRef + PlaybackSource)

Add the frontend-agnostic contract for the source-aware playback context:

- qbz-models: new `source` module with `PlaybackSource` (Qobuz / OfflineCache
  / Local / Plex; parses the existing stringly-typed `source` field, defaults
  to Qobuz) and `ArtworkRef` (Remote / LocalFile / PlexThumb / Embedded /
  None). `QueueTrack::source_kind()` + `QueueTrack::artwork_ref()` derive both
  without changing the wire format. `PlaybackSource::is_qobuz_streamable()`
  seeds the Qobuz Connect queue gate (only Qobuz-sourced tracks are castable).

- qbz-slint artwork pipeline: `fetch_and_decode_ref(&ArtworkRef, ...)` resolves
  cover bytes per origin — HTTP/Plex through the disk cache, local files read
  directly, embedded bytes in place. This fixes the root cause of local/Plex
  artwork never reaching the UI (the loader was HTTP-only). The URL-only
  `fetch_and_decode` becomes a thin wrapper, so all existing card/row jobs are
  unchanged. The now-playing loader now takes an `ArtworkRef` derived from the
  queue track, so local-library/Plex covers reach the now-playing bar.

Groundwork for the Local Library Slint port (source-aware playback context
designed in from the start). qbz-models tests pass; qbz-slint cargo check green.

cc
This commit is contained in:
vicrodh committed 2026-05-28 16:36:02 -06:00
1 parent 2fe7008e23
commit 827ba9d77a
4 files changed
+270 -18

No files matched your search

+55 -13
View File
@@ -9,6 +9,7 @@
use std::sync::{Arc, Mutex};
use qbz_cache::ImageCacheService;
use qbz_models::ArtworkRef;
use slint::{ComponentHandle, Model};
use tokio::sync::Semaphore;
@@ -225,21 +226,16 @@ pub fn spawn_loads(jobs: Vec<ArtworkJob>, window: slint::Weak<AppWindow>, cache:
}
}
/// Resolve one cover image to raw RGBA8 pixels, downscaled to `decode_size`.
/// Reads from the shared cache on a hit; on a miss downloads, stores, and
/// uses the bytes. Runs on a worker thread; the result tuple is `Send`.
pub async fn fetch_and_decode(
url: &str,
cache: &ImageCache,
decode_size: u32,
) -> Option<(Vec<u8>, u32, u32)> {
/// Resolve a remote URL to raw bytes via the shared disk cache: a hit reads
/// from disk, a miss downloads and stores. HTTP(S) only.
async fn fetch_cached_http(url: &str, cache: &ImageCache) -> Option<Vec<u8>> {
let cached_path = {
let guard = cache.lock().ok()?;
guard.as_ref().and_then(|service| service.get(url))
};
let bytes: Vec<u8> = match cached_path {
Some(path) => tokio::fs::read(&path).await.ok()?,
match cached_path {
Some(path) => tokio::fs::read(&path).await.ok(),
None => {
let downloaded = reqwest::get(url).await.ok()?.bytes().await.ok()?.to_vec();
if let Ok(guard) = cache.lock() {
@@ -247,11 +243,14 @@ pub async fn fetch_and_decode(
let _ = service.store(url, &downloaded);
}
}
downloaded
Some(downloaded)
}
};
}
}
let rgba = image::load_from_memory(&bytes)
/// Decode raw image bytes to RGBA8, downscaled to `decode_size`.
fn decode_rgba(bytes: &[u8], decode_size: u32) -> Option<(Vec<u8>, u32, u32)> {
let rgba = image::load_from_memory(bytes)
.ok()?
.thumbnail(decode_size, decode_size)
.to_rgba8();
@@ -259,6 +258,49 @@ pub async fn fetch_and_decode(
Some((rgba.into_raw(), width, height))
}
/// Resolve an [`ArtworkRef`] to raw RGBA8 pixels, downscaled to
/// `decode_size`, regardless of origin. This is the source-aware entry
/// point that fixes local/Plex artwork never reaching the UI: HTTP and Plex
/// thumbnails go through the disk cache, local files are read directly, and
/// embedded bytes decode in place. Runs on a worker thread; the result tuple
/// is `Send`.
pub async fn fetch_and_decode_ref(
art: &ArtworkRef,
cache: &ImageCache,
decode_size: u32,
) -> Option<(Vec<u8>, u32, u32)> {
if art.is_empty() {
return None;
}
let bytes: Vec<u8> = match art {
ArtworkRef::None => return None,
ArtworkRef::Embedded(b) => b.clone(),
ArtworkRef::LocalFile(path) => tokio::fs::read(path).await.ok()?,
ArtworkRef::Remote(url) => fetch_cached_http(url, cache).await?,
ArtworkRef::PlexThumb {
base_url,
token,
path,
} => {
let sep = if path.contains('?') { '&' } else { '?' };
let url = format!("{base_url}{path}{sep}X-Plex-Token={token}");
fetch_cached_http(&url, cache).await?
}
};
decode_rgba(&bytes, decode_size)
}
/// Resolve one cover image (by remote URL) to raw RGBA8 pixels. Kept for the
/// many card/row jobs that already hold a URL; source-aware call sites
/// (local library, Plex) use [`fetch_and_decode_ref`].
pub async fn fetch_and_decode(
url: &str,
cache: &ImageCache,
decode_size: u32,
) -> Option<(Vec<u8>, u32, u32)> {
fetch_and_decode_ref(&ArtworkRef::Remote(url.to_string()), cache, decode_size).await
}
/// Representative color of decoded RGBA pixels for the header gradient.
///
/// A plain average desaturates badly (everything trends grey), so the
+8 -5
View File
@@ -146,8 +146,11 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak<AppWindow>, track_id
}
/// Resolve the now-playing cover and apply it to `NowPlayingState`.
fn load_now_playing_artwork(weak: slint::Weak<AppWindow>, url: String) {
if url.is_empty() {
///
/// Takes a source-aware [`qbz_models::ArtworkRef`] so local-library and Plex
/// covers reach the now-playing bar, not just remote Qobuz URLs.
fn load_now_playing_artwork(weak: slint::Weak<AppWindow>, art: qbz_models::ArtworkRef) {
if art.is_empty() {
return;
}
let Some(cache) = crate::artwork::shared_cache() else {
@@ -155,7 +158,7 @@ fn load_now_playing_artwork(weak: slint::Weak<AppWindow>, url: String) {
};
tokio::spawn(async move {
let Some((pixels, w, h)) =
crate::artwork::fetch_and_decode(&url, &cache, 160).await
crate::artwork::fetch_and_decode_ref(&art, &cache, 160).await
else {
return;
};
@@ -199,7 +202,7 @@ async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindo
let artist_id = track.artist_id.map(|id| id.to_string()).unwrap_or_default();
let track_id = track.id.to_string();
let duration = track.duration_secs;
let artwork_url = track.artwork_url.clone().unwrap_or_default();
let artwork = track.artwork_ref();
let _ = weak.upgrade_in_event_loop(move |w| {
let np = w.global::<NowPlayingState>();
@@ -222,7 +225,7 @@ async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindo
np.set_artwork(slint::Image::default());
});
load_now_playing_artwork(weak.clone(), artwork_url);
load_now_playing_artwork(weak.clone(), artwork);
}
/// Build a `QueueTrack` for the queue from the catalog `Track`, filling