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:
7 files changed
+140
-16
No files matched your search
Generated
+1
@@ -5337,6 +5337,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user