feat(library): serve provider entries' local art via the host art proxy
deb / build-publish-host (push) Successful in 9m32s
deb / build-publish (push) Successful in 12m29s
windows-host / package (push) Successful in 16m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m48s
arch / build-publish (push) Successful in 19m57s
android / android (push) Successful in 22m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m30s
ci / rust (push) Successful in 29m59s
docker / deploy-docs (push) Successful in 23s
ci / web (push) Successful in 1m0s
apple / swift (push) Successful in 1m17s
ci / docs-site (push) Successful in 1m22s
decky / build-publish (push) Successful in 43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / bench (push) Successful in 6m34s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
apple / screenshots (push) Successful in 6m25s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m27s
deb / build-publish-host (push) Successful in 9m32s
deb / build-publish (push) Successful in 12m29s
windows-host / package (push) Successful in 16m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m48s
arch / build-publish (push) Successful in 19m57s
android / android (push) Successful in 22m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m30s
ci / rust (push) Successful in 29m59s
docker / deploy-docs (push) Successful in 23s
ci / web (push) Successful in 1m0s
apple / swift (push) Successful in 1m17s
ci / docs-site (push) Successful in 1m22s
decky / build-publish (push) Successful in 43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / bench (push) Successful in 6m34s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
apple / screenshots (push) Successful in 6m25s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m27s
A provider plugin that runs on the host (e.g. the Playnite sync plugin) can now reconcile cover art as an on-host FILE PATH instead of an inlined data URL. `GET /library` rewrites such local-file art into `/library/art/<id>/ <kind>` proxy URLs (the same relative-proxy shape Steam art already uses), and the art proxy serves the bytes from the stored path. Moonlight's /appasset proxy reads the local file too. This is what lets a large Playnite library sync with covers: the reconcile payload carries paths, not bytes, so it no longer blows past the mgmt API's request-body limit and scales to thousands of titles. Cross-platform; local-path detection is Windows-shaped (Playnite is Windows-only). Unit-tested (compiles + 5/5 art tests on Linux). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -145,6 +145,76 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
|
||||
(!bytes.is_empty()).then_some((bytes, ctype))
|
||||
}
|
||||
|
||||
/// A stored [`Artwork`] value that is a **local filesystem path** to an image on the host — as
|
||||
/// opposed to an `http(s)`/`data:` URL or an already-relative host proxy path. Provider plugins that
|
||||
/// run on the host (e.g. the Playnite sync plugin) set these: the reconcile payload stays tiny
|
||||
/// (paths, not inlined bytes, so it scales to thousands of titles) and the host serves the bytes
|
||||
/// through the art proxy, exactly like Steam's cache art. Windows-shaped only (`C:\…`, `C:/…`, or a
|
||||
/// `\\server\share` UNC) — Playnite, the only local-art provider, is Windows-only, and this keeps the
|
||||
/// check from ever mistaking the `/api/…` proxy path (or a POSIX abs path) for a local file.
|
||||
pub fn is_local_art_path(v: &str) -> bool {
|
||||
if v.starts_with("http://") || v.starts_with("https://") || v.starts_with("data:") {
|
||||
return false;
|
||||
}
|
||||
let b = v.as_bytes();
|
||||
(b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\")
|
||||
}
|
||||
|
||||
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
|
||||
/// existing regular file, is empty, or exceeds 16 MiB (a cover never approaches that; the cap bounds
|
||||
/// host memory). Content-type is guessed from the extension.
|
||||
pub fn local_art_bytes(path: &str) -> Option<(Vec<u8>, String)> {
|
||||
let p = std::path::Path::new(path);
|
||||
let meta = std::fs::metadata(p).ok()?;
|
||||
if !meta.is_file() || meta.len() == 0 || meta.len() > 16 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let ctype = match p
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("png") => "image/png",
|
||||
Some("webp") => "image/webp",
|
||||
Some("gif") => "image/gif",
|
||||
Some("bmp") => "image/bmp",
|
||||
Some("ico") => "image/x-icon",
|
||||
Some("tga") => "image/x-tga",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
.to_string();
|
||||
Some((std::fs::read(p).ok()?, ctype))
|
||||
}
|
||||
|
||||
/// Resolve one art value to bytes for the Moonlight `/appasset` proxy: a local host file
|
||||
/// ([`is_local_art_path`]) is read directly, anything else is a URL fetched by [`fetch_image`].
|
||||
fn resolve_art_bytes(v: &str) -> Option<(Vec<u8>, String)> {
|
||||
if is_local_art_path(v) {
|
||||
local_art_bytes(v)
|
||||
} else {
|
||||
fetch_image(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite any **local-file** art paths on an entry into host art-proxy URLs
|
||||
/// (`/api/v1/library/art/<id>/<kind>`, the same relative-proxy shape Steam art uses, resolved by the
|
||||
/// client against the host). `http(s)`/`data:` URLs and already-relative proxy paths are left as-is.
|
||||
/// Applied to the `GET /library` response so a client fetches a provider's local covers from the host
|
||||
/// instead of receiving an unreachable `C:\…` path.
|
||||
pub fn proxy_local_art(id: &str, art: &mut Artwork) {
|
||||
let rw = |field: &mut Option<String>, kind: &str| {
|
||||
if field.as_deref().is_some_and(is_local_art_path) {
|
||||
*field = Some(format!("/api/v1/library/art/{id}/{kind}"));
|
||||
}
|
||||
};
|
||||
rw(&mut art.portrait, "portrait");
|
||||
rw(&mut art.hero, "hero");
|
||||
rw(&mut art.logo, "logo");
|
||||
rw(&mut art.header, "header");
|
||||
}
|
||||
|
||||
/// Resolve + fetch the best box-art cover for a library id (the GameStream `/appasset` proxy — Moonlight
|
||||
/// fetches per-app covers from the HOST, not the CDN, so we proxy the bytes). Tries the portrait (tall
|
||||
/// capsule Moonlight wants) → header → hero → logo, returning the first that fetches as
|
||||
@@ -171,7 +241,7 @@ pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
|
||||
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|url| fetch_image(&url))
|
||||
.find_map(|url| resolve_art_bytes(&url))
|
||||
}
|
||||
|
||||
/// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https.
|
||||
@@ -264,4 +334,53 @@ mod tests {
|
||||
// Empty payload → None (never serve a 0-byte cover).
|
||||
assert!(fetch_image("data:image/png;base64,").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_art_path_detection() {
|
||||
// Windows-shaped local paths a provider (Playnite) would store.
|
||||
assert!(is_local_art_path(r"C:\Users\me\cover.jpg"));
|
||||
assert!(is_local_art_path("C:/Users/me/cover.png"));
|
||||
assert!(is_local_art_path(r"\\nas\share\art.jpg"));
|
||||
// URLs and the host proxy path are NOT local files.
|
||||
assert!(!is_local_art_path("https://cdn/x.jpg"));
|
||||
assert!(!is_local_art_path("http://host/x.jpg"));
|
||||
assert!(!is_local_art_path("data:image/png;base64,AAAA"));
|
||||
assert!(!is_local_art_path(
|
||||
"/api/v1/library/art/custom:abc/portrait"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_local_art_rewrites_only_local_paths() {
|
||||
let mut art = Artwork {
|
||||
portrait: Some(r"C:\art\p.jpg".into()),
|
||||
hero: Some("https://cdn/h.jpg".into()),
|
||||
logo: None,
|
||||
header: Some("/api/v1/library/art/custom:x/header".into()),
|
||||
};
|
||||
proxy_local_art("custom:abc", &mut art);
|
||||
// The local path becomes a host proxy URL; the remote URL and an already-proxied path stay.
|
||||
assert_eq!(
|
||||
art.portrait.as_deref(),
|
||||
Some("/api/v1/library/art/custom:abc/portrait")
|
||||
);
|
||||
assert_eq!(art.hero.as_deref(), Some("https://cdn/h.jpg"));
|
||||
assert_eq!(
|
||||
art.header.as_deref(),
|
||||
Some("/api/v1/library/art/custom:x/header")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_art_bytes_reads_a_real_file() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("cover.png");
|
||||
std::fs::write(&f, [1u8, 2, 3, 4]).unwrap();
|
||||
let (bytes, ctype) = local_art_bytes(f.to_str().unwrap()).expect("reads file");
|
||||
assert_eq!(bytes, vec![1, 2, 3, 4]);
|
||||
assert_eq!(ctype, "image/png");
|
||||
assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,24 @@ pub fn load_custom() -> Vec<CustomEntry> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a custom/provider entry's stored **local** art file for one [`ArtKind`] — the non-Steam
|
||||
/// branch of the art proxy (`GET /library/art/custom:<id>/<kind>`). `id` is the bare custom id (the
|
||||
/// `custom:` prefix already stripped by the handler). `None` if the entry is unknown, has no art of
|
||||
/// that kind, or that art value isn't a servable local file (e.g. an `http` URL the client fetches
|
||||
/// itself). Blocking IO — call off the async runtime.
|
||||
pub fn custom_local_art_bytes(id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
|
||||
let entry = load_custom().into_iter().find(|e| e.id == id)?;
|
||||
let field = match kind {
|
||||
ArtKind::Portrait => entry.art.portrait,
|
||||
ArtKind::Hero => entry.art.hero,
|
||||
ArtKind::Logo => entry.art.logo,
|
||||
ArtKind::Header => entry.art.header,
|
||||
}?;
|
||||
is_local_art_path(&field)
|
||||
.then(|| local_art_bytes(&field))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
|
||||
let dir = pf_paths::config_dir();
|
||||
// Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a
|
||||
|
||||
@@ -36,6 +36,12 @@ pub(crate) async fn get_library(
|
||||
if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
|
||||
games.retain(|g| g.provider.as_deref() == Some(provider.as_str()));
|
||||
}
|
||||
// Rewrite provider entries' local-file art into host art-proxy URLs so a client fetches covers
|
||||
// from the host (a provider like Playnite stores on-host paths; the payload stays tiny at any
|
||||
// library size, and the client never sees an unreachable `C:\…`).
|
||||
for g in &mut games {
|
||||
crate::library::proxy_local_art(&g.id, &mut g.art);
|
||||
}
|
||||
Json(games)
|
||||
}
|
||||
|
||||
@@ -246,14 +252,31 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
|
||||
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
|
||||
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
|
||||
};
|
||||
let Some(appid) = id
|
||||
// Steam: CDN / local-cache proxy (id `steam:<appid>`).
|
||||
if let Some(appid) = id
|
||||
.strip_prefix("steam:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
else {
|
||||
return api_error(StatusCode::NOT_FOUND, "no art proxy for this store");
|
||||
};
|
||||
match tokio::task::spawn_blocking(move || crate::library::steam_art_bytes(appid, kind)).await {
|
||||
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
||||
{
|
||||
return match tokio::task::spawn_blocking(move || {
|
||||
crate::library::steam_art_bytes(appid, kind)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
||||
};
|
||||
}
|
||||
// Custom/provider entry (id `custom:<id>`): serve its stored LOCAL art file — e.g. the Playnite
|
||||
// plugin's covers, reconciled as on-host paths rather than inlined bytes.
|
||||
if let Some(cid) = id.strip_prefix("custom:").map(str::to_owned) {
|
||||
return match tokio::task::spawn_blocking(move || {
|
||||
crate::library::custom_local_art_bytes(&cid, kind)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
||||
};
|
||||
}
|
||||
api_error(StatusCode::NOT_FOUND, "no art proxy for this store")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user