feat: add GET /api/mobile/search endpoint (DB-only, performance tags)

This commit is contained in:
2026-05-16 15:57:28 +08:00
parent 33f5aa8ec6
commit fda5c4d60e
+161 -1
View File
@@ -533,4 +533,164 @@ export const mobileRoutes = new Elysia({
{
params: t.Object({ gameId: t.String() }),
},
)
)
// ── Mobile Search (DB-synced games only, no Steam results) ──────
.get(
"/search",
async ({ query, set }) => {
if (!query.q || query.q.length < 2) {
set.status = 400
return { error: "Query must be at least 2 characters" }
}
const { ilike, or, sql: dsql, eq: deq, and: dand, desc: ddesc, inArray, gte } = await import("drizzle-orm")
const { fuzzySearchTerm } = await import("@/lib/db/search")
const titleTerm = fuzzySearchTerm(query.q)
const term = `%${query.q}%`
// Build filter conditions
const baseFilterConditions = [
or(
ilike(games.title, titleTerm),
ilike(games.developer, term),
ilike(games.publisher, term),
),
]
if (query.playabilityStatus) {
baseFilterConditions.push(dsql`${games.playabilityStatus} = ${query.playabilityStatus}`)
}
// Search local database only
const localGames = await db
.select({
id: games.id,
steamAppId: games.steamAppId,
title: games.title,
capsuleImage: games.capsuleImage,
headerImage: games.headerImage,
playabilityStatus: games.playabilityStatus,
steamReviewScore: games.steamReviewScore,
})
.from(games)
.where(dand(...baseFilterConditions))
.limit(40)
if (localGames.length === 0) {
return { results: [], total: 0 }
}
const gameIds = localGames.map((g) => g.id)
// Fetch benchmark counts
const benchmarkCounts = await db
.select({ gameId: gameVersions.gameId, count: sql<number>`count(*)::int` })
.from(performanceEntries)
.innerJoin(gameVersions, deq(performanceEntries.versionId, gameVersions.id))
.where(dand(inArray(gameVersions.gameId, gameIds), deq(performanceEntries.isRemoved, false)))
.groupBy(gameVersions.gameId)
const bmMap = new Map<string, number>()
for (const r of benchmarkCounts) bmMap.set(r.gameId, r.count)
// Fetch comment counts
const commentCounts = await db
.select({ gameId: gameComments.gameId, count: sql<number>`count(*)::int` })
.from(gameComments)
.where(inArray(gameComments.gameId, gameIds))
.groupBy(gameComments.gameId)
const cmMap = new Map<string, number>()
for (const r of commentCounts) cmMap.set(r.gameId, r.count)
// Platform support (for playability status per game)
const platformRows = await db
.select({ gameId: gamePlatformSupport.gameId, hardwareSlug: gamePlatformSupport.hardwareSlug, protonStatus: gamePlatformSupport.protonStatus })
.from(gamePlatformSupport)
.where(inArray(gamePlatformSupport.gameId, gameIds))
const platformMap = new Map<string, string>()
for (const r of platformRows) {
const existing = platformMap.get(r.gameId)
if (!existing || (!existing.startsWith("steamdeck") && r.hardwareSlug.startsWith("steamdeck"))) {
platformMap.set(r.gameId, r.protonStatus)
}
}
// Performance stats (raw performer, poor performance, best FPS)
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, deq(performanceEntries.versionId, gameVersions.id))
.where(dand(inArray(gameVersions.gameId, gameIds), deq(performanceEntries.isRemoved, false)))
.groupBy(gameVersions.gameId)
const perfMap = new Map<string, { bestFps: number | null; isRawPerformer: boolean; isPoorPerformance: boolean }>()
for (const r of perfStats) {
perfMap.set(r.gameId, { bestFps: r.bestFps, isRawPerformer: r.isRawPerformer, isPoorPerformance: r.isPoorPerformance })
}
// Battery estimates (handheld only)
const batteryStats = await db
.select({
gameId: gameVersions.gameId,
estimatedBatteryMin: sql<number>`ROUND((${hardware.wattHours}::real / ${performanceEntries.tdpWatts}) * 60)::int`,
})
.from(performanceEntries)
.innerJoin(gameVersions, deq(performanceEntries.versionId, gameVersions.id))
.innerJoin(hardware, deq(performanceEntries.hardwareSlug, hardware.slug))
.where(
dand(
inArray(gameVersions.gameId, gameIds),
deq(performanceEntries.isRemoved, false),
deq(hardware.deviceType, "handheld"),
dsql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`,
dsql`${hardware.wattHours} IS NOT NULL`,
),
)
.orderBy(ddesc(performanceEntries.fpsAvg))
const batteryMap = new Map<string, number>()
const seen = new Set<string>()
for (const r of batteryStats) {
if (!seen.has(r.gameId)) { seen.add(r.gameId); batteryMap.set(r.gameId, r.estimatedBatteryMin) }
}
// Build results
const results = localGames.map((g) => {
const p = perfMap.get(g.id)
return {
id: g.id,
title: g.title,
capsuleImage: g.capsuleImage,
headerImage: g.headerImage,
playabilityStatus: g.playabilityStatus ?? null,
platformStatus: platformMap.get(g.id) ?? null,
isRawPerformer: p?.isRawPerformer ?? false,
isPoorPerformance: p?.isPoorPerformance ?? false,
bestFps: p?.bestFps ?? null,
estimatedBatteryMin: batteryMap.get(g.id) ?? null,
benchmarkCount: bmMap.get(g.id) ?? 0,
commentCount: cmMap.get(g.id) ?? 0,
steamReviewScore: g.steamReviewScore ?? null,
}
})
return { results, total: results.length }
},
{
query: t.Object({
q: t.String(),
playabilityStatus: t.Optional(t.String()),
}),
detail: {
description: "Search synced games only — returns mobile-optimized results with performance tags. No Steam-only entries.",
},
},
)