feat(library): descriptive metadata on every entry — platform, year, genres, and friends
apple / swift (push) Successful in 5m38s
windows-host / package (push) Successful in 19m0s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
ci / bench (push) Successful in 5m41s
ci / rust-arm64 (push) Successful in 9m32s
ci / rust (push) Failing after 12m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
android / android (push) Successful in 17m49s
decky / build-publish (push) Successful in 1m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
arch / build-publish (push) Successful in 19m46s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 39s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m22s
deb / build-publish (push) Successful in 9m9s
deb / build-publish-client-arm64 (push) Successful in 7m26s
deb / build-publish-host (push) Successful in 9m46s
flatpak / build-publish (push) Failing after 8m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m56s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 11s
apple / screenshots (push) Successful in 25m51s

Emulation-and-beyond libraries need more than a title and a poster. Every
library shape (GameEntry, CustomEntry, CustomInput, ProviderEntryInput)
now carries a shared, flattened GameMeta: platform, description,
developer, publisher, release_year, genres, tags, region, players. All
fields are optional and flat on the wire, so existing library.json files,
provider plugins, and clients keep working unchanged.

- Installed-store scanners (Steam, Lutris, Heroic, Epic, GOG, Xbox) stamp
  platform=PC; custom/provider entries carry whatever was authored.
- GET /library grows a ?platform= filter (case-insensitive) beside
  ?provider=.
- Console: the add/edit form gets a Details section (round-tripping every
  field, since update replaces the entry), the poster tile a platform
  badge (non-PC only — the store badge already implies PC) and the year.
- plugin-kit: ProviderEntry accepts the same fields (new GameMeta schema,
  spread flat); SDK + OpenAPI spec regenerated.
