feat: Implemented filtering and searching

This commit is contained in:
Simeon Radivoev 2026-04-12 22:19:24 +03:00
parent 4806f3487a
commit 444d8c4c27
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
49 changed files with 841 additions and 290 deletions

View file

@ -1,6 +1,6 @@
import Elysia, { status } from "elysia";
import { config, db, emulatorsDb, plugins, taskQueue } from "../app";
import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm";
import { and, eq, getTableColumns, ilike, inArray, like, sql } from "drizzle-orm";
import z from "zod";
import * as schema from "@schema/app";
import fs from "node:fs/promises";
@ -20,6 +20,7 @@ import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmula
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
import { host } from "@/bun/utils/host";
import { LaunchGameJob } from "../jobs/launch-game-job";
import { cores } from "../emulatorjs/emulatorjs";
// A custom jimp that supports webp
const Jimp = createJimp({
@ -134,12 +135,24 @@ export default new Elysia()
.get('/games', async ({ query, set }) =>
{
const games: FrontEndGameType[] = [];
const filterSets: FrontEndFilterSets = {
age_ratings: new Set(),
player_counts: new Set(),
languages: new Set(),
companies: new Set(),
genres: new Set()
};
if (query.source === 'store')
{
const shuffledGames = await getShuffledStoreGames();
set.headers['x-max-items'] = shuffledGames.length;
const storeGames = await Promise.all(shuffledGames
const storeGames = await Promise.all(shuffledGames.filter(g =>
{
if (query.search)
return path.basename(g.path).toLocaleLowerCase().includes(query.search.toLocaleLowerCase());
return true;
})
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length))
.map(async (e) =>
{
@ -185,6 +198,11 @@ export default new Elysia()
}
}
if (query.search)
{
where.push(like(schema.games.name, query.search));
}
if (query.source)
{
where.push(eq(schema.games.source, query.source));
@ -218,7 +236,7 @@ export default new Elysia()
{
// Collections are just a remote thing for now.
const remoteGames: FrontEndGameTypeWithIds[] = [];
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e));
games.push(...remoteGames.map(g =>
{
if (localGameExistsPredicate(g))
@ -233,37 +251,74 @@ export default new Elysia()
} else
{
games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g =>
games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).filter(g =>
{
if (query.genres && query.genres.length > 0)
{
if (!g.metadata) return false;
if (!g.metadata.genres) return false;
if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false;
}
return true;
}).map(g =>
{
return convertLocalToFrontend(g);
}));
const remoteGames: FrontEndGameTypeWithIds[] = [];
const remoteGameSet = new Set<string>();
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
games.push(...remoteGames.filter(g =>
if (query.localOnly !== true)
{
if (localGameExistsPredicate(g))
const remoteGames: FrontEndGameTypeWithIds[] = [];
const remoteGameSet = new Set<string>();
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e));
games.push(...remoteGames.filter(g =>
{
return false;
}
if (localGameExistsPredicate(g))
{
return false;
}
if (g.igdb_id)
if (g.igdb_id)
{
const igdbId = `igdb@${g.igdb_id}`;
if (remoteGameSet.has(igdbId)) return false;
remoteGameSet.add(igdbId);
}
if (g.ra_id)
{
const raId = `ra@${g.ra_id}`;
if (remoteGameSet.has(raId)) return false;
remoteGameSet.add(raId);
}
return true;
}));
} else
{
await plugins.hooks.games.fetchFilters.promise({ filters: filterSets }).catch(e => console.error(e));
}
localGames.map(g =>
{
const metadata: any = g.metadata;
if (metadata.genres && Array.isArray(metadata.genres))
{
const igdbId = `igdb@${g.igdb_id}`;
if (remoteGameSet.has(igdbId)) return false;
remoteGameSet.add(igdbId);
metadata.genres.forEach((g: string) => filterSets.genres.add(g));
}
if (g.ra_id)
if (metadata.age_ratings && Array.isArray(metadata.age_ratings))
{
const raId = `ra@${g.ra_id}`;
if (remoteGameSet.has(raId)) return false;
remoteGameSet.add(raId);
metadata.age_ratings.forEach((g: string) => filterSets.age_ratings.add(g));
}
return true;
}));
if (metadata.companies && Array.isArray(metadata.companies))
{
metadata.companies.forEach((g: string) => filterSets.companies.add(g));
}
if (metadata.player_count)
{
filterSets.player_counts.add(metadata.player_count);
}
});
}
}
@ -280,11 +335,22 @@ export default new Elysia()
case 'name':
games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
break;
case "release":
games.sort((a, b) => (b.metadata.first_release_date?.getTime() ?? 0) - (a.metadata.first_release_date?.getTime() ?? 0));
break;
}
}
return { games };
const filterLists: FrontEndFilterLists = {
age_ratings: Array.from(filterSets.age_ratings),
player_counts: Array.from(filterSets.player_counts),
languages: Array.from(filterSets.languages),
companies: Array.from(filterSets.companies),
genres: Array.from(filterSets.genres)
};
return { games, filters: filterLists };
}, {
query: GameListFilterSchema,
})
@ -341,8 +407,22 @@ export default new Elysia()
return {
name: 'EMULATORJS',
validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }],
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
systems: [],
logo: 'https://emulatorjs.org/logo/EmulatorJS.png',
systems: await Promise.all(Object.keys(cores).map(async c =>
{
const mapping = await emulatorsDb.query.systemMappings.findFirst({
where (fields, operators)
{
return operators.and(operators.eq(fields.source, "romm"), operators.eq(fields.system, c));
}, columns: { sourceSlug: true }
});
const system: EmulatorSystem = {
id: c,
name: c,
iconUrl: `/api/romm/image/romm/assets/platforms/${mapping?.sourceSlug}.svg`
};
return system;
})),
gameCount: 0,
integrations: []
} satisfies FrontEndGameTypeDetailedEmulator;
@ -536,8 +616,8 @@ export default new Elysia()
const sourceData = await getSourceGameDetailed(source, id);
if (!sourceData) return status("Not Found");
const sourceCompaniesSet = new Set(sourceData.companies);
const sourceGenresSet = new Set(sourceData.genres);
const sourceCompaniesSet = new Set(sourceData.metadata.companies);
const sourceGenresSet = new Set(sourceData.metadata.genres);
const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined;
@ -550,7 +630,7 @@ export default new Elysia()
const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g => ({ ...convertLocalToFrontend(g), metadata: g.metadata })));
games.push(...localGames.map(g => convertLocalToFrontend(g)));
const shuffledGames = await getShuffledStoreGames();
const storeGames = await Promise.all(shuffledGames
@ -559,7 +639,7 @@ export default new Elysia()
const system = path.dirname(g.path);
const id = path.basename(g.path, path.extname(g.path));
if (localGamesSourceSet.has(`${system}@${id}`))
if (localGamesSourceSet.has(`store@${system}@${id}`))
return false;
if (esSystem)

View file

@ -60,7 +60,19 @@ export async function fixSource (source: string, id: string)
if (foundGame)
{
await db.update(appSchema.games).set({ source: foundGame.id.source, source_id: foundGame.id.id }).where(eq(appSchema.games.id, valid.localGame.id));
await db.update(appSchema.games).set({
source: foundGame.id.source,
source_id: foundGame.id.id,
metadata: {
age_ratings: foundGame.metadata.age_ratings,
genres: foundGame.metadata.genres,
player_count: foundGame.metadata.player_count ?? undefined,
companies: foundGame.metadata.companies,
game_modes: foundGame.metadata.game_modes,
average_rating: foundGame.metadata.average_rating ?? undefined,
first_release_date: foundGame.metadata.first_release_date?.getTime() ?? undefined,
}
}).where(eq(appSchema.games.id, valid.localGame.id));
return true;
} else
{
@ -82,6 +94,9 @@ export async function validateGameSource (source: string, id: string): Promise<{
if (!localGame) return { valid: true };
if (localGame.source && localGame.source_id)
{
// Store should be immutable
if (localGame.source === 'store') return { valid: true, localGame };
const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id });
if (!sourceGame) return { valid: false, reason: "Source Missing", localGame };
if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined))

