feat(library): shared cover-art warmer + cache (GOG + Xbox art)
A disk-backed art cache (library-art-cache.json in the canonical host config dir) is the source of truth read by all_games(), so the library list + launch-resolve never block on the network. A host-lifetime background warmer (start_art_warmer, started in serve()) fetches uncached art OFF the hot path: GOG via the public no-auth api.gog.com product API, Xbox via the unofficial no-auth displaycatalog (keyed by StoreId). Both best-effort (protocol-relative URLs normalized to https; results cached even when empty so they aren't re-fetched). The GOG + Xbox providers now read cached_art() (title-only until warmed). Cross-platform (ureq blocking HTTP — no tokio on this path) so the fetch/parse code is compiled + checked everywhere; a host whose stores all self-provide art (Steam CDN / Heroic CDN / Lutris data: URLs) does no fetching. Dep: ureq (webpki roots, no system certs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+311
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
@@ -735,6 +741,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.5.1"
|
||||
@@ -1100,6 +1115,16 @@ version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -1751,12 +1776,115 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.15.0"
|
||||
@@ -2022,6 +2150,12 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -2116,6 +2250,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
@@ -2548,6 +2692,15 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
@@ -2742,6 +2895,7 @@ dependencies = [
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"utoipa",
|
||||
"utoipa-axum",
|
||||
"utoipa-scalar",
|
||||
@@ -3566,6 +3720,12 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -3648,6 +3808,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -3800,6 +3966,16 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinytemplate"
|
||||
version = "1.2.1"
|
||||
@@ -4105,6 +4281,40 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
@@ -4432,6 +4642,24 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "0.7.33"
|
||||
@@ -5061,6 +5289,12 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.16.0"
|
||||
@@ -5104,6 +5338,29 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.16.0"
|
||||
@@ -5180,12 +5437,66 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -28,6 +28,11 @@ hex = "0.4"
|
||||
# Cover-art delivery in the game library: encode Lutris's local JPEGs into `data:` URLs and decode
|
||||
# the Epic launcher's base64 `catcache.bin`. Cross-platform (Linux Lutris art + Windows Epic art).
|
||||
base64 = "0.22"
|
||||
# Blocking HTTP for the library cover-art warmer (no-auth GOG api.gog.com + Xbox displaycatalog),
|
||||
# run on a background thread off the hot path. `ureq` is small + sync (no tokio here) and bundles
|
||||
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
|
||||
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
|
||||
ureq = "2"
|
||||
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
|
||||
x509-parser = "0.16"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
|
||||
@@ -775,13 +775,15 @@ fn gog_games() -> Vec<GameEntry> {
|
||||
let Some((exe, args, workdir)) = gog_play_task(&path, &sub) else {
|
||||
continue;
|
||||
};
|
||||
let id = format!("gog:{sub}");
|
||||
// Art (public api.gog.com) is resolved off the hot path by the background warmer; read
|
||||
// whatever it has cached (title-only until warmed).
|
||||
let art = cached_art(&id).unwrap_or_default();
|
||||
out.push(GameEntry {
|
||||
id: format!("gog:{sub}"),
|
||||
id,
|
||||
store: "gog".into(),
|
||||
title,
|
||||
// GOG cover art is the public api.gog.com (no key) but needs an HTTP fetch + cache off the
|
||||
// hot all_games() path — deferred; title-only for now (the client renders that gracefully).
|
||||
art: Artwork::default(),
|
||||
art,
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "gog".into(),
|
||||
value: format!("{exe}\t{args}\t{workdir}"),
|
||||
@@ -904,13 +906,15 @@ fn xbox_games() -> Vec<GameEntry> {
|
||||
} else {
|
||||
store_id
|
||||
};
|
||||
let id = format!("xbox:{id_key}");
|
||||
// Art (unofficial displaycatalog, keyed by StoreId) is resolved off the hot path by the
|
||||
// background warmer; read whatever it has cached (title-only until warmed / if no StoreId).
|
||||
let art = cached_art(&id).unwrap_or_default();
|
||||
games.push(GameEntry {
|
||||
id: format!("xbox:{id_key}"),
|
||||
id,
|
||||
store: "xbox".into(),
|
||||
title,
|
||||
// displaycatalog.mp.microsoft.com cover art (no key, but unofficial + needs an HTTP
|
||||
// fetch + cache off the hot path) is deferred → title-only (rendered gracefully).
|
||||
art: Artwork::default(),
|
||||
art,
|
||||
launch: Some(LaunchSpec {
|
||||
kind: "aumid".into(),
|
||||
value: format!("{pfn}!{app_id}"),
|
||||
@@ -994,6 +998,171 @@ fn pfn_from_full(dir_name: &str, identity: &str) -> Option<String> {
|
||||
(!hash.is_empty() && hash != dir_name).then(|| format!("{identity}_{hash}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Cover-art resolver + cache (shared by the Windows GOG + Xbox providers, which have no local
|
||||
// art). A disk cache is the source of truth read by all_games() (so the list/launch path never
|
||||
// blocks on the network); a host-lifetime background warmer fetches uncached art (GOG's public
|
||||
// api.gog.com + Xbox's displaycatalog, both no-auth) and persists it. Cross-platform so the
|
||||
// HTTP/JSON code is compiled + checked everywhere; the warmer simply finds nothing to fetch on a
|
||||
// host whose stores all carry their own art (Steam CDN / Heroic CDN / Lutris data: URLs).
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// The persisted art cache: GameEntry id → resolved [`Artwork`]. An entry's PRESENCE means "already
|
||||
/// resolved" (even an empty Artwork = fetched, none found) so the warmer never re-fetches it.
|
||||
fn art_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Artwork>> {
|
||||
static CACHE: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, Artwork>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
CACHE.get_or_init(|| {
|
||||
let loaded = std::fs::read_to_string(art_cache_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
std::sync::Mutex::new(loaded)
|
||||
})
|
||||
}
|
||||
|
||||
/// The art cache lives in the canonical HOST config dir (`%ProgramData%\punktfunk` on Windows /
|
||||
/// `~/.config/punktfunk` on Linux — gamestream::config_dir, NOT the legacy XDG/HOME `config_dir`
|
||||
/// below that the custom store still uses).
|
||||
fn art_cache_path() -> PathBuf {
|
||||
crate::gamestream::config_dir().join("library-art-cache.json")
|
||||
}
|
||||
|
||||
/// The cached art for a library id, if it has been resolved (positive or negative). `None` = not yet
|
||||
/// warmed → the provider shows title-only until the warmer fills it in.
|
||||
fn cached_art(id: &str) -> Option<Artwork> {
|
||||
art_cache().lock().unwrap().get(id).cloned()
|
||||
}
|
||||
|
||||
/// Record resolved art for a library id + persist the cache (write-then-rename; best-effort).
|
||||
fn store_art(id: &str, art: Artwork) {
|
||||
let mut cache = art_cache().lock().unwrap();
|
||||
cache.insert(id.to_string(), art);
|
||||
if let Ok(json) = serde_json::to_string(&*cache) {
|
||||
let path = art_cache_path();
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, json).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the host-lifetime cover-art warmer: every few minutes, fetch + cache art for any library
|
||||
/// entry whose store needs a network lookup (GOG / Xbox) and isn't cached yet. Idempotent — once
|
||||
/// everything is cached a pass makes no network calls (and a host with only self-art stores never
|
||||
/// fetches at all). Call once from `serve()`; the returned handle can be dropped to detach it.
|
||||
pub fn start_art_warmer() -> std::thread::JoinHandle<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("pf-art-warmer".into())
|
||||
.spawn(|| loop {
|
||||
warm_art_once();
|
||||
std::thread::sleep(std::time::Duration::from_secs(300));
|
||||
})
|
||||
.expect("spawn art warmer thread")
|
||||
}
|
||||
|
||||
/// One warming pass: resolve uncached GOG/Xbox art. Other stores carry their own art (Steam CDN
|
||||
/// template, Heroic CDN URLs, Lutris data: URLs, custom user URLs) and are skipped.
|
||||
fn warm_art_once() {
|
||||
for g in all_games() {
|
||||
if cached_art(&g.id).is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some((store, localid)) = g.id.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let art = match store {
|
||||
"gog" => fetch_gog_art(localid),
|
||||
// The xbox id is the StoreId when present, else the PFN (contains '_', no displaycatalog
|
||||
// entry) → cache empty for those so they aren't retried every pass.
|
||||
"xbox" if !localid.contains('_') => fetch_xbox_art(localid),
|
||||
"xbox" => Artwork::default(),
|
||||
_ => continue, // steam/heroic/lutris/custom resolve their own art
|
||||
};
|
||||
store_art(&g.id, art);
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP GET + parse JSON with a bounded timeout. `None` on any network/parse failure (best-effort —
|
||||
/// art is non-essential, so a failure just leaves the title-only card).
|
||||
fn fetch_json(url: &str) -> Option<serde_json::Value> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build();
|
||||
let body = agent.get(url).call().ok()?.into_string().ok()?;
|
||||
serde_json::from_str(&body).ok()
|
||||
}
|
||||
|
||||
/// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https.
|
||||
fn abs_url(u: &str) -> String {
|
||||
u.strip_prefix("//")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
.unwrap_or_else(|| u.to_string())
|
||||
}
|
||||
|
||||
/// GOG cover art via the public (no-auth) product API. Field names / URL shapes are GOG-specific and
|
||||
/// best-effort (worth on-box confirmation); a wrong URL just degrades to the title card client-side.
|
||||
fn fetch_gog_art(product_id: &str) -> Artwork {
|
||||
let Some(v) = fetch_json(&format!(
|
||||
"https://api.gog.com/products/{product_id}?expand=images"
|
||||
)) else {
|
||||
return Artwork::default();
|
||||
};
|
||||
let img = |k: &str| {
|
||||
v.get("images")
|
||||
.and_then(|i| i.get(k))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(abs_url)
|
||||
};
|
||||
Artwork {
|
||||
portrait: img("verticalCover"),
|
||||
hero: img("background"),
|
||||
logo: img("logo2x"),
|
||||
header: img("logo"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Xbox cover art via the (unofficial, no-auth) Microsoft display catalog, keyed by StoreId. Best-
|
||||
/// effort: the endpoint is internal/unstable, so on drift this just yields no art (title-only).
|
||||
fn fetch_xbox_art(store_id: &str) -> Artwork {
|
||||
let Some(v) = fetch_json(&format!(
|
||||
"https://displaycatalog.mp.microsoft.com/v7.0/products/{store_id}?market=US&languages=en-us&fieldsTemplate=Details"
|
||||
)) else {
|
||||
return Artwork::default();
|
||||
};
|
||||
let images = v
|
||||
.get("Products")
|
||||
.and_then(|p| p.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|p| p.get("LocalizedProperties"))
|
||||
.and_then(|l| l.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|lp| lp.get("Images"))
|
||||
.and_then(|i| i.as_array());
|
||||
let mut art = Artwork::default();
|
||||
for img in images.into_iter().flatten() {
|
||||
let (Some(purpose), Some(uri)) = (
|
||||
img.get("ImagePurpose").and_then(|v| v.as_str()),
|
||||
img.get("Uri").and_then(|v| v.as_str()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let url = abs_url(uri);
|
||||
match purpose {
|
||||
"Poster" => art.portrait = Some(url),
|
||||
"SuperHeroArt" | "Hero" => art.hero = Some(url),
|
||||
"Logo" => art.logo = Some(url),
|
||||
"BoxArt" => art.header = Some(url),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
art
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Custom store (user-curated entries, persisted + CRUD'd via the mgmt API)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@@ -209,6 +209,10 @@ pub(crate) async fn serve(opts: Punktfunk1Options, np: Arc<NativePairing>) -> Re
|
||||
// restores the box's autologin gaming session on idle, not per-disconnect — see
|
||||
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
|
||||
let _restore_worker = crate::vdisplay::start_restore_worker();
|
||||
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
|
||||
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
|
||||
// blocks on the network. A no-op on a host whose stores all carry their own art.
|
||||
let _art_warmer = crate::library::start_art_warmer();
|
||||
// Pairing state (arming PIN + trust store) is shared with the management API. If it was armed
|
||||
// at startup (the CLI flags), surface the PIN the headless operator reads from the log; the
|
||||
// web console arms it on demand instead (a fresh, time-limited PIN).
|
||||
|
||||
Reference in New Issue
Block a user