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

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:
2026-07-19 00:53:40 +02:00
parent e5eec51a78
commit 017b083e32
3 changed files with 168 additions and 8 deletions
+30 -7
View File
@@ -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")
}