View file

@ -32,7 +32,7 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
})
{
const game: FrontEndGameType = {
platform_display_name: g.platform?.name ?? "Local",
platform_display_name: g.platform?.name ?? null,
id: { id: String(g.id), source: 'local' },
updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`,
@ -45,17 +45,24 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
slug: g.slug,
name: g.name,
platform_id: g.platform_id,
platform_slug: g.platform?.slug ?? null
platform_slug: g.platform?.slug ?? null,
metadata: {
first_release_date: g.metadata?.first_release_date !== undefined ? new Date(g.metadata?.first_release_date) : null
}
};
return game;
}
export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
platform?: typeof schema.platforms.$inferSelect | null;
export async function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
platform?: { name: string | null, slug: string | null; } | null;
screenshotIds?: number[];
})
{
const exists = await checkInstalled(g.path_fs);
const fileSize = await calculateSize(g.path_fs);
const game: FrontEndGameTypeDetailed = {
platform_display_name: g.platform?.name ?? "Local",
id: { id: String(g.id), source: 'local' },
@ -72,9 +79,18 @@ export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSel
platform_id: g.platform_id,
platform_slug: g.platform?.slug ?? null,
summary: g.summary,
fs_size_bytes: 0,
missing: false,
local: true
fs_size_bytes: fileSize,
missing: !exists,
local: true,
metadata: {
genres: g.metadata.genres ?? [],
companies: g.metadata.companies ?? [],
game_modes: g.metadata.game_modes ?? [],
age_ratings: g.metadata.age_ratings ?? [],
player_count: g.metadata.player_count ?? null,
average_rating: g.metadata.average_rating ?? null,
first_release_date: g.metadata.first_release_date ? new Date(g.metadata.first_release_date) : null
}
};
return game;
@ -107,7 +123,10 @@ export async function convertStoreToFrontend (system: string, id: string, storeG
name: storeGame.title,
platform_id: null,
platform_slug: rommSystem?.sourceSlug ?? system,
paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? []
paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [],
metadata: {
first_release_date: null
}
};
return game;
@ -131,6 +150,15 @@ export async function convertStoreToFrontendDetailed (system: string, id: string
fs_size_bytes: size,
missing: false,
local: false,
metadata: {
genres: storeGame.tags,
companies: [],
game_modes: [],
age_ratings: [],
player_count: "",
average_rating: null,
first_release_date: null
}
};
return detailed;
@ -148,29 +176,7 @@ export async function getLocalGameDetailed (match: any)
if (localGame)
{
const exists = await checkInstalled(localGame.path_fs);
const fileSize = await calculateSize(localGame.path_fs);
const game: FrontEndGameTypeDetailed = {
path_cover: `/api/romm/game/local/${localGame.id}/cover`,
updated_at: localGame.created_at,
id: { id: String(localGame.id), source: 'local' },
path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`,
fs_size_bytes: fileSize ?? null,
paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`),
local: true,
missing: !exists,
platform_display_name: localGame.platform?.name,
summary: localGame.summary,
source: localGame.source,
source_id: localGame.source_id,
path_fs: localGame.path_fs,
last_played: localGame.last_played,
slug: localGame.slug,
name: localGame.name,
platform_id: localGame.platform_id,
platform_slug: localGame.platform.slug
};
return game;
return convertLocalToFrontendDetailed({ ...localGame, screenshotIds: localGame.screenshots.map(s => s.id) });
}
return undefined;

View file

@ -37,6 +37,10 @@ export class GameHooks
fetchGames = new AsyncSeriesHook<[ctx: {
query: GameListFilterType;
games: FrontEndGameTypeWithIds[];
filters: FrontEndFilterSets;
}]>(['ctx']);
fetchFilters = new AsyncSeriesHook<[ctx: {
filters: FrontEndFilterSets;
}]>(['ctx']);
fetchGame = new AsyncSeriesBailHook<[ctx: {
source: string;

View file

@ -168,7 +168,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
if (typeof filename === 'string')
{
console.log("Save File Changed", filename);
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! });
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath!, shared: true });
}
});

View file

@ -2,7 +2,7 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
import { config, events } from "@/bun/api/app";
import path from 'node:path';
import fs from 'node:fs/promises';
@ -16,6 +16,12 @@ import { validateGameSource } from "@/bun/api/games/services/statusService";
export default class RommIntegration implements PluginType
{
isSteamDeck = false;
orderByMap: Record<string, string> = {
added: "created_at",
activity: "created_at",
name: "name",
release: "metadatum.first_release_date"
};
async updateClient ()
{
@ -49,8 +55,11 @@ export default class RommIntegration implements PluginType
const game: FrontEndGameType = {
id: { id: String(rom.id), source: 'romm' },
path_cover: `/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`,
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
last_played: rom.rom_user.last_played !== null ? new Date(rom.rom_user.last_played) : null,
updated_at: new Date(rom.created_at),
metadata: {
first_release_date: rom.metadatum.first_release_date !== null ? new Date(rom.metadatum.first_release_date) : null,
},
slug: rom.slug,
platform_id: rom.platform_id,
platform_display_name: rom.platform_display_name,
@ -74,11 +83,17 @@ export default class RommIntegration implements PluginType
fs_size_bytes: rom.fs_size_bytes,
local: false,
missing: rom.missing_from_fs,
genres: rom.metadatum.genres,
companies: rom.metadatum.companies,
release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined,
imdb_id: rom.igdb_id ?? undefined,
ra_id: rom.ra_id ?? undefined
ra_id: rom.ra_id ?? undefined,
metadata: {
age_ratings: rom.metadatum.age_ratings,
genres: rom.metadatum.genres,
companies: rom.metadatum.companies,
game_modes: rom.metadatum.game_modes,
player_count: rom.metadatum.player_count,
average_rating: rom.metadatum.average_rating,
first_release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : null
}
};
const userData = await getCurrentUserApiUsersMeGet();
@ -119,26 +134,32 @@ export default class RommIntegration implements PluginType
load (ctx: PluginContextType)
{
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) =>
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games, filters }) =>
{
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
{
const orderByMap: Record<string, string> = {
added: "created_at",
activity: "created_at",
name: "name"
};
const rommGames = await getRomsApiRomsGet({
query: {
platform_ids: query.platform_id ? [query.platform_id] : undefined,
collection_id: query.collection_id,
limit: query.limit,
offset: query.offset,
order_by: orderByMap[query.orderBy ?? '']
order_by: this.orderByMap[query.orderBy ?? ''],
with_filter_values: true,
genres: query.genres,
genres_logic: "all",
age_ratings: query.age_ratings,
search_term: query.search,
}, throwOnError: true
});
rommGames.data.filter_values.age_ratings.forEach(r => filters.age_ratings.add(r));
rommGames.data.filter_values.companies.forEach(r => filters.companies.add(r));
rommGames.data.filter_values.languages.forEach(r => filters.languages.add(r));
rommGames.data.filter_values.player_counts.forEach(r => filters.player_counts.add(r));
rommGames.data.filter_values.genres.forEach(r => filters.genres.add(r));
games.push(...rommGames.data.items.map(g =>
{
const game: FrontEndGameTypeWithIds = {
@ -151,6 +172,16 @@ export default class RommIntegration implements PluginType
}
});
ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters }) =>
{
const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true });
rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r));
rommFilters.data.companies.forEach(r => filters.companies.add(r));
rommFilters.data.languages.forEach(r => filters.languages.add(r));
rommFilters.data.player_counts.forEach(r => filters.player_counts.add(r));
rommFilters.data.genres.forEach(r => filters.genres.add(r));
});
ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) =>
{
if (service !== 'romm') return;
@ -277,10 +308,10 @@ export default class RommIntegration implements PluginType
const rommPlatform = rommPlatforms.find(p => p.slug === game.platform_slug);
if (rommPlatform)
{
const rommGames = await getRomsApiRomsGet({ query: { genres: game.genres, genres_logic: 'any' } });
const rommGames = await getRomsApiRomsGet({ query: { genres: game.metadata.genres, genres_logic: 'any' } });
if (rommGames.data)
{
games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g), metadata: g.metadatum })));
games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g) })));
}
}
}

View file

@ -11,7 +11,15 @@ export const games = sqliteTable('games', {
path_fs: text("path_fs"),
last_played: integer("last_played", { mode: 'timestamp' }),
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`),
metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{
genres?: string[],
companies?: string[],
game_modes?: string[],
age_ratings?: string[];
player_count?: string;
first_release_date?: number;
average_rating?: number;
}>().notNull(),
slug: text("slug").unique(),
platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(),
cover: blob("cover", { mode: 'buffer' }),

View file

@ -25,7 +25,22 @@ export const store = new Elysia({ prefix: '/api/store' })
});
const emulatesParsed = await getAllStoreEmulatorPackages();
let frontEndEmulators = await Promise.all(emulatesParsed
.filter(e => e.os.includes(process.platform as any))
.filter(e =>
{
if (!e.os.includes(process.platform as any)) return false;
if (query.search)
{
const lowerCaseSearch = query.search.toLocaleLowerCase();
if (e.name.toLocaleLowerCase().includes(lowerCaseSearch) || e.systems.some(s => s.toLocaleLowerCase().includes(lowerCaseSearch)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(lowerCaseSearch)))
{
return true;
}
return false;
}
return true;
})
.map(async (emulator) =>
{
const systems = await buildStoreFrontendEmulatorSystems(emulator);
@ -77,7 +92,8 @@ export const store = new Elysia({ prefix: '/api/store' })
limit: z.coerce.number().optional(),
missing: z.stringbool().optional().describe("Show Only Non Installed emulators"),
orderBy: z.enum(['name', 'recently_updated', 'importance']).optional(),
related: z.string().optional()
related: z.string().optional(),
search: z.string().optional()
})
})
.get('/games/featured', async () =>