feat(host/library): game library API — Steam adapter + custom store
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 30s
apple / swift (push) Successful in 1m15s
ci / bench (push) Successful in 1m35s
ci / rust (push) Successful in 2m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m19s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m53s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m31s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 30s
apple / swift (push) Successful in 1m15s
ci / bench (push) Successful in 1m35s
ci / rust (push) Successful in 2m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m19s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m53s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m31s
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_<appid>.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) <noreply@anthropic.com>
This commit is contained in:
@@ -149,7 +149,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, 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<Arc<MgmtState>>) -> 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<Vec<crate::library::GameEntry>> {
|
||||
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<crate::library::CustomInput>) -> 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<String>,
|
||||
ApiJson(input): ApiJson<crate::library::CustomInput>,
|
||||
) -> 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<String>) -> 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
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user