feat: Implemented emulator installation

feat: Updated romm API version
feat: Updated es-de rules
feat: Added tabs to game details
refactor: returned to global query definitions to help with typescript performance
This commit is contained in:
Simeon Radivoev 2026-03-22 01:11:21 +02:00
parent cf6fff6fac
commit 3750e9ed8f
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
103 changed files with 4888 additions and 1632 deletions

View file

@ -1,22 +1,27 @@
import Elysia, { status } from "elysia";
import { activeGame, config, db, events, taskQueue } from "../app";
import { and, eq, getTableColumns, sql } from "drizzle-orm";
import z from "zod";
import { activeGame, config, db, emulatorsDb, events, taskQueue } from "../app";
import { and, eq, getTableColumns, inArray, not, or, sql } from "drizzle-orm";
import z, { number } from "zod";
import * as schema from "@schema/app";
import fs from "node:fs/promises";
import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants";
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
import { FrontEndEmulator, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedEmulator, GameListFilterSchema, SERVER_URL } from "@shared/constants";
import { getCurrentUserApiUsersMeGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameMatch } from "./services/utils";
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameDetailed, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler";
import { launchCommand } from "./services/launchGameService";
import { getErrorMessage } from "@/bun/utils";
import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService";
import { getErrorMessage, SeededRandom, shuffleInPlace } from "@/bun/utils";
import { defaultFormats, defaultPlugins } from 'jimp';
import { createJimp } from "@jimp/core";
import webp from "@jimp/wasm-webp";
import { extractStoreGameSourceId, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
import * as emulatorSchema from '@schema/emulators';
import { buildStoreFrontendEmulatorSystems, extractStoreGameSourceId, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
import { use } from "react";
import { CACHE_KEYS, getOrCached } from "../cache";
import { host } from "@/bun/utils/host";
// A custom jimp that supports webp
const Jimp = createJimp({
@ -123,22 +128,52 @@ export default new Elysia()
})
.get('/games', async ({ query, set }) =>
{
const where: any[] = [];
if (query.platform_slug)
{
where.push(eq(schema.platforms.slug, query.platform_slug));
}
if (query.source)
{
where.push(eq(schema.games.source, query.source));
}
const games: FrontEndGameType[] = [];
let localGamesSet: Set<string> | undefined;
if (!query.collection_id)
if (query.source === 'store')
{
const shuffledGames = await getShuffledStoreGames();
set.headers['x-max-items'] = shuffledGames.length;
const storeGames = await Promise.all(shuffledGames
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length))
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const localGame = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.where(and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)));
if (localGame.length > 0) return convertLocalToFrontend(localGame[0]);
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
games.push(...storeGames.filter(g => g !== undefined));
} else
{
const where: any[] = [];
let localGamesSet: Set<string> | undefined;
if (query.platform_slug)
{
where.push(eq(schema.platforms.slug, query.platform_slug));
}
if (query.source)
{
where.push(eq(schema.games.source, query.source));
}
const localGames = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
@ -153,52 +188,30 @@ export default new Elysia()
.where(and(...where));
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g =>
if (!query.collection_id)
{
return convertLocalToFrontend(g);
}));
}
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
{
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
}, throwOnError: true
});
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(`romm@${g.id}`)).map(g =>
{
return convertRomToFrontend(g);
}));
}
if (query.source === 'store')
{
const gamesManifest = await getStoreGameManifest();
set.headers['x-max-items'] = gamesManifest.filter(g => g.type === 'blob').length;
const storeGames = await Promise.all(gamesManifest
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), gamesManifest.length))
.map(async (e) =>
games.push(...localGames.map(g =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
if (localGame)
{
return undefined;
}
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
return convertLocalToFrontend(g);
}));
games.push(...storeGames.filter(g => g !== undefined));
}
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
{
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
}, throwOnError: true
});
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(`romm@${g.id}`)).map(g =>
{
return convertRomToFrontend(g);
}));
}
}
return { games };
@ -231,92 +244,59 @@ export default new Elysia()
})
.get('/game/:source/:id', async ({ params: { source, id } }) =>
{
async function getLocalGameDetailed (match: any)
const sourceData = await getSourceGameDetailed(source, id);
if (sourceData)
{
const localGame = await db.query.games.findFirst({
where: match,
with: {
screenshots: { columns: { id: true } },
platform: { columns: { name: true, slug: true } }
}
});
if (localGame)
if (sourceData.platform_slug)
{
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 undefined;
}
if (source === 'local')
{
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
if (localGame) return localGame;
return status('Not Found');
}
else
{
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
if (localGame) return localGame;
if (source === 'romm')
{
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
if (rom.data)
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) });
if (systemMapping)
{
const romGame = convertRomToFrontendDetailed(rom.data);
return romGame;
const emulatorNames = await getEmulatorsForSystem(systemMapping.system);
const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e }))));
sourceData.emulators = await Promise.all(emulators.map(async ({ name, data }) =>
{
if (data)
{
const systems = await buildStoreFrontendEmulatorSystems(data);
return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true };
}
else if (name === 'EMULATORJS')
{
return {
name: 'EMULATORJS',
validSource: { binPath: SERVER_URL(host), type: 'js', exists: true },
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
systems: [],
gameCount: 0
} satisfies FrontEndGameTypeDetailedEmulator;
}
else
{
return {
name: name,
logo: "",
systems: [],
gameCount: 0
} satisfies FrontEndGameTypeDetailedEmulator;
}
}));
}
return status("Not Found", rom.response);
}
else if (source === 'store')
{
const gameId = extractStoreGameSourceId(id);
const storeGame = await getStoreGame(gameId.system, gameId.id);
if (!storeGame) return status("Not Found");
return convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
}
return sourceData;
} else
{
return status("Not Found");
}
}, {
params: z.object({ source: z.string(), id: z.string() })
})
.get('/status/:source/:id', async ({ params: { source, id }, set }) =>
{
set.headers["content-type"] = 'text/event-stream';
set.headers["cache-control"] = 'no-cache';
set.headers['connection'] = 'keep-alive';
return buildStatusResponse(source, id);
}, {
response: z.any(),
params: z.object({ id: z.string(), source: z.string() }),
query: z.object({ isLocal: z.boolean().optional() })
})
.use(buildStatusResponse())
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
{
const deleted = await db.delete(schema.games).where(getLocalGameMatch(id, source)).returning({ path_fs: schema.games.path_fs });
@ -332,11 +312,11 @@ export default new Elysia()
})
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
{
if (!taskQueue.hasActive())
if (!taskQueue.findJob(`install-rom-${source}-${id}`, InstallJob))
{
if (source === 'romm' || source === 'store')
{
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id, { dryRun: true }));
return status(200);
}
@ -349,7 +329,20 @@ export default new Elysia()
params: z.object({ id: z.string(), source: z.string() }),
response: z.any()
})
.post('/game/:source/:id/play', async ({ params: { id, source }, query, set }) =>
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
{
const job = taskQueue.findJob(`install-rom-${source}-${id}`, InstallJob);
if (job)
{
job.abort('cancel');
return status('OK');
}
return status('Not Found');
}, {
params: z.object({ id: z.string(), source: z.string() }),
response: z.any()
})
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
{
const validCommands = await getValidLaunchCommandsForGame(source, id);
if (validCommands)
@ -362,11 +355,11 @@ export default new Elysia()
{
try
{
const validCommand = query.command_id ? validCommands.commands.find(c => c.id === query.command_id) : validCommands.commands[0];
const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0];
if (validCommand)
{
// launch command waits for the game to exit, we don't want that.
launchCommand(validCommand.command, source, id, validCommands.gameId);
launchCommand(validCommand, source, id, validCommands.gameId);
return { type: 'application', command: null };
} else
{
@ -382,7 +375,7 @@ export default new Elysia()
}
}, {
params: z.object({ id: z.string(), source: z.string() }),
query: z.object({ command_id: z.number().or(z.string()).optional() }),
body: z.object({ command_id: z.number().or(z.string()).optional() }),
response: z.object({ type: z.enum(['emulatorjs', 'application']), command: z.string().nullable() })
})
.post("/stop", async ({ }) =>
@ -404,4 +397,190 @@ export default new Elysia()
.get('/emulatorjs/data/*', async () =>
{
return status("Not Found");
})
.get('/recommended/games/emulator/:id', async ({ params: { id } }) =>
{
const emulator = await getStoreEmulatorPackage(id);
if (!emulator) return status("Not Found");
const systems = await buildStoreFrontendEmulatorSystems(emulator);
const systemsIdSet = new Set(systems.map(s => s.id));
const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!));
const games: FrontEndGameType[] = [];
let localGamesSet: Set<string> | undefined;
const localGames = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.where(inArray(schema.platforms.slug, systems.map(s => s.id)));
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g =>
{
return convertLocalToFrontend(g);
}).slice(0, 3));
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e));
if (rommPlatforms)
{
const platformIds = rommPlatforms.filter(p => systemsRommSlugSet.has(p.slug)).map(s => s.id);
if (platformIds.length > 0)
{
const rommGames = await getRomsApiRomsGet({
query: {
platform_ids: platformIds
}
});
let gamesPerSystem = Math.round(3 / systemsRommSlugSet.size);
for (const slug of systemsRommSlugSet)
{
const systemRommGames = rommGames.data?.items.filter(g => !localGamesSet?.has(`romm@${g.id}`) && slug === g.platform_slug).map(g =>
{
return convertRomToFrontend(g);
}).slice(0, gamesPerSystem) ?? [];
games.push(...systemRommGames);
}
}
}
const gamesManifest = await getStoreGameManifest();
const storeGames = await Promise.all(gamesManifest
.filter(g => systemsIdSet.has(path.dirname(g.path)))
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
if (localGame)
{
return undefined;
}
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
games.push(...storeGames.filter(g => g !== undefined).slice(0, 3));
return games;
})
.get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) =>
{
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 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;
const games: (FrontEndGameType & { metadata?: any; })[] = [];
const localGames = await db.select({ ...getTableColumns(schema.games), platform: schema.platforms })
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.groupBy(schema.games.id);
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 })));
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e));
if (rommPlatforms)
{
const rommPlatform = rommPlatforms.find(p => p.slug === sourceData.platform_slug);
if (rommPlatform)
{
const rommGames = await getRomsApiRomsGet({ query: { genres: sourceData.genres, genres_logic: 'any' } });
if (rommGames.data)
{
games.push(...rommGames.data.items.filter(g => !localGamesSourceSet.has(`romm@${g.id}`)).map(g => ({ ...convertRomToFrontend(g), metadata: g.metadatum })));
}
}
}
const shuffledGames = await getShuffledStoreGames();
const storeGames = await Promise.all(shuffledGames
.filter(g =>
{
const system = path.dirname(g.path);
const id = path.basename(g.path, path.extname(g.path));
if (localGamesSourceSet.has(`${system}@${id}`))
return false;
if (esSystem)
{
if (path.dirname(g.path) === esSystem.system) return true;
}
return false;
})
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
if (storeGames)
{
games.push(...storeGames.slice(0, 3));
}
const random = new SeededRandom(Math.round(new Date().getTime() / 1000 / 60 / 60));
const rankedGames = games.filter(g =>
{
if (sourceData.source && g.id.id === sourceData.source_id && g.id.source === sourceData.source)
{
return false;
}
if (g.id.id === sourceData.id.id && g.id.source === sourceData.id.source)
{
return false;
}
return true;
}).map(g =>
{
let rank = random.next();
if (g.platform_slug === sourceData.platform_slug)
rank += 1;
if (g.metadata)
{
if (g.metadata.companies instanceof Array && g.metadata.companies.some((c: string) => sourceCompaniesSet.has(c)))
{
rank += 1;
}
if (g.metadata.genres instanceof Array && g.metadata.genres.some((g: string) => sourceGenresSet.has(g)))
{
rank += 1;
}
}
return { rank: rank, game: g };
});
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
return rankedGames.map(g => g.game).slice(0, 10);
});

