feat: Implemented launching and downloading of roms
This is just an initial implementation lots of kings to iron out
This commit is contained in:
parent
ef08fa6114
commit
f15bf9a1e0
117 changed files with 37776 additions and 1073 deletions
245
src/bun/api/games/games.ts
Normal file
245
src/bun/api/games/games.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { activeGame, config, db, events, setActiveGame, taskQueue } from "../app";
|
||||
import { and, eq, getTableColumns } from "drizzle-orm";
|
||||
import z from "zod";
|
||||
import * as schema from "../schema/app";
|
||||
import fs from "node:fs/promises";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
|
||||
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import { InstallJob } from "../jobs/install-job";
|
||||
import path from "node:path";
|
||||
import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils";
|
||||
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
|
||||
import { errorToResponse } from "elysia/adapter/bun/handler";
|
||||
|
||||
export default new Elysia()
|
||||
.get('/game/local/:id/cover', async ({ params: { id }, set }) =>
|
||||
{
|
||||
const coverBlob = await db.query.games.findFirst({ columns: { cover: true, cover_type: true }, where: eq(schema.games.id, id) });
|
||||
if (!coverBlob || !coverBlob.cover)
|
||||
{
|
||||
return status(404);
|
||||
}
|
||||
if (coverBlob.cover_type)
|
||||
{
|
||||
set.headers["content-type"] = coverBlob.cover_type;
|
||||
}
|
||||
return status(200, coverBlob.cover);
|
||||
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) })
|
||||
.get('/screenshot/:id', async ({ params: { id }, set }) =>
|
||||
{
|
||||
const screenshot = await db.query.screenshots.findFirst({ where: eq(schema.screenshots.id, id), columns: { content: true, type: true } });
|
||||
if (screenshot)
|
||||
{
|
||||
if (screenshot.type)
|
||||
{
|
||||
set.headers["content-type"] = screenshot.type;
|
||||
}
|
||||
return screenshot.content;
|
||||
|
||||
}
|
||||
|
||||
return status(404);
|
||||
}, { params: z.object({ id: z.coerce.number() }) })
|
||||
.get("/game/local/:id/installed", async ({ params: { id } }) =>
|
||||
{
|
||||
const data = await db.query.games.findFirst({ where: eq(schema.games.id, id) });
|
||||
if (data && data.path_fs)
|
||||
{
|
||||
return { installed: await fs.exists(data.path_fs) };
|
||||
}
|
||||
|
||||
return { installed: false };
|
||||
}, {
|
||||
params: z.object({ id: z.number() }),
|
||||
response: z.object({ installed: z.boolean() })
|
||||
}).get('/games', async ({ query: { platform_id, collection_id } }) =>
|
||||
{
|
||||
const where: any[] = [];
|
||||
if (platform_id)
|
||||
{
|
||||
where.push(eq(schema.games.id, platform_id));
|
||||
}
|
||||
|
||||
const games: FrontEndGameType[] = [];
|
||||
|
||||
const localGames = await db.select({
|
||||
platform_display_name: schema.platforms.name,
|
||||
id: schema.games.id,
|
||||
last_played: schema.games.last_played,
|
||||
created_at: schema.games.created_at,
|
||||
platform_id: schema.games.platform_id,
|
||||
slug: schema.games.slug,
|
||||
name: schema.games.name,
|
||||
path_fs: schema.games.path_fs,
|
||||
source_id: schema.games.source_id,
|
||||
source: schema.games.source
|
||||
}).from(schema.games).leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id)).where(and(...where));
|
||||
|
||||
const localGamesSet = new Set(localGames.map(g => g.source_id));
|
||||
games.push(...localGames.map(g =>
|
||||
{
|
||||
const game: FrontEndGameType = {
|
||||
...g,
|
||||
platform_display_name: g.platform_display_name ?? "Local",
|
||||
id: { id: g.id, source: 'local' },
|
||||
updated_at: g.created_at,
|
||||
path_cover: `/api/romm/game/local/${g.id}/cover`,
|
||||
source_id: g.source_id,
|
||||
source: g.source,
|
||||
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`
|
||||
};
|
||||
return game;
|
||||
}));
|
||||
|
||||
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true });
|
||||
games.push(...rommGames.data.items.filter(g => !localGamesSet.has(g.id)).map(g =>
|
||||
{
|
||||
return convertRomToFrontend(g);
|
||||
}));
|
||||
|
||||
return { games };
|
||||
}, {
|
||||
query: z.object({ platform_id: z.coerce.number().optional(), collection_id: z.coerce.number().optional() }),
|
||||
})
|
||||
.get('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
async function getLocalGameDetailed (match: any)
|
||||
{
|
||||
const localGames = await db.select({
|
||||
platform_display_name: schema.platforms.name,
|
||||
...getTableColumns(schema.games)
|
||||
}).from(schema.games).where(match).leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id));
|
||||
if (localGames.length > 0)
|
||||
{
|
||||
const screenshots = await db.query.screenshots.findMany({ where: eq(schema.screenshots.game_id, localGames[0].id), columns: { id: true } });
|
||||
const exists = await checkInstalled(localGames[0].path_fs);
|
||||
const fileSize = await calculateSize(localGames[0].path_fs);
|
||||
const game: FrontEndGameTypeDetailed = {
|
||||
...localGames[0],
|
||||
path_cover: `/api/romm/game/local/${localGames[0].id}/cover`,
|
||||
updated_at: localGames[0].created_at,
|
||||
id: { id: localGames[0].id, source: 'local' },
|
||||
path_platform_cover: `/api/romm/platform/local/${localGames[0].platform_id}/cover`,
|
||||
fs_size_bytes: fileSize ?? null,
|
||||
paths_screenshots: screenshots.map(s => `/api/romm/screenshot/${s.id}`),
|
||||
local: true,
|
||||
missing: !exists
|
||||
};
|
||||
return game;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (source === 'local')
|
||||
{
|
||||
|
||||
const localGame = await getLocalGameDetailed(eq(schema.games.id, id));
|
||||
if (localGame) return localGame;
|
||||
return status('Not Found');
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
|
||||
if (localGame) return localGame;
|
||||
|
||||
const rom = await getRomApiRomsIdGet({ path: { id } });
|
||||
if (rom.data)
|
||||
{
|
||||
const romGame = convertRomToFrontendDetailed(rom.data);
|
||||
return romGame;
|
||||
}
|
||||
|
||||
return status("Not Found", rom.response);
|
||||
}
|
||||
|
||||
}, {
|
||||
params: z.object({ source: z.string(), id: z.coerce.number() })
|
||||
})
|
||||
.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.coerce.number(), source: z.string() }),
|
||||
query: z.object({ isLocal: z.boolean().optional() })
|
||||
})
|
||||
.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 });
|
||||
const downloadPath = config.get('downloadPath');
|
||||
await Promise.all(deleted.filter(d => !!d.path_fs).map(async d =>
|
||||
{
|
||||
await fs.rm(path.join(downloadPath, d.path_fs!), { recursive: true, force: true });
|
||||
}));
|
||||
|
||||
return status(deleted.length > 0 ? 'OK' : 'Not Modified');
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
})
|
||||
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||
{
|
||||
if (!taskQueue.hasActive())
|
||||
{
|
||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id));
|
||||
return status(200);
|
||||
} else
|
||||
{
|
||||
return status('Not Implemented');
|
||||
}
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
response: z.any()
|
||||
})
|
||||
.post('/game/:source/:id/play', async ({ params: { id, source }, set }) =>
|
||||
{
|
||||
const validCommand = await getValidLaunchCommandsForGame(source, id);
|
||||
if (validCommand)
|
||||
{
|
||||
if (validCommand instanceof Error)
|
||||
{
|
||||
return errorToResponse(validCommand, set);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
if (activeGame && activeGame.process.killed === false)
|
||||
{
|
||||
return status('Conflict', `${activeGame.name} currently running`);
|
||||
}
|
||||
|
||||
const localGame = await db.query.games.findFirst({
|
||||
where: eq(schema.games.id, validCommand.gameId), columns: {
|
||||
name: true
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
const game = setActiveGame({
|
||||
process: Bun.spawn({
|
||||
cmd: validCommand.command.command.split(' '), onExit (subprocess, exitCode, signalCode, error)
|
||||
{
|
||||
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
|
||||
},
|
||||
}),
|
||||
name: localGame?.name ?? "Unknown",
|
||||
gameId: validCommand.gameId,
|
||||
command: validCommand.command.command
|
||||
});
|
||||
|
||||
await game.process.exited;
|
||||
if (game.process.exitCode && game.process.exitCode > 0)
|
||||
{
|
||||
return status('Internal Server Error');
|
||||
}
|
||||
return status('OK');
|
||||
}
|
||||
}
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
});
|
||||
86
src/bun/api/games/platforms.ts
Normal file
86
src/bun/api/games/platforms.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet } from "@clients/romm";
|
||||
import z from "zod";
|
||||
import { count, eq, getTableColumns, notInArray } from "drizzle-orm";
|
||||
import { db } from "../app";
|
||||
import { FrontEndPlatformType } from "@shared/constants";
|
||||
import * as schema from "../schema/app";
|
||||
|
||||
export default new Elysia()
|
||||
.get('/platforms', async () =>
|
||||
{
|
||||
const platforms: FrontEndPlatformType[] = [];
|
||||
let rommPlatformsSet: Set<string> | undefined;
|
||||
const { data: rommPlatforms } = await getPlatformsApiPlatformsGet();
|
||||
if (rommPlatforms)
|
||||
{
|
||||
const frontEndPlatforms = rommPlatforms.map(p =>
|
||||
{
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: p.slug,
|
||||
name: p.display_name,
|
||||
family_name: p.family_name,
|
||||
path_cover: `/api/romm/assets/platforms/${p.slug}.svg`,
|
||||
game_count: p.rom_count,
|
||||
updated_at: new Date(p.updated_at),
|
||||
id: { source: 'romm', id: p.id },
|
||||
source: null,
|
||||
source_id: null
|
||||
};
|
||||
|
||||
return platform;
|
||||
});
|
||||
rommPlatformsSet = new Set(rommPlatforms.map(p => p.slug));
|
||||
platforms.push(...frontEndPlatforms);
|
||||
}
|
||||
|
||||
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
|
||||
.from(schema.platforms)
|
||||
.leftJoin(schema.games, eq(schema.games.platform_id, schema.platforms.id))
|
||||
.groupBy(schema.platforms.id)
|
||||
.where(notInArray(schema.platforms.slug, Array.from(rommPlatformsSet ?? [])));
|
||||
platforms.push(...localPlatforms.map(p =>
|
||||
{
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
family_name: p.family_name,
|
||||
path_cover: `/api/romm/platform/local/${p.id}/cover`,
|
||||
game_count: p.game_count,
|
||||
updated_at: p.created_at,
|
||||
id: { source: 'local', id: p.id },
|
||||
source: null,
|
||||
source_id: null
|
||||
};
|
||||
|
||||
return platform;
|
||||
}));
|
||||
|
||||
return { platforms };
|
||||
}).get('/platforms/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
const rommPlatform = await getPlatformApiPlatformsIdGet({ path: { id } });
|
||||
if (rommPlatform.data)
|
||||
{
|
||||
return rommPlatform.data;
|
||||
}
|
||||
|
||||
return status("Not Found", rommPlatform.response);
|
||||
}, { params: z.object({ source: z.string(), id: z.coerce.number() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
|
||||
{
|
||||
const coverBlob = await db.query.platforms.findFirst({
|
||||
columns: {
|
||||
cover: true, cover_type: true
|
||||
|
||||
}, where: eq(schema.platforms.id, id)
|
||||
});
|
||||
if (!coverBlob || !coverBlob.cover)
|
||||
{
|
||||
return status(404);
|
||||
}
|
||||
if (coverBlob.cover_type)
|
||||
{
|
||||
set.headers["content-type"] = coverBlob.cover_type;
|
||||
}
|
||||
return status(200, coverBlob.cover);
|
||||
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) });
|
||||
219
src/bun/api/games/services/launchGameService.ts
Normal file
219
src/bun/api/games/services/launchGameService.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import path, { basename, dirname } from 'node:path';
|
||||
import { which } from 'bun';
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as schema from '../../schema/emulators';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { config, emulatorsDb } from '../../app';
|
||||
import os from 'node:os';
|
||||
|
||||
export const varRegex = /%([^%]+)%/g;
|
||||
|
||||
interface CommandEntry
|
||||
{
|
||||
label?: string;
|
||||
command: string;
|
||||
valid: boolean;
|
||||
emulator?: string;
|
||||
}
|
||||
|
||||
export async function getValidLaunchCommands (data: {
|
||||
systemSlug: string;
|
||||
gamePath: string;
|
||||
customEmulatorConfig: {
|
||||
get: (id: string) => string | undefined,
|
||||
has: (id: string) => boolean,
|
||||
};
|
||||
}): Promise<CommandEntry[]>
|
||||
{
|
||||
|
||||
const system = await emulatorsDb.query.systems.findFirst({ with: { commands: true }, where: eq(schema.systems.name, data.systemSlug) });
|
||||
|
||||
if (!system)
|
||||
{
|
||||
throw new Error(`Could not find system '${data.systemSlug}'`);
|
||||
}
|
||||
|
||||
if (!system.extension || system.extension.length <= 0)
|
||||
{
|
||||
throw new Error(`No extensions listed for system '${data.systemSlug}'`);
|
||||
}
|
||||
|
||||
const downloadPath = config.get('downloadPath');
|
||||
const gamePath = path.join(downloadPath, data.gamePath);
|
||||
|
||||
const validFiles: string[] = [];
|
||||
if (!existsSync(gamePath))
|
||||
{
|
||||
throw new Error(`Provided rom path is missing: '${gamePath}'`);
|
||||
}
|
||||
|
||||
const gamePathStat = await fs.stat(gamePath);
|
||||
|
||||
const extensionList = system.extension.join(',');
|
||||
|
||||
if (gamePathStat.isDirectory())
|
||||
{
|
||||
for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`)))
|
||||
{
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
if (validFiles.length <= 0)
|
||||
{
|
||||
throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`);
|
||||
}
|
||||
} else
|
||||
{
|
||||
if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase())))
|
||||
{
|
||||
validFiles.push(gamePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedCommands = await Promise.all(system.commands.map(async command =>
|
||||
{
|
||||
const label = command.label;
|
||||
const cmd = command.command;
|
||||
|
||||
const matches = cmd.match(varRegex);
|
||||
if (matches)
|
||||
{
|
||||
let emulator: string | undefined = undefined;
|
||||
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 findExec(emulatorName);
|
||||
if (data.customEmulatorConfig.has(emulatorName))
|
||||
{
|
||||
exec = data.customEmulatorConfig.get(emulatorName);
|
||||
}
|
||||
|
||||
emulator = emulatorName;
|
||||
return [value, exec];
|
||||
}
|
||||
|
||||
const key = value.substring(1, value.length - 1);
|
||||
return [value, process.env[key]];
|
||||
}));
|
||||
const vars = Object.fromEntries(varList);
|
||||
vars['%ROM%'] = validFiles[0];
|
||||
vars['%ESPATH%'] = config.get('downloadPath');
|
||||
|
||||
// missing variable
|
||||
const invalid = Object.entries(vars).find(c => c[1] === undefined);
|
||||
|
||||
const command = cmd.replace(varRegex, (s) => vars[s] ?? '');
|
||||
return { label: label ?? undefined, command, valid: !invalid, emulator } satisfies CommandEntry;
|
||||
}
|
||||
}));
|
||||
|
||||
return formattedCommands.filter(c => !!c);
|
||||
}
|
||||
|
||||
export async function findExec (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}`);
|
||||
}
|
||||
if (os.platform() === 'win32')
|
||||
{
|
||||
const regValues = emulator.winregistrypath;
|
||||
if (regValues.length > 0)
|
||||
{
|
||||
for (const node of regValues)
|
||||
{
|
||||
const registryValue = await readRegistryValue(node);
|
||||
if (registryValue)
|
||||
{
|
||||
return registryValue;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const systempaths = emulator.systempath;
|
||||
if (systempaths.length > 0)
|
||||
{
|
||||
const systemPath = await resolveSystemPath(systempaths);
|
||||
if (systemPath)
|
||||
{
|
||||
return systemPath;
|
||||
}
|
||||
}
|
||||
|
||||
const staticPaths = emulator.staticpath;
|
||||
if (staticPaths.length > 0)
|
||||
{
|
||||
const staticPath = await resolveStaticPath(staticPaths);
|
||||
if (staticPath)
|
||||
{
|
||||
return staticPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readRegistryValue (text: string)
|
||||
{
|
||||
const params = text.split('|');
|
||||
const key = dirname(params[0]);
|
||||
const value = basename(params[0]);
|
||||
const bin = params.length > 1 ? params[1] : undefined;
|
||||
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["reg", "QUERY", key, "/v", value],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
if (!output.includes(value)) return null;
|
||||
|
||||
const lines = output.split("\n");
|
||||
for (const line of lines)
|
||||
{
|
||||
if (line.includes(value))
|
||||
{
|
||||
const parts = line.trim().split(/\s{4,}/);
|
||||
return bin ? path.join(parts[2], bin) : parts[2]; // registry value
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveStaticPath (entries: string[])
|
||||
{
|
||||
for (const entry of entries)
|
||||
{
|
||||
for await (const match of fs.glob(entry))
|
||||
{
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveSystemPath (entries: string[])
|
||||
{
|
||||
for (const entry of entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
const found = which(entry);
|
||||
return found;
|
||||
} catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
171
src/bun/api/games/services/statusService.ts
Normal file
171
src/bun/api/games/services/statusService.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { GameInstallProgress, GameStatusType, } from "@shared/constants";
|
||||
import { activeGame, customEmulators, db, events, taskQueue } from "../../app";
|
||||
import { getValidLaunchCommands } from "./launchGameService";
|
||||
import * as schema from '../../schema/app';
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { getLocalGameMatch } from "./utils";
|
||||
|
||||
class CommandSearchError extends Error
|
||||
{
|
||||
constructor(status: GameStatusType, message: string)
|
||||
{
|
||||
super(message);
|
||||
this.name = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLocalGame (source: string, id: number)
|
||||
{
|
||||
const localGames = await db.select({ id: schema.games.id, path_fs: schema.games.path_fs, platform_slug: schema.platforms.es_slug })
|
||||
.from(schema.games)
|
||||
.where(getLocalGameMatch(id, source))
|
||||
.leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id));
|
||||
|
||||
if (localGames.length > 0)
|
||||
{
|
||||
return localGames[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getValidLaunchCommandsForGame (source: string, id: number)
|
||||
{
|
||||
const localGame = await getLocalGame(source, id);
|
||||
if (localGame)
|
||||
{
|
||||
if (localGame.platform_slug)
|
||||
{
|
||||
if (localGame.path_fs)
|
||||
{
|
||||
try
|
||||
{
|
||||
const commands = await getValidLaunchCommands({ systemSlug: localGame.platform_slug, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs });
|
||||
const validCommand = commands.find(c => c.valid);
|
||||
if (validCommand)
|
||||
{
|
||||
return { command: validCommand, gameId: localGame.id, source: source, sourceId: id };
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator).join(',')}`);
|
||||
}
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
return new CommandSearchError('error', getErrorMessage(error));
|
||||
}
|
||||
|
||||
} else
|
||||
{
|
||||
return new CommandSearchError('error', 'Missing Path');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return new CommandSearchError('error', 'Missing Platform');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default async function buildStatusResponse (source: string, id: number)
|
||||
{
|
||||
let cleanup: (() => void) | undefined;
|
||||
return new Response(new ReadableStream({
|
||||
async start (controller)
|
||||
{
|
||||
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh')
|
||||
{
|
||||
const evntString = event ? `event: ${event}\n` : '';
|
||||
controller.enqueue(`${evntString}data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
const sourceId = `${source}-${id}`;
|
||||
|
||||
async function sendLatests ()
|
||||
{
|
||||
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
|
||||
const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`);
|
||||
if (activeTask)
|
||||
{
|
||||
enqueue({
|
||||
progress: activeTask.progress,
|
||||
status: activeTask.state as any
|
||||
});
|
||||
|
||||
} else if (activeGame && activeGame.gameId === localGame?.id)
|
||||
{
|
||||
enqueue({ status: 'playing' as GameStatusType, details: 'Playing' });
|
||||
}
|
||||
else
|
||||
{
|
||||
const validCommand = await getValidLaunchCommandsForGame(source, id);
|
||||
if (validCommand)
|
||||
{
|
||||
if (validCommand instanceof Error)
|
||||
{
|
||||
enqueue({ status: validCommand.name as GameStatusType, error: validCommand.message });
|
||||
}
|
||||
else
|
||||
{
|
||||
enqueue({ status: 'installed', details: validCommand.command.label });
|
||||
}
|
||||
|
||||
} else
|
||||
{
|
||||
enqueue({ status: 'install', details: 'Install' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await sendLatests();
|
||||
|
||||
const dispose: Function[] = [];
|
||||
const handleActiveExit = async () =>
|
||||
{
|
||||
await sendLatests();
|
||||
};
|
||||
events.on('activegameexit', handleActiveExit);
|
||||
dispose.push(() => events.off('activegameexit', handleActiveExit));
|
||||
dispose.push(taskQueue.on('progress', ({ id, progress, state }) =>
|
||||
{
|
||||
if (id.endsWith(sourceId))
|
||||
{
|
||||
enqueue({ progress, status: state as any });
|
||||
}
|
||||
}));
|
||||
dispose.push(taskQueue.on('completed', ({ id }) =>
|
||||
{
|
||||
if (id.endsWith(sourceId))
|
||||
{
|
||||
enqueue({}, 'refresh');
|
||||
}
|
||||
}));
|
||||
dispose.push(taskQueue.on('error', ({ id, error }) =>
|
||||
{
|
||||
if (id.endsWith(sourceId))
|
||||
{
|
||||
enqueue({
|
||||
status: 'error',
|
||||
error: error
|
||||
}, 'error');
|
||||
}
|
||||
}));
|
||||
|
||||
cleanup = () =>
|
||||
{
|
||||
dispose.forEach(f => f());
|
||||
};
|
||||
},
|
||||
cancel (reason)
|
||||
{
|
||||
cleanup?.();
|
||||
cleanup = undefined;
|
||||
},
|
||||
}));
|
||||
}
|
||||
65
src/bun/api/games/services/utils.ts
Normal file
65
src/bun/api/games/services/utils.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import getFolderSize from "get-folder-size";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { config } from "../../app";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as schema from "../../schema/app";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
|
||||
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
|
||||
|
||||
export async function calculateSize (installPath: string | null)
|
||||
{
|
||||
if (!installPath) return null;
|
||||
return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size;
|
||||
}
|
||||
|
||||
export async function checkInstalled (installPath: string | null)
|
||||
{
|
||||
if (!installPath) return false;
|
||||
return fs.exists(path.join(config.get('downloadPath'), installPath));
|
||||
}
|
||||
|
||||
export function getLocalGameMatch (id: number, source: string)
|
||||
{
|
||||
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, id);
|
||||
}
|
||||
|
||||
export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
|
||||
{
|
||||
const game: FrontEndGameType = {
|
||||
id: { id: rom.id, source: 'romm' },
|
||||
path_cover: `/api/romm${rom.path_cover_large}`,
|
||||
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
||||
updated_at: new Date(rom.updated_at),
|
||||
slug: rom.slug,
|
||||
platform_id: rom.platform_id,
|
||||
platform_display_name: rom.platform_display_name,
|
||||
name: rom.name,
|
||||
path_fs: null,
|
||||
path_platform_cover: `/api/romm/assets/platforms/${rom.platform_slug}.svg`,
|
||||
source: null,
|
||||
source_id: null
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
|
||||
{
|
||||
const detailed: FrontEndGameTypeDetailed = {
|
||||
...convertRomToFrontend(rom),
|
||||
summary: rom.summary,
|
||||
fs_size_bytes: rom.fs_size_bytes,
|
||||
paths_screenshots: rom.merged_screenshots.map(s => `/api/romm${s}`),
|
||||
local: false,
|
||||
missing: rom.missing_from_fs
|
||||
};
|
||||
if (rom.merged_ra_metadata?.achievements)
|
||||
{
|
||||
detailed.achievements = {
|
||||
unlocked: rom.merged_ra_metadata.achievements?.map(a => a.num_awarded).length,
|
||||
total: rom.merged_ra_metadata.achievements.length
|
||||
};
|
||||
}
|
||||
return detailed;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue