feat: extend games listing API with new filters and sort options
Add 8 new filter parameters to GET /api/games/listing: - minFps/maxFps: filter games with benchmarks in FPS range - fsrSupport: filter games that have FSR entries - protonNative: filter by proton status (proton/native/both) - antiCheatStatus: filter by anti-cheat support status - playabilityStatus: filter by playability (great/playable/needs_tweaks/unplayable) - steamReviewScore: minimum Steam review score threshold - isFree: filter free-to-play games - hasMultiplayer: filter games with multiplayer support Add 4 new sort options: - performance: sort by average FPS (subquery) - popularity: sort by recommendationsTotal - release_date: sort by releaseDate - steam_reviews: sort by steamReviewScore Also returns additional fields in response: isFree, releaseDate, steamReviewScore, recommendationsTotal, playabilityStatus, onlineMultiplayerStatus.
This commit is contained in:
+169
-2
@@ -7,7 +7,7 @@ import {
|
|||||||
gamePlatformSupport,
|
gamePlatformSupport,
|
||||||
hardware,
|
hardware,
|
||||||
} from "@/lib/db/schema"
|
} from "@/lib/db/schema"
|
||||||
import { ilike, or, sql, eq, and, desc, asc, inArray } from "drizzle-orm"
|
import { ilike, or, sql, eq, and, desc, asc, inArray, gte, lte } from "drizzle-orm"
|
||||||
|
|
||||||
const MAX_OFFSET = 10000
|
const MAX_OFFSET = 10000
|
||||||
const PAGE_SIZE = 24
|
const PAGE_SIZE = 24
|
||||||
@@ -23,9 +23,21 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
|||||||
const sort = query.sort || "recent"
|
const sort = query.sort || "recent"
|
||||||
const order = query.order === "asc" ? asc : desc
|
const order = query.order === "asc" ? asc : desc
|
||||||
|
|
||||||
|
// ── New filter parameters ─────────────────────────────────────
|
||||||
|
const minFps = query.minFps
|
||||||
|
const maxFps = query.maxFps
|
||||||
|
const fsrSupport = query.fsrSupport
|
||||||
|
const protonNative = query.protonNative
|
||||||
|
const antiCheatStatus = query.antiCheatStatus
|
||||||
|
const playabilityStatus = query.playabilityStatus
|
||||||
|
const steamReviewScore = query.steamReviewScore
|
||||||
|
const isFree = query.isFree
|
||||||
|
const hasMultiplayer = query.hasMultiplayer
|
||||||
|
|
||||||
// Build where conditions
|
// Build where conditions
|
||||||
const conditions = []
|
const conditions = []
|
||||||
|
|
||||||
|
// Search filter (title, developer, publisher)
|
||||||
if (search) {
|
if (search) {
|
||||||
const term = `%${search}%`
|
const term = `%${search}%`
|
||||||
conditions.push(
|
conditions.push(
|
||||||
@@ -37,10 +49,12 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Genre filter
|
||||||
if (genre) {
|
if (genre) {
|
||||||
conditions.push(sql`${games.genres} @> ${JSON.stringify([genre])}::jsonb`)
|
conditions.push(sql`${games.genres} @> ${JSON.stringify([genre])}::jsonb`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device filter
|
||||||
if (device) {
|
if (device) {
|
||||||
// Get games with platform support
|
// Get games with platform support
|
||||||
const supportedIds = await db
|
const supportedIds = await db
|
||||||
@@ -74,6 +88,104 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── FPS range filter (games with benchmarks in this range) ────
|
||||||
|
if (minFps || maxFps) {
|
||||||
|
const fpsConditions = [
|
||||||
|
eq(performanceEntries.isRemoved, false),
|
||||||
|
minFps ? gte(performanceEntries.fpsAvg, Number(minFps)) : undefined,
|
||||||
|
maxFps ? lte(performanceEntries.fpsAvg, Number(maxFps)) : undefined,
|
||||||
|
].filter(Boolean) as any[]
|
||||||
|
|
||||||
|
const fpsSubquery = db
|
||||||
|
.select({ gameId: gameVersions.gameId })
|
||||||
|
.from(performanceEntries)
|
||||||
|
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||||
|
.where(and(...fpsConditions))
|
||||||
|
.groupBy(gameVersions.gameId)
|
||||||
|
|
||||||
|
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${fpsSubquery}) AS fps_sub)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FSR support filter ────────────────────────────────────────
|
||||||
|
if (fsrSupport === "true") {
|
||||||
|
const fsrSubquery = db
|
||||||
|
.select({ gameId: gameVersions.gameId })
|
||||||
|
.from(performanceEntries)
|
||||||
|
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(performanceEntries.isRemoved, false),
|
||||||
|
eq(performanceEntries.upscalerType, "fsr"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(gameVersions.gameId)
|
||||||
|
|
||||||
|
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${fsrSubquery}) AS fsr_sub)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Proton / Native filter ────────────────────────────────────
|
||||||
|
if (protonNative && ["proton", "native", "both"].includes(protonNative)) {
|
||||||
|
const protonConditions: any[] = []
|
||||||
|
|
||||||
|
if (protonNative === "proton" || protonNative === "both") {
|
||||||
|
protonConditions.push(eq(gamePlatformSupport.protonStatus, "proton"))
|
||||||
|
}
|
||||||
|
if (protonNative === "native" || protonNative === "both") {
|
||||||
|
protonConditions.push(eq(gamePlatformSupport.protonStatus, "native"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const protonSubquery = db
|
||||||
|
.select({ gameId: gamePlatformSupport.gameId })
|
||||||
|
.from(gamePlatformSupport)
|
||||||
|
.where(or(...protonConditions))
|
||||||
|
.groupBy(gamePlatformSupport.gameId)
|
||||||
|
|
||||||
|
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${protonSubquery}) AS proton_sub)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Anti-cheat status filter ──────────────────────────────────
|
||||||
|
const validAcStatuses = ["supported", "unsupported", "unknown", "none"]
|
||||||
|
if (antiCheatStatus && antiCheatStatus !== "any" && validAcStatuses.includes(antiCheatStatus)) {
|
||||||
|
const acConditions: any[] = [
|
||||||
|
eq(gamePlatformSupport.antiCheatRelevant, true),
|
||||||
|
eq(gamePlatformSupport.antiCheatStatus, antiCheatStatus as "none" | "supported" | "unsupported" | "unknown"),
|
||||||
|
]
|
||||||
|
|
||||||
|
const acSubquery = db
|
||||||
|
.select({ gameId: gamePlatformSupport.gameId })
|
||||||
|
.from(gamePlatformSupport)
|
||||||
|
.where(and(...acConditions))
|
||||||
|
.groupBy(gamePlatformSupport.gameId)
|
||||||
|
|
||||||
|
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${acSubquery}) AS ac_sub)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Playability status filter ─────────────────────────────────
|
||||||
|
const validPlayStatuses = ["great", "playable", "needs_tweaks", "unplayable", "unknown"]
|
||||||
|
if (playabilityStatus && validPlayStatuses.includes(playabilityStatus)) {
|
||||||
|
conditions.push(eq(games.playabilityStatus, playabilityStatus as "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Steam review score filter (minimum score) ─────────────────
|
||||||
|
if (steamReviewScore) {
|
||||||
|
conditions.push(gte(games.steamReviewScore, Number(steamReviewScore)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Free-to-play filter ───────────────────────────────────────
|
||||||
|
if (isFree === "true") {
|
||||||
|
conditions.push(eq(games.isFree, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Has multiplayer filter ────────────────────────────────────
|
||||||
|
if (hasMultiplayer === "true") {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
eq(games.onlineMultiplayerStatus, "supported"),
|
||||||
|
eq(games.onlineMultiplayerStatus, "unknown"),
|
||||||
|
)!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||||
|
|
||||||
// Fetch all genres (for filter options)
|
// Fetch all genres (for filter options)
|
||||||
@@ -105,11 +217,44 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
|||||||
|
|
||||||
// Determine sort order
|
// Determine sort order
|
||||||
let orderBy
|
let orderBy
|
||||||
|
let needsPostSort = false
|
||||||
|
let postSortField: string | null = null
|
||||||
|
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
case "name":
|
case "name":
|
||||||
orderBy = order(games.title)
|
orderBy = order(games.title)
|
||||||
break
|
break
|
||||||
|
case "popularity":
|
||||||
|
orderBy = order(games.recommendationsTotal)
|
||||||
|
break
|
||||||
|
case "release_date":
|
||||||
|
orderBy = order(games.releaseDate)
|
||||||
|
break
|
||||||
|
case "steam_reviews":
|
||||||
|
orderBy = order(games.steamReviewScore)
|
||||||
|
break
|
||||||
|
case "performance":
|
||||||
|
// Performance sort requires a subquery — use SQL ORDER BY directly
|
||||||
|
orderBy = sql`(
|
||||||
|
SELECT AVG(pe."fps_avg")
|
||||||
|
FROM ${performanceEntries} pe
|
||||||
|
JOIN ${gameVersions} gv ON pe."version_id" = gv.id
|
||||||
|
WHERE gv."game_id" = ${games.id} AND pe."is_removed" = false
|
||||||
|
) DESC NULLS LAST`
|
||||||
|
if (query.order === "asc") {
|
||||||
|
orderBy = sql`(
|
||||||
|
SELECT AVG(pe."fps_avg")
|
||||||
|
FROM ${performanceEntries} pe
|
||||||
|
JOIN ${gameVersions} gv ON pe."version_id" = gv.id
|
||||||
|
WHERE gv."game_id" = ${games.id} AND pe."is_removed" = false
|
||||||
|
) ASC NULLS LAST`
|
||||||
|
}
|
||||||
|
break
|
||||||
case "benchmarks":
|
case "benchmarks":
|
||||||
|
needsPostSort = true
|
||||||
|
postSortField = "benchmarkCount"
|
||||||
|
orderBy = order(games.createdAt)
|
||||||
|
break
|
||||||
case "recent":
|
case "recent":
|
||||||
default:
|
default:
|
||||||
orderBy = order(games.createdAt)
|
orderBy = order(games.createdAt)
|
||||||
@@ -128,6 +273,12 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
|||||||
genres: games.genres,
|
genres: games.genres,
|
||||||
source: games.source,
|
source: games.source,
|
||||||
createdAt: games.createdAt,
|
createdAt: games.createdAt,
|
||||||
|
isFree: games.isFree,
|
||||||
|
releaseDate: games.releaseDate,
|
||||||
|
steamReviewScore: games.steamReviewScore,
|
||||||
|
recommendationsTotal: games.recommendationsTotal,
|
||||||
|
playabilityStatus: games.playabilityStatus,
|
||||||
|
onlineMultiplayerStatus: games.onlineMultiplayerStatus,
|
||||||
})
|
})
|
||||||
.from(games)
|
.from(games)
|
||||||
.where(where)
|
.where(where)
|
||||||
@@ -190,12 +341,18 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
|||||||
headerImage: g.headerImage,
|
headerImage: g.headerImage,
|
||||||
genres: g.genres,
|
genres: g.genres,
|
||||||
source: g.source,
|
source: g.source,
|
||||||
|
isFree: g.isFree,
|
||||||
|
releaseDate: g.releaseDate,
|
||||||
|
steamReviewScore: g.steamReviewScore,
|
||||||
|
recommendationsTotal: g.recommendationsTotal,
|
||||||
|
playabilityStatus: g.playabilityStatus,
|
||||||
|
onlineMultiplayerStatus: g.onlineMultiplayerStatus,
|
||||||
benchmarkCount: benchmarkMap.get(g.id) ?? 0,
|
benchmarkCount: benchmarkMap.get(g.id) ?? 0,
|
||||||
deckStatus: platformMap.get(g.id) ?? null,
|
deckStatus: platformMap.get(g.id) ?? null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// If sorting by benchmarks, re-sort the enriched data
|
// If sorting by benchmarks, re-sort the enriched data
|
||||||
if (sort === "benchmarks") {
|
if (needsPostSort && postSortField === "benchmarkCount") {
|
||||||
const sortFn = query.order === "asc"
|
const sortFn = query.order === "asc"
|
||||||
? (a: { benchmarkCount: number }, b: { benchmarkCount: number }) => a.benchmarkCount - b.benchmarkCount
|
? (a: { benchmarkCount: number }, b: { benchmarkCount: number }) => a.benchmarkCount - b.benchmarkCount
|
||||||
: (a: { benchmarkCount: number }, b: { benchmarkCount: number }) => b.benchmarkCount - a.benchmarkCount
|
: (a: { benchmarkCount: number }, b: { benchmarkCount: number }) => b.benchmarkCount - a.benchmarkCount
|
||||||
@@ -222,6 +379,16 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
|||||||
device: t.Optional(t.String()),
|
device: t.Optional(t.String()),
|
||||||
sort: t.Optional(t.String()),
|
sort: t.Optional(t.String()),
|
||||||
order: t.Optional(t.String()),
|
order: t.Optional(t.String()),
|
||||||
|
// New filter parameters
|
||||||
|
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()),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Reference in New Issue
Block a user