From 5acc12d9e9c1a189bd11fb0f8ea7827fad339682 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 26 Jun 2026 08:00:31 +0000 Subject: [PATCH] feat(library): shared cover-art warmer + cache (GOG + Xbox art) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Cargo.lock | 311 ++++++++++++++++++++++++ crates/punktfunk-host/Cargo.toml | 5 + crates/punktfunk-host/src/library.rs | 185 +++++++++++++- crates/punktfunk-host/src/punktfunk1.rs | 4 + 4 files changed, 497 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e53558..611cdbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 3bb4383..921b370 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -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"] } diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index aaf024f..49f5da9 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -775,13 +775,15 @@ fn gog_games() -> Vec { 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 { } 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 { (!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> { + static CACHE: std::sync::OnceLock< + std::sync::Mutex>, + > = 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 { + 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 { + 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) // --------------------------------------------------------------------------------------- diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 166f3b1..6bf95cc 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -209,6 +209,10 @@ pub(crate) async fn serve(opts: Punktfunk1Options, np: Arc) -> 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).