perf(slint): server-transcoded Plex thumbs + shared HTTP client for images

Plex cover images were fetched full-res on every surface (Slint built the raw
/library/.../thumb URL), where Tauri requests a server-transcoded thumb sized to
the render target. On a real library this made the views feel heavy and starved
the LAN bandwidth that track playback needs.

- qbz-models: ArtworkRef::PlexThumb gains a size: Option<u32>; new plex_thumb_url()
  emits the Tauri-format transcode URL when Some
  (/photo/:/transcode?url={enc}&width=N&height=N&minSize=1&X-Plex-Token=), raw
  tokenized path when None (album/MPRIS full-res preserved). urlencoding dep added
  to match encodeURIComponent. Tests extended.
- qbz-slint: each PlexThumb construction site passes its surface decode size
  (grid/detail 264, queue/now-playing-item 96, now-playing bar 160, MPRIS full-res);
  fetch_and_decode_ref uses plex_thumb_url.
- artwork.rs: static LazyLock<reqwest::Client> reused in fetch_cached_http instead
  of reqwest::get (per-image client) — keep-alive + no fd churn (EMFILE smell).

Audio path untouched; local/Qobuz artwork unchanged.

cc
This commit is contained in:
vicrodh committed 2026-06-08 09:03:37 -06:00
1 parent 381c0b1707
commit a680da5ad8
7 files changed
+140 -16

No files matched your search

