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

+2
View File
@@ -33,6 +33,7 @@ pub mod error;
pub mod events;
pub mod mixtape;
pub mod playback;
pub mod source;
pub mod traits;
pub mod types;
@@ -40,6 +41,7 @@ pub mod types;
pub use error::{QbzError, QbzResult};
pub use events::CoreEvent;
pub use playback::{PlaybackState, PlaybackStatus, QueueState, QueueTrack, RepeatMode};
pub use source::{ArtworkRef, PlaybackSource};
pub use traits::{FrontendAdapter, LoggingAdapter, NoOpAdapter};
pub use types::{
Album,
+205
View File
@@ -0,0 +1,205 @@
//! Source-aware playback types.
//!
//! Playable tracks reach the queue from multiple origins: Qobuz streaming,
//! the offline cache (downloaded Qobuz), local files, and Plex. These types
//! let every frontend reason about a track's origin and resolve its cover
//! art uniformly, instead of branching on stringly-typed `source` values at
//! each call site.
//!
//! This is the frontend-agnostic contract behind the source-aware playback
//! context: the now-playing bar, the queue, and the artwork pipeline consume
//! `PlaybackSource` + [`ArtworkRef`] and never special-case a source themselves.
//! The same contract drives the Qobuz Connect queue gate (only Qobuz-streamable
//! tracks may be cast — see [`PlaybackSource::is_qobuz_streamable`]).
use serde::{Deserialize, Serialize};
use crate::playback::QueueTrack;
/// Where a playable track comes from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlaybackSource {
/// The Qobuz streaming catalog.
Qobuz,
/// A Qobuz track downloaded into the offline cache (`qobuz_download`).
OfflineCache,
/// A local file indexed in the user's library.
Local,
/// A track served by a Plex Media Server.
Plex,
}
impl PlaybackSource {
/// Parse the stringly-typed `source` value carried by `QueueTrack` /
/// `LocalTrack`. Unknown or absent values default to [`Qobuz`] — every
/// pre-existing queue track was Qobuz, so this preserves history.
///
/// [`Qobuz`]: PlaybackSource::Qobuz
pub fn from_source_str(s: Option<&str>) -> Self {
match s {
Some("local") => Self::Local,
Some("plex") => Self::Plex,
Some("qobuz_download") => Self::OfflineCache,
_ => Self::Qobuz,
}
}
/// The canonical string written to `source` fields.
pub fn as_source_str(self) -> &'static str {
match self {
Self::Qobuz => "qobuz",
Self::OfflineCache => "qobuz_download",
Self::Local => "local",
Self::Plex => "plex",
}
}
/// Whether this source streams from Qobuz and may therefore be cast onto
/// a Qobuz Connect queue. Offline-cache copies, local files, and Plex are
/// **not** castable to a Qobuz Connect receiver — this is the first-class
/// gate the queue pipeline consults before syncing to Qobuz Connect.
pub fn is_qobuz_streamable(self) -> bool {
matches!(self, Self::Qobuz)
}
}
/// A reference to a piece of cover art, resolvable regardless of origin.
///
/// The artwork loaders historically handled only remote HTTP URLs, which is
/// why local-file and Plex artwork failed to reach the UI. This enum is the
/// uniform contract: a frontend's artwork pipeline matches on it and fetches
/// the bytes the right way for each variant.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ArtworkRef {
/// An HTTP(S) URL (Qobuz covers; Plex thumbs that already carry a token).
Remote(String),
/// A path to a cover image on the local filesystem.
LocalFile(String),
/// A Plex thumbnail fetched with an auth token appended. `path` is the
/// server-relative thumb path (e.g. `/library/metadata/42/thumb/1`).
PlexThumb {
base_url: String,
token: String,
path: String,
},
/// Cover bytes already in memory (e.g. embedded tags).
Embedded(Vec<u8>),
/// No artwork available.
None,
}
impl ArtworkRef {
/// True when there is effectively nothing to load (explicit `None` or an
/// empty Remote/LocalFile string).
pub fn is_empty(&self) -> bool {
match self {
ArtworkRef::None => true,
ArtworkRef::Remote(s) | ArtworkRef::LocalFile(s) => s.is_empty(),
ArtworkRef::Embedded(b) => b.is_empty(),
ArtworkRef::PlexThumb { path, .. } => path.is_empty(),
}
}
}
impl QueueTrack {
/// The track's playback source, parsed from its `source` field.
pub fn source_kind(&self) -> PlaybackSource {
PlaybackSource::from_source_str(self.source.as_deref())
}
/// A uniform reference to this track's cover art.
///
/// The heuristic is source-agnostic (it does not trust `source` to be
/// set): an `http(s)://` value is [`ArtworkRef::Remote`]; a `file://`
/// value or a bare filesystem path is [`ArtworkRef::LocalFile`] (local
/// library + offline-cache covers live on disk). Plex thumbnails that
/// need a token are produced by the Plex queue builder via
/// [`ArtworkRef::PlexThumb`] directly, not here.
pub fn artwork_ref(&self) -> ArtworkRef {
let raw = self.artwork_url.as_deref().unwrap_or("");
if raw.is_empty() {
return ArtworkRef::None;
}
if raw.starts_with("http://") || raw.starts_with("https://") {
ArtworkRef::Remote(raw.to_string())
} else if let Some(path) = raw.strip_prefix("file://") {
ArtworkRef::LocalFile(path.to_string())
} else {
ArtworkRef::LocalFile(raw.to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_str_roundtrip_and_default() {
assert_eq!(PlaybackSource::from_source_str(Some("local")), PlaybackSource::Local);
assert_eq!(PlaybackSource::from_source_str(Some("plex")), PlaybackSource::Plex);
assert_eq!(
PlaybackSource::from_source_str(Some("qobuz_download")),
PlaybackSource::OfflineCache
);
assert_eq!(PlaybackSource::from_source_str(Some("qobuz")), PlaybackSource::Qobuz);
// Unknown / absent -> Qobuz (historical default).
assert_eq!(PlaybackSource::from_source_str(None), PlaybackSource::Qobuz);
assert_eq!(PlaybackSource::from_source_str(Some("???")), PlaybackSource::Qobuz);
for s in [
PlaybackSource::Qobuz,
PlaybackSource::OfflineCache,
PlaybackSource::Local,
PlaybackSource::Plex,
] {
assert_eq!(PlaybackSource::from_source_str(Some(s.as_source_str())), s);
}
}
#[test]
fn only_qobuz_is_castable() {
assert!(PlaybackSource::Qobuz.is_qobuz_streamable());
assert!(!PlaybackSource::OfflineCache.is_qobuz_streamable());
assert!(!PlaybackSource::Local.is_qobuz_streamable());
assert!(!PlaybackSource::Plex.is_qobuz_streamable());
}
fn track_with(source: Option<&str>, artwork: Option<&str>) -> QueueTrack {
QueueTrack {
id: 1,
title: "t".into(),
version: None,
artist: "a".into(),
album: "al".into(),
duration_secs: 0,
artwork_url: artwork.map(|s| s.to_string()),
hires: false,
bit_depth: None,
sample_rate: None,
is_local: false,
album_id: None,
artist_id: None,
streamable: true,
source: source.map(|s| s.to_string()),
parental_warning: false,
source_item_id_hint: None,
}
}
#[test]
fn artwork_ref_classifies_by_value() {
assert_eq!(track_with(None, None).artwork_ref(), ArtworkRef::None);
assert_eq!(
track_with(Some("qobuz"), Some("https://x/cover.jpg")).artwork_ref(),
ArtworkRef::Remote("https://x/cover.jpg".into())
);
assert_eq!(
track_with(Some("local"), Some("/home/u/cover.jpg")).artwork_ref(),
ArtworkRef::LocalFile("/home/u/cover.jpg".into())
);
assert_eq!(
track_with(Some("local"), Some("file:///home/u/cover.jpg")).artwork_ref(),
ArtworkRef::LocalFile("/home/u/cover.jpg".into())
);
}
}
+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