From 017b083e327b36100515f0d73234dc8a3b954669 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 00:53:40 +0200 Subject: [PATCH] feat(library): serve provider entries' local art via the host art proxy 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// ` 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) --- crates/punktfunk-host/src/library/art.rs | 121 +++++++++++++++++++- crates/punktfunk-host/src/library/custom.rs | 18 +++ crates/punktfunk-host/src/mgmt/library.rs | 37 ++++-- 3 files changed, 168 insertions(+), 8 deletions(-) diff --git a/crates/punktfunk-host/src/library/art.rs b/crates/punktfunk-host/src/library/art.rs index 41fc961f..9536c1fa 100644 --- a/crates/punktfunk-host/src/library/art.rs +++ b/crates/punktfunk-host/src/library/art.rs @@ -145,6 +145,76 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec, 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, 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, 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//`, 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, 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, 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); + } } diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index b7a23230..bca027c6 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -92,6 +92,24 @@ pub fn load_custom() -> Vec { } } +/// 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` 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, 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 diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index 3aa6a545..7f679661 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -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:`). + if let Some(appid) = id .strip_prefix("steam:") .and_then(|s| s.parse::().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:`): 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") }