feat: implemented a basic store and emulatorjs

This commit is contained in:
Simeon Radivoev 2026-03-14 02:15:57 +02:00
parent 2f32cbc730
commit 7286541822
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
121 changed files with 5900 additions and 1092 deletions

View file

@ -0,0 +1,193 @@
import * as appSchema from '@schema/app';
import { findExec, findExecByName } from "../games/services/launchGameService";
import * as emulatorSchema from "@schema/emulators";
import { eq, inArray } from 'drizzle-orm';
import { customEmulators, db, emulatorsDb } from '../app';
import fs from 'node:fs/promises';
import { cores } from '../emulatorjs/emulatorjs';
/**
* Get emulators based on local games. Only the ones we probably need.
* */
export async function getRelevantEmulators ()
{
const localGames = await db.select({ es_slug: appSchema.platforms.es_slug, platform_id: appSchema.platforms.id, platform_name: appSchema.platforms.name })
.from(appSchema.games)
.leftJoin(appSchema.platforms, eq(appSchema.games.platform_id, appSchema.platforms.id))
.groupBy(appSchema.platforms.es_slug);
const platformLookup = new Map(localGames.filter(g => g.es_slug).map(g => [g.es_slug!, g]));
const platformViability = new Map(localGames.filter(g => g.es_slug).map(g => [g.es_slug!, false]));
// check emulator js
for (const platform of platformLookup)
{
if (cores[platform[0]])
platformViability.set(platform[0], true);
}
// all commands based on the local games
const commands = await emulatorsDb
.select({ command: emulatorSchema.commands.command, system_slug: emulatorSchema.systems.name })
.from(emulatorSchema.commands).where(inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.es_slug).map(s => s.es_slug!)))))
.leftJoin(emulatorSchema.systems, eq(emulatorSchema.systems.name, emulatorSchema.commands.system));
// get all emulators in said commands
const emulators = commands
.flatMap(command =>
{
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
if (!matches)
{
return undefined;
}
return matches?.map(m => ({ emulator: m, system: command.system_slug }));
}
).filter(c => !!c);
// Group them by name
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
{
let execPath: { path: string; type: string, } | undefined;
if (customEmulators.has(emulator))
{
execPath = { path: customEmulators.get(emulator), type: 'custom' };
} else
{
execPath = await findExecByName(emulator);
}
let platform: number | null | undefined = null;
const validSystemSlug = system_slug.find(s => s.system);
if (validSystemSlug?.system)
{
platform = platformLookup.get(validSystemSlug.system)?.platform_id;
}
// check if automatic or custom path found existing binary.
// This might not be the actual emulator but I don't care.
const exists = !!execPath && await fs.exists(execPath.path);
const systems = Array.from(new Set(system_slug.filter(s => s.system).map(s => s.system!)));
if (exists)
{
systems.forEach(s => platformViability.set(s, true));
}
return {
emulator: emulator,
path: execPath,
exists: exists,
isCritical: false,
path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null,
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s)
};
}));
finalEmulators.push({
emulator: 'emulatorjs',
exists: true,
path: { path: 'localhost', type: 'js' },
path_cover: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
isCritical: false,
systems: []
});
return finalEmulators.map(e =>
{
e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!));
return e;
});
}
/**
* Only emulators we strictly need based on local games. Emulator JS is included as bundled.
* If there is even single emulator for a system don't include emulators for that system.
*/
/*export async function getMissingEmulators ()
{
const localGames = await db.query.games.findMany({
columns: {
platform_id: true,
slug: true
},
with: {
platform: {
columns: {
name: true,
es_slug: true
}
},
}
});
const platformLookup = new Map(localGames.map(g => [g.platform.es_slug, g]));
const platformViability = new Map(localGames.map(g => [g.platform.es_slug, false]));
// all commands based on the local games
const commands = await emulatorsDb.query.commands.findMany({
columns: { command: true },
where: inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.platform.es_slug).map(s => s.platform.es_slug!)))),
with: { system: { columns: { name: true } } }
});
// get all emulators in said commands
const emulators = commands
.flatMap(command =>
{
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
if (!matches)
{
return undefined;
}
return matches?.map(m => ({ emulator: m, system: command.system?.name }));
}
).filter(c => !!c);
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
{
let execPath: { path: string; type: string, } | undefined;
if (customEmulators.has(emulator))
{
execPath = { path: customEmulators.get(emulator), type: 'custom' };
} else
{
execPath = await findExecByName(emulator);
}
let platform: number | null | undefined = null;
if (system_slug.length <= 1)
{
platform = platformLookup.get(system_slug[0].system)?.platform_id;
}
// check if automatic or custom path found existing binary.
// This might not be the actual emulator but I don't care.
const exists = !!execPath && await fs.exists(execPath.path);
const systems = Array.from(new Set(system_slug.map(s => s.system)));
if (exists)
{
systems.forEach(s => platformViability.set(s, true));
}
return {
emulator: emulator,
path: execPath,
exists: exists,
isCritical: false,
path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null,
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s)
};
}));
return finalEmulators.map(e =>
{
e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!));
return e;
});
}*/

View file

@ -0,0 +1,94 @@
import z from "zod";
import { SettingsSchema } from "@shared/constants";
import Elysia, { status } from "elysia";
import { config, customEmulators, taskQueue } from "../app";
import fs from 'node:fs/promises';
import { existsSync } from "node:fs";
import { InstallJob } from "../jobs/install-job";
import { move } from "fs-extra";
import { getRelevantEmulators } from "./services";
export const settings = new Elysia({ prefix: '/api/settings' })
.get('/emulators/automatic', async () =>
{
return getRelevantEmulators();
})
.put('/emulators/custom/:id', async ({ params: { id }, body: { value } }) =>
{
return customEmulators.set(id, value);
},
{
body: z.object({ value: z.string() })
})
.delete('/emulators/custom/:id', async ({ params: { id } }) =>
{
return customEmulators.delete(id);
})
.get('/emulators/custom/:id', async ({ params: { id } }) =>
{
return customEmulators.get(id);
},
{
response: z.string()
})
.get('/emulators/custom', async () =>
{
return Object.keys(customEmulators.store);
}, {
response: z.array(z.string())
})
.put('/path/download', async ({ body: { manualPath, drive } }) =>
{
if (taskQueue.hasActiveOfType(InstallJob))
{
return status("Forbidden", "Installation in progress");
}
const oldDownloadPath = config.get('downloadPath');
if (!existsSync(oldDownloadPath))
{
return status("Not Found", "Old download path doesn't exist");
}
async function isDirEmpty (dirname: string)
{
const files = await fs.readdir(dirname);
return files.length === 0;
}
const path = manualPath ?? drive;
if (!path)
{
return;
}
if (existsSync(path) && !isDirEmpty(path))
{
return status("Conflict", "New location already exists and is not empty");
}
await move(oldDownloadPath, path);
config.set('downloadPath', manualPath);
return manualPath;
}, {
body: z.object({
manualPath: z.string().optional(),
drive: z.string().optional()
})
})
.get("/:id", async ({ params: { id } }) =>
{
const value = config.get(id);
return { value: value };
}, {
params: z.object({ id: z.keyof(SettingsSchema) }),
}).post('/:id',
async ({ params: { id }, body: { value }, }) =>
{
config.set(id, value);
}, {
params: z.object({ id: z.keyof(SettingsSchema) }),
body: z.object({ value: z.any() }),
});