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
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:
+261
-147
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:\…`).
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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>>
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user