+1
View File
@@ -5337,6 +5337,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.18",
"url",
"urlencoding",
]
[[package]]
+4
View File
@@ -24,3 +24,7 @@ chrono = { workspace = true }
# Correct file:// URI building for media-controls art (ArtworkRef::to_mpris_url)
url = "2"
# Percent-encoding the Plex transcode `url=`/token params (plex_thumb_url),
# matching the Tauri frontend's buildPlexArtworkUrl. Same crate used across
# qbz-slint / qbz-media-controls / qbz-integrations.
urlencoding = "2"
+1 -1
View File
@@ -41,7 +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, TrackOriginTag};
pub use source::{plex_thumb_url, ArtworkRef, PlaybackSource, TrackOriginTag};
pub use traits::{FrontendAdapter, LoggingAdapter, NoOpAdapter};
pub use types::{
Album,
+102 -10
View File
@@ -122,10 +122,16 @@ pub enum ArtworkRef {
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`).
///
/// `size` selects the URL form (see [`plex_thumb_url`]): `Some(n)` builds
/// a server-side transcode URL downscaled to `n`×`n` (so we download what
/// we render, not the full-res original); `None` keeps the raw full-res
/// tokenized path (album-detail full covers / fallbacks).
PlexThumb {
base_url: String,
token: String,
path: String,
size: Option<u32>,
},
/// Cover bytes already in memory (e.g. embedded tags).
Embedded(Vec<u8>),
@@ -168,15 +174,39 @@ impl ArtworkRef {
base_url,
token,
path,
} if !path.is_empty() => {
let sep = if path.contains('?') { '&' } else { '?' };
Some(format!("{base_url}{path}{sep}X-Plex-Token={token}"))
}
size,
} if !path.is_empty() => Some(plex_thumb_url(base_url, token, path, *size)),
_ => None,
}
}
}
/// Build the fetchable Plex artwork URL.
///
/// `Some(size)` → a server-side **transcode** URL downscaled to `size`×`size`
/// (so the client downloads roughly what it renders instead of the full-res
/// original). `None` (or `Some(0)`) → the raw full-res tokenized path.
///
/// Mirrors the Tauri frontend's `buildPlexArtworkUrl` exactly: the base is
/// trailing-slash-stripped, the inner `url=` (the original `/library` path)
/// and the token are percent-encoded, and `minSize=1` is appended. The raw
/// fallback keeps the non-percent-encoded token to match the historical
/// behavior (and the existing regression tests).
pub fn plex_thumb_url(base_url: &str, token: &str, path: &str, size: Option<u32>) -> String {
let base = base_url.trim_end_matches('/');
match size {
Some(n) if n > 0 => format!(
"{base}/photo/:/transcode?url={}&width={n}&height={n}&minSize=1&X-Plex-Token={}",
urlencoding::encode(path),
urlencoding::encode(token),
),
_ => {
let sep = if path.contains('?') { '&' } else { '?' };
format!("{base}{path}{sep}X-Plex-Token={token}")
}
}
}
impl QueueTrack {
/// The track's playback source, parsed from its `source` field.
pub fn source_kind(&self) -> PlaybackSource {
@@ -217,7 +247,17 @@ impl QueueTrack {
/// `{base_url, token}` (from the Slint Plex settings store) and threads them
/// in. When creds are missing, or the path is not a Plex thumb path, this
/// falls back to [`artwork_ref`](Self::artwork_ref).
pub fn artwork_ref_with_plex(&self, base_url: &str, token: &str) -> ArtworkRef {
///
/// `size` is threaded into the resulting [`ArtworkRef::PlexThumb`] so the
/// caller picks the render-target size: `Some(n)` → server-side transcode
/// at `n`×`n`; `None` → raw full-res (e.g. for MPRIS art that wants a
/// larger image). See [`plex_thumb_url`].
pub fn artwork_ref_with_plex(
&self,
base_url: &str,
token: &str,
size: Option<u32>,
) -> ArtworkRef {
let raw = self.artwork_url.as_deref().unwrap_or("");
let is_plex_path = raw.starts_with("/library/") || raw.starts_with("/photo/");
if is_plex_path && !base_url.is_empty() && !token.is_empty() {
@@ -225,6 +265,7 @@ impl QueueTrack {
base_url: base_url.to_string(),
token: token.to_string(),
path: raw.to_string(),
size,
};
}
self.artwork_ref()
@@ -334,21 +375,34 @@ mod tests {
// Plex thumb path + creds → PlexThumb (the now-playing/queue/MPRIS path).
assert_eq!(
track_with(Some("plex"), Some("/library/metadata/42/thumb/1"))
.artwork_ref_with_plex("http://plex.local:32400", "tok"),
.artwork_ref_with_plex("http://plex.local:32400", "tok", None),
ArtworkRef::PlexThumb {
base_url: "http://plex.local:32400".into(),
token: "tok".into(),
path: "/library/metadata/42/thumb/1".into(),
size: None,
}
);
// `/photo/...` transcode paths classify the same way.
assert_eq!(
track_with(Some("plex"), Some("/photo/:/transcode?url=x"))
.artwork_ref_with_plex("http://plex.local:32400", "tok"),
.artwork_ref_with_plex("http://plex.local:32400", "tok", None),
ArtworkRef::PlexThumb {
base_url: "http://plex.local:32400".into(),
token: "tok".into(),
path: "/photo/:/transcode?url=x".into(),
size: None,
}
);
// A size is threaded straight into the ref (URL form decided later).
assert_eq!(
track_with(Some("plex"), Some("/library/metadata/42/thumb/1"))
.artwork_ref_with_plex("http://plex.local:32400", "tok", Some(264)),
ArtworkRef::PlexThumb {
base_url: "http://plex.local:32400".into(),
token: "tok".into(),
path: "/library/metadata/42/thumb/1".into(),
size: Some(264),
}
);
}
@@ -358,29 +412,31 @@ mod tests {
// Missing creds → no PlexThumb; falls back to LocalFile (the raw path).
assert_eq!(
track_with(Some("plex"), Some("/library/metadata/42/thumb/1"))
.artwork_ref_with_plex("", ""),
.artwork_ref_with_plex("", "", Some(264)),
ArtworkRef::LocalFile("/library/metadata/42/thumb/1".into())
);
// Non-Plex value is untouched: http stays Remote even with creds.
assert_eq!(
track_with(Some("qobuz"), Some("https://x/cover.jpg"))
.artwork_ref_with_plex("http://plex.local:32400", "tok"),
.artwork_ref_with_plex("http://plex.local:32400", "tok", Some(264)),
ArtworkRef::Remote("https://x/cover.jpg".into())
);
// A local file:// path stays LocalFile, never mistaken for Plex.
assert_eq!(
track_with(Some("local"), Some("file:///home/u/cover.jpg"))
.artwork_ref_with_plex("http://plex.local:32400", "tok"),
.artwork_ref_with_plex("http://plex.local:32400", "tok", Some(264)),
ArtworkRef::LocalFile("/home/u/cover.jpg".into())
);
}
#[test]
fn plex_thumb_to_mpris_url_tokenizes() {
// size: None → raw full-res tokenized path (historical behavior).
let no_query = ArtworkRef::PlexThumb {
base_url: "http://plex.local:32400".into(),
token: "tok".into(),
path: "/library/metadata/42/thumb/1".into(),
size: None,
};
assert_eq!(
no_query.to_mpris_url().as_deref(),
@@ -390,10 +446,46 @@ mod tests {
base_url: "http://plex.local:32400".into(),
token: "tok".into(),
path: "/photo/:/transcode?url=x".into(),
size: None,
};
assert_eq!(
with_query.to_mpris_url().as_deref(),
Some("http://plex.local:32400/photo/:/transcode?url=x&X-Plex-Token=tok")
);
}
#[test]
fn plex_thumb_url_size_emits_transcode_form() {
// Some(size) → server-side transcode URL, percent-encoded url + token,
// trailing-slash-stripped base, minSize=1 (1:1 with Tauri's
// buildPlexArtworkUrl). The `/` in the path is encoded to %2F.
assert_eq!(
plex_thumb_url(
"http://plex.local:32400",
"tok",
"/library/metadata/42/thumb/1",
Some(264),
),
"http://plex.local:32400/photo/:/transcode?url=%2Flibrary%2Fmetadata%2F42%2Fthumb%2F1&width=264&height=264&minSize=1&X-Plex-Token=tok"
);
// Trailing slashes on the base are stripped before composing.
assert_eq!(
plex_thumb_url(
"http://plex.local:32400/",
"t/k",
"/library/metadata/7/thumb/9",
Some(96),
),
"http://plex.local:32400/photo/:/transcode?url=%2Flibrary%2Fmetadata%2F7%2Fthumb%2F9&width=96&height=96&minSize=1&X-Plex-Token=t%2Fk"
);
// None / Some(0) → raw tokenized path (no transcode).
assert_eq!(
plex_thumb_url("http://plex.local:32400", "tok", "/library/metadata/42/thumb/1", None),
"http://plex.local:32400/library/metadata/42/thumb/1?X-Plex-Token=tok"
);
assert_eq!(
plex_thumb_url("http://plex.local:32400", "tok", "/library/metadata/42/thumb/1", Some(0)),
"http://plex.local:32400/library/metadata/42/thumb/1?X-Plex-Token=tok"
);
}
}
+20 -3
View File
@@ -186,6 +186,16 @@ pub fn open_cache() -> ImageCache {
/// resolve cover art. Set once at startup.
static SHARED_CACHE: std::sync::OnceLock<ImageCache> = std::sync::OnceLock::new();
/// 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 +
/// connection pool on every cache miss (the fd-churn / deferred EMFILE risk).
/// 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());
/// Publish the image cache for `shared_cache()` consumers. Call once.
pub fn set_shared_cache(cache: ImageCache) {
let _ = SHARED_CACHE.set(cache);
@@ -306,6 +316,10 @@ pub fn spawn_local_or_plex_loads(
base_url: (*plex_base_url).clone(),
token: (*plex_token).clone(),
path: job.url.clone(),
// Request a server-side transcode at the surface's decode
// size (grid cards 264, row thumbs 96, …) instead of the
// full-res original.
size: Some(decode_size),
}
} else {
ArtworkRef::LocalFile(job.url.clone())
@@ -331,7 +345,7 @@ async fn fetch_cached_http(url: &str, cache: &ImageCache) -> Option<Vec<u8>> {
match cached_path {
Some(path) => tokio::fs::read(&path).await.ok(),
None => {
let downloaded = reqwest::get(url).await.ok()?.bytes().await.ok()?.to_vec();
let downloaded = HTTP.get(url).send().await.ok()?.bytes().await.ok()?.to_vec();
if let Ok(guard) = cache.lock() {
if let Some(service) = guard.as_ref() {
let _ = service.store(url, &downloaded);
@@ -375,9 +389,12 @@ pub async fn fetch_and_decode_ref(
base_url,
token,
path,
size,
} => {
let sep = if path.contains('?') { '&' } else { '?' };
let url = format!("{base_url}{path}{sep}X-Plex-Token={token}");
// Shared builder: `Some(size)` → server-side transcode (downscaled),
// `None` → raw full-res. The cache key is this final URL, so each
// surface's transcode size caches independently.
let url = qbz_models::plex_thumb_url(base_url, token, path, *size);
fetch_cached_http(&url, cache).await?
}
};
+9 -2
View File
@@ -818,7 +818,14 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
// get the fetchable cover. For non-Plex tracks this is identical to
// `artwork_ref()` (it falls back cleanly).
let plex = crate::plex_settings::get();
let artwork = track.artwork_ref_with_plex(&plex.base_url, &plex.token);
// Two refs from the same track: MPRIS / desktop-notification art wants a
// larger image, so it gets the raw full-res Plex path (`size: None`). The
// now-playing bar renders small and decodes to 160 (see
// `load_now_playing_artwork`), so it requests a 160px server-side
// transcode — downloading ~what it renders instead of the full original.
// For non-Plex tracks both collapse to the same `artwork_ref()`.
let artwork = track.artwork_ref_with_plex(&plex.base_url, &plex.token, None);
let bar_artwork = track.artwork_ref_with_plex(&plex.base_url, &plex.token, Some(160));
// Quality badge: tier from bit depth (24-bit+ = Hi-Res), exact detail line
// reused from the shared formatter so it matches the track-row badges.
let quality_tier = match track.bit_depth {
@@ -902,7 +909,7 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
np.set_artwork(slint::Image::default());
});
load_now_playing_artwork(weak.clone(), artwork);
load_now_playing_artwork(weak.clone(), bar_artwork);
}
/// Record the playback CONTEXT — the source the queue was launched from — on
+3
View File
@@ -788,6 +788,9 @@ fn load_artwork(
base_url: plex_base_url.clone(),
token: plex_token.clone(),
path: url,
// Queue rows + now-playing item render small; request a
// 96px server-side transcode (the decode size used below).
size: Some(96),
}
} else {
qbz_models::ArtworkRef::LocalFile(url)