//! Game library (plan: "surface the user's games"). A small adapter layer over the *stores* //! installed on the host — today **Steam** (read from local files, no API key) and a //! user-curated **custom** store (CRUD'd via the management API / web console). Every store //! produces the same [`GameEntry`], so a client renders one uniform grid and never has to know //! which launcher a title came from. Future stores (Heroic/Epic, GOG, Lutris, EmuDeck) are just //! more [`LibraryProvider`]s. //! //! Artwork is keyed only by Steam appid against the public Steam CDN (no auth) — the client //! fetches the posters directly. Custom entries carry user-supplied art URLs. //! //! This module is read-mostly metadata; *launching* a chosen title (mapping [`LaunchSpec`] onto a //! gamescope session) is a later step — the launch hint is carried here so that wiring is trivial. // Shared vocabulary re-exported to the submodules (each is `use super::*`). pub(crate) use anyhow::{Context, Result}; pub(crate) use serde::{Deserialize, Serialize}; pub(crate) use sha2::{Digest, Sha256}; pub(crate) use std::collections::HashSet; pub(crate) use std::path::{Path, PathBuf}; pub(crate) use std::time::{SystemTime, UNIX_EPOCH}; pub(crate) use utoipa::ToSchema; mod art; mod custom; mod detect; #[cfg(windows)] mod epic; #[cfg(windows)] mod gog; #[cfg(target_os = "linux")] mod heroic; mod launch; #[cfg(target_os = "linux")] mod lutris; mod scanners; mod steam; #[cfg(windows)] mod xbox; pub use art::*; pub use custom::*; pub use detect::*; #[cfg(windows)] pub use epic::*; #[cfg(windows)] pub use gog::*; #[cfg(target_os = "linux")] pub use heroic::*; pub use launch::*; #[cfg(target_os = "linux")] pub use lutris::*; pub use scanners::*; pub use steam::*; #[cfg(windows)] pub use xbox::*; /// Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for /// custom). The client prefers `portrait` for a grid and falls back to `header` when a title has /// no 600×900 capsule (common for older Steam apps). #[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)] pub struct Artwork { /// Vertical capsule / poster (Steam `library_600x900.jpg`). Best for a grid. #[serde(skip_serializing_if = "Option::is_none")] pub portrait: Option, /// Wide background (Steam `library_hero.jpg`). #[serde(skip_serializing_if = "Option::is_none")] pub hero: Option, /// Transparent title logo (Steam `logo.png`). #[serde(skip_serializing_if = "Option::is_none")] pub logo: Option, /// Horizontal header (Steam `header.jpg`) — the universal fallback. #[serde(skip_serializing_if = "Option::is_none")] pub header: Option, } /// How the host would launch a title (consumed by the session launcher in a later step). Kept /// open-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/`; /// `command` → run `` nested in a gamescope session. #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct LaunchSpec { /// `"steam_appid"` or `"command"`. #[schema(example = "steam_appid")] pub kind: String, /// The appid (for `steam_appid`) or the shell command (for `command`). pub value: String, } /// Descriptive metadata for a title — everything a richer library UI (details pane, platform /// filter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to /// absent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is /// `#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat /// wire shape everywhere. /// /// Values are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite) /// each have their own vocabulary and the host has no business normalizing it. #[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)] pub struct GameMeta { /// The system the title runs on — `"PS2"`, `"Xbox 360"`, `"SNES"`, … Installed-store /// scanners stamp `"PC"`; `GET /library?platform=` filters on it (case-insensitive). #[serde(default, skip_serializing_if = "Option::is_none")] #[schema(example = "PS2")] pub platform: Option, /// Short blurb for a details pane. #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub developer: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub publisher: Option, /// Year of first release — the granularity metadata sources reliably agree on. #[serde(default, skip_serializing_if = "Option::is_none")] #[schema(example = 2001)] pub release_year: Option, /// Genre taxonomy from the metadata source (`"RPG"`, `"Platformer"`, …). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub genres: Vec, /// Free-form organizational labels (`"co-op"`, `"kids"`, `"finished"`, …). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, /// Release region — emulation-relevant (`"NTSC-U"`, `"PAL"`, `"NTSC-J"`). #[serde(default, skip_serializing_if = "Option::is_none")] pub region: Option, /// Maximum simultaneous (local) players. #[serde(default, skip_serializing_if = "Option::is_none")] pub players: Option, } impl GameMeta { /// The one field an installed-store scanner can assert about its own titles: they run on this /// host, i.e. on a PC. Everything else stays absent (the launchers' local files don't carry it). pub(crate) fn pc() -> Self { GameMeta { platform: Some("PC".into()), ..Default::default() } } } /// One title in the unified library, regardless of which store it came from. #[derive(Clone, Debug, Serialize, ToSchema)] pub struct GameEntry { /// Stable, store-qualified id: `steam:` or `custom:`. #[schema(example = "steam:570")] pub id: String, /// Which store surfaced it: `"steam"` or `"custom"`. #[schema(example = "steam")] pub store: String, pub title: String, pub art: Artwork, /// How the host would launch it, when known. #[serde(skip_serializing_if = "Option::is_none")] pub launch: Option, /// The external provider owning this entry (custom-store entries synced by a provider /// plugin, RFC §8) — `None` for installed-store titles and manual custom entries. The /// console uses it for attribution; `GET /library?provider=` filters on it. #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, /// How to recognize this title's process(es) once it is running ([`DetectSpec`]) — filled in by /// each provider from paths it already read while scanning. /// /// **Host-internal: never serialized.** It names local filesystem paths, so it stays out of both /// the catalog JSON the client renders and the OpenAPI schema; it rides here only so the /// providers that already hold this data don't have to be re-scanned. #[serde(skip)] #[schema(ignore)] pub detect: DetectSpec, /// Descriptive metadata, flattened — see [`GameMeta`]. #[serde(flatten)] pub meta: GameMeta, } /// A store that contributes titles to the library. The trait is the extension point for future /// launchers; today only [`SteamProvider`] implements it. pub trait LibraryProvider { /// Stable store id (`"steam"`, …). fn store(&self) -> &'static str; /// Enumerate installed/owned titles. Best-effort: returns empty (not an error) when the store /// isn't present, so one missing launcher never fails the whole library. fn list(&self) -> Vec; } /// Steam art, keyed to one of the four [`Artwork`] fields. Newer/recently-updated titles serve /// their CDN assets from a per-asset-hash path the client can't predict (e.g. /// `.../apps///header.jpg`), so the flat legacy URL [`steam_art`] guesses 404s for them — /// [`steam_art_bytes`] is the robust resolver: local Steam cache (exact, no guessing) first, the /// flat CDN URL as a fallback (still correct for the many titles that haven't been re-hashed). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ArtKind { Portrait, Hero, Logo, Header, } impl ArtKind { pub fn parse(s: &str) -> Option { match s { "portrait" => Some(Self::Portrait), "hero" => Some(Self::Hero), "logo" => Some(Self::Logo), "header" => Some(Self::Header), _ => None, } } /// Filenames Steam itself caches this kind under in `appcache/librarycache///`, /// tried in order (the 2x portrait, when present, is the sharper asset). fn local_filenames(self) -> &'static [&'static str] { match self { Self::Portrait => &["library_600x900_2x.jpg", "library_600x900.jpg"], Self::Hero => &["library_hero.jpg"], Self::Logo => &["logo.png"], // Steam's local cache names the header asset differently from the store CDN's // `header.jpg` (see `cdn_filename`). Self::Header => &["library_header.jpg"], } } /// The legacy flat-URL filename on the public Steam CDN (works for any title the CDN hasn't /// migrated to a per-asset hash path). fn cdn_filename(self) -> &'static str { match self { Self::Portrait => "library_600x900.jpg", Self::Hero => "library_hero.jpg", Self::Logo => "logo.png", Self::Header => "header.jpg", } } } /// The full library: every *enabled* store's titles merged + the custom entries, sorted by title. /// The operator's scanner toggles (`scanners.rs`) gate each installed-store provider; the custom /// store is not a scanner and always contributes. pub fn all_games() -> Vec { let off = disabled_scanners(); let on = |id: &str| !off.contains(id); let mut games = Vec::new(); if on("steam") { games.extend(SteamProvider.list()); } // The Lutris + Heroic providers are Linux-only (their launchers are); on other hosts the library // is Steam + custom. Each provider is best-effort (empty when its store isn't present). #[cfg(target_os = "linux")] { if on("lutris") { games.extend(LutrisProvider.list()); } if on("heroic") { games.extend(HeroicProvider.list()); } } // Windows store providers (their launchers are Windows-only): Epic + GOG + Xbox/Game Pass. #[cfg(windows)] { if on("epic") { games.extend(EpicProvider.list()); } if on("gog") { games.extend(GogProvider.list()); } if on("xbox") { games.extend(XboxProvider.list()); } } games.extend(load_custom().into_iter().map(GameEntry::from)); games.sort_by_key(|g| g.title.to_lowercase()); games }