Files
deckyvault/lib/api/search-unified.ts
T
adrianbonpin fbfc43832e feat: add OpenAPI support and in-process cron scheduler
- Install @elysia/openapi to auto-generate API docs at /api/openapi
- Install @elysia/cron for in-process scheduled jobs (orphan detection at 2am, storage cleanup at 3am)
- Add Scalar UI docs with 14 tag groups and Bearer JWT security scheme
- Add route-level detail annotations (tags, summaries) to all 30+ route files
- Upgrade crud-builder to accept optional tags config
- Keep HTTP cron endpoint as manual/admin fallback
2026-05-14 21:43:33 +08:00

532 lines
18 KiB
TypeScript

import { Elysia, t } from "elysia"
import { db } from "@/lib/db/index"
import {
games,
gameVersions,
performanceEntries,
gameComments,
} from "@/lib/db/schema"
import { ilike, or, sql, eq, inArray, and, gte, desc } from "drizzle-orm"
import { fuzzySearchTerm } from "@/lib/db/search"
interface SteamSearchItem {
id: number
name: string
tiny_image: string
metascore: string
price?: { currency: string; initial: number; final: number }
platforms: { windows: boolean; mac: boolean; linux: boolean }
controller_support?: string
}
interface SteamSearchResponse {
items: SteamSearchItem[]
total: number
}
export const searchUnifiedRoutes = new Elysia({ prefix: "/search", detail: { tags: ["Search"] } }).get(
"/unified",
async ({ query, set }) => {
if (!query.q || query.q.length < 2) {
set.status = 400
return { error: "Query must be at least 2 characters" }
}
const titleTerm = fuzzySearchTerm(query.q)
const term = `%${query.q}%`
// ── Build filter conditions for columns on the games table ────
const baseFilterConditions = [
or(
ilike(games.title, titleTerm),
ilike(games.developer, term),
ilike(games.publisher, term),
),
]
if (query.playabilityStatus) {
baseFilterConditions.push(sql`${games.playabilityStatus} = ${query.playabilityStatus}`)
}
if (query.steamReviewScore) {
const minScore = parseInt(query.steamReviewScore, 10)
if (!isNaN(minScore)) {
baseFilterConditions.push(gte(games.steamReviewScore, minScore))
}
}
if (query.isFree === "true") {
baseFilterConditions.push(eq(games.isFree, true))
}
if (query.hasMultiplayer === "true") {
baseFilterConditions.push(sql`${games.onlineMultiplayerStatus} = 'supported'`)
}
// ── 1. Search local database ────────────────────────────────────
const localGames = await db
.select()
.from(games)
.where(and(...baseFilterConditions))
.limit(40)
// ── 1b. Post-process filters requiring joins ───────────────────
let filteredGameIds = new Set(localGames.map((g) => g.id))
// Device filter: keep only games that have at least one benchmark for the device
if (query.device && filteredGameIds.size > 0) {
const matchingGameIds = await db
.select({ gameId: gameVersions.gameId })
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(
and(
inArray(gameVersions.gameId, [...filteredGameIds]),
eq(performanceEntries.hardwareSlug, query.device),
eq(performanceEntries.isRemoved, false),
),
)
.groupBy(gameVersions.gameId)
filteredGameIds = new Set(matchingGameIds.map((r) => r.gameId))
}
// FSR support filter: keep games with at least one benchmark using upscaler !== 'none'
if (query.fsrSupport === "true" && filteredGameIds.size > 0) {
const matchingGameIds = await db
.select({ gameId: gameVersions.gameId })
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(
and(
inArray(gameVersions.gameId, [...filteredGameIds]),
sql`${performanceEntries.upscalerType} != 'none'`,
eq(performanceEntries.isRemoved, false),
),
)
.groupBy(gameVersions.gameId)
filteredGameIds = new Set(matchingGameIds.map((r) => r.gameId))
}
// FPS range filter: keep games whose bestFps falls within [minFps, maxFps]
const minFps = query.minFps ? parseInt(query.minFps, 10) : undefined
const maxFps = query.maxFps ? parseInt(query.maxFps, 10) : undefined
if ((minFps !== undefined || maxFps !== undefined) && filteredGameIds.size > 0) {
const fpsStats = await db
.select({
gameId: gameVersions.gameId,
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(
and(
inArray(gameVersions.gameId, [...filteredGameIds]),
eq(performanceEntries.isRemoved, false),
),
)
.groupBy(gameVersions.gameId)
const fpsMatchIds = new Set<string>()
for (const row of fpsStats) {
if (minFps !== undefined && row.bestFps < minFps) continue
if (maxFps !== undefined && row.bestFps > maxFps) continue
fpsMatchIds.add(row.gameId)
}
filteredGameIds = fpsMatchIds
}
// ── Filter local games to only those that passed all filters so far ──
const filteredLocalGames = localGames.filter((g) => filteredGameIds.has(g.id))
const filteredIds = filteredLocalGames.map((g) => g.id)
// Fetch platform support + anti-cheat for local games
const platformSupportMap = new Map<
string,
{
isSupported: boolean
protonStatus: string
antiCheatRelevant: boolean
antiCheatName: string | null
antiCheatStatus: string
}
>()
if (filteredIds.length > 0) {
const { gamePlatformSupport } = await import("@/lib/db/schema")
const supportRows = await db
.select({
gameId: gamePlatformSupport.gameId,
isSupported: gamePlatformSupport.isSupported,
protonStatus: gamePlatformSupport.protonStatus,
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
antiCheatName: gamePlatformSupport.antiCheatName,
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
})
.from(gamePlatformSupport)
.where(inArray(gamePlatformSupport.gameId, filteredIds))
for (const row of supportRows) {
platformSupportMap.set(row.gameId, {
isSupported: row.isSupported,
protonStatus: row.protonStatus,
antiCheatRelevant: row.antiCheatRelevant,
antiCheatName: row.antiCheatName,
antiCheatStatus: row.antiCheatStatus,
})
}
}
// ── 1c. Proton/Native and Anti-cheat post-filters ─────────────
if (query.protonNative && query.protonNative !== "any" && filteredIds.length > 0) {
const protonMatchIds = new Set<string>()
for (const g of filteredLocalGames) {
const platform = platformSupportMap.get(g.id)
const protonStatus = platform
? platform.protonStatus
: g.platforms?.linux
? "native"
: g.platforms?.windows
? "proton"
: "unknown"
if (
(query.protonNative === "native" && protonStatus === "native") ||
(query.protonNative === "proton" && protonStatus === "proton")
) {
protonMatchIds.add(g.id)
}
}
filteredGameIds = protonMatchIds
}
if (query.antiCheatStatus && query.antiCheatStatus !== "any" && filteredIds.length > 0) {
const acMatchIds = new Set<string>()
for (const g of filteredLocalGames) {
const platform = platformSupportMap.get(g.id)
const acStatus = platform ? platform.antiCheatStatus : "unknown"
if (acStatus === query.antiCheatStatus) {
acMatchIds.add(g.id)
}
}
filteredGameIds = acMatchIds
}
// Final local games after all filters
const finalLocalGames = filteredLocalGames.filter((g) => filteredGameIds.has(g.id))
const finalIds = finalLocalGames.map((g) => g.id)
const finalSteamAppIds = new Set(
finalLocalGames.map((g) => g.steamAppId).filter(Boolean),
)
// ── 2. Count related data for local games ───────────────────────
let benchmarkCounts: { gameId: string; count: number }[] = []
let presetCounts: { gameId: string; count: number }[] = []
let commentCounts: { gameId: string; count: number }[] = []
if (finalIds.length > 0) {
const [bCounts, pCounts, cCounts] = await Promise.all([
db
.select({
gameId: gameVersions.gameId,
count: sql<number>`count(*)::int`,
})
.from(performanceEntries)
.innerJoin(
gameVersions,
eq(performanceEntries.versionId, gameVersions.id),
)
.where(
and(
inArray(gameVersions.gameId, finalIds),
eq(performanceEntries.isRemoved, false),
),
)
.groupBy(gameVersions.gameId),
db
.select({
gameId: gameVersions.gameId,
count: sql<number>`count(*)::int`,
})
.from(performanceEntries)
.innerJoin(
gameVersions,
eq(performanceEntries.versionId, gameVersions.id),
)
.where(
and(
inArray(gameVersions.gameId, finalIds),
eq(performanceEntries.isRemoved, false),
sql`${performanceEntries.settingsJson} IS NOT NULL`,
),
)
.groupBy(gameVersions.gameId),
db
.select({
gameId: gameComments.gameId,
count: sql<number>`count(*)::int`,
})
.from(gameComments)
.where(inArray(gameComments.gameId, finalIds))
.groupBy(gameComments.gameId),
])
benchmarkCounts = bCounts
presetCounts = pCounts
commentCounts = cCounts
}
const countMap = new Map<
string,
{ benchmarks: number; presets: number; comments: number }
>()
for (const g of finalLocalGames) {
countMap.set(g.id, { benchmarks: 0, presets: 0, comments: 0 })
}
for (const c of benchmarkCounts) {
countMap.get(c.gameId)!.benchmarks = c.count
}
for (const c of presetCounts) {
countMap.get(c.gameId)!.presets = c.count
}
for (const c of commentCounts) {
countMap.get(c.gameId)!.comments = c.count
}
// ── 2b. Raw Performer + Poor Performance + best FPS ────────────
const rawPerformerMap = new Map<string, boolean>()
const poorPerformerMap = new Map<string, boolean>()
const bestFpsMap = new Map<string, number>()
if (finalIds.length > 0) {
const perfStats = await db
.select({
gameId: gameVersions.gameId,
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
isRawPerformer: sql<boolean>`BOOL_OR(
${performanceEntries.fpsAvg} >= 60
AND ${performanceEntries.upscalerType} = 'none'
AND ${performanceEntries.frameGenMethod} = 'none'
)`,
isPoorPerformance: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} < 30)`,
})
.from(performanceEntries)
.innerJoin(
gameVersions,
eq(performanceEntries.versionId, gameVersions.id),
)
.where(
and(
inArray(gameVersions.gameId, finalIds),
eq(performanceEntries.isRemoved, false),
),
)
.groupBy(gameVersions.gameId)
for (const row of perfStats) {
bestFpsMap.set(row.gameId, row.bestFps)
rawPerformerMap.set(row.gameId, row.isRawPerformer)
poorPerformerMap.set(row.gameId, row.isPoorPerformance)
}
}
// ── 2b2. Battery estimate for handheld devices ────────────────────
const batteryMinMap = new Map<string, number>()
if (finalIds.length > 0) {
const { hardware: hardwareTable } = await import("@/lib/db/schema")
const batteryStats = await db
.select({
gameId: gameVersions.gameId,
estimatedBatteryMin: sql<number>`ROUND(
(${hardwareTable.wattHours}::real / ${performanceEntries.tdpWatts}) * 60
)::int`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(hardwareTable, eq(performanceEntries.hardwareSlug, hardwareTable.slug))
.where(
and(
inArray(gameVersions.gameId, finalIds),
eq(performanceEntries.isRemoved, false),
eq(hardwareTable.deviceType, "handheld"),
sql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`,
sql`${hardwareTable.wattHours} IS NOT NULL`,
),
)
.orderBy(desc(performanceEntries.fpsAvg))
// Deduplicate — keep only the first (best fps) entry per game
const seenGames = new Set<string>()
for (const row of batteryStats) {
if (!seenGames.has(row.gameId)) {
seenGames.add(row.gameId)
batteryMinMap.set(row.gameId, row.estimatedBatteryMin)
}
}
}
// ── 2c. Latest version ──────────────────────────────────────────
const latestVersionMap = new Map<string, string>()
if (finalIds.length > 0) {
const versionRows = await db
.select({
gameId: gameVersions.gameId,
versionString: gameVersions.versionString,
})
.from(gameVersions)
.where(
and(
inArray(gameVersions.gameId, finalIds),
eq(gameVersions.isLatest, true),
),
)
for (const row of versionRows) {
if (row.versionString) {
latestVersionMap.set(row.gameId, row.versionString)
}
}
}
// ── 3. Search Steam ─────────────────────────────────────────────
let steamItems: SteamSearchItem[] = []
try {
const url = new URL("https://store.steampowered.com/api/storesearch/")
url.searchParams.set("term", query.q)
url.searchParams.set("cc", "US")
url.searchParams.set("l", "en")
const res = await fetch(url.toString(), {
headers: { Accept: "application/json" },
})
if (res.ok) {
const data = (await res.json()) as SteamSearchResponse
steamItems = (data.items || []).filter((item) => {
const name = item.name.toLowerCase()
const exclude = [
"soundtrack",
" original soundtrack",
" ost",
" - ost",
"dlc",
"expansion",
"season pass",
" deluxe edition",
" ultimate edition",
" premium edition",
" demo",
" trial",
" playtest",
" beta",
" artbook",
" soundtrack bundle",
]
return !exclude.some((kw) => name.includes(kw))
})
}
} catch {
// Steam search failure is non-fatal
}
// ── 4. Build unified results ────────────────────────────────────
const results = []
// Add local games
for (const g of finalLocalGames) {
const counts = countMap.get(g.id)!
const platform = platformSupportMap.get(g.id)
results.push({
kind: "local" as const,
id: g.id,
appId: g.steamAppId,
title: g.title,
image: g.capsuleImage || g.headerImage,
tinyImage: g.capsuleImage || g.headerImage || null,
developer: g.developer,
publisher: g.publisher,
description: g.description,
genres: g.genres,
source: g.source,
counts,
platforms: g.platforms,
platformSupport: platform
? {
isSupported: platform.isSupported,
protonStatus: platform.protonStatus,
antiCheatRelevant: platform.antiCheatRelevant,
antiCheatName: platform.antiCheatName,
antiCheatStatus: platform.antiCheatStatus,
}
: g.platforms
? {
isSupported: g.platforms.linux || g.platforms.windows || false,
protonStatus: g.platforms.linux
? "native"
: g.platforms.windows
? "proton"
: "unsupported",
antiCheatRelevant: false,
antiCheatName: null,
antiCheatStatus: "unknown",
}
: null,
isRawPerformer: rawPerformerMap.get(g.id) ?? false,
isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
bestFps: bestFpsMap.get(g.id) ?? null,
estimatedBatteryMin: batteryMinMap.get(g.id) ?? null,
latestVersion: latestVersionMap.get(g.id) ?? null,
playabilityStatus: g.playabilityStatus,
steamReviewScore: g.steamReviewScore,
steamReviewSentiment: g.steamReviewSentiment,
antiCheatRelevant: platform?.antiCheatRelevant ?? null,
antiCheatStatus: platform?.antiCheatStatus ?? null,
antiCheatName: platform?.antiCheatName ?? null,
})
}
// Add Steam-only games
for (const item of steamItems) {
if (finalSteamAppIds.has(item.id)) continue
results.push({
kind: "steam" as const,
appId: item.id,
title: item.name,
image: `https://cdn.akamai.steamstatic.com/steam/apps/${item.id}/library_600x900.jpg`,
tinyImage: item.tiny_image || null,
developer: null,
publisher: null,
description: null,
genres: null,
source: "steam" as const,
counts: null,
platformSupport: null,
metascore: item.metascore || null,
price: item.price
? {
currency: item.price.currency,
initial: item.price.initial,
final: item.price.final,
}
: null,
platforms: item.platforms,
controllerSupport: item.controller_support || null,
isRawPerformer: false,
bestFps: null,
latestVersion: null,
})
}
return { results, total: results.length }
},
{
query: t.Object({
q: t.String(),
device: t.Optional(t.String()),
minFps: t.Optional(t.String()),
maxFps: t.Optional(t.String()),
fsrSupport: t.Optional(t.String()),
protonNative: t.Optional(t.String()),
antiCheatStatus: t.Optional(t.String()),
playabilityStatus: t.Optional(t.String()),
steamReviewScore: t.Optional(t.String()),
isFree: t.Optional(t.String()),
hasMultiplayer: t.Optional(t.String()),
}),
},
)