- pf-client-core decodes platform for future client badges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 19:00:30 +02:00
co-authored by Claude Opus 5
parent d8b7a86366
commit 1ee06defa6
21 changed files with 698 additions and 233 deletions
+261 -147
View File
@@ -965,7 +965,7 @@
"library" "library"
], ],
"summary": "List the game 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", "operationId": "getLibrary",
"parameters": [ "parameters": [
{ {
@@ -976,6 +976,15 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "platform",
"in": "query",
"description": "Only entries on this platform (case-insensitive, e.g. `PS2`)",
"required": false,
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@@ -4012,95 +4021,111 @@
} }
}, },
"CustomEntry": { "CustomEntry": {
"type": "object", "allOf": [
"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": [ "$ref": "#/components/schemas/GameMeta",
"id", "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
"title"
],
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
}, },
"detect": { {
"$ref": "#/components/schemas/DetectHint", "type": "object",
"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." "required": [
}, "id",
"external_id": { "title"
"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." "properties": {
}, "art": {
"id": { "$ref": "#/components/schemas/Artwork"
"type": "string",
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
},
"launch": {
"oneOf": [
{
"type": "null"
}, },
{ "detect": {
"$ref": "#/components/schemas/LaunchSpec" "$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": { "CustomInput": {
"type": "object", "allOf": [
"description": "Request body to create or replace a custom entry (no `id` — the host owns it).", {
"required": [ "$ref": "#/components/schemas/GameMeta",
"title" "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept."
],
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
}, },
"detect": { {
"$ref": "#/components/schemas/DetectHint", "type": "object",
"description": "How to recognize this title's process — see [`CustomEntry::detect`]." "required": [
}, "title"
"launch": { ],
"oneOf": [ "properties": {
{ "art": {
"type": "null" "$ref": "#/components/schemas/Artwork"
}, },
{ "detect": {
"$ref": "#/components/schemas/LaunchSpec" "$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": { "CustomPreset": {
"type": "object", "type": "object",
@@ -4750,48 +4775,129 @@
] ]
}, },
"GameEntry": { "GameEntry": {
"type": "object", "allOf": [
"description": "One title in the unified library, regardless of which store it came from.", {
"required": [ "$ref": "#/components/schemas/GameMeta",
"id", "description": "Descriptive metadata, flattened — see [`GameMeta`]."
"store",
"title",
"art"
],
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
}, },
"id": { {
"type": "string", "type": "object",
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.", "required": [
"example": "steam:570" "id",
}, "store",
"launch": { "title",
"oneOf": [ "art"
{ ],
"type": "null" "properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
}, },
{ "id": {
"$ref": "#/components/schemas/LaunchSpec", "type": "string",
"description": "How the host would launch it, when known." "description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
"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": [ "type": [
"string", "string",
"null" "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": { "developer": {
"type": "string", "type": [
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.", "string",
"example": "steam" "null"
]
}, },
"title": { "genres": {
"type": "string" "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": { "ProviderEntryInput": {
"type": "object", "allOf": [
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key.", {
"required": [ "$ref": "#/components/schemas/GameMeta",
"external_id", "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
"title"
],
"properties": {
"art": {
"$ref": "#/components/schemas/Artwork"
}, },
"detect": { {
"$ref": "#/components/schemas/DetectHint", "type": "object",
"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." "required": [
}, "external_id",
"external_id": { "title"
"type": "string", ],
"description": "The provider's stable id for this title (the reconcile diff key)." "properties": {
}, "art": {
"launch": { "$ref": "#/components/schemas/Artwork"
"oneOf": [
{
"type": "null"
}, },
{ "detect": {
"$ref": "#/components/schemas/LaunchSpec" "$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": { "ProviderRemoved": {
"type": "object", "type": "object",
+1
View File
@@ -620,6 +620,7 @@ fn mock_library() -> (
store: store.to_string(), store: store.to_string(),
title: title.to_string(), title: title.to_string(),
art: crate::library::Artwork::default(), art: crate::library::Artwork::default(),
platform: None,
}; };
let games = vec![ let games = vec![
game("steam:570", "steam", "Dota 2"), game("steam:570", "steam", "Dota 2"),
+10 -1
View File
@@ -62,6 +62,10 @@ pub struct GameEntry {
pub title: String, pub title: String,
#[serde(default)] #[serde(default)]
pub art: Artwork, 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<String>,
} }
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet"). /// 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() { fn game_entry_decodes_the_wire_shape() {
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored). // The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
let json = r#"[ 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"}, "art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
"launch":{"kind":"steam_appid","value":"570"}}, "launch":{"kind":"steam_appid","value":"570"}},
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}} {"id":"custom:abc","store":"custom","title":"My Emu","art":{}}
@@ -288,7 +292,12 @@ mod tests {
let games: Vec<GameEntry> = serde_json::from_str(json).unwrap(); let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
assert_eq!(games.len(), 2); assert_eq!(games.len(), 2);
assert_eq!(games[0].id, "steam:570"); 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].art.portrait.is_none());
assert!(
games[1].platform.is_none(),
"pre-metadata hosts still parse"
);
} }
#[test] #[test]
+54
View File
@@ -85,6 +85,57 @@ pub struct LaunchSpec {
pub value: String, 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<String>,
/// Short blurb for a details pane.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub developer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
/// 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<u16>,
/// Genre taxonomy from the metadata source (`"RPG"`, `"Platformer"`, …).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub genres: Vec<String>,
/// Free-form organizational labels (`"co-op"`, `"kids"`, `"finished"`, …).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// Release region — emulation-relevant (`"NTSC-U"`, `"PAL"`, `"NTSC-J"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
/// Maximum simultaneous (local) players.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub players: Option<u8>,
}
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. /// One title in the unified library, regardless of which store it came from.
#[derive(Clone, Debug, Serialize, ToSchema)] #[derive(Clone, Debug, Serialize, ToSchema)]
pub struct GameEntry { pub struct GameEntry {
@@ -113,6 +164,9 @@ pub struct GameEntry {
#[serde(skip)] #[serde(skip)]
#[schema(ignore)] #[schema(ignore)]
pub detect: DetectSpec, 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 /// A store that contributes titles to the library. The trait is the extension point for future
@@ -37,6 +37,9 @@ pub struct CustomEntry {
/// the host would otherwise lose the game the moment the shim returns. /// the host would otherwise lose the game the moment the shim returns.
#[serde(default, skip_serializing_if = "DetectHint::is_empty")] #[serde(default, skip_serializing_if = "DetectHint::is_empty")]
pub detect: DetectHint, 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). /// 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`]. /// How to recognize this title's process — see [`CustomEntry::detect`].
#[serde(default)] #[serde(default)]
pub detect: DetectHint, 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 /// 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. /// through the provider's own client still end its session when the player quits.
#[serde(default)] #[serde(default)]
pub detect: DetectHint, pub detect: DetectHint,
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`].
#[serde(flatten)]
pub meta: GameMeta,
} }
impl From<CustomEntry> for GameEntry { impl From<CustomEntry> for GameEntry {
@@ -98,6 +108,7 @@ impl From<CustomEntry> for GameEntry {
launch: c.launch, launch: c.launch,
provider: c.provider, provider: c.provider,
detect, detect,
meta: c.meta,
} }
} }
} }
@@ -188,6 +199,7 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
provider: None, provider: None,
external_id: None, external_id: None,
detect: input.detect, detect: input.detect,
meta: input.meta,
}; };
entries.push(entry.clone()); entries.push(entry.clone());
save_custom(&entries)?; save_custom(&entries)?;
@@ -210,6 +222,7 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
slot.launch = input.launch; slot.launch = input.launch;
slot.prep = input.prep; slot.prep = input.prep;
slot.detect = input.detect; slot.detect = input.detect;
slot.meta = input.meta;
let updated = slot.clone(); let updated = slot.clone();
save_custom(&entries)?; save_custom(&entries)?;
emit_changed("manual"); emit_changed("manual");
@@ -305,6 +318,7 @@ fn reconcile_entries(
provider: Some(provider.to_string()), provider: Some(provider.to_string()),
external_id: Some(input.external_id), external_id: Some(input.external_id),
detect: input.detect, detect: input.detect,
meta: input.meta,
}); });
} }
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile). // `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
@@ -383,6 +397,7 @@ mod tests {
provider: None, provider: None,
external_id: None, external_id: None,
detect: DetectHint::default(), detect: DetectHint::default(),
meta: GameMeta::default(),
} }
} }
@@ -394,6 +409,7 @@ mod tests {
launch: None, launch: None,
prep: Vec::new(), prep: Vec::new(),
detect: DetectHint::default(), detect: DetectHint::default(),
meta: GameMeta::default(),
} }
} }
@@ -407,8 +423,43 @@ mod tests {
let mut e = manual("def456", "Synced"); let mut e = manual("def456", "Synced");
e.provider = Some("romm".into()); e.provider = Some("romm".into());
e.external_id = Some("rom-1".into()); e.external_id = Some("rom-1".into());
e.meta.platform = Some("PS2".into());
let g: GameEntry = e.into(); let g: GameEntry = e.into();
assert_eq!(g.provider.as_deref(), Some("romm")); assert_eq!(g.provider.as_deref(), Some("romm"));
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
}
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
/// pre-metadata `library.json` / payload still parses (all-optional).
#[test]
fn meta_is_flat_and_optional_on_the_wire() {
let mut e = manual("abc123", "Shadow of the Colossus");
e.meta = GameMeta {
platform: Some("PS2".into()),
release_year: Some(2005),
genres: vec!["Adventure".into()],
..Default::default()
};
let v = serde_json::to_value(&e).unwrap();
assert_eq!(v["platform"], "PS2");
assert_eq!(v["release_year"], 2005);
assert_eq!(v["genres"][0], "Adventure");
assert!(v.get("meta").is_none(), "flattened, not nested");
assert!(v.get("description").is_none(), "absent fields are omitted");
assert!(v.get("tags").is_none(), "empty lists are omitted");
// A pre-metadata entry (what an existing library.json holds) still deserializes.
let old = r#"{"id":"abc","title":"Old"}"#;
let e: CustomEntry = serde_json::from_str(old).unwrap();
assert!(e.meta.platform.is_none());
// And a provider payload can carry the fields flat, next to `title` (RFC §8 shape).
let payload = r#"{"external_id":"rom-1","title":"OoT","platform":"N64",
"region":"NTSC-U","players":1,"tags":["kids"]}"#;
let p: ProviderEntryInput = serde_json::from_str(payload).unwrap();
assert_eq!(p.meta.platform.as_deref(), Some("N64"));
assert_eq!(p.meta.players, Some(1));
} }
/// The RFC §8 contract in one walk: add keeps ids stable across re-syncs, updates flow, /// The RFC §8 contract in one walk: add keeps ids stable across re-syncs, updates flow,
@@ -100,6 +100,7 @@ fn epic_entry(
}; };
Some(GameEntry { Some(GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id: format!("epic:{app_name}"), id: format!("epic:{app_name}"),
store: "epic".into(), store: "epic".into(),
title, title,
+1
View File
@@ -57,6 +57,7 @@ fn gog_games() -> Vec<GameEntry> {
let detect = DetectSpec::exe(&exe).with_dir(&path); let detect = DetectSpec::exe(&exe).with_dir(&path);
out.push(GameEntry { out.push(GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id, id,
store: "gog".into(), store: "gog".into(),
title, title,
@@ -109,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
}; };
games.push(GameEntry { games.push(GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id: format!("heroic:{runner}:{app_name}"), id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(), store: "heroic".into(),
title, title,
@@ -84,6 +84,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
for (id, slug, name, directory) in rows.flatten() { for (id, slug, name, directory) in rows.flatten() {
games.push(GameEntry { games.push(GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id: format!("lutris:{id}"), id: format!("lutris:{id}"),
store: "lutris".into(), store: "lutris".into(),
title: name, title: name,
@@ -29,6 +29,7 @@ impl LibraryProvider for SteamProvider {
.filter(|app| !is_steam_tool(app.appid, &app.name)) .filter(|app| !is_steam_tool(app.appid, &app.name))
.map(|app| GameEntry { .map(|app| GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id: format!("steam:{}", app.appid), id: format!("steam:{}", app.appid),
store: "steam".into(), store: "steam".into(),
art: steam_art(app.appid), art: steam_art(app.appid),
@@ -382,6 +383,7 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
} }
Some(GameEntry { Some(GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id: format!("steam:{}", sc.appid), id: format!("steam:{}", sc.appid),
store: "steam".into(), store: "steam".into(),
title: sc.name, title: sc.name,
@@ -70,6 +70,7 @@ fn xbox_games() -> Vec<GameEntry> {
let art = cached_art(&id).unwrap_or_default(); let art = cached_art(&id).unwrap_or_default();
games.push(GameEntry { games.push(GameEntry {
provider: None, provider: None,
meta: GameMeta::pc(),
id, id,
store: "xbox".into(), store: "xbox".into(),
title, title,
+13 -1
View File
@@ -8,6 +8,8 @@ use axum::http::header;
pub(crate) struct LibraryQuery { pub(crate) struct LibraryQuery {
/// Only entries owned by this external provider (RFC §8). /// Only entries owned by this external provider (RFC §8).
provider: Option<String>, provider: Option<String>,
/// Only entries on this platform (case-insensitive).
platform: Option<String>,
} }
/// List the game library /// 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) /// 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 /// 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 /// 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( #[utoipa::path(
get, get,
path = "/library", path = "/library",
@@ -23,6 +26,7 @@ pub(crate) struct LibraryQuery {
operation_id = "getLibrary", operation_id = "getLibrary",
params( params(
("provider" = Option<String>, Query, description = "Only entries owned by this external provider"), ("provider" = Option<String>, Query, description = "Only entries owned by this external provider"),
("platform" = Option<String>, Query, description = "Only entries on this platform (case-insensitive, e.g. `PS2`)"),
), ),
responses( responses(
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]), (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()) { if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
games.retain(|g| g.provider.as_deref() == Some(provider.as_str())); 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 // 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 // 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:\…`). // library size, and the client never sees an unreachable `C:\…`).
+1
View File
@@ -21,6 +21,7 @@ export { type CacheStore, makeCacheStore } from "./cache-store.js";
export { export {
Artwork, Artwork,
DetectHint, DetectHint,
GameMeta,
LaunchSpec, LaunchSpec,
PrepStep, PrepStep,
ProviderClient, ProviderClient,
+24
View File
@@ -46,6 +46,29 @@ export const DetectHint = Schema.Struct({
}); });
export type DetectHint = typeof DetectHint.Type; 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({ export const ProviderEntry = Schema.Struct({
external_id: Schema.String, external_id: Schema.String,
title: Schema.String, title: Schema.String,
@@ -53,5 +76,6 @@ export const ProviderEntry = Schema.Struct({
launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)), launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)),
prep: Schema.optionalKey(Schema.Array(PrepStep)), prep: Schema.optionalKey(Schema.Array(PrepStep)),
detect: Schema.optionalKey(DetectHint), detect: Schema.optionalKey(DetectHint),
...GameMeta.fields,
}); });
export type ProviderEntry = typeof ProviderEntry.Type; export type ProviderEntry = typeof ProviderEntry.Type;
+15 -14
View File
@@ -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 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<ApiDisplayInfo> } export type DisplayStateResponse = { readonly "displays": ReadonlyArray<ApiDisplayInfo> }
export const DisplayStateResponse = Schema.Struct({ "displays": Schema.Array(ApiDisplayInfo) }).annotate({ "description": "The host's managed virtual displays right now." }) 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<ApiGpu>, 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 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<ApiGpu>, 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 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 "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 type GameEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray<string>, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray<string>, 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:<appid>` or `custom:<id>`." }), "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 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:<appid>` or `custom:<id>`." }), "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<HookEntry> } export type HooksConfig = { readonly "hooks"?: ReadonlyArray<HookEntry> }
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 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<LogEntry>, readonly "next": number } export type LogPage = { readonly "dropped": boolean, readonly "entries": ReadonlyArray<LogEntry>, 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": "`{\"<identity_slot>\": {\"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 const DisplayLayoutRequest = Schema.Struct({ "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).annotate({ "description": "`{\"<identity_slot>\": {\"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 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 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<PrepCmd>, readonly "provider"?: string | null, readonly "title": string } export type CustomEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray<string>, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray<string>, 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<PrepCmd>, 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 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 "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<PrepCmd>, readonly "title": string } export type CustomInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray<string>, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray<string>, 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<PrepCmd>, 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 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 "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<PrepCmd>, readonly "title": string } export type ProviderEntryInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray<string>, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray<string>, 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<PrepCmd>, 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 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<CatalogEntry>, readonly "sources": ReadonlyArray<SourceView> } export type CatalogResponse = { readonly "busy": boolean, readonly "host": HostFacts, readonly "plugins": ReadonlyArray<CatalogEntry>, readonly "sources": ReadonlyArray<SourceView> }
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 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<StageTiming>, readonly "t_ms": number } 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<StageTiming>, readonly "t_ms": number }
@@ -316,8 +316,8 @@ export type GetHostInfo200 = HostInfo
export const GetHostInfo200 = HostInfo export const GetHostInfo200 = HostInfo
export type GetHostInfo401 = ApiError export type GetHostInfo401 = ApiError
export const GetHostInfo401 = ApiError export const GetHostInfo401 = ApiError
export type GetLibraryParams = { readonly "provider"?: string } export type GetLibraryParams = { readonly "provider"?: string, readonly "platform"?: string }
export const GetLibraryParams = Schema.Struct({ "provider": Schema.optionalKey(Schema.String) }) export const GetLibraryParams = Schema.Struct({ "provider": Schema.optionalKey(Schema.String), "platform": Schema.optionalKey(Schema.String) })
export type GetLibrary200 = ReadonlyArray<GameEntry> export type GetLibrary200 = ReadonlyArray<GameEntry>
export const GetLibrary200 = Schema.Array(GameEntry) export const GetLibrary200 = Schema.Array(GameEntry)
export type GetLibrary401 = ApiError export type GetLibrary401 = ApiError
@@ -893,7 +893,7 @@ export const make = (
})) }))
), ),
"getLibrary": (options) => HttpClientRequest.get(`/api/v1/library`).pipe( "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({ withResponse(options?.config)(HttpClientResponse.matchStatus({
"2xx": decodeSuccess(GetLibrary200), "2xx": decodeSuccess(GetLibrary200),
"401": decodeError("GetLibrary401", GetLibrary401), "401": decodeError("GetLibrary401", GetLibrary401),
@@ -1447,7 +1447,8 @@ readonly "getHostInfo": <Config extends OperationConfig>(options: { readonly con
* Every installed-store title (Steam, read from the host's local files no Steam API key) * 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 * 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 * 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": <Config extends OperationConfig>(options: { readonly params?: typeof GetLibraryParams.Encoded | undefined; readonly config?: Config | undefined } | undefined) => Effect.Effect<WithOptionalResponse<typeof GetLibrary200.Type, Config>, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibrary401", typeof GetLibrary401.Type>> readonly "getLibrary": <Config extends OperationConfig>(options: { readonly params?: typeof GetLibraryParams.Encoded | undefined; readonly config?: Config | undefined } | undefined) => Effect.Effect<WithOptionalResponse<typeof GetLibrary200.Type, Config>, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibrary401", typeof GetLibrary401.Type>>
/** /**
+14
View File
@@ -187,6 +187,20 @@
"library_field_logo": "Logo-Bild-URL", "library_field_logo": "Logo-Bild-URL",
"library_field_command": "Startbefehl", "library_field_command": "Startbefehl",
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.", "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_save": "Speichern",
"library_create": "Hinzufügen", "library_create": "Hinzufügen",
"library_cancel": "Abbrechen", "library_cancel": "Abbrechen",
+14
View File
@@ -187,6 +187,20 @@
"library_field_logo": "Logo art URL", "library_field_logo": "Logo art URL",
"library_field_command": "Launch command", "library_field_command": "Launch command",
"library_field_command_help": "Optional. The command the host runs to launch this title.", "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_save": "Save",
"library_create": "Add", "library_create": "Add",
"library_cancel": "Cancel", "library_cancel": "Cancel",
+13 -1
View File
@@ -65,13 +65,20 @@ export const GameCard: FC<GameCardProps> = ({
{game.title} {game.title}
</div> </div>
)} )}
<div className="absolute left-2 top-2"> <div className="absolute left-2 top-2 flex flex-wrap gap-1">
<Badge <Badge
variant={isCustom ? "secondary" : "outline"} variant={isCustom ? "secondary" : "outline"}
className="bg-background/80 backdrop-blur" className="bg-background/80 backdrop-blur"
> >
{storeLabel(game.store)} {storeLabel(game.store)}
</Badge> </Badge>
{/* 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" && (
<Badge variant="outline" className="bg-background/80 backdrop-blur">
{game.platform}
</Badge>
)}
</div> </div>
{isCustom && ( {isCustom && (
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"> <div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
@@ -102,6 +109,11 @@ export const GameCard: FC<GameCardProps> = ({
title={game.title} title={game.title}
> >
{game.title} {game.title}
{game.release_year != null && (
<span className="ml-1.5 font-normal text-muted-foreground">
{game.release_year}
</span>
)}
</div> </div>
</Card> </Card>
); );
+194 -69
View File
@@ -22,6 +22,17 @@ interface FormState {
header: string; header: string;
logo: string; logo: string;
command: 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 = { const emptyForm: FormState = {
@@ -31,6 +42,15 @@ const emptyForm: FormState = {
header: "", header: "",
logo: "", logo: "",
command: "", command: "",
platform: "",
description: "",
developer: "",
publisher: "",
releaseYear: "",
genres: "",
tags: "",
region: "",
players: "",
}; };
function formFrom(entry: GameEntry): FormState { function formFrom(entry: GameEntry): FormState {
@@ -41,17 +61,38 @@ function formFrom(entry: GameEntry): FormState {
header: entry.art.header ?? "", header: entry.art.header ?? "",
logo: entry.art.logo ?? "", logo: entry.art.logo ?? "",
command: entry.launch?.kind === "command" ? entry.launch.value : "", 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` /** 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 * REPLACES the whole entry (art AND the metadata fields), so every field the form knows must
* a `logo` would silently drop it). */ * round-trip (else editing a game with a `logo` or a `platform` would silently drop it). */
function toInput(f: FormState): CustomInput { function toInput(f: FormState): CustomInput {
const trim = (s: string) => { const trim = (s: string) => {
const t = s.trim(); const t = s.trim();
return t ? t : undefined; 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(); const command = f.command.trim();
return { return {
title: f.title.trim(), title: f.title.trim(),
@@ -62,6 +103,15 @@ function toInput(f: FormState): CustomInput {
logo: trim(f.logo), logo: trim(f.logo),
}, },
launch: command ? { kind: "command", value: command } : null, 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 }) => (
<div className="space-y-2">
<Label htmlFor={`lib-${id}`}>{label}</Label>
<Input
id={`lib-${id}`}
type={type}
inputMode={
type === "url" ? "url" : type === "number" ? "numeric" : undefined
}
required={required}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{help && <p className="text-xs text-muted-foreground">{help}</p>}
</div>
);
/** /**
* The add/edit form card. Owns only its own field state (re-seeded per mount the * 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. * parent keys it by target); reports a ready-to-send `CustomInput` on submit.
@@ -113,6 +189,8 @@ export const GameForm: FC<{
isSaving: boolean; isSaving: boolean;
}> = ({ initial, mode, onSubmit, onCancel, isSaving }) => { }> = ({ initial, mode, onSubmit, onCancel, isSaving }) => {
const [form, setForm] = useState<FormState>(initial); const [form, setForm] = useState<FormState>(initial);
const set = (key: keyof FormState) => (value: string) =>
setForm((f) => ({ ...f, [key]: value }));
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -138,74 +216,121 @@ export const GameForm: FC<{
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <Field
<Label htmlFor="lib-title">{m.library_field_title()}</Label> id="title"
<Input label={m.library_field_title()}
id="lib-title" value={form.title}
required onChange={set("title")}
value={form.title} required
onChange={(e) => />
setForm((f) => ({ ...f, title: e.target.value })) <Field
} id="portrait"
/> label={m.library_field_portrait()}
</div> value={form.portrait}
<div className="space-y-2"> onChange={set("portrait")}
<Label htmlFor="lib-portrait">{m.library_field_portrait()}</Label> type="url"
<Input />
id="lib-portrait" <Field
type="url" id="hero"
inputMode="url" label={m.library_field_hero()}
value={form.portrait} value={form.hero}
onChange={(e) => onChange={set("hero")}
setForm((f) => ({ ...f, portrait: e.target.value })) type="url"
} />
/> <Field
</div> id="header"
<div className="space-y-2"> label={m.library_field_header()}
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label> value={form.header}
<Input onChange={set("header")}
id="lib-hero" type="url"
type="url" />
inputMode="url" <Field
value={form.hero} id="logo"
onChange={(e) => setForm((f) => ({ ...f, hero: e.target.value }))} label={m.library_field_logo()}
/> value={form.logo}
</div> onChange={set("logo")}
<div className="space-y-2"> type="url"
<Label htmlFor="lib-header">{m.library_field_header()}</Label> />
<Input <Field
id="lib-header" id="command"
type="url" label={m.library_field_command()}
inputMode="url" value={form.command}
value={form.header} onChange={set("command")}
onChange={(e) => help={m.library_field_command_help()}
setForm((f) => ({ ...f, header: e.target.value })) />
} <fieldset className="space-y-4 border-t pt-2">
/> <legend className="sr-only">{m.library_details_legend()}</legend>
</div> <p
<div className="space-y-2"> aria-hidden
<Label htmlFor="lib-logo">{m.library_field_logo()}</Label> className="text-sm font-medium text-muted-foreground"
<Input >
id="lib-logo" {m.library_details_legend()}
type="url"
inputMode="url"
value={form.logo}
onChange={(e) => setForm((f) => ({ ...f, logo: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
<Input
id="lib-command"
value={form.command}
onChange={(e) =>
setForm((f) => ({ ...f, command: e.target.value }))
}
/>
<p className="text-xs text-muted-foreground">
{m.library_field_command_help()}
</p> </p>
</div> <Field
id="platform"
label={m.library_field_platform()}
value={form.platform}
onChange={set("platform")}
help={m.library_field_platform_help()}
/>
<Field
id="description"
label={m.library_field_description()}
value={form.description}
onChange={set("description")}
/>
<div className="grid grid-cols-2 gap-4">
<Field
id="developer"
label={m.library_field_developer()}
value={form.developer}
onChange={set("developer")}
/>
<Field
id="publisher"
label={m.library_field_publisher()}
value={form.publisher}
onChange={set("publisher")}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
id="releaseYear"
label={m.library_field_release_year()}
value={form.releaseYear}
onChange={set("releaseYear")}
type="number"
/>
<Field
id="players"
label={m.library_field_players()}
value={form.players}
onChange={set("players")}
type="number"
/>
</div>
<Field
id="region"
label={m.library_field_region()}
value={form.region}
onChange={set("region")}
help={m.library_field_region_help()}
/>
<Field
id="genres"
label={m.library_field_genres()}
value={form.genres}
onChange={set("genres")}
help={m.library_field_genres_help()}
/>
<Field
id="tags"
label={m.library_field_tags()}
value={form.tags}
onChange={set("tags")}
help={m.library_field_tags_help()}
/>
</fieldset>
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="submit" disabled={isSaving || !form.title.trim()}> <Button type="submit" disabled={isSaving || !form.title.trim()}>
{mode === "edit" ? m.library_save() : m.library_create()} {mode === "edit" ? m.library_save() : m.library_create()}
+9
View File
@@ -13,6 +13,15 @@ const emptyForm = {
header: "", header: "",
logo: "", logo: "",
command: "", command: "",
platform: "",
description: "",
developer: "",
publisher: "",
releaseYear: "",
genres: "",
tags: "",
region: "",
players: "",
}; };
// The overview grid and the add/edit form are separate components now, so the stories // The overview grid and the add/edit form are separate components now, so the stories
+17
View File
@@ -153,6 +153,23 @@ export const library: GameEntry[] = [
art: noArt, art: noArt,
launch: null, launch: null,
}, },
// An emulated title with the metadata fields filled — exercises the platform
// badge (non-PC) and the year in the caption.
{
id: "custom:sotc",
store: "custom",
title: "Shadow of the Colossus",
art: noArt,
launch: null,
platform: "PS2",
developer: "Team Ico",
publisher: "Sony Computer Entertainment",
release_year: 2005,
genres: ["Adventure"],
tags: ["favorite"],
region: "PAL",
players: 1,
},
]; ];
// --- Performance (stats) page ------------------------------------------------ // --- Performance (stats) page ------------------------------------------------