From 0ad133492b819fd798bf77f5ed45cf6c3f7d43c3 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Mon, 13 Jul 2026 05:03:26 +0800 Subject: [PATCH] feat(api): public plugin game-lookup endpoint for library panel --- .../lib/api/__tests__/plugin-public.test.ts | 80 ++++++ apps/web/lib/api/app.ts | 2 + apps/web/lib/api/plugin-public.ts | 260 ++++++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 apps/web/lib/api/__tests__/plugin-public.test.ts create mode 100644 apps/web/lib/api/plugin-public.ts diff --git a/apps/web/lib/api/__tests__/plugin-public.test.ts b/apps/web/lib/api/__tests__/plugin-public.test.ts new file mode 100644 index 0000000..5bfa415 --- /dev/null +++ b/apps/web/lib/api/__tests__/plugin-public.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +vi.mock("drizzle-orm", () => ({ + eq: vi.fn((col, val) => ({ col, val })), + and: vi.fn((...args) => ({ and: args })), + desc: vi.fn((col) => ({ desc: col })), + avg: vi.fn((col) => ({ avg: col })), + min: vi.fn((col) => ({ min: col })), + max: vi.fn((col) => ({ max: col })), + count: vi.fn((col) => ({ count: col })), + sql: vi.fn((strings, ...vals) => ({ strings, vals })), +})) +vi.mock("drizzle-orm/pg-core", () => ({ + pgTable: vi.fn((n, c, i) => ({ name: n, columns: c, indexes: i })), + pgEnum: vi.fn((n, v) => ({ name: n, values: v })), + text: vi.fn((n) => n), integer: vi.fn((n) => n), real: vi.fn((n) => n), + boolean: vi.fn((n) => n), timestamp: vi.fn((n) => n), jsonb: vi.fn((n) => n), + index: vi.fn((n) => ({ on: vi.fn() })), +})) +vi.mock("@/lib/db/schema", () => ({ + games: { id: "id", steamAppId: "steam_app_id" }, + gameVersions: { id: "id", gameId: "game_id", isLatest: "is_latest", createdAt: "created_at" }, + performanceEntries: { id: "id", versionId: "version_id", hardwareSlug: "hardware_slug", + fpsAvg: "fps_avg", fpsLow: "fps_low", fpsOnePercentLow: "fps_one_percent_low", + fpsHigh: "fps_high", isRemoved: "is_removed", isPinned: "is_pinned", upvotes: "upvotes", + upscalerType: "upscaler_type", frameGenMethod: "frame_gen_method", protonVersion: "proton_version", + osVersion: "os_version", tdpWatts: "tdp_watts", settingsJson: "settings_json", + createdAt: "created_at", userId: "user_id" }, + hardware: { slug: "slug", name: "name" }, + user: { id: "id", name: "name", image: "image" }, +})) +vi.mock("@/lib/db/index", () => ({ + db: { + select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn(() => ({ limit: vi.fn(() => ({ orderBy: vi.fn(() => []) })) })) })) })), + }, +})) + +import { buildPluginGameResponse } from "@/lib/api/plugin-public" + +describe("buildPluginGameResponse — shape contract", () => { + beforeEach(() => vi.clearAllMocks()) + + it("returns { game: null, error } shape when game is missing", async () => { + const r = await buildPluginGameResponse({ game: null }) + expect(r.game).toBeNull() + expect(typeof r.error).toBe("string") + expect(r.estFps).toBeNull() + expect(r.topEntries).toEqual([]) + expect(r.recentEntries).toEqual([]) + }) + + it("returns estFps null when there are no entries", async () => { + const r = await buildPluginGameResponse({ game: { id: "g1", steamAppId: 123, title: "X", slug: "x" }, entries: [], recent: [] }) + expect(r.game).not.toBeNull() + expect(r.estFps).toBeNull() + expect(r.topEntries).toEqual([]) + expect(r.recentEntries).toEqual([]) + }) + + it("computes estFps from entries and trims entry fields", async () => { + const entries = [ + { id: "e1", hardwareSlug: "steamdeck-oled", fpsAvg: 60, fpsLow: 40, fpsOnePercentLow: 45, fpsHigh: 90, + upscalerType: "none", frameGenMethod: "none", protonVersion: "9", osVersion: "SteamOS 3", tdpWatts: 12, + settingsJson: null, upvotes: 5, isPinned: true, createdAt: new Date("2026-01-01"), + userName: "u", userImage: null }, + { id: "e2", hardwareSlug: "steamdeck-oled", fpsAvg: 80, fpsLow: 55, fpsOnePercentLow: 60, fpsHigh: 120, + upscalerType: "fsr", frameGenMethod: "none", protonVersion: "9", osVersion: "SteamOS 3", tdpWatts: 15, + settingsJson: null, upvotes: 2, isPinned: false, createdAt: new Date("2026-02-01"), + userName: "u2", userImage: null }, + ] + const r = await buildPluginGameResponse({ game: { id: "g1", steamAppId: 123, title: "X", slug: "x" }, entries, recent: entries }) + expect(r.estFps).not.toBeNull() + expect(r.estFps!.avg).toBeCloseTo(70, 1) + expect(r.estFps!.count).toBe(2) + expect(r.estFps!.high).toBe(120) + expect(r.estFps!.low).toBe(40) + expect(r.topEntries.length).toBe(2) + expect(r.topEntries[0].id).toBe("e1") // pinned first + }) +}) \ No newline at end of file diff --git a/apps/web/lib/api/app.ts b/apps/web/lib/api/app.ts index d7d629e..1ce7f7b 100644 --- a/apps/web/lib/api/app.ts +++ b/apps/web/lib/api/app.ts @@ -53,6 +53,7 @@ import { mobileRoutes } from "@/lib/api/mobile" import { gamesLookupRoutes } from "@/lib/api/games-lookup" import { performanceImportRoutes } from "@/lib/api/performance-import" import { pluginPairingRoutes } from "@/lib/api/plugin-pairing" +import { pluginPublicRoutes } from "@/lib/api/plugin-public" const betterAuth = new Elysia({ name: "better-auth" }) .mount(auth.handler) @@ -247,6 +248,7 @@ export const app = new Elysia({ prefix: "/api" }) .use(gamesLookupRoutes) .use(mobileRoutes) .use(pluginPairingRoutes) + .use(pluginPublicRoutes) ) // ── Write routes ─────────────────────────────────────────── .group("", (app) => diff --git a/apps/web/lib/api/plugin-public.ts b/apps/web/lib/api/plugin-public.ts new file mode 100644 index 0000000..c738df6 --- /dev/null +++ b/apps/web/lib/api/plugin-public.ts @@ -0,0 +1,260 @@ +import { Elysia, t } from "elysia" +import { db } from "@/lib/db/index" +import { + games, + gameVersions, + performanceEntries, + hardware, + user, +} from "@/lib/db/schema" +import { eq, and, desc, sql } from "drizzle-orm" + +// ── Pure helpers (unit-tested directly) ────────────────────────── + +export type PluginGameRow = { + id: string + steamAppId: number | null + title: string + slug: string | null +} +export type PluginEntryRow = { + id: string + hardwareSlug: string + fpsAvg: number + fpsLow: number | null + fpsOnePercentLow: number | null + fpsHigh: number | null + upscalerType: string + frameGenMethod: string + protonVersion: string | null + osVersion: string | null + tdpWatts: number | null + settingsJson: unknown + upvotes: number + isPinned: boolean + createdAt: Date + userName: string | null + userImage: string | null +} + +export type PluginGameResponse = { + game: { id: string; steamAppId: number | null; title: string; slug: string | null } | null + estFps: { avg: number; low: number | null; onePct: number | null; high: number | null; count: number } | null + topEntries: ReturnType[] + recentEntries: ReturnType[] + error?: string +} + +function trimEntry(e: PluginEntryRow) { + return { + id: e.id, + hardwareSlug: e.hardwareSlug, + fpsAvg: e.fpsAvg, + fpsLow: e.fpsLow, + fpsOnePercentLow: e.fpsOnePercentLow, + fpsHigh: e.fpsHigh, + upscalerType: e.upscalerType, + frameGenMethod: e.frameGenMethod, + protonVersion: e.protonVersion, + osVersion: e.osVersion, + tdpWatts: e.tdpWatts, + settingsJson: e.settingsJson, + upvotes: e.upvotes, + isPinned: e.isPinned, + createdAt: e.createdAt.toISOString(), + userName: e.userName, + userImage: e.userImage, + } +} + +export async function buildPluginGameResponse(args: { + game: PluginGameRow | null + entries?: PluginEntryRow[] + recent?: PluginEntryRow[] +}): Promise { + if (!args.game) { + return { game: null, estFps: null, topEntries: [], recentEntries: [], error: "Game not in DeckyVault" } + } + const entries = args.entries ?? [] + const recent = args.recent ?? [] + if (entries.length === 0) { + return { game: { ...args.game }, estFps: null, topEntries: [], recentEntries: [] } + } + const fpsAvgVals = entries.map((e) => e.fpsAvg) + const estFps = { + avg: Math.round((fpsAvgVals.reduce((a, b) => a + b, 0) / fpsAvgVals.length) * 10) / 10, + low: entries.reduce((m, e) => (m == null ? e.fpsLow : Math.min(m, e.fpsLow ?? m)), null), + onePct: entries.reduce((m, e) => (m == null ? e.fpsOnePercentLow : Math.min(m, e.fpsOnePercentLow ?? m)), null), + high: entries.reduce((m, e) => (m == null ? e.fpsHigh : Math.max(m, e.fpsHigh ?? m)), null), + count: entries.length, + } + const topEntries = entries.map(trimEntry) + const recentEntries = recent.map(trimEntry) + return { game: { ...args.game }, estFps, topEntries, recentEntries } +} + +// ── Route ──────────────────────────────────────────────────────── + +export const pluginPublicRoutes = new Elysia({ + prefix: "/plugin", + detail: { tags: ["Plugin"] }, +}) + .get( + "/game/:steamAppId", + async ({ params, query, set }) => { + const steamAppId = Number(params.steamAppId) + if (!Number.isInteger(steamAppId) || steamAppId <= 0) { + set.status = 400 + return { error: "Invalid steamAppId" } + } + + const [game] = await db + .select({ + id: games.id, + steamAppId: games.steamAppId, + title: games.title, + slug: games.slug, + }) + .from(games) + .where(eq(games.steamAppId, steamAppId)) + .limit(1) + + if (!game) { + set.status = 404 + return await buildPluginGameResponse({ game: null }) + } + + // Resolve latest version + const [latestVersion] = await db + .select({ id: gameVersions.id }) + .from(gameVersions) + .where(and(eq(gameVersions.gameId, game.id), eq(gameVersions.isLatest, true))) + .limit(1) + + let versionId = latestVersion?.id + if (!versionId) { + const [anyVersion] = await db + .select({ id: gameVersions.id }) + .from(gameVersions) + .where(eq(gameVersions.gameId, game.id)) + .orderBy(gameVersions.createdAt) + .limit(1) + versionId = anyVersion?.id + } + if (!versionId) { + return await buildPluginGameResponse({ game, entries: [], recent: [] }) + } + + const hardwareFilter = query.hardware ? eq(performanceEntries.hardwareSlug, query.hardware) : undefined + const baseWhere = and( + eq(performanceEntries.versionId, versionId), + eq(performanceEntries.isRemoved, false), + ...(hardwareFilter ? [hardwareFilter] : []), + ) + + // Top entries: pinned -> upvotes + const topRows = await db + .select({ + id: performanceEntries.id, + hardwareSlug: performanceEntries.hardwareSlug, + fpsAvg: performanceEntries.fpsAvg, + fpsLow: performanceEntries.fpsLow, + fpsOnePercentLow: performanceEntries.fpsOnePercentLow, + fpsHigh: performanceEntries.fpsHigh, + upscalerType: performanceEntries.upscalerType, + frameGenMethod: performanceEntries.frameGenMethod, + protonVersion: performanceEntries.protonVersion, + osVersion: performanceEntries.osVersion, + tdpWatts: performanceEntries.tdpWatts, + settingsJson: performanceEntries.settingsJson, + upvotes: performanceEntries.upvotes, + isPinned: performanceEntries.isPinned, + createdAt: performanceEntries.createdAt, + userName: user.name, + userImage: user.image, + }) + .from(performanceEntries) + .innerJoin(user, eq(performanceEntries.userId, user.id)) + .where(baseWhere) + .orderBy(desc(performanceEntries.isPinned), desc(performanceEntries.upvotes)) + .limit(query.limit ?? 3) + + // Recent entries + const recentRows = await db + .select({ + id: performanceEntries.id, + hardwareSlug: performanceEntries.hardwareSlug, + fpsAvg: performanceEntries.fpsAvg, + fpsLow: performanceEntries.fpsLow, + fpsOnePercentLow: performanceEntries.fpsOnePercentLow, + fpsHigh: performanceEntries.fpsHigh, + upscalerType: performanceEntries.upscalerType, + frameGenMethod: performanceEntries.frameGenMethod, + protonVersion: performanceEntries.protonVersion, + osVersion: performanceEntries.osVersion, + tdpWatts: performanceEntries.tdpWatts, + settingsJson: performanceEntries.settingsJson, + upvotes: performanceEntries.upvotes, + isPinned: performanceEntries.isPinned, + createdAt: performanceEntries.createdAt, + userName: user.name, + userImage: user.image, + }) + .from(performanceEntries) + .innerJoin(user, eq(performanceEntries.userId, user.id)) + .where(baseWhere) + .orderBy(desc(performanceEntries.createdAt)) + .limit(query.limit ?? 3) + + set.headers["Cache-Control"] = "public, max-age=60" + return await buildPluginGameResponse({ + game, + entries: topRows as unknown as PluginEntryRow[], + recent: recentRows as unknown as PluginEntryRow[], + }) + }, + { + params: t.Object({ steamAppId: t.Numeric() }), + query: t.Object({ + hardware: t.Optional(t.String()), + limit: t.Optional(t.Numeric()), + }), + detail: { + description: + "Public read endpoint for the DeckyVault Decky plugin's library app-details panel. " + + "Returns game status, device-scoped estimated FPS, and top/recent entries.", + }, + }, + ) + .get( + "/game/:steamAppId/devices", + async ({ params, set }) => { + const steamAppId = Number(params.steamAppId) + const [game] = await db + .select({ id: games.id }) + .from(games) + .where(eq(games.steamAppId, steamAppId)) + .limit(1) + if (!game) { + set.status = 404 + return { error: "Game not in DeckyVault", devices: [] } + } + const rows = await db + .select({ + slug: performanceEntries.hardwareSlug, + count: sql`count(*)::int`, + name: hardware.name, + }) + .from(performanceEntries) + .innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug)) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .where(and(eq(gameVersions.gameId, game.id), eq(performanceEntries.isRemoved, false))) + .groupBy(performanceEntries.hardwareSlug, hardware.name) + .orderBy(desc(sql`count(*)`)) + set.headers["Cache-Control"] = "public, max-age=60" + return { devices: rows } + }, + { + params: t.Object({ steamAppId: t.Numeric() }), + }, + ) \ No newline at end of file