diff --git a/api/openapi.json b/api/openapi.json index d251c187..9f4ec651 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -965,7 +965,7 @@ "library" ], "summary": "List the game library", - "description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns.", + "description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).", "operationId": "getLibrary", "parameters": [ { @@ -976,6 +976,15 @@ "schema": { "type": "string" } + }, + { + "name": "platform", + "in": "query", + "description": "Only entries on this platform (case-insensitive, e.g. `PS2`)", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -4012,95 +4021,111 @@ } }, "CustomEntry": { - "type": "object", - "description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits.", - "required": [ - "id", - "title" - ], - "properties": { - "art": { - "$ref": "#/components/schemas/Artwork" + "allOf": [ + { + "$ref": "#/components/schemas/GameMeta", + "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }, - "detect": { - "$ref": "#/components/schemas/DetectHint", - "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." - }, - "external_id": { - "type": [ - "string", - "null" + { + "type": "object", + "required": [ + "id", + "title" ], - "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." - }, - "id": { - "type": "string", - "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." - }, - "launch": { - "oneOf": [ - { - "type": "null" + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" }, - { - "$ref": "#/components/schemas/LaunchSpec" + "detect": { + "$ref": "#/components/schemas/DetectHint", + "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." + }, + "external_id": { + "type": [ + "string", + "null" + ], + "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." + }, + "id": { + "type": "string", + "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec" + } + ] + }, + "prep": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrepCmd" + }, + "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." + }, + "provider": { + "type": [ + "string", + "null" + ], + "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." + }, + "title": { + "type": "string" } - ] - }, - "prep": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PrepCmd" - }, - "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." - }, - "provider": { - "type": [ - "string", - "null" - ], - "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." - }, - "title": { - "type": "string" + } } - } + ], + "description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits." }, "CustomInput": { - "type": "object", - "description": "Request body to create or replace a custom entry (no `id` — the host owns it).", - "required": [ - "title" - ], - "properties": { - "art": { - "$ref": "#/components/schemas/Artwork" + "allOf": [ + { + "$ref": "#/components/schemas/GameMeta", + "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept." }, - "detect": { - "$ref": "#/components/schemas/DetectHint", - "description": "How to recognize this title's process — see [`CustomEntry::detect`]." - }, - "launch": { - "oneOf": [ - { - "type": "null" + { + "type": "object", + "required": [ + "title" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" }, - { - "$ref": "#/components/schemas/LaunchSpec" + "detect": { + "$ref": "#/components/schemas/DetectHint", + "description": "How to recognize this title's process — see [`CustomEntry::detect`]." + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec" + } + ] + }, + "prep": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrepCmd" + }, + "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." + }, + "title": { + "type": "string" } - ] - }, - "prep": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PrepCmd" - }, - "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." - }, - "title": { - "type": "string" + } } - } + ], + "description": "Request body to create or replace a custom entry (no `id` — the host owns it)." }, "CustomPreset": { "type": "object", @@ -4750,48 +4775,129 @@ ] }, "GameEntry": { - "type": "object", - "description": "One title in the unified library, regardless of which store it came from.", - "required": [ - "id", - "store", - "title", - "art" - ], - "properties": { - "art": { - "$ref": "#/components/schemas/Artwork" + "allOf": [ + { + "$ref": "#/components/schemas/GameMeta", + "description": "Descriptive metadata, flattened — see [`GameMeta`]." }, - "id": { - "type": "string", - "description": "Stable, store-qualified id: `steam:` or `custom:`.", - "example": "steam:570" - }, - "launch": { - "oneOf": [ - { - "type": "null" + { + "type": "object", + "required": [ + "id", + "store", + "title", + "art" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" }, - { - "$ref": "#/components/schemas/LaunchSpec", - "description": "How the host would launch it, when known." + "id": { + "type": "string", + "description": "Stable, store-qualified id: `steam:` or `custom:`.", + "example": "steam:570" + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec", + "description": "How the host would launch it, when known." + } + ] + }, + "provider": { + "type": [ + "string", + "null" + ], + "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." + }, + "store": { + "type": "string", + "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.", + "example": "steam" + }, + "title": { + "type": "string" } - ] - }, - "provider": { + } + } + ], + "description": "One title in the unified library, regardless of which store it came from." + }, + "GameMeta": { + "type": "object", + "description": "Descriptive metadata for a title — everything a richer library UI (details pane, platform\nfilter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to\nabsent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is\n`#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat\nwire shape everywhere.\n\nValues are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite)\neach have their own vocabulary and the host has no business normalizing it.", + "properties": { + "description": { "type": [ "string", "null" ], - "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." + "description": "Short blurb for a details pane." }, - "store": { - "type": "string", - "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.", - "example": "steam" + "developer": { + "type": [ + "string", + "null" + ] }, - "title": { - "type": "string" + "genres": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." + }, + "platform": { + "type": [ + "string", + "null" + ], + "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive).", + "example": "PS2" + }, + "players": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Maximum simultaneous (local) players.", + "minimum": 0 + }, + "publisher": { + "type": [ + "string", + "null" + ] + }, + "region": { + "type": [ + "string", + "null" + ], + "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." + }, + "release_year": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Year of first release — the granularity metadata sources reliably agree on.", + "example": 2001, + "minimum": 0 + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." } } }, @@ -6003,45 +6109,53 @@ } }, "ProviderEntryInput": { - "type": "object", - "description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key.", - "required": [ - "external_id", - "title" - ], - "properties": { - "art": { - "$ref": "#/components/schemas/Artwork" + "allOf": [ + { + "$ref": "#/components/schemas/GameMeta", + "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }, - "detect": { - "$ref": "#/components/schemas/DetectHint", - "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." - }, - "external_id": { - "type": "string", - "description": "The provider's stable id for this title (the reconcile diff key)." - }, - "launch": { - "oneOf": [ - { - "type": "null" + { + "type": "object", + "required": [ + "external_id", + "title" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" }, - { - "$ref": "#/components/schemas/LaunchSpec" + "detect": { + "$ref": "#/components/schemas/DetectHint", + "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." + }, + "external_id": { + "type": "string", + "description": "The provider's stable id for this title (the reconcile diff key)." + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec" + } + ] + }, + "prep": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrepCmd" + }, + "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." + }, + "title": { + "type": "string" } - ] - }, - "prep": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PrepCmd" - }, - "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." - }, - "title": { - "type": "string" + } } - } + ], + "description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key." }, "ProviderRemoved": { "type": "object", diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index 5b11f0fd..d14e9ade 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -620,6 +620,7 @@ fn mock_library() -> ( store: store.to_string(), title: title.to_string(), art: crate::library::Artwork::default(), + platform: None, }; let games = vec![ game("steam:570", "steam", "Dota 2"), diff --git a/crates/pf-client-core/src/library.rs b/crates/pf-client-core/src/library.rs index 6b24b9b5..6cd40089 100644 --- a/crates/pf-client-core/src/library.rs +++ b/crates/pf-client-core/src/library.rs @@ -62,6 +62,10 @@ pub struct GameEntry { pub title: String, #[serde(default)] pub art: Artwork, + /// The system the title runs on (`"PC"`, `"PS2"`, …) — free-form display string from the + /// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it. + #[serde(default)] + pub platform: Option, } /// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet"). @@ -280,7 +284,7 @@ mod tests { fn game_entry_decodes_the_wire_shape() { // The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored). let json = r#"[ - {"id":"steam:570","store":"steam","title":"Dota 2", + {"id":"steam:570","store":"steam","title":"Dota 2","platform":"PC", "art":{"portrait":"/api/v1/library/art/steam:570/portrait"}, "launch":{"kind":"steam_appid","value":"570"}}, {"id":"custom:abc","store":"custom","title":"My Emu","art":{}} @@ -288,7 +292,12 @@ mod tests { let games: Vec = serde_json::from_str(json).unwrap(); assert_eq!(games.len(), 2); assert_eq!(games[0].id, "steam:570"); + assert_eq!(games[0].platform.as_deref(), Some("PC")); assert!(games[1].art.portrait.is_none()); + assert!( + games[1].platform.is_none(), + "pre-metadata hosts still parse" + ); } #[test] diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index 6dc51b28..a1eb1105 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -85,6 +85,57 @@ pub struct LaunchSpec { 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 { @@ -113,6 +164,9 @@ pub struct GameEntry { #[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 diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index 65444b17..de1bbf6c 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -37,6 +37,9 @@ pub struct CustomEntry { /// the host would otherwise lose the game the moment the shim returns. #[serde(default, skip_serializing_if = "DetectHint::is_empty")] pub detect: DetectHint, + /// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. + #[serde(flatten)] + pub meta: GameMeta, } /// Request body to create or replace a custom entry (no `id` — the host owns it). @@ -53,6 +56,10 @@ pub struct CustomInput { /// How to recognize this title's process — see [`CustomEntry::detect`]. #[serde(default)] pub detect: DetectHint, + /// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced + /// wholesale on update, like `art`: an edit must round-trip every field it wants kept. + #[serde(flatten)] + pub meta: GameMeta, } /// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the @@ -74,6 +81,9 @@ pub struct ProviderEntryInput { /// through the provider's own client still end its session when the player quits. #[serde(default)] pub detect: DetectHint, + /// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. + #[serde(flatten)] + pub meta: GameMeta, } impl From for GameEntry { @@ -98,6 +108,7 @@ impl From for GameEntry { launch: c.launch, provider: c.provider, detect, + meta: c.meta, } } } @@ -188,6 +199,7 @@ pub fn add_custom(input: CustomInput) -> Result { provider: None, external_id: None, detect: input.detect, + meta: input.meta, }; entries.push(entry.clone()); save_custom(&entries)?; @@ -210,6 +222,7 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result Vec { let detect = DetectSpec::exe(&exe).with_dir(&path); out.push(GameEntry { provider: None, + meta: GameMeta::pc(), id, store: "gog".into(), title, diff --git a/crates/punktfunk-host/src/library/heroic.rs b/crates/punktfunk-host/src/library/heroic.rs index 495443ab..bc4f92a7 100644 --- a/crates/punktfunk-host/src/library/heroic.rs +++ b/crates/punktfunk-host/src/library/heroic.rs @@ -109,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result rusqlite::Result> { for (id, slug, name, directory) in rows.flatten() { games.push(GameEntry { provider: None, + meta: GameMeta::pc(), id: format!("lutris:{id}"), store: "lutris".into(), title: name, diff --git a/crates/punktfunk-host/src/library/steam.rs b/crates/punktfunk-host/src/library/steam.rs index c3942715..d42b1b11 100644 --- a/crates/punktfunk-host/src/library/steam.rs +++ b/crates/punktfunk-host/src/library/steam.rs @@ -29,6 +29,7 @@ impl LibraryProvider for SteamProvider { .filter(|app| !is_steam_tool(app.appid, &app.name)) .map(|app| GameEntry { provider: None, + meta: GameMeta::pc(), id: format!("steam:{}", app.appid), store: "steam".into(), art: steam_art(app.appid), @@ -382,6 +383,7 @@ fn shortcut_entry(sc: Shortcut) -> Option { } Some(GameEntry { provider: None, + meta: GameMeta::pc(), id: format!("steam:{}", sc.appid), store: "steam".into(), title: sc.name, diff --git a/crates/punktfunk-host/src/library/xbox.rs b/crates/punktfunk-host/src/library/xbox.rs index 67e3e8f5..49d60151 100644 --- a/crates/punktfunk-host/src/library/xbox.rs +++ b/crates/punktfunk-host/src/library/xbox.rs @@ -70,6 +70,7 @@ fn xbox_games() -> Vec { let art = cached_art(&id).unwrap_or_default(); games.push(GameEntry { provider: None, + meta: GameMeta::pc(), id, store: "xbox".into(), title, diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index b8d7701c..e8292d26 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -8,6 +8,8 @@ use axum::http::header; pub(crate) struct LibraryQuery { /// Only entries owned by this external provider (RFC §8). provider: Option, + /// Only entries on this platform (case-insensitive). + platform: Option, } /// List the game library @@ -15,7 +17,8 @@ pub(crate) struct LibraryQuery { /// Every installed-store title (Steam, read from the host's local files — no Steam API key) /// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client /// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the -/// entries a given external provider owns. +/// entries a given external provider owns; `?platform=` to one platform (case-insensitive — +/// installed-store titles are `PC`, custom/provider entries carry whatever was authored). #[utoipa::path( get, path = "/library", @@ -23,6 +26,7 @@ pub(crate) struct LibraryQuery { operation_id = "getLibrary", params( ("provider" = Option, Query, description = "Only entries owned by this external provider"), + ("platform" = Option, Query, description = "Only entries on this platform (case-insensitive, e.g. `PS2`)"), ), responses( (status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]), @@ -36,6 +40,14 @@ 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())); } + if let Some(platform) = q.platform.filter(|p| !p.is_empty()) { + games.retain(|g| { + g.meta + .platform + .as_deref() + .is_some_and(|p| p.eq_ignore_ascii_case(&platform)) + }); + } // 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:\…`). diff --git a/plugin-kit/src/index.ts b/plugin-kit/src/index.ts index fe540c1c..3d2758dd 100644 --- a/plugin-kit/src/index.ts +++ b/plugin-kit/src/index.ts @@ -21,6 +21,7 @@ export { type CacheStore, makeCacheStore } from "./cache-store.js"; export { Artwork, DetectHint, + GameMeta, LaunchSpec, PrepStep, ProviderClient, diff --git a/plugin-kit/src/wire.ts b/plugin-kit/src/wire.ts index c94f2e6f..566cc851 100644 --- a/plugin-kit/src/wire.ts +++ b/plugin-kit/src/wire.ts @@ -46,6 +46,29 @@ export const DetectHint = Schema.Struct({ }); export type DetectHint = typeof DetectHint.Type; +/** Descriptive metadata, flat on the wire beside `title` (mirrors the host's flattened + * `GameMeta`). All fields optional; values are free-form display strings — the host does not + * normalize platform/genre vocabularies. */ +export const GameMeta = Schema.Struct({ + /** The system the title runs on — `"PS2"`, `"Xbox 360"`, `"SNES"`, … */ + platform: Schema.optionalKey(Schema.NullOr(Schema.String)), + /** Short blurb for a details pane. */ + description: Schema.optionalKey(Schema.NullOr(Schema.String)), + developer: Schema.optionalKey(Schema.NullOr(Schema.String)), + publisher: Schema.optionalKey(Schema.NullOr(Schema.String)), + /** Year of first release. */ + release_year: Schema.optionalKey(Schema.NullOr(Schema.Number)), + /** Genre taxonomy from the metadata source (`"RPG"`, `"Platformer"`, …). */ + genres: Schema.optionalKey(Schema.Array(Schema.String)), + /** Free-form organizational labels (`"co-op"`, `"kids"`, …). */ + tags: Schema.optionalKey(Schema.Array(Schema.String)), + /** Release region — `"NTSC-U"`, `"PAL"`, `"NTSC-J"`. */ + region: Schema.optionalKey(Schema.NullOr(Schema.String)), + /** Maximum simultaneous (local) players. */ + players: Schema.optionalKey(Schema.NullOr(Schema.Number)), +}); +export type GameMeta = typeof GameMeta.Type; + export const ProviderEntry = Schema.Struct({ external_id: Schema.String, title: Schema.String, @@ -53,5 +76,6 @@ export const ProviderEntry = Schema.Struct({ launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)), prep: Schema.optionalKey(Schema.Array(PrepStep)), detect: Schema.optionalKey(DetectHint), + ...GameMeta.fields, }); export type ProviderEntry = typeof ProviderEntry.Type; diff --git a/sdk/src/gen/punktfunk.ts b/sdk/src/gen/punktfunk.ts index 45c92a21..ac6896cb 100644 --- a/sdk/src/gen/punktfunk.ts +++ b/sdk/src/gen/punktfunk.ts @@ -135,10 +135,10 @@ export type RuntimeStatus = { readonly "active_sessions": number, readonly "audi export const RuntimeStatus = Schema.Struct({ "active_sessions": Schema.Number.annotate({ "description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "audio_streaming": Schema.Boolean.annotate({ "description": "True while the audio stream thread is running." }), "games": Schema.Array(ActiveGame).annotate({ "description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game." }), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a pairing handshake is parked waiting for the user's PIN\n(submit it via `POST /api/v1/pair/pin`)." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "stream": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "bitrate_kbps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "codec": ApiCodec, "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "last_resize_ms": Schema.optionalKey(Schema.Never), "min_fec": Schema.Number.annotate({ "description": "Client's parity floor per FEC block (`minRequiredFecPackets`).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "packet_size": Schema.Number.annotate({ "description": "Video payload size per packet (bytes).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "time_to_first_frame_ms": Schema.optionalKey(Schema.Never), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming." })], { mode: "oneOf" })), "video_streaming": Schema.Boolean.annotate({ "description": "True while the video stream thread is running." }) }).annotate({ "description": "Live host status (changes as clients launch/end sessions)." }) export type DisplayStateResponse = { readonly "displays": ReadonlyArray } export const DisplayStateResponse = Schema.Struct({ "displays": Schema.Array(ApiDisplayInfo) }).annotate({ "description": "The host's managed virtual displays right now." }) -export type GpuState = { readonly "active"?: null | { readonly "backend": string, readonly "id": string, readonly "name": string, readonly "sessions": number, readonly "vendor": string }, readonly "env_override"?: string | null, readonly "gpus": ReadonlyArray, readonly "mode": string, readonly "preferred_available": boolean, readonly "preferred_id"?: string | null, readonly "preferred_name"?: string | null, readonly "selected"?: null | { readonly "id": string, readonly "name": string, readonly "source": string, readonly "vendor": string } } -export const GpuState = Schema.Struct({ "active": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "backend": Schema.String.annotate({ "description": "The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`)." }), "id": Schema.String.annotate({ "description": "Stable id matching an entry of `gpus` (empty for the CPU/software encoder)." }), "name": Schema.String, "sessions": Schema.Number.annotate({ "description": "Number of live encode sessions on it.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU live sessions use right now (absent while nothing is streaming)." })], { mode: "oneOf" })), "env_override": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it." })), "gpus": Schema.Array(ApiGpu).annotate({ "description": "The host's hardware GPUs." }), "mode": Schema.String.annotate({ "description": "`auto` or `manual`." }), "preferred_available": Schema.Boolean.annotate({ "description": "Whether the preferred GPU is currently present." }), "preferred_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent." })), "preferred_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stored name of the preferred GPU (a usable label even when it is absent)." })), "selected": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "id": Schema.String, "name": Schema.String, "source": Schema.String.annotate({ "description": "Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming)." }), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU the next session will use." })], { mode: "oneOf" })) }).annotate({ "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now." }) -export type GameEntry = { readonly "art": Artwork, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "store": string, readonly "title": string } -export const GameEntry = Schema.Struct({ "art": Artwork, "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:` or `custom:`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String }).annotate({ "description": "One title in the unified library, regardless of which store it came from." }) +export type GpuState = { readonly "active"?: null | { readonly "backend": string, readonly "id": string, readonly "name": string, readonly "sessions": number, readonly "vendor": string }, readonly "encoder_pin"?: string | null, readonly "env_override"?: string | null, readonly "gpus": ReadonlyArray, readonly "mode": string, readonly "preferred_available": boolean, readonly "preferred_id"?: string | null, readonly "preferred_name"?: string | null, readonly "selected"?: null | { readonly "id": string, readonly "name": string, readonly "source": string, readonly "vendor": string } } +export const GpuState = Schema.Struct({ "active": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "backend": Schema.String.annotate({ "description": "The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`)." }), "id": Schema.String.annotate({ "description": "Stable id matching an entry of `gpus` (empty for the CPU/software encoder)." }), "name": Schema.String, "sessions": Schema.Number.annotate({ "description": "Number of live encode sessions on it.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU live sessions use right now (absent while nothing is streaming)." })], { mode: "oneOf" })), "encoder_pin": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`\n(e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected\nGPU is overridden at session open — the adapter wins — so the console can warn that the\npin is stale rather than letting the selection look broken." })), "env_override": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it." })), "gpus": Schema.Array(ApiGpu).annotate({ "description": "The host's hardware GPUs." }), "mode": Schema.String.annotate({ "description": "`auto` or `manual`." }), "preferred_available": Schema.Boolean.annotate({ "description": "Whether the preferred GPU is currently present." }), "preferred_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent." })), "preferred_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stored name of the preferred GPU (a usable label even when it is absent)." })), "selected": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "id": Schema.String, "name": Schema.String, "source": Schema.String.annotate({ "description": "Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming)." }), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU the next session will use." })], { mode: "oneOf" })) }).annotate({ "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now." }) +export type GameEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art": Artwork, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "store": string, readonly "title": string } +export const GameEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Artwork, "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:` or `custom:`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String }).annotate({ "description": "Descriptive metadata, flattened — see [`GameMeta`]." }) export type HooksConfig = { readonly "hooks"?: ReadonlyArray } export const HooksConfig = Schema.Struct({ "hooks": Schema.optionalKey(Schema.Array(HookEntry)) }).annotate({ "description": "The operator's hook configuration — the `hooks.json` document and the `/api/v1/hooks` body." }) export type LogPage = { readonly "dropped": boolean, readonly "entries": ReadonlyArray, readonly "next": number } @@ -159,12 +159,12 @@ export type DisplayLayoutRequest = { readonly "positions"?: { readonly [x: strin export const DisplayLayoutRequest = Schema.Struct({ "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).annotate({ "description": "`{\"\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits." }).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`)." }) export type Layout = { readonly "mode"?: LayoutMode, readonly "positions"?: { readonly [x: string]: Position } } export const Layout = Schema.Struct({ "mode": Schema.optionalKey(LayoutMode), "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON)." }) -export type CustomEntry = { readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "provider"?: string | null, readonly "title": string } -export const CustomEntry = Schema.Struct({ "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." })), "external_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." })), "title": Schema.String }).annotate({ "description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits." }) -export type CustomInput = { readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "title": string } -export const CustomInput = Schema.Struct({ "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Request body to create or replace a custom entry (no `id` — the host owns it)." }) -export type ProviderEntryInput = { readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "title": string } -export const ProviderEntryInput = Schema.Struct({ "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." })), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key." }) +export type CustomEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "provider"?: string | null, readonly "title": string } +export const CustomEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." })), "external_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) +export type CustomInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "title": string } +export const CustomInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept." }) +export type ProviderEntryInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "title": string } +export const ProviderEntryInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." })), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) export type CatalogResponse = { readonly "busy": boolean, readonly "host": HostFacts, readonly "plugins": ReadonlyArray, readonly "sources": ReadonlyArray } export const CatalogResponse = Schema.Struct({ "busy": Schema.Boolean.annotate({ "description": "True while a package operation is in flight — the console disables install buttons." }), "host": HostFacts, "plugins": Schema.Array(CatalogEntry), "sources": Schema.Array(SourceView) }) export type StatsSample = { readonly "bitrate_kbps": number, readonly "fec_recovered": number, readonly "fps": number, readonly "frames_dropped": number, readonly "mbps": number, readonly "packets_dropped": number, readonly "repeat_fps": number, readonly "send_dropped": number, readonly "session_id": number, readonly "stages": ReadonlyArray, readonly "t_ms": number } @@ -316,8 +316,8 @@ export type GetHostInfo200 = HostInfo export const GetHostInfo200 = HostInfo export type GetHostInfo401 = ApiError export const GetHostInfo401 = ApiError -export type GetLibraryParams = { readonly "provider"?: string } -export const GetLibraryParams = Schema.Struct({ "provider": Schema.optionalKey(Schema.String) }) +export type GetLibraryParams = { readonly "provider"?: string, readonly "platform"?: string } +export const GetLibraryParams = Schema.Struct({ "provider": Schema.optionalKey(Schema.String), "platform": Schema.optionalKey(Schema.String) }) export type GetLibrary200 = ReadonlyArray export const GetLibrary200 = Schema.Array(GameEntry) export type GetLibrary401 = ApiError @@ -893,7 +893,7 @@ export const make = ( })) ), "getLibrary": (options) => HttpClientRequest.get(`/api/v1/library`).pipe( - HttpClientRequest.setUrlParams({ "provider": options?.params?.["provider"] as any }), + HttpClientRequest.setUrlParams({ "provider": options?.params?.["provider"] as any, "platform": options?.params?.["platform"] as any }), withResponse(options?.config)(HttpClientResponse.matchStatus({ "2xx": decodeSuccess(GetLibrary200), "401": decodeError("GetLibrary401", GetLibrary401), @@ -1447,7 +1447,8 @@ readonly "getHostInfo": (options: { readonly con * Every installed-store title (Steam, read from the host's local files — no Steam API key) * merged with the user's custom entries, sorted by title. Artwork fields are URLs the client * fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the -* entries a given external provider owns. +* entries a given external provider owns; `?platform=` to one platform (case-insensitive — +* installed-store titles are `PC`, custom/provider entries carry whatever was authored). */ readonly "getLibrary": (options: { readonly params?: typeof GetLibraryParams.Encoded | undefined; readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibrary401", typeof GetLibrary401.Type>> /** diff --git a/web/messages/de.json b/web/messages/de.json index e348ce74..30ccc018 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -187,6 +187,20 @@ "library_field_logo": "Logo-Bild-URL", "library_field_command": "Startbefehl", "library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.", + "library_field_platform": "Plattform", + "library_field_platform_help": "Das System, auf dem dieser Titel läuft, z. B. PS2, Xbox 360, SNES, PC.", + "library_field_description": "Beschreibung", + "library_field_developer": "Entwickler", + "library_field_publisher": "Publisher", + "library_field_release_year": "Erscheinungsjahr", + "library_field_genres": "Genres", + "library_field_genres_help": "Kommagetrennt, z. B. RPG, Plattformer.", + "library_field_tags": "Tags", + "library_field_tags_help": "Kommagetrennte Labels zum Organisieren, z. B. Koop, Kinder.", + "library_field_region": "Region", + "library_field_region_help": "z. B. NTSC-U, PAL, NTSC-J.", + "library_field_players": "Spieler", + "library_details_legend": "Details (optional)", "library_save": "Speichern", "library_create": "Hinzufügen", "library_cancel": "Abbrechen", diff --git a/web/messages/en.json b/web/messages/en.json index 5e07a4b5..3b0eebeb 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -187,6 +187,20 @@ "library_field_logo": "Logo art URL", "library_field_command": "Launch command", "library_field_command_help": "Optional. The command the host runs to launch this title.", + "library_field_platform": "Platform", + "library_field_platform_help": "The system this title runs on, e.g. PS2, Xbox 360, SNES, PC.", + "library_field_description": "Description", + "library_field_developer": "Developer", + "library_field_publisher": "Publisher", + "library_field_release_year": "Release year", + "library_field_genres": "Genres", + "library_field_genres_help": "Comma-separated, e.g. RPG, Platformer.", + "library_field_tags": "Tags", + "library_field_tags_help": "Comma-separated labels for organizing, e.g. co-op, kids.", + "library_field_region": "Region", + "library_field_region_help": "e.g. NTSC-U, PAL, NTSC-J.", + "library_field_players": "Players", + "library_details_legend": "Details (optional)", "library_save": "Save", "library_create": "Add", "library_cancel": "Cancel", diff --git a/web/src/sections/Library/GameCard.tsx b/web/src/sections/Library/GameCard.tsx index 483cf645..58d6f144 100644 --- a/web/src/sections/Library/GameCard.tsx +++ b/web/src/sections/Library/GameCard.tsx @@ -65,13 +65,20 @@ export const GameCard: FC = ({ {game.title} )} -
+
{storeLabel(game.store)} + {/* Platform badge — "PC" is implied by every installed store, so only + non-PC platforms (the emulation case) earn a second badge. */} + {game.platform && game.platform.toUpperCase() !== "PC" && ( + + {game.platform} + + )}
{isCustom && (
@@ -102,6 +109,11 @@ export const GameCard: FC = ({ title={game.title} > {game.title} + {game.release_year != null && ( + + {game.release_year} + + )}
); diff --git a/web/src/sections/Library/GameForm.tsx b/web/src/sections/Library/GameForm.tsx index fb01a356..f2226c67 100644 --- a/web/src/sections/Library/GameForm.tsx +++ b/web/src/sections/Library/GameForm.tsx @@ -22,6 +22,17 @@ interface FormState { header: string; logo: string; command: string; + // Details — the flattened GameMeta fields; numbers and lists are kept as the raw + // text the user typed and only parsed on submit. + platform: string; + description: string; + developer: string; + publisher: string; + releaseYear: string; + genres: string; + tags: string; + region: string; + players: string; } const emptyForm: FormState = { @@ -31,6 +42,15 @@ const emptyForm: FormState = { header: "", logo: "", command: "", + platform: "", + description: "", + developer: "", + publisher: "", + releaseYear: "", + genres: "", + tags: "", + region: "", + players: "", }; function formFrom(entry: GameEntry): FormState { @@ -41,17 +61,38 @@ function formFrom(entry: GameEntry): FormState { header: entry.art.header ?? "", logo: entry.art.logo ?? "", command: entry.launch?.kind === "command" ? entry.launch.value : "", + platform: entry.platform ?? "", + description: entry.description ?? "", + developer: entry.developer ?? "", + publisher: entry.publisher ?? "", + releaseYear: entry.release_year?.toString() ?? "", + genres: entry.genres?.join(", ") ?? "", + tags: entry.tags?.join(", ") ?? "", + region: entry.region ?? "", + players: entry.players?.toString() ?? "", }; } /** Map the form to the API body — only attach `launch` when a command was given. `update_custom` - * REPLACES the whole `art`, so every field the form knows must round-trip (else editing a game with - * a `logo` would silently drop it). */ + * REPLACES the whole entry (art AND the metadata fields), so every field the form knows must + * round-trip (else editing a game with a `logo` or a `platform` would silently drop it). */ function toInput(f: FormState): CustomInput { const trim = (s: string) => { const t = s.trim(); return t ? t : undefined; }; + // "RPG, Platformer" → ["RPG", "Platformer"]; empty input → omitted entirely. + const list = (s: string) => { + const items = s + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + return items.length ? items : undefined; + }; + const int = (s: string) => { + const n = Number.parseInt(s.trim(), 10); + return Number.isFinite(n) ? n : undefined; + }; const command = f.command.trim(); return { title: f.title.trim(), @@ -62,6 +103,15 @@ function toInput(f: FormState): CustomInput { logo: trim(f.logo), }, launch: command ? { kind: "command", value: command } : null, + platform: trim(f.platform), + description: trim(f.description), + developer: trim(f.developer), + publisher: trim(f.publisher), + release_year: int(f.releaseYear), + genres: list(f.genres), + tags: list(f.tags), + region: trim(f.region), + players: int(f.players), }; } @@ -101,6 +151,32 @@ export const GameFormSection: FC<{ ); }; +/** One labeled text input bound to a FormState key — the form is a stack of these. */ +const Field: FC<{ + id: keyof FormState; + label: string; + value: string; + onChange: (value: string) => void; + help?: string; + type?: string; + required?: boolean; +}> = ({ id, label, value, onChange, help, type, required }) => ( +
+ + onChange(e.target.value)} + /> + {help &&

{help}

} +
+); + /** * The add/edit form card. Owns only its own field state (re-seeded per mount — the * parent keys it by target); reports a ready-to-send `CustomInput` on submit. @@ -113,6 +189,8 @@ export const GameForm: FC<{ isSaving: boolean; }> = ({ initial, mode, onSubmit, onCancel, isSaving }) => { const [form, setForm] = useState(initial); + const set = (key: keyof FormState) => (value: string) => + setForm((f) => ({ ...f, [key]: value })); const handleSubmit = (e: FormEvent) => { e.preventDefault(); @@ -138,74 +216,121 @@ export const GameForm: FC<{
-
- - - setForm((f) => ({ ...f, title: e.target.value })) - } - /> -
-
- - - setForm((f) => ({ ...f, portrait: e.target.value })) - } - /> -
-
- - setForm((f) => ({ ...f, hero: e.target.value }))} - /> -
-
- - - setForm((f) => ({ ...f, header: e.target.value })) - } - /> -
-
- - setForm((f) => ({ ...f, logo: e.target.value }))} - /> -
-
- - - setForm((f) => ({ ...f, command: e.target.value })) - } - /> -

- {m.library_field_command_help()} + + + + +

+ {m.library_details_legend()} +

+ {m.library_details_legend()}

-
+ + +
+ + +
+
+ + +
+ + + +