From 6351d516e0200b978139709c6ba20b69477f4f16 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 14 Jun 2026 13:43:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(host/library):=20game=20library=20API=20?= =?UTF-8?q?=E2=80=94=20Steam=20adapter=20+=20custom=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new `library` module + four mgmt endpoints surface the host's games to clients (plan: "surface the user's games"). An adapter layer (`LibraryProvider`) so future stores (Heroic/Epic, GOG, Lutris) slot in behind one uniform `GameEntry`. - SteamProvider: reads the LOCAL Steam install — no Steam Web API key, no network. Installed titles from steamapps/appmanifest_.acf; extra library folders (incl. paths with spaces) from libraryfolders.vdf; candidate roots cover classic, Flatpak and Deck layouts, canonicalized + deduped (the .steam/{steam,root} symlinks all fold to one). Runtimes/redistributables (Proton, Steam Linux Runtime, Steamworks Common, SteamVR) filtered out. Artwork = the public Steam CDN by appid (portrait/hero/logo/header), fetched directly by the client. - Custom store: ~/.config/punktfunk/library.json, write-then-rename persisted, CRUD'd via the API — the "create custom entries via the admin web UI" requirement. - API (under /api/v1, OpenAPI-documented + checked in): GET /library (all stores merged, sorted), POST /library/custom, PUT/DELETE /library/custom/{id}. - `punktfunk-host library` subcommand dumps the resolved library as JSON (diagnostic, mirrors `openapi`). Validated live against the real Steam library on the Bazzite box: 89 appmanifests → 78 games (11 tools filtered), correct titles/sort, and the CDN art URLs return 200. 5 unit tests for the VDF/ACF parsing, tool filter, art URLs, custom mapping. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/library.rs | 435 +++++++++++++++++++++++++++ crates/punktfunk-host/src/main.rs | 7 + crates/punktfunk-host/src/mgmt.rs | 108 ++++++- docs/api/openapi.json | 383 +++++++++++++++++++++++ 4 files changed, 932 insertions(+), 1 deletion(-) create mode 100644 crates/punktfunk-host/src/library.rs diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs new file mode 100644 index 0000000..b909f1a --- /dev/null +++ b/crates/punktfunk-host/src/library.rs @@ -0,0 +1,435 @@ +//! Game library (plan: "surface the user's games"). A small adapter layer over the *stores* +//! installed on the host — today **Steam** (read from local files, no API key) and a +//! user-curated **custom** store (CRUD'd via the management API / web console). Every store +//! produces the same [`GameEntry`], so a client renders one uniform grid and never has to know +//! which launcher a title came from. Future stores (Heroic/Epic, GOG, Lutris, EmuDeck) are just +//! more [`LibraryProvider`]s. +//! +//! Artwork is keyed only by Steam appid against the public Steam CDN (no auth) — the client +//! fetches the posters directly. Custom entries carry user-supplied art URLs. +//! +//! This module is read-mostly metadata; *launching* a chosen title (mapping [`LaunchSpec`] onto a +//! gamescope session) is a later step — the launch hint is carried here so that wiring is trivial. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use utoipa::ToSchema; + +/// Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for +/// custom). The client prefers `portrait` for a grid and falls back to `header` when a title has +/// no 600×900 capsule (common for older Steam apps). +#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)] +pub struct Artwork { + /// Vertical capsule / poster (Steam `library_600x900.jpg`). Best for a grid. + #[serde(skip_serializing_if = "Option::is_none")] + pub portrait: Option, + /// Wide background (Steam `library_hero.jpg`). + #[serde(skip_serializing_if = "Option::is_none")] + pub hero: Option, + /// Transparent title logo (Steam `logo.png`). + #[serde(skip_serializing_if = "Option::is_none")] + pub logo: Option, + /// Horizontal header (Steam `header.jpg`) — the universal fallback. + #[serde(skip_serializing_if = "Option::is_none")] + pub header: Option, +} + +/// How the host would launch a title (consumed by the session launcher in a later step). Kept +/// open-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/`; +/// `command` → run `` nested in a gamescope session. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct LaunchSpec { + /// `"steam_appid"` or `"command"`. + #[schema(example = "steam_appid")] + pub kind: String, + /// The appid (for `steam_appid`) or the shell command (for `command`). + pub value: String, +} + +/// One title in the unified library, regardless of which store it came from. +#[derive(Clone, Debug, Serialize, ToSchema)] +pub struct GameEntry { + /// Stable, store-qualified id: `steam:` or `custom:`. + #[schema(example = "steam:570")] + pub id: String, + /// Which store surfaced it: `"steam"` or `"custom"`. + #[schema(example = "steam")] + pub store: String, + pub title: String, + pub art: Artwork, + /// How the host would launch it, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub launch: Option, +} + +/// A store that contributes titles to the library. The trait is the extension point for future +/// launchers; today only [`SteamProvider`] implements it. +pub trait LibraryProvider { + /// Stable store id (`"steam"`, …). + fn store(&self) -> &'static str; + /// Enumerate installed/owned titles. Best-effort: returns empty (not an error) when the store + /// isn't present, so one missing launcher never fails the whole library. + fn list(&self) -> Vec; +} + +// --------------------------------------------------------------------------------------- +// Steam +// --------------------------------------------------------------------------------------- + +/// Reads the **local** Steam install — no Steam Web API key, no network. Installed titles come +/// from `steamapps/appmanifest_.acf`; extra library folders from +/// `steamapps/libraryfolders.vdf`; artwork from the public Steam CDN by appid. +pub struct SteamProvider; + +impl LibraryProvider for SteamProvider { + fn store(&self) -> &'static str { + "steam" + } + + fn list(&self) -> Vec { + let mut by_appid: std::collections::BTreeMap = Default::default(); + for steamapps in steam_library_dirs() { + for (appid, name) in scan_manifests(&steamapps) { + by_appid.entry(appid).or_insert(name); // first library wins; dedups shared appids + } + } + by_appid + .into_iter() + .filter(|(appid, name)| !is_steam_tool(*appid, name)) + .map(|(appid, title)| GameEntry { + id: format!("steam:{appid}"), + store: "steam".into(), + title, + art: steam_art(appid), + launch: Some(LaunchSpec { + kind: "steam_appid".into(), + value: appid.to_string(), + }), + }) + .collect() + } +} + +/// The Steam CDN poster/hero/logo/header for an appid (public, no auth). Not every appid has a +/// 600×900 capsule, but `header.jpg` is effectively universal — the client falls back to it. +fn steam_art(appid: u32) -> Artwork { + let base = format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}"); + Artwork { + portrait: Some(format!("{base}/library_600x900.jpg")), + hero: Some(format!("{base}/library_hero.jpg")), + logo: Some(format!("{base}/logo.png")), + header: Some(format!("{base}/header.jpg")), + } +} + +/// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped. +fn steam_roots() -> Vec { + let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { + return Vec::new(); + }; + let candidates = [ + home.join(".local/share/Steam"), + home.join(".steam/steam"), + home.join(".steam/root"), + home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), // Flatpak Steam + ]; + let mut seen = HashSet::new(); + let mut roots = Vec::new(); + for c in candidates { + if let Ok(canon) = c.canonicalize() { + if canon.join("steamapps").is_dir() && seen.insert(canon.clone()) { + roots.push(canon); + } + } + } + roots +} + +/// Every `steamapps` dir holding installed titles: each root's own, plus the extra library +/// folders listed in `libraryfolders.vdf` (Steam lets you install games on other drives). +fn steam_library_dirs() -> Vec { + let mut seen = HashSet::new(); + let mut dirs = Vec::new(); + let mut push = |steamapps: PathBuf, dirs: &mut Vec| { + if let Ok(canon) = steamapps.canonicalize() { + if canon.is_dir() && seen.insert(canon.clone()) { + dirs.push(canon); + } + } + }; + for root in steam_roots() { + let steamapps = root.join("steamapps"); + if let Ok(text) = std::fs::read_to_string(steamapps.join("libraryfolders.vdf")) { + for path in vdf_paths(&text) { + push(PathBuf::from(path).join("steamapps"), &mut dirs); + } + } + push(steamapps, &mut dirs); + } + dirs +} + +/// Pull every `"path" ""` value out of a `libraryfolders.vdf`. We don't need a full VDF +/// parser for the two flat fields we read — Linux library paths never contain the `"` or `\` +/// that would require unescaping. +fn vdf_paths(text: &str) -> Vec { + text.lines() + .filter_map(|l| vdf_value(l.trim(), "path")) + .map(str::to_string) + .collect() +} + +/// `"" ""` on a single line → ``. Used for both VDF and ACF flat fields. +fn vdf_value<'a>(line: &'a str, key: &str) -> Option<&'a str> { + let rest = line.strip_prefix(&format!("\"{key}\""))?; + let after = &rest[rest.find('"')? + 1..]; + Some(&after[..after.find('"')?]) +} + +/// Scan a `steamapps` dir for `appmanifest_*.acf` files → (appid, name) of installed titles. +fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> { + let Ok(rd) = std::fs::read_dir(steamapps) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for entry in rd.flatten() { + let fname = entry.file_name(); + let fname = fname.to_string_lossy(); + if !(fname.starts_with("appmanifest_") && fname.ends_with(".acf")) { + continue; + } + if let Ok(text) = std::fs::read_to_string(entry.path()) { + let appid = text.lines().find_map(|l| vdf_value(l.trim(), "appid")); + let name = text.lines().find_map(|l| vdf_value(l.trim(), "name")); + if let (Some(Ok(appid)), Some(name)) = (appid.map(str::parse::), name) { + out.push((appid, name.to_string())); + } + } + } + out +} + +/// Steam installs runtimes/redistributables as "apps" too — keep them out of a *game* library. +fn is_steam_tool(appid: u32, name: &str) -> bool { + // Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier). + const TOOL_IDS: &[u32] = &[228980, 1070560, 1391110, 1628350, 1493710]; + if TOOL_IDS.contains(&appid) { + return true; + } + let n = name.to_ascii_lowercase(); + n.contains("proton") + || n.starts_with("steam linux runtime") + || n.contains("steamworks common") + || n.contains("steamvr") +} + +// --------------------------------------------------------------------------------------- +// Custom store (user-curated entries, persisted + CRUD'd via the mgmt API) +// --------------------------------------------------------------------------------------- + +/// A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API +/// returns and the web console edits. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct CustomEntry { + /// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path). + pub id: String, + pub title: String, + #[serde(default)] + pub art: Artwork, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub launch: Option, +} + +/// Request body to create or replace a custom entry (no `id` — the host owns it). +#[derive(Clone, Debug, Deserialize, ToSchema)] +pub struct CustomInput { + pub title: String, + #[serde(default)] + pub art: Artwork, + #[serde(default)] + pub launch: Option, +} + +impl From for GameEntry { + fn from(c: CustomEntry) -> Self { + GameEntry { + id: format!("custom:{}", c.id), + store: "custom".into(), + title: c.title, + art: c.art, + launch: c.launch, + } + } +} + +fn config_dir() -> PathBuf { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(|| PathBuf::from(".")) + .join("punktfunk") +} + +fn custom_path() -> PathBuf { + config_dir().join("library.json") +} + +/// Load the custom entries (empty + non-fatal if the file is absent or malformed). +pub fn load_custom() -> Vec { + match std::fs::read_to_string(custom_path()) { + Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| { + tracing::warn!(error = %e, "library.json malformed — ignoring custom entries"); + Vec::new() + }), + Err(_) => Vec::new(), + } +} + +fn save_custom(entries: &[CustomEntry]) -> Result<()> { + let dir = config_dir(); + std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + let json = serde_json::to_string_pretty(entries)?; + // Write-then-rename so a crash mid-write never truncates the catalog. + let tmp = custom_path().with_extension("json.tmp"); + std::fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?; + std::fs::rename(&tmp, custom_path()).context("rename library.json")?; + Ok(()) +} + +/// 12 hex chars from the title + wall-clock nanos — collision-free in practice, no uuid dep. +fn new_id(title: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6]) +} + +/// Create a custom entry, returning it with its assigned id. +pub fn add_custom(input: CustomInput) -> Result { + let mut entries = load_custom(); + let entry = CustomEntry { + id: new_id(&input.title), + title: input.title, + art: input.art, + launch: input.launch, + }; + entries.push(entry.clone()); + save_custom(&entries)?; + Ok(entry) +} + +/// Replace a custom entry's fields (id preserved). `None` ⇒ no entry with that id. +pub fn update_custom(id: &str, input: CustomInput) -> Result> { + let mut entries = load_custom(); + let Some(slot) = entries.iter_mut().find(|e| e.id == id) else { + return Ok(None); + }; + slot.title = input.title; + slot.art = input.art; + slot.launch = input.launch; + let updated = slot.clone(); + save_custom(&entries)?; + Ok(Some(updated)) +} + +/// Delete a custom entry. `false` ⇒ no entry with that id. +pub fn delete_custom(id: &str) -> Result { + let mut entries = load_custom(); + let before = entries.len(); + entries.retain(|e| e.id != id); + if entries.len() == before { + return Ok(false); + } + save_custom(&entries)?; + Ok(true) +} + +// --------------------------------------------------------------------------------------- +// Unified library +// --------------------------------------------------------------------------------------- + +/// The full library: every store's titles merged + the custom entries, sorted by title. +pub fn all_games() -> Vec { + let mut games = SteamProvider.list(); + games.extend(load_custom().into_iter().map(GameEntry::from)); + games.sort_by_key(|g| g.title.to_lowercase()); + games +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vdf_value_extracts_quoted_field() { + assert_eq!( + vdf_value("\"path\"\t\t\"/mnt/games/SteamLibrary\"", "path"), + Some("/mnt/games/SteamLibrary") + ); + assert_eq!(vdf_value("\"appid\"\t\t\"570\"", "appid"), Some("570")); + assert_eq!(vdf_value("\"name\"\t\t\"Dota 2\"", "name"), Some("Dota 2")); + assert_eq!(vdf_value("\"installdir\"\t\t\"x\"", "appid"), None); + } + + #[test] + fn vdf_paths_pulls_all_library_folders() { + let vdf = r#" +"libraryfolders" +{ + "0" + { + "path" "/home/u/.local/share/Steam" + "apps" { "570" "123" } + } + "1" + { + "path" "/mnt/ssd/SteamLibrary" + } +} +"#; + assert_eq!( + vdf_paths(vdf), + vec![ + "/home/u/.local/share/Steam".to_string(), + "/mnt/ssd/SteamLibrary".to_string() + ] + ); + } + + #[test] + fn tools_are_filtered_but_games_kept() { + assert!(is_steam_tool(228980, "Steamworks Common Redistributables")); + assert!(is_steam_tool(1493710, "Proton Experimental")); + assert!(is_steam_tool(0, "Steam Linux Runtime 3.0 (sniper)")); + assert!(!is_steam_tool(570, "Dota 2")); + assert!(!is_steam_tool(1245620, "ELDEN RING")); + } + + #[test] + fn steam_art_uses_cdn_by_appid() { + let art = steam_art(570); + assert_eq!( + art.portrait.as_deref(), + Some("https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_600x900.jpg") + ); + assert!(art.header.unwrap().ends_with("/570/header.jpg")); + } + + #[test] + fn custom_entry_maps_to_game_entry() { + let g: GameEntry = CustomEntry { + id: "abc123".into(), + title: "My ROM".into(), + art: Artwork::default(), + launch: None, + } + .into(); + assert_eq!(g.id, "custom:abc123"); + assert_eq!(g.store, "custom"); + } +} diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index ede2031..66685f7 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -21,6 +21,7 @@ mod drm_sync; mod encode; mod gamestream; mod inject; +mod library; mod m0; mod m3; mod mgmt; @@ -70,6 +71,12 @@ fn real_main() -> Result<()> { print!("{}", mgmt::openapi_json()); Ok(()) } + // Dump the resolved game library (installed stores + custom entries) as JSON — the same + // payload `GET /api/v1/library` serves. A diagnostic for "does the host see my games?". + Some("library") => { + println!("{}", serde_json::to_string_pretty(&library::all_games())?); + Ok(()) + } // Standalone input-injection smoke test (no client needed): open the session's input // backend and inject a scripted mouse/keyboard pattern. Watch a focused app / `wev`. Some("input-test") => input_test(), diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index f4ac8de..7303e30 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -149,7 +149,10 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(approve_pending_device)) .routes(routes!(deny_pending_device)) .routes(routes!(stop_session)) - .routes(routes!(request_idr)), + .routes(routes!(request_idr)) + .routes(routes!(get_library)) + .routes(routes!(create_custom_game)) + .routes(routes!(update_custom_game, delete_custom_game)), ) .split_for_parts() } @@ -180,6 +183,7 @@ pub fn openapi_json() -> String { (name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"), (name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"), (name = "session", description = "Active streaming session control"), + (name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"), ) )] struct ApiDoc; @@ -1067,6 +1071,108 @@ async fn request_idr(State(st): State>) -> Response { StatusCode::ACCEPTED.into_response() } +// --------------------------------------------------------------------------------------- +// Library +// --------------------------------------------------------------------------------------- + +/// List the game library +/// +/// Every installed-store title (Steam, read from the host's local files — no Steam API key) +/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client +/// fetches directly (the public Steam CDN for Steam titles). +#[utoipa::path( + get, + path = "/library", + tag = "library", + operation_id = "getLibrary", + responses( + (status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn get_library() -> Json> { + Json(crate::library::all_games()) +} + +/// Add a custom library entry +/// +/// Creates a user-curated title (e.g. a non-Steam game, an emulator, a ROM) with caller-supplied +/// artwork URLs. The host assigns a stable id, returned in the body. +#[utoipa::path( + post, + path = "/library/custom", + tag = "library", + operation_id = "createCustomGame", + request_body = crate::library::CustomInput, + responses( + (status = CREATED, description = "Entry created", body = crate::library::CustomEntry), + (status = BAD_REQUEST, description = "Empty title", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError), + ) +)] +async fn create_custom_game(ApiJson(input): ApiJson) -> Response { + if input.title.trim().is_empty() { + return api_error(StatusCode::BAD_REQUEST, "title must not be empty"); + } + match crate::library::add_custom(input) { + Ok(entry) => (StatusCode::CREATED, Json(entry)).into_response(), + Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +/// Update a custom library entry +#[utoipa::path( + put, + path = "/library/custom/{id}", + tag = "library", + operation_id = "updateCustomGame", + params(("id" = String, Path, description = "The custom entry id (without the `custom:` prefix)")), + request_body = crate::library::CustomInput, + responses( + (status = OK, description = "Entry updated", body = crate::library::CustomEntry), + (status = BAD_REQUEST, description = "Empty title", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = NOT_FOUND, description = "No custom entry with that id", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError), + ) +)] +async fn update_custom_game( + Path(id): Path, + ApiJson(input): ApiJson, +) -> Response { + if input.title.trim().is_empty() { + return api_error(StatusCode::BAD_REQUEST, "title must not be empty"); + } + match crate::library::update_custom(&id, input) { + Ok(Some(entry)) => Json(entry).into_response(), + Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"), + Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +/// Delete a custom library entry +#[utoipa::path( + delete, + path = "/library/custom/{id}", + tag = "library", + operation_id = "deleteCustomGame", + params(("id" = String, Path, description = "The custom entry id (without the `custom:` prefix)")), + responses( + (status = NO_CONTENT, description = "Entry deleted"), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = NOT_FOUND, description = "No custom entry with that id", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError), + ) +)] +async fn delete_custom_game(Path(id): Path) -> Response { + match crate::library::delete_custom(&id) { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"), + Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + // --------------------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------------------- diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 468823b..33ddc45 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -194,6 +194,238 @@ } } }, + "/api/v1/library": { + "get": { + "tags": [ + "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).", + "operationId": "getLibrary", + "responses": { + "200": { + "description": "Unified library across all stores", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GameEntry" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/library/custom": { + "post": { + "tags": [ + "library" + ], + "summary": "Add a custom library entry", + "description": "Creates a user-curated title (e.g. a non-Steam game, an emulator, a ROM) with caller-supplied\nartwork URLs. The host assigns a stable id, returned in the body.", + "operationId": "createCustomGame", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Entry created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomEntry" + } + } + } + }, + "400": { + "description": "Empty title", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/library/custom/{id}": { + "put": { + "tags": [ + "library" + ], + "summary": "Update a custom library entry", + "operationId": "updateCustomGame", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The custom entry id (without the `custom:` prefix)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Entry updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomEntry" + } + } + } + }, + "400": { + "description": "Empty title", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No custom entry with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "library" + ], + "summary": "Delete a custom library entry", + "operationId": "deleteCustomGame", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The custom entry id (without the `custom:` prefix)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Entry deleted" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No custom entry with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/native/clients": { "get": { "tags": [ @@ -831,6 +1063,40 @@ } } }, + "Artwork": { + "type": "object", + "description": "Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).", + "properties": { + "header": { + "type": [ + "string", + "null" + ], + "description": "Horizontal header (Steam `header.jpg`) — the universal fallback." + }, + "hero": { + "type": [ + "string", + "null" + ], + "description": "Wide background (Steam `library_hero.jpg`)." + }, + "logo": { + "type": [ + "string", + "null" + ], + "description": "Transparent title logo (Steam `logo.png`)." + }, + "portrait": { + "type": [ + "string", + "null" + ], + "description": "Vertical capsule / poster (Steam `library_600x900.jpg`). Best for a grid." + } + } + }, "AvailableCompositor": { "type": "object", "description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.", @@ -859,6 +1125,100 @@ } } }, + "CustomEntry": { + "type": "object", + "description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.", + "required": [ + "id", + "title" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" + }, + "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" + } + ] + }, + "title": { + "type": "string" + } + } + }, + "CustomInput": { + "type": "object", + "description": "Request body to create or replace a custom entry (no `id` — the host owns it).", + "required": [ + "title" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec" + } + ] + }, + "title": { + "type": "string" + } + } + }, + "GameEntry": { + "type": "object", + "description": "One title in the unified library, regardless of which store it came from.", + "required": [ + "id", + "store", + "title", + "art" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" + }, + "id": { + "type": "string", + "description": "Stable, store-qualified id: `steam:` or `custom:`.", + "example": "steam:570" + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec", + "description": "How the host would launch it, when known." + } + ] + }, + "store": { + "type": "string", + "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.", + "example": "steam" + }, + "title": { + "type": "string" + } + } + }, "Health": { "type": "object", "description": "Liveness + version probe.", @@ -941,6 +1301,25 @@ } } }, + "LaunchSpec": { + "type": "object", + "description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/`;\n`command` → run `` nested in a gamescope session.", + "required": [ + "kind", + "value" + ], + "properties": { + "kind": { + "type": "string", + "description": "`\"steam_appid\"` or `\"command\"`.", + "example": "steam_appid" + }, + "value": { + "type": "string", + "description": "The appid (for `steam_appid`) or the shell command (for `command`)." + } + } + }, "NativeClient": { "type": "object", "description": "A paired native (punktfunk/1) client.", @@ -1313,6 +1692,10 @@ { "name": "session", "description": "Active streaming session control" + }, + { + "name": "library", + "description": "Game library: installed-store titles (Steam) plus user-curated custom entries" } ] }