feat(games): filter search results by downloaded state

This commit is contained in:
Darkraise
2026-08-11 17:33:29 +07:00
parent a8f9b1db11
commit 52cf237f5e
4 changed files with 155 additions and 41 deletions
+7 -1
View File
@@ -34,6 +34,7 @@ const searchBodySchema = z.object({
playModes: z.array(z.string()).optional(),
languages: z.array(z.string()).optional(),
library: z.enum(['arcade', 'theatre']).optional(),
downloaded: z.boolean().optional(),
tags: z.array(z.string()).optional(),
yearFrom: z.number().int().min(1970).max(2100).optional(),
yearTo: z.number().int().min(1970).max(2100).optional(),
@@ -62,6 +63,7 @@ router.post(
'playModes',
'languages',
'library',
'downloaded',
'tags',
'yearFrom',
'yearTo',
@@ -90,6 +92,7 @@ router.post(
playModes: body.playModes,
languages: body.languages,
library: body.library,
downloaded: body.downloaded,
tags: body.tags,
yearFrom: body.yearFrom,
yearTo: body.yearTo,
@@ -188,7 +191,10 @@ router.get(
if (req.query.platforms) {
const raw = req.query.platforms;
if (typeof raw === 'string') {
platforms = raw.split(',').map((p) => p.trim()).filter(Boolean);
platforms = raw
.split(',')
.map((p) => p.trim())
.filter(Boolean);
} else if (Array.isArray(raw)) {
platforms = (raw as string[])
.filter((p) => typeof p === 'string')
+1
View File
@@ -57,6 +57,7 @@ export class GameSearchCache {
playModes: query.playModes ? [...query.playModes].sort().join(',') : '',
languages: query.languages ? [...query.languages].sort().join(',') : '',
library: query.library || '',
downloaded: query.downloaded ?? '',
tags: query.tags ? [...query.tags].sort().join(',') : '',
yearFrom: query.yearFrom ?? '',
yearTo: query.yearTo ?? '',
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import BetterSqlite3 from 'better-sqlite3';
vi.mock('./DatabaseService', () => ({
DatabaseService: {
all: vi.fn(),
get: vi.fn(),
getDatabase: vi.fn(),
},
}));
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
import { DatabaseService } from './DatabaseService';
import { GameService } from './GameService';
let db: BetterSqlite3.Database;
function seed(): void {
db.exec(`
CREATE TABLE game (
id TEXT PRIMARY KEY, title TEXT, alternateTitles TEXT, series TEXT,
developer TEXT, publisher TEXT, platformName TEXT, library TEXT,
orderTitle TEXT, dateAdded TEXT, dateModified TEXT, releaseDate TEXT,
playMode TEXT, language TEXT, tagsStr TEXT, broken INTEGER DEFAULT 0,
extreme INTEGER DEFAULT 0, activeDataId INTEGER, activeDataOnDisk INTEGER,
launchCommand TEXT, archiveState INTEGER
);
CREATE TABLE game_data (
id INTEGER PRIMARY KEY, gameId TEXT, dateAdded TEXT, sha256 TEXT,
path TEXT, presentOnDisk INTEGER DEFAULT 0, launchCommand TEXT
);
INSERT INTO game (id, title, orderTitle, platformName, library) VALUES
('11111111-1111-1111-1111-111111111111', 'Downloaded Game', 'downloaded game', 'HTML5', 'arcade'),
('22222222-2222-2222-2222-222222222222', 'Absent Game', 'absent game', 'HTML5', 'arcade');
INSERT INTO game_data (id, gameId, dateAdded, presentOnDisk) VALUES
(1, '11111111-1111-1111-1111-111111111111', '2020-01-01T00:00:00.000Z', 1),
(2, '22222222-2222-2222-2222-222222222222', '2020-01-01T00:00:00.000Z', 0);
`);
}
beforeEach(() => {
db = new BetterSqlite3(':memory:');
seed();
vi.mocked(DatabaseService.all).mockImplementation(
(sql: string, params: unknown[] = []) => db.prepare(sql).all(...params) as unknown[]
);
vi.mocked(DatabaseService.get).mockImplementation(
(sql: string, params: unknown[] = []) => db.prepare(sql).get(...params) as unknown
);
});
afterEach(() => {
db.close();
vi.clearAllMocks();
});
const baseQuery = {
sortBy: 'title',
sortOrder: 'asc' as const,
page: 1,
limit: 50,
showBroken: false,
showExtreme: false,
fields: 'list' as const,
};
describe('GameService downloaded filter', () => {
it('returns only games with presentOnDisk=1 when downloaded is true', async () => {
const result = await new GameService().searchGames({ ...baseQuery, downloaded: true });
expect(result.data.map((g) => g.title)).toEqual(['Downloaded Game']);
});
it('returns all games when downloaded is undefined', async () => {
const result = await new GameService().searchGames(baseQuery);
expect(result.data).toHaveLength(2);
});
it('returns all games when downloaded is false', async () => {
const result = await new GameService().searchGames({ ...baseQuery, downloaded: false });
expect(result.data).toHaveLength(2);
});
});
+62 -40
View File
@@ -11,6 +11,8 @@ export interface GameSearchQuery {
playModes?: string[]; // Array of play modes for OR condition
languages?: string[]; // Array of languages for OR condition
library?: string;
/** true = only games with a game_data row where presentOnDisk = 1; false/undefined = no restriction */
downloaded?: boolean;
tags?: string[];
yearFrom?: number;
yearTo?: number;
@@ -110,7 +112,6 @@ export class GameService {
private static flashSwfCacheExpiry = 0;
private static readonly FLASH_SWF_CACHE_TTL = 3600000; // 1 hour
/**
* Cached filter options results keyed by platform+library combination.
* The 8 GROUP BY queries (~1.8s total) only change when flashpoint.sqlite reloads,
@@ -240,7 +241,6 @@ export class GameService {
return GameService.flashSwfGameIds;
}
/**
* Clear the Flash SWF game IDs cache.
* Call when the flashpoint.sqlite database is reloaded.
@@ -405,9 +405,7 @@ export class GameService {
// Developers are semicolon-delimited (e.g., "Studio A; Studio B")
// Use INSTR for matching - game should have ANY of the selected developers
sql += ` AND g.developer IS NOT NULL AND g.developer != ''`;
const devConditions = query.developers.map(
() => `INSTR(';' || g.developer || ';', ?) > 0`
);
const devConditions = query.developers.map(() => `INSTR(';' || g.developer || ';', ?) > 0`);
sql += ` AND (${devConditions.join(' OR ')})`;
params.push(...query.developers.map((dev) => `;${dev};`));
}
@@ -416,9 +414,7 @@ export class GameService {
// Publishers are semicolon-delimited (e.g., "Publisher A; Publisher B")
// Use INSTR for matching - game should have ANY of the selected publishers
sql += ` AND g.publisher IS NOT NULL AND g.publisher != ''`;
const pubConditions = query.publishers.map(
() => `INSTR(';' || g.publisher || ';', ?) > 0`
);
const pubConditions = query.publishers.map(() => `INSTR(';' || g.publisher || ';', ?) > 0`);
sql += ` AND (${pubConditions.join(' OR ')})`;
params.push(...query.publishers.map((pub) => `;${pub};`));
}
@@ -427,9 +423,7 @@ export class GameService {
// Play modes are semicolon-delimited (e.g., "Single Player;Multiplayer")
// Use INSTR for matching - game should have ANY of the selected play modes
sql += ` AND g.playMode IS NOT NULL AND g.playMode != ''`;
const modeConditions = query.playModes.map(
() => `INSTR(';' || g.playMode || ';', ?) > 0`
);
const modeConditions = query.playModes.map(() => `INSTR(';' || g.playMode || ';', ?) > 0`);
sql += ` AND (${modeConditions.join(' OR ')})`;
params.push(...query.playModes.map((mode) => `;${mode};`));
}
@@ -441,8 +435,9 @@ export class GameService {
// Normalize: lowercase + remove all common whitespace chars to match JS \s regex
// Space, Tab(9), LF(10), VT(11), FF(12), CR(13)
// Use OR logic - game should have ANY of the selected languages
const langConditions = query.languages.map(() =>
`INSTR(';' || LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(g.language, ' ', ''), CHAR(9), ''), CHAR(10), ''), CHAR(11), ''), CHAR(12), ''), CHAR(13), '')) || ';', ?) > 0`
const langConditions = query.languages.map(
() =>
`INSTR(';' || LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(g.language, ' ', ''), CHAR(9), ''), CHAR(10), ''), CHAR(11), ''), CHAR(12), ''), CHAR(13), '')) || ';', ?) > 0`
);
sql += ` AND (${langConditions.join(' OR ')})`;
params.push(...query.languages.map((lang) => `;${lang.toLowerCase()};`));
@@ -453,6 +448,13 @@ export class GameService {
params.push(query.library);
}
if (query.downloaded === true) {
sql += ` AND EXISTS (
SELECT 1 FROM game_data gd
WHERE gd.gameId = g.id AND gd.presentOnDisk = 1
)`;
}
// OPTIMIZATION: Use INSTR instead of LIKE for tag matching (faster in SQLite)
if (query.tags && query.tags.length > 0) {
// Check if tags are in tagsStr field using INSTR with semicolon boundaries
@@ -495,8 +497,7 @@ export class GameService {
// Exclude Flash games without SWF launch commands (not web-playable)
// Skip this filter when explicitly filtering by Flash platform - show all Flash games
const isFlashOnlyFilter =
query.platforms?.length === 1 && query.platforms[0] === 'Flash';
const isFlashOnlyFilter = query.platforms?.length === 1 && query.platforms[0] === 'Flash';
if (!isFlashOnlyFilter) {
sql += ` AND ${this.getFlashSwfCondition('g')}`;
}
@@ -835,7 +836,15 @@ export class GameService {
*/
private buildFilterOptionsConditions(
params?: FilterOptionsParams,
excludeFilter?: 'series' | 'developers' | 'publishers' | 'playModes' | 'languages' | 'tags' | 'platforms' | 'yearRange'
excludeFilter?:
| 'series'
| 'developers'
| 'publishers'
| 'playModes'
| 'languages'
| 'tags'
| 'platforms'
| 'yearRange'
): {
conditions: string[];
queryParams: unknown[];
@@ -867,9 +876,7 @@ export class GameService {
// Use OR logic - game should have ANY of the selected developers
if (params?.developers?.length && excludeFilter !== 'developers') {
conditions.push("developer IS NOT NULL AND developer != ''");
const devConditions = params.developers.map(
() => `INSTR(';' || developer || ';', ?) > 0`
);
const devConditions = params.developers.map(() => `INSTR(';' || developer || ';', ?) > 0`);
conditions.push(`(${devConditions.join(' OR ')})`);
queryParams.push(...params.developers.map((dev) => `;${dev};`));
}
@@ -878,9 +885,7 @@ export class GameService {
// Use OR logic - game should have ANY of the selected publishers
if (params?.publishers?.length && excludeFilter !== 'publishers') {
conditions.push("publisher IS NOT NULL AND publisher != ''");
const pubConditions = params.publishers.map(
() => `INSTR(';' || publisher || ';', ?) > 0`
);
const pubConditions = params.publishers.map(() => `INSTR(';' || publisher || ';', ?) > 0`);
conditions.push(`(${pubConditions.join(' OR ')})`);
queryParams.push(...params.publishers.map((pub) => `;${pub};`));
}
@@ -889,9 +894,7 @@ export class GameService {
// Use OR logic - game should have ANY of the selected play modes
if (params?.playModes?.length && excludeFilter !== 'playModes') {
conditions.push("playMode IS NOT NULL AND playMode != ''");
const modeConditions = params.playModes.map(
() => `INSTR(';' || playMode || ';', ?) > 0`
);
const modeConditions = params.playModes.map(() => `INSTR(';' || playMode || ';', ?) > 0`);
conditions.push(`(${modeConditions.join(' OR ')})`);
queryParams.push(...params.playModes.map((mode) => `;${mode};`));
}
@@ -900,8 +903,9 @@ export class GameService {
// Use OR logic - game should have ANY of the selected languages
if (params?.languages?.length && excludeFilter !== 'languages') {
conditions.push("language IS NOT NULL AND language != ''");
const langConditions = params.languages.map(() =>
`INSTR(';' || LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(language, ' ', ''), CHAR(9), ''), CHAR(10), ''), CHAR(11), ''), CHAR(12), ''), CHAR(13), '')) || ';', ?) > 0`
const langConditions = params.languages.map(
() =>
`INSTR(';' || LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(language, ' ', ''), CHAR(9), ''), CHAR(10), ''), CHAR(11), ''), CHAR(12), ''), CHAR(13), '')) || ';', ?) > 0`
);
conditions.push(`(${langConditions.join(' OR ')})`);
queryParams.push(...params.languages.map((lang) => `;${lang.toLowerCase()};`));
@@ -1005,10 +1009,14 @@ export class GameService {
result,
expiry: now + GameService.DYNAMIC_FILTER_CACHE_TTL,
});
logger.debug(`[GameService] Dynamic filter options computed in ${duration}ms, cached for 30s`);
logger.debug(
`[GameService] Dynamic filter options computed in ${duration}ms, cached for 30s`
);
} else {
GameService.filterOptionsCache.set(cacheKey, result);
logger.debug(`[GameService] Base filter options computed in ${duration}ms, cached indefinitely`);
logger.debug(
`[GameService] Base filter options computed in ${duration}ms, cached indefinitely`
);
}
} else {
logger.debug(
@@ -1091,7 +1099,10 @@ export class GameService {
const developerSet = new Set<string>();
for (const row of results) {
for (const dev of row.developer.split(';').map((d) => d.trim()).filter((d) => d)) {
for (const dev of row.developer
.split(';')
.map((d) => d.trim())
.filter((d) => d)) {
developerSet.add(dev);
}
}
@@ -1126,7 +1137,10 @@ export class GameService {
const publisherSet = new Set<string>();
for (const row of results) {
for (const pub of row.publisher.split(';').map((p) => p.trim()).filter((p) => p)) {
for (const pub of row.publisher
.split(';')
.map((p) => p.trim())
.filter((p) => p)) {
publisherSet.add(pub);
}
}
@@ -1162,7 +1176,10 @@ export class GameService {
for (const row of results) {
// Split and add to set (auto-deduplicates)
for (const mode of row.playMode.split(';').map((m) => m.trim()).filter((m) => m)) {
for (const mode of row.playMode
.split(';')
.map((m) => m.trim())
.filter((m) => m)) {
playModeSet.add(mode);
}
}
@@ -1200,12 +1217,12 @@ export class GameService {
for (const row of results) {
const normalized = row.language
.replace(/ /g, '') // space
.replace(/\t/g, '') // tab (CHAR 9)
.replace(/\n/g, '') // LF (CHAR 10)
.replace(/\v/g, '') // VT (CHAR 11)
.replace(/\f/g, '') // FF (CHAR 12)
.replace(/\r/g, '') // CR (CHAR 13)
.replace(/ /g, '') // space
.replace(/\t/g, '') // tab (CHAR 9)
.replace(/\n/g, '') // LF (CHAR 10)
.replace(/\v/g, '') // VT (CHAR 11)
.replace(/\f/g, '') // FF (CHAR 12)
.replace(/\r/g, '') // CR (CHAR 13)
.toLowerCase();
// Split and add to set (auto-deduplicates)
@@ -1243,7 +1260,10 @@ export class GameService {
const tagSet = new Set<string>();
for (const row of results) {
for (const tag of row.tagsStr.split(';').map((t) => t.trim()).filter((t) => t)) {
for (const tag of row.tagsStr
.split(';')
.map((t) => t.trim())
.filter((t) => t)) {
tagSet.add(tag);
}
}
@@ -1288,7 +1308,9 @@ export class GameService {
getYearRange(params?: FilterOptionsParams): { min: number; max: number } {
try {
const { conditions, queryParams } = this.buildFilterOptionsConditions(params, 'yearRange');
conditions.unshift("releaseDate IS NOT NULL AND releaseDate != '' AND LENGTH(releaseDate) >= 4");
conditions.unshift(
"releaseDate IS NOT NULL AND releaseDate != '' AND LENGTH(releaseDate) >= 4"
);
const sql = `
SELECT