c2bba13405
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m29s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Failing after 24s
ci / bench (push) Successful in 5m31s
deb / build-publish (push) Successful in 9m21s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
android / android (push) Successful in 19m41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish-host (push) Successful in 9m44s
arch / build-publish (push) Successful in 18m17s
ci / rust (push) Successful in 19m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m10s
windows-host / package (push) Successful in 17m47s
docker / deploy-docs (push) Successful in 10s
Every installed-store scanner (Steam; Lutris+Heroic on Linux; Epic/GOG/
Xbox on Windows) was hardwired on. New library-scanners.json persists the
operator's disabled set (default all on; absent/malformed = all on);
all_games() gates each provider, so disabling one hides its titles from
every surface (console grid, native clients, GameStream app list, launch
resolve). GET /library/scanners lists this platform's scanners + state;
PUT /library/scanners/{id} toggles and emits library.changed — admin lane
only (the cert allowlist's exact-path /library match keeps both off the
LAN surface). The console's Library page grows a "Game sources" card with
one chip per scanner (platform-shaped by the API), EN+DE strings, story.
The scanners are slated to become plugins; the stable per-scanner ids are
the migration seam.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
349 lines
15 KiB
Rust
349 lines
15 KiB
Rust
//! Library-tagged management endpoints: installed-store + custom game entries and box art.
|
|
//! Split out of the `mgmt` facade (plan §W5).
|
|
|
|
use super::shared::*;
|
|
use axum::http::header;
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct LibraryQuery {
|
|
/// Only entries owned by this external provider (RFC §8).
|
|
provider: Option<String>,
|
|
}
|
|
|
|
/// 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). `?provider=` narrows to the
|
|
/// entries a given external provider owns.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/library",
|
|
tag = "library",
|
|
operation_id = "getLibrary",
|
|
params(
|
|
("provider" = Option<String>, Query, description = "Only entries owned by this external provider"),
|
|
),
|
|
responses(
|
|
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
|
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
|
)
|
|
)]
|
|
pub(crate) async fn get_library(
|
|
Query(q): Query<LibraryQuery>,
|
|
) -> Json<Vec<crate::library::GameEntry>> {
|
|
let mut games = crate::library::all_games();
|
|
if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
|
|
games.retain(|g| g.provider.as_deref() == Some(provider.as_str()));
|
|
}
|
|
// Rewrite provider entries' local-file art into host art-proxy URLs so a client fetches covers
|
|
// from the host (a provider like Playnite stores on-host paths; the payload stays tiny at any
|
|
// library size, and the client never sees an unreachable `C:\…`).
|
|
for g in &mut games {
|
|
crate::library::proxy_local_art(&g.id, &mut g.art);
|
|
}
|
|
Json(games)
|
|
}
|
|
|
|
/// Request body for `setLibraryScanner`.
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub(crate) struct ScannerToggle {
|
|
/// Whether the scanner should run on this host.
|
|
enabled: bool,
|
|
}
|
|
|
|
/// List the library scanners
|
|
///
|
|
/// The installed-store scanners this host supports — the list is platform-dependent (Steam
|
|
/// everywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console
|
|
/// renders a toggle only for scanners that can do anything here. Scanners default to enabled;
|
|
/// disabling one hides its titles from every library surface from the next read. The user-curated
|
|
/// custom store is not a scanner and is always on.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/library/scanners",
|
|
tag = "library",
|
|
operation_id = "listLibraryScanners",
|
|
responses(
|
|
(status = OK, description = "This host's scanners with their enable state", body = [crate::library::ScannerInfo]),
|
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
|
)
|
|
)]
|
|
pub(crate) async fn list_library_scanners() -> Json<Vec<crate::library::ScannerInfo>> {
|
|
Json(crate::library::list_scanners())
|
|
}
|
|
|
|
/// Enable or disable a library scanner
|
|
///
|
|
/// Persists the toggle and applies it from the next library read (no restart). Disabling a scanner
|
|
/// hides its titles everywhere — the console grid, native clients, and the GameStream app list —
|
|
/// and re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits
|
|
/// `library.changed` with the scanner id as `source` when the state changed.
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/library/scanners/{id}",
|
|
tag = "library",
|
|
operation_id = "setLibraryScanner",
|
|
params(("id" = String, Path, description = "The scanner id (e.g. `steam`)")),
|
|
request_body = ScannerToggle,
|
|
responses(
|
|
(status = OK, description = "Toggle stored; the full scanner list", body = [crate::library::ScannerInfo]),
|
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
|
(status = NOT_FOUND, description = "No such scanner on this platform", body = ApiError),
|
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the settings", body = ApiError),
|
|
)
|
|
)]
|
|
pub(crate) async fn set_library_scanner(
|
|
Path(id): Path<String>,
|
|
ApiJson(toggle): ApiJson<ScannerToggle>,
|
|
) -> Response {
|
|
match crate::library::set_scanner_enabled(&id, toggle.enabled) {
|
|
Ok(Some(scanners)) => {
|
|
tracing::info!(
|
|
scanner = %id,
|
|
enabled = toggle.enabled,
|
|
"management API: library scanner toggled"
|
|
);
|
|
Json(scanners).into_response()
|
|
}
|
|
Ok(None) => api_error(StatusCode::NOT_FOUND, "no such scanner on this platform"),
|
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
)
|
|
)]
|
|
pub(crate) 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),
|
|
)
|
|
)]
|
|
pub(crate) 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");
|
|
}
|
|
use crate::library::MutateOutcome;
|
|
match crate::library::update_custom(&id, input) {
|
|
Ok(MutateOutcome::Done(entry)) => Json(entry).into_response(),
|
|
Ok(MutateOutcome::NotFound) => {
|
|
api_error(StatusCode::NOT_FOUND, "no custom entry with that id")
|
|
}
|
|
Ok(MutateOutcome::ProviderOwned(p)) => api_error(
|
|
StatusCode::CONFLICT,
|
|
&format!("entry is owned by provider `{p}` — update it through its reconcile"),
|
|
),
|
|
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),
|
|
)
|
|
)]
|
|
pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
|
|
use crate::library::MutateOutcome;
|
|
match crate::library::delete_custom(&id) {
|
|
Ok(MutateOutcome::Done(())) => StatusCode::NO_CONTENT.into_response(),
|
|
Ok(MutateOutcome::NotFound) => {
|
|
api_error(StatusCode::NOT_FOUND, "no custom entry with that id")
|
|
}
|
|
Ok(MutateOutcome::ProviderOwned(p)) => api_error(
|
|
StatusCode::CONFLICT,
|
|
&format!(
|
|
"entry is owned by provider `{p}` — remove it there, or DELETE the provider set"
|
|
),
|
|
),
|
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
/// The count envelope a provider uninstall returns.
|
|
#[derive(Serialize, ToSchema)]
|
|
pub(crate) struct ProviderRemoved {
|
|
/// How many entries the provider owned (and were removed).
|
|
removed: usize,
|
|
}
|
|
|
|
/// Replace a provider's library entries (declarative reconcile)
|
|
///
|
|
/// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the
|
|
/// provider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each
|
|
/// surviving title's host id stable across reconciles, drops orphans, and never touches manual
|
|
/// entries or other providers'. An empty array removes everything the provider owns. Emits
|
|
/// `library.changed` with the provider as `source`.
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/library/provider/{provider}",
|
|
tag = "library",
|
|
operation_id = "reconcileProviderEntries",
|
|
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
|
|
request_body = Vec<crate::library::ProviderEntryInput>,
|
|
responses(
|
|
(status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]),
|
|
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
|
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
|
)
|
|
)]
|
|
pub(crate) async fn reconcile_provider_entries(
|
|
Path(provider): Path<String>,
|
|
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
|
|
) -> Response {
|
|
if let Err(e) = crate::library::validate_provider_name(&provider) {
|
|
return api_error(StatusCode::BAD_REQUEST, &e);
|
|
}
|
|
if let Err(e) = crate::library::validate_provider_payload(&inputs) {
|
|
return api_error(StatusCode::BAD_REQUEST, &e);
|
|
}
|
|
match crate::library::reconcile_provider(&provider, inputs) {
|
|
Ok(entries) => {
|
|
tracing::info!(
|
|
provider,
|
|
count = entries.len(),
|
|
"library provider reconciled"
|
|
);
|
|
Json(entries).into_response()
|
|
}
|
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
/// Remove a provider's library entries
|
|
///
|
|
/// Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin
|
|
/// (RFC §8). Emits `library.changed` when anything was removed.
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/library/provider/{provider}",
|
|
tag = "library",
|
|
operation_id = "deleteProviderEntries",
|
|
params(("provider" = String, Path, description = "The provider id")),
|
|
responses(
|
|
(status = OK, description = "How many entries were removed", body = ProviderRemoved),
|
|
(status = BAD_REQUEST, description = "Invalid provider id", body = ApiError),
|
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
|
)
|
|
)]
|
|
pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Response {
|
|
if let Err(e) = crate::library::validate_provider_name(&provider) {
|
|
return api_error(StatusCode::BAD_REQUEST, &e);
|
|
}
|
|
match crate::library::delete_provider(&provider) {
|
|
Ok(removed) => {
|
|
if removed > 0 {
|
|
tracing::info!(provider, removed, "library provider entries removed");
|
|
}
|
|
Json(ProviderRemoved { removed }).into_response()
|
|
}
|
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
/// Fetch one cover-art image for a library entry
|
|
///
|
|
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
|
|
/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —
|
|
/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL
|
|
/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host
|
|
/// can't predict, in which case this 404s and the client falls through to its next art candidate).
|
|
/// Only Steam ids are backed today; any other store 404s.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/library/art/{id}/{kind}",
|
|
tag = "library",
|
|
operation_id = "getLibraryArt",
|
|
params(
|
|
("id" = String, Path, description = "The store-qualified library id, e.g. `steam:570`"),
|
|
("kind" = String, Path, description = "`portrait` | `hero` | `logo` | `header`"),
|
|
),
|
|
responses(
|
|
(status = OK, description = "Image bytes", content_type = "image/jpeg"),
|
|
(status = UNAUTHORIZED, description = "Missing or invalid credentials", body = ApiError),
|
|
(status = NOT_FOUND, description = "No art of that kind for that id", body = ApiError),
|
|
)
|
|
)]
|
|
pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) -> Response {
|
|
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
|
|
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
|
|
};
|
|
// Steam: CDN / local-cache proxy (id `steam:<appid>`).
|
|
if let Some(appid) = id
|
|
.strip_prefix("steam:")
|
|
.and_then(|s| s.parse::<u32>().ok())
|
|
{
|
|
return match tokio::task::spawn_blocking(move || {
|
|
crate::library::steam_art_bytes(appid, kind)
|
|
})
|
|
.await
|
|
{
|
|
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
|
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
|
};
|
|
}
|
|
// Custom/provider entry (id `custom:<id>`): serve its stored LOCAL art file — e.g. the Playnite
|
|
// plugin's covers, reconciled as on-host paths rather than inlined bytes.
|
|
if let Some(cid) = id.strip_prefix("custom:").map(str::to_owned) {
|
|
return match tokio::task::spawn_blocking(move || {
|
|
crate::library::custom_local_art_bytes(&cid, kind)
|
|
})
|
|
.await
|
|
{
|
|
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
|
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
|
|
};
|
|
}
|
|
api_error(StatusCode::NOT_FOUND, "no art proxy for this store")
|
|
}
|