Files
deckyvault/lib/api/search-unified.ts
T
adrianbonpin 8a063bfb6c fix: include benchmark games in device filter and exclude soft-deleted entries from search
Task 10: Device filter now combines games with platform support entries
AND games with performance entries for the selected hardware, instead of
only checking gamePlatformSupport. This ensures games that have benchmarks
but no explicit platform support entry still appear in filtered results.

Task 11: The benchmark count query (bCounts) in unified search now filters
out soft-deleted performance entries using isRemoved = false. The pCounts
and perfStats queries already had this filter; only bCounts was missing it.
2026-04-29 01:43:37 +08:00

340 lines
11 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 } from "drizzle-orm"
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" }).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 term = `%${query.q}%`
// ── 1. Search local database ────────────────────────────────────
const localGames = await db
.select()
.from(games)
.where(
or(
ilike(games.title, term),
ilike(games.developer, term),
ilike(games.publisher, term),
),
)
.limit(20)
const localGameIds = localGames.map((g) => g.id)
const localSteamAppIds = new Set(
localGames.map((g) => g.steamAppId).filter(Boolean),
)
// 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 (localGameIds.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, localGameIds))
for (const row of supportRows) {
platformSupportMap.set(row.gameId, {
isSupported: row.isSupported,
protonStatus: row.protonStatus,
antiCheatRelevant: row.antiCheatRelevant,
antiCheatName: row.antiCheatName,
antiCheatStatus: row.antiCheatStatus,
})
}
}
// ── 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 (localGameIds.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, localGameIds),
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, localGameIds),
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, localGameIds))
.groupBy(gameComments.gameId),
])
benchmarkCounts = bCounts
presetCounts = pCounts
commentCounts = cCounts
}
const countMap = new Map<
string,
{ benchmarks: number; presets: number; comments: number }
>()
for (const g of localGames) {
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 (localGameIds.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, localGameIds),
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)
}
}
// ── 2c. Latest version ──────────────────────────────────────────
const latestVersionMap = new Map<string, string>()
if (localGameIds.length > 0) {
const versionRows = await db
.select({
gameId: gameVersions.gameId,
versionString: gameVersions.versionString,
})
.from(gameVersions)
.where(
and(
inArray(gameVersions.gameId, localGameIds),
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 localGames) {
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,
platformSupport: platform
? {
isSupported: platform.isSupported,
protonStatus: platform.protonStatus,
antiCheatRelevant: platform.antiCheatRelevant,
antiCheatName: platform.antiCheatName,
antiCheatStatus: platform.antiCheatStatus,
}
: null,
isRawPerformer: rawPerformerMap.get(g.id) ?? false,
isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
bestFps: bestFpsMap.get(g.id) ?? null,
latestVersion: latestVersionMap.get(g.id) ?? null,
})
}
// Add Steam-only games
for (const item of steamItems) {
if (localSteamAppIds.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(),
}),
},
)