feat(games): filter search results by downloaded state
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user