View file

@ -1,7 +1,7 @@
import Elysia, { status } from "elysia";
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
import z from "zod";
import { count, eq, getTableColumns } from "drizzle-orm";
import { and, count, eq, getTableColumns, not } from "drizzle-orm";
import { db } from "../app";
import { FrontEndPlatformType } from "@shared/constants";
import * as schema from "@schema/app";
@ -25,17 +25,35 @@ export default new Elysia()
{
const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p =>
{
const game = await getRomsApiRomsGet({ query: { platform_ids: [p.id] } });
const screenshots: string[] = [];
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: [p.id], limit: 3 } }).then(d => d.data);
if (rommGames)
{
const rommScreenshots = rommGames.items.find(i => i.merged_screenshots.length > 0)?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`);
if (rommScreenshots)
screenshots.push(...rommScreenshots);
}
if (screenshots.length <= 0)
{
const localScreenshots = await db.select({ id: schema.screenshots.id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(eq(schema.platforms.slug, p.slug)).leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)).limit(1);
if (localScreenshots)
screenshots.push(...localScreenshots.map(s => `/api/romm/screenshot/${s.id}`));
}
const localGames = await db.select({ id: schema.games.id, source: schema.games.source, souceId: schema.games.source_id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(and(eq(schema.platforms.slug, p.slug), not(eq(schema.games.source, 'romm')))).groupBy(schema.games.id);
const platform: FrontEndPlatformType = {
slug: p.slug,
name: p.display_name,
family_name: p.family_name,
path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`,
game_count: p.rom_count,
game_count: p.rom_count + localGames.length,
updated_at: new Date(p.updated_at),
id: { source: 'romm', id: String(p.id) },
hasLocal: localPlatformSet.has(p.slug),
paths_screenshots: game.data?.items[0]?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`) ?? []
paths_screenshots: screenshots
};
return platform;

View file

@ -5,16 +5,18 @@ import { existsSync, readFileSync } from 'node:fs';
import * as schema from '@schema/emulators';
import * as appSchema from "@schema/app";
import { eq } from 'drizzle-orm';
import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app';
import { activeGame, config, customEmulators, db, emulatorsDb, events, setActiveGame } from '../../app';
import os from 'node:os';
import { $ } from 'bun';
import { spawn } from 'node:child_process';
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
import { CommandEntry } from '@/shared/constants';
import { CommandEntry, EmulatorSourceType } from '@/shared/constants';
import { cores } from '../../emulatorjs/emulatorjs';
export const varRegex = /%([^%]+)%/g;
export const assignRegex = /(%\w+%)=(\S+) /g;
export async function launchCommand (validCommand: string, source: string, sourceId: string, id: number)
export async function launchCommand (validCommand: { command: string, startDir?: string; }, source: string, sourceId: string, id: number)
{
if (activeGame && activeGame.process?.killed === false)
{
@ -31,8 +33,9 @@ export async function launchCommand (validCommand: string, source: string, sourc
await new Promise((resolve, reject) =>
{
const game = spawn(validCommand, {
shell: true
const game = spawn(validCommand.command, {
shell: true,
cwd: validCommand.startDir
});
game.stdout.on('data', data => console.log(data));
game.on('close', (code) =>
@ -99,6 +102,54 @@ export async function launchCommand (validCommand: string, source: string, sourc
}*/
}
/**
* Get the emulators related to the given system
* @param systemSlug the ES-DE slug for the system
*/
export async function getEmulatorsForSystem (systemSlug: string)
{
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(schema.systems.name, systemSlug)
});
if (!system)
{
throw new Error(`Could not find system '${systemSlug}'`);
}
const emulators = new Set<string>();
await Promise.all(system.commands.map(async (command, index) =>
{
let cmd = command.command;
const matches = Array.from(cmd.matchAll(varRegex));
matches.forEach(([value]) =>
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
emulators.add(emulatorName);
return;
}
});
}));
if (cores[systemSlug])
{
emulators.add('EMULATORJS');
}
return Array.from(emulators);
}
/**
*
* @param data Uses es-de system slug
* @returns
*/
export async function getValidLaunchCommands (data: {
systemSlug: string;
gamePath: string;
@ -160,101 +211,151 @@ export async function getValidLaunchCommands (data: {
}
}
const formattedCommands = await Promise.all(system.commands.map(async (command, index) =>
function escapeWindowsArg (arg: string): string
{
const label = command.label;
let cmd = command.command;
return `"${arg
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes
.replace(/(\\*)$/, '$1$1') // escape trailing backslashes
}"`;
}
let emulator: string | undefined = undefined;
let rom = validFiles[0];
if (cmd.includes('%ESCAPESPECIALS%'))
rom = rom.replace(/[&()^=;,]/g, '');
const staticVars: Record<string, string> = {
'%ROM%': $.escape(rom),
'%ROMRAW%': validFiles[0],
'%ROMRAWWIN%': $.escape(validFiles[0].replace('/', '\\')),
'%ESPATH%': $.escape(path.dirname(Bun.main)),
'%ROMPATH%': $.escape(gamePath),
'%BASENAME%': $.escape(path.basename(validFiles[0], path.extname(validFiles[0]))),
'%FILENAME%': $.escape(path.basename(validFiles[0]))
};
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (_, injectFile: string) =>
const formattedCommands = await Promise.all(system.commands
.filter(c => !c.command.includes(`%ENABLESHORTCUTS%`))
.map(async (command, index) =>
{
try
const label = command.label;
let cmd = command.command;
let emulator: string | undefined = undefined;
let rom = validFiles[0];
if (cmd.includes('%ESCAPESPECIALS%'))
rom = rom.replace(/[&()^=;,]/g, '');
const staticVars: Record<string, string> = {
'%ROM%': escapeWindowsArg(rom),
'%ROMRAW%': validFiles[0],
'%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')),
'%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)),
'%ROMPATH%': escapeWindowsArg(gamePath),
'%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))),
'%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])),
'%ESCAPESPECIALS%': "",
'%HIDEWINDOW%': ""
};
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (_, injectFile: string) =>
{
const resolvedInjectFile = injectFile.replace(varRegex, (a) =>
try
{
return staticVars[a] ?? a;
const resolvedInjectFile = injectFile.replace(varRegex, (a) =>
{
return staticVars[a] ?? a;
});
if (existsSync(resolvedInjectFile))
{
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
}
return '';
} catch (error)
{
return '';
}
});
const matches = Array.from(cmd.matchAll(varRegex));
const varList = await Promise.all(matches.map(async ([value]) =>
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
let execs = await findExecsByName(emulatorName);
let validExec = execs.find(e => e.exists);
emulator = emulatorName;
return [[value, validExec ? validExec.path : undefined], ['%EMUDIR%', validExec ? escapeWindowsArg(path.dirname(validExec.path)) : undefined]];
}
const key = value[0].substring(1, value.length - 1);
return [[value, process.env[key]]];
}));
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
let startDir: string | undefined = undefined;
if ('%STARTDIR%' in vars)
{
delete vars['%STARTDIR%'];
cmd = cmd.replace(assignRegex, (match, p1, p2) =>
{
if (p1 === '%STARTDIR%')
{
startDir = varRegex.test(p2) ? staticVars[p2] : p2;
}
return "";
});
if (existsSync(resolvedInjectFile))
{
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
}
return '';
} catch (error)
{
return '';
}
});
const matches = Array.from(cmd.matchAll(varRegex));
const varList = await Promise.all(matches.map(async ([value]) =>
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
let exec = await findExecByName(emulatorName);
if (data.customEmulatorConfig.has(emulatorName))
{
exec = { path: data.customEmulatorConfig.get(emulatorName)!, type: 'custom' };
}
emulator = emulatorName;
return [[value, exec ? exec.path : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec.path)) : undefined]];
}
const key = value[0].substring(1, value.length - 1);
return [[value, process.env[key]]];
// missing variable
const invalid = Object.entries(vars).find(c => c[1] === undefined);
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
return {
id: index,
label: label ?? undefined,
command: formattedCommand,
startDir,
valid: !invalid, emulator
} satisfies CommandEntry;
}));
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
vars['%ESCAPESPECIALS%'] = "";
vars['%HIDEWINDOW%'] = '';
// missing variable
const invalid = Object.entries(vars).find(c => c[1] === undefined);
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
return {
id: index,
label: label ?? undefined,
command: formattedCommand,
valid: !invalid, emulator
} satisfies CommandEntry;
}));
return formattedCommands.filter(c => !!c);
}
export async function findExecByName (emulatorName: string)
export async function findExecsByName (emulatorName: string)
{
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) });
if (!emulator)
{
throw new Error(`Could not find emulator ${emulatorName}`);
}
return findExec(emulator);
return findExecs(emulatorName, emulator);
}
export async function findExec (emulator: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
export function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): EmulatorSourceType | undefined
{
if (os.platform() === 'win32')
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
const storeExecName = emulator?.systempath.find(name => existsSync(path.join(storeEmulatorFolder, name)));
if (storeExecName)
{
return { binPath: path.join(storeEmulatorFolder, storeExecName), rootPath: storeEmulatorFolder, exists: true, type: "store" };
}
return undefined;
}
export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
{
const execs: EmulatorSourceType[] = [];
if (customEmulators.has(id))
{
execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) });
}
if (emulator && emulator.systempath.length > 0)
{
const storePath = findStoreEmulatorExec(id, emulator);
if (storePath) execs.push(storePath);
}
if (emulator && os.platform() === 'win32')
{
const regValues = emulator.winregistrypath;
if (regValues.length > 0)
@ -264,32 +365,32 @@ export async function findExec (emulator: { winregistrypath: string[], systempat
const registryValue = await readRegistryValue(node);
if (registryValue)
{
return { path: registryValue, type: 'registry' };
execs.push({ binPath: registryValue, type: 'registry', exists: true });
}
}
}
}
const systempaths = emulator.systempath;
if (systempaths.length > 0)
if (emulator && emulator.systempath.length > 0)
{
const systemPath = await resolveSystemPath(systempaths);
const systemPath = await resolveSystemPath(emulator.systempath);
if (systemPath)
{
return { path: systemPath, type: 'system' };
execs.push({ binPath: systemPath, type: 'system', exists: true });
}
}
const staticPaths = emulator.staticpath;
if (staticPaths.length > 0)
if (emulator && emulator.staticpath.length > 0)
{
const staticPath = await resolveStaticPath(staticPaths);
const staticPath = await resolveStaticPath(emulator.staticpath);
if (staticPath)
{
return { path: staticPath, type: 'static' };
execs.push({ binPath: staticPath, type: 'static', exists: true });
}
}
return execs;
}
async function readRegistryValue (text: string)

View file

@ -11,6 +11,10 @@ import { ErrorLike } from "elysia/universal";
import { getStoreGameFromId } from "../../store/services/gamesService";
import { cores } from "../../emulatorjs/emulatorjs";
import { host } from "@/bun/utils/host";
import Elysia from "elysia";
import z from "zod";
import data from "@emulators";
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
class CommandSearchError extends Error
{
@ -54,8 +58,11 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
{
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`;
commands.push({
id: 'emulatorjs',
label: "Emulator JS", command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, emulator: 'emulatorjs'
id: 'EMULATORJS',
label: "Emulator JS",
command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`,
valid: true,
emulator: 'EMULATORJS'
});
}
@ -89,97 +96,93 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
return undefined;
}
export default async function buildStatusResponse (source: string, id: string)
export default function buildStatusResponse ()
{
let cleanup: (() => void) | undefined;
let closed = false;
return new Response(new ReadableStream({
async start (controller)
return new Elysia().ws('/status/:source/:id', {
response: z.discriminatedUnion('status', [
z.object({ status: z.literal('error'), error: z.unknown() }),
z.object({ status: z.literal('installed'), commands: z.array(z.any()), details: z.string().optional() }),
z.object({ status: z.literal(['refresh', 'queued']) }),
z.object({ status: z.literal('playing'), details: z.string() }),
z.object({ status: z.literal('install'), details: z.string() }),
z.object({ status: z.literal(['download', 'extract']), progress: z.number() }),
]),
message (ws, data)
{
const encoder = new TextEncoder();
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping')
if (data === 'cancel')
{
if (closed) return;
const evntString = event ? `event: ${event}\n` : '';
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob);
activeTask?.abort('cancel');
}
await sendLatests();
// seems to help with issue of buffers not flushing, keeping the connection open forcefully
const keepAlive = setInterval(() =>
{
if (closed) return clearInterval(keepAlive);
try
{
enqueue({}, 'ping');
} catch
{
closed = true;
clearInterval(keepAlive);
}
}, 15000);
const sourceId = `${source}-${id}`;
},
async open (ws)
{
sendLatests();
async function sendLatests ()
{
if (closed) return;
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`);
if (ws.readyState > 1) return;
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source), columns: { id: true } });
const activeTask = taskQueue.findJob(`install-rom-${ws.data.params.source}-${ws.data.params.id}`, InstallJob);
if (activeTask)
{
enqueue({
progress: activeTask.progress,
status: activeTask.state as any
});
if (activeTask.status === 'queued')
{
ws.send({ status: 'queued' });
} else
{
ws.send({ status: activeTask.state as InstallJobStates, progress: activeTask.progress });
}
} else if (activeGame && activeGame.gameId === localGame?.id)
{
enqueue({ status: 'playing' as GameStatusType, details: 'Playing' });
ws.send({ status: 'playing', details: 'Playing' });
}
else
{
const validCommand = await getValidLaunchCommandsForGame(source, id);
const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id);
if (validCommand)
{
if (validCommand instanceof Error)
{
enqueue({ status: validCommand.name as GameStatusType, error: validCommand.message });
ws.send({ status: 'error', error: validCommand.message });
}
else
{
enqueue({ status: 'installed', details: validCommand.commands[0].label, commands: validCommand.commands });
ws.send({
status: 'installed',
details: validCommand.commands[0].label,
commands: validCommand.commands
});
}
}
else if (source === 'romm')
else if (ws.data.params.source === 'romm')
{
// TODO: Add Caching
const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(id) } });
const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(ws.data.params.id) } });
const stats = await fs.statfs(config.get('downloadPath'));
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
{
enqueue({ status: 'error', error: "Not Enough Free Space" });
ws.send({ status: 'error', error: "Not Enough Free Space" });
} else
{
enqueue({ status: 'install', details: 'Install' });
ws.send({ status: 'install', details: 'Install' });
}
} else if (source === 'store')
} else if (ws.data.params.source === 'store')
{
const storeGame = await getStoreGameFromId(id);
const storeGame = await getStoreGameFromId(ws.data.params.id);
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
const size = Number(fileResponse.headers.get('content-length'));
const stats = await fs.statfs(config.get('downloadPath'));
if (size > stats.bsize * stats.bavail)
{
enqueue({ status: 'error', error: "Not Enough Free Space" });
ws.send({ status: 'error', error: "Not Enough Free Space" });
} else
{
enqueue({ status: 'install', details: 'Install' });
ws.send({ status: 'install', details: 'Install' });
}
}
}
@ -190,50 +193,56 @@ export default async function buildStatusResponse (source: string, id: string)
{
if (data.error)
{
enqueue({
ws.send({
status: 'error',
error: data.error
}, 'error');
});
}
await sendLatests();
};
events.on('activegameexit', handleActiveExit);
dispose.push(() => events.off('activegameexit', handleActiveExit));
dispose.push(taskQueue.on('progress', ({ id, progress, state }) =>
dispose.push(taskQueue.on('progress', (data) =>
{
if (id.endsWith(sourceId))
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
{
enqueue({ progress, status: state as any });
ws.send({ status: data.job.state as InstallJobStates, progress: data.progress });
}
}));
dispose.push(taskQueue.on('completed', ({ id }) =>
dispose.push(taskQueue.on('queued', (data) =>
{
if (id.endsWith(sourceId))
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
{
enqueue({}, 'refresh');
ws.send({ status: 'queued' });
}
}));
dispose.push(taskQueue.on('error', ({ id, error }) =>
dispose.push(taskQueue.on('completed', (data) =>
{
if (id.endsWith(sourceId))
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
{
enqueue({
ws.send({ status: 'refresh' });
}
}));
dispose.push(taskQueue.on('error', (data) =>
{
if (data.id === `install-rom-${ws.data.params.source}-${ws.data.params.id}`)
{
ws.send({
status: 'error',
error: getErrorMessage(error)
}, 'error');
error: getErrorMessage(data.error)
});
}
}));
cleanup = () =>
(ws.data as any).cleanup = () =>
{
closed = true;
dispose.forEach(f => f());
};
},
cancel ()
close (ws, code, reason)
{
cleanup?.();
cleanup = undefined;
(ws.data as any).cleanup?.();
},
}));
});
}

View file

@ -1,12 +1,14 @@
import getFolderSize from "get-folder-size";
import fs from "node:fs/promises";
import path from "node:path";
import { config, emulatorsDb } from "../../app";
import { config, db, emulatorsDb } from "../../app";
import { and, eq } from "drizzle-orm";
import * as schema from "@schema/app";
import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants";
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
import { FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, StoreGameType } from "@shared/constants";
import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm";
import * as emulatorSchema from "@schema/emulators";
import romm from "@/mainview/scripts/queries/romm";
import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService";
export async function calculateSize (installPath: string | null)
{
@ -127,7 +129,7 @@ export async function convertStoreToFrontend (system: string, id: string, storeG
slug: null,
name: storeGame.title,
platform_id: null,
platform_slug: system,
platform_slug: rommSystem?.sourceSlug ?? system,
paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? []
};
@ -157,21 +159,138 @@ export async function convertStoreToFrontendDetailed (system: string, id: string
return detailed;
}
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
export async function convertRomToFrontendDetailed (rom: DetailedRomSchema)
{
const detailed: FrontEndGameTypeDetailed = {
...convertRomToFrontend(rom),
summary: rom.summary,
fs_size_bytes: rom.fs_size_bytes,
local: false,
missing: rom.missing_from_fs
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
};
const userData = await getCurrentUserApiUsersMeGet();
const gameAchievements = userData.data?.ra_progression?.results?.find(p => p.rom_ra_id == rom.ra_id);
if (rom.merged_ra_metadata?.achievements)
{
const earnedMap = new Map<string, { date: Date; date_hardcode?: Date; }>(gameAchievements?.earned_achievements.map(a => [a.id, { date: new Date(a.date), date_hardcore: a.date_hardcore ? new Date(a.date_hardcore) : undefined }]));
detailed.achievements = {
unlocked: rom.merged_ra_metadata.achievements?.map(a => a.num_awarded).length,
unlocked: gameAchievements?.num_awarded ?? 0,
entires: rom.merged_ra_metadata.achievements.map(a =>
{
const earned = a.badge_id ? earnedMap.get(a.badge_id) : undefined;
const ach: FrontEndGameTypeDetailedAchievement = {
id: a.badge_id ?? String(a.ra_id) ?? 'unknown',
title: a.title ?? "Unknown",
badge_url: (earned ? a.badge_url : a.badge_url_lock) ?? undefined,
date: earned?.date,
date_hardcode: earned?.date_hardcode,
description: a.description ?? undefined,
display_order: a.display_order ?? 0,
type: a.type ?? undefined
};
return ach;
}).sort((a, b) => a.display_order - b.display_order),
total: rom.merged_ra_metadata.achievements.length
};
}
return detailed;
}
export async function getLocalGameDetailed (match: any)
{
const localGame = await db.query.games.findFirst({
where: match,
with: {
screenshots: { columns: { id: true } },
platform: { columns: { name: true, slug: true } }
}
});
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 undefined;
}
export async function getSourceGameDetailed (source: string, id: string)
{
if (source === 'local')
{
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
if (localGame) return localGame;
return undefined;
}
else
{
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
if (source === 'romm')
{
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
if (rom.data)
{
const romGame = await convertRomToFrontendDetailed(rom.data);
if (localGame)
{
return {
...romGame,
...localGame,
};
}
return romGame;
}
else if (localGame)
{
return localGame;
}
return undefined;
}
else if (source === 'store')
{
const gameId = extractStoreGameSourceId(id);
const storeGame = await getStoreGame(gameId.system, gameId.id);
if (!storeGame) return undefined;
const storeFrontendGame = await convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
if (localGame)
{
return { ...storeFrontendGame, ...localGame };
}
return storeFrontendGame;
} else if (localGame)
{
return localGame;
}
return undefined;
}
}