feat: implemented a basic store and emulatorjs
This commit is contained in:
parent
2f32cbc730
commit
7286541822
121 changed files with 5900 additions and 1092 deletions
|
|
@ -1,23 +1,32 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { config, db, taskQueue } from "../app";
|
||||
import { activeGame, config, db, events, taskQueue } from "../app";
|
||||
import { and, eq, getTableColumns, sql } from "drizzle-orm";
|
||||
import z from "zod";
|
||||
import * as schema from "../schema/app";
|
||||
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 { InstallJob } from "../jobs/install-job";
|
||||
import path from "node:path";
|
||||
import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils";
|
||||
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameMatch } 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 { Jimp } from 'jimp';
|
||||
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";
|
||||
|
||||
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height }: { blur?: number, width?: number, height?: number; })
|
||||
// A custom jimp that supports webp
|
||||
const Jimp = createJimp({
|
||||
formats: [...defaultFormats, webp],
|
||||
plugins: defaultPlugins,
|
||||
});
|
||||
|
||||
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height, noBlur }: { blur?: number, width?: number, height?: number; noBlur?: boolean; })
|
||||
{
|
||||
if (blur)
|
||||
if (blur && !noBlur)
|
||||
{
|
||||
const jimp = await Jimp.read(img);
|
||||
if (width)
|
||||
|
|
@ -48,6 +57,8 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width,
|
|||
export default new Elysia()
|
||||
.get('/game/local/:id/cover', async ({ params: { id }, query, set }) =>
|
||||
{
|
||||
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||
|
||||
const coverBlob = await db.query.games.findFirst({ columns: { cover: true, cover_type: true }, where: eq(schema.games.id, id) });
|
||||
if (!coverBlob || !coverBlob.cover)
|
||||
{
|
||||
|
|
@ -71,7 +82,7 @@ export default new Elysia()
|
|||
return processImage(`${rommAdress}/${path}`, query);
|
||||
}
|
||||
return status('Not Found');
|
||||
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) })
|
||||
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional(), noBlur: z.coerce.boolean().optional() }) })
|
||||
.get('/image', async ({ query }) =>
|
||||
{
|
||||
return processImage(query.url, query);
|
||||
|
|
@ -106,18 +117,24 @@ export default new Elysia()
|
|||
}, {
|
||||
params: z.object({ id: z.number() }),
|
||||
response: z.object({ installed: z.boolean() })
|
||||
}).get('/games', async ({ query: { platform_source, platform_slug, platform_id, collection_id } }) =>
|
||||
})
|
||||
.get('/games', async ({ query, set }) =>
|
||||
{
|
||||
const where: any[] = [];
|
||||
if (platform_slug)
|
||||
if (query.platform_slug)
|
||||
{
|
||||
where.push(eq(schema.platforms.slug, 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<number> | undefined;
|
||||
let localGamesSet: Set<string> | undefined;
|
||||
|
||||
if (!collection_id)
|
||||
if (!query.collection_id)
|
||||
{
|
||||
const localGames = await db.select({
|
||||
...getTableColumns(schema.games),
|
||||
|
|
@ -128,45 +145,87 @@ export default new Elysia()
|
|||
.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)
|
||||
|
||||
.offset(query.offset ?? 0)
|
||||
.limit(query.limit ?? 50)
|
||||
.where(and(...where));
|
||||
|
||||
localGamesSet = new Set(localGames.filter(g => !!g.source_id).map(g => g.source_id!));
|
||||
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
|
||||
games.push(...localGames.map(g =>
|
||||
{
|
||||
const game: FrontEndGameType = {
|
||||
platform_display_name: g.platform?.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`,
|
||||
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
|
||||
path_fs: g.path_fs,
|
||||
last_played: g.last_played,
|
||||
slug: g.slug,
|
||||
name: g.name,
|
||||
platform_id: g.platform_id
|
||||
};
|
||||
return game;
|
||||
return convertLocalToFrontend(g);
|
||||
}));
|
||||
}
|
||||
|
||||
if ((!platform_source || platform_source === 'romm') || !!collection_id)
|
||||
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
|
||||
{
|
||||
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 =>
|
||||
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) =>
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
return { games };
|
||||
}, {
|
||||
query: GameListFilterSchema,
|
||||
})
|
||||
.get('/rom/:source/:id', async ({ params: { id, source } }) =>
|
||||
{
|
||||
const localGame = await db.query.games.findFirst({
|
||||
where: getLocalGameMatch(id, source),
|
||||
columns: { path_fs: true }
|
||||
});
|
||||
|
||||
if (!localGame?.path_fs)
|
||||
{
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
const downloadPath = config.get('downloadPath');
|
||||
const path_fs = path.join(downloadPath, localGame.path_fs);
|
||||
const stats = await fs.stat(path_fs);
|
||||
if (stats.isDirectory())
|
||||
{
|
||||
return status("Not Found", "Rom is a folder");
|
||||
}
|
||||
|
||||
return Bun.file(path_fs);
|
||||
}, {
|
||||
params: z.object({ source: z.string(), id: z.string() })
|
||||
})
|
||||
.get('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
async function getLocalGameDetailed (match: any)
|
||||
|
|
@ -175,7 +234,7 @@ export default new Elysia()
|
|||
where: match,
|
||||
with: {
|
||||
screenshots: { columns: { id: true } },
|
||||
platform: { columns: { name: true } }
|
||||
platform: { columns: { name: true, slug: true } }
|
||||
}
|
||||
});
|
||||
if (localGame)
|
||||
|
|
@ -185,7 +244,7 @@ export default new Elysia()
|
|||
const game: FrontEndGameTypeDetailed = {
|
||||
path_cover: `/api/romm/game/local/${localGame.id}/cover`,
|
||||
updated_at: localGame.created_at,
|
||||
id: { id: localGame.id, source: 'local' },
|
||||
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}`),
|
||||
|
|
@ -199,7 +258,8 @@ export default new Elysia()
|
|||
last_played: localGame.last_played,
|
||||
slug: localGame.slug,
|
||||
name: localGame.name,
|
||||
platform_id: localGame.platform_id
|
||||
platform_id: localGame.platform_id,
|
||||
platform_slug: localGame.platform.slug
|
||||
};
|
||||
return game;
|
||||
}
|
||||
|
|
@ -209,7 +269,7 @@ export default new Elysia()
|
|||
|
||||
if (source === 'local')
|
||||
{
|
||||
const localGame = await getLocalGameDetailed(eq(schema.games.id, id));
|
||||
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
|
||||
if (localGame) return localGame;
|
||||
return status('Not Found');
|
||||
}
|
||||
|
|
@ -218,18 +278,30 @@ export default new Elysia()
|
|||
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
|
||||
if (localGame) return localGame;
|
||||
|
||||
const rom = await getRomApiRomsIdGet({ path: { id } });
|
||||
if (rom.data)
|
||||
if (source === 'romm')
|
||||
{
|
||||
const romGame = convertRomToFrontendDetailed(rom.data);
|
||||
return romGame;
|
||||
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
|
||||
if (rom.data)
|
||||
{
|
||||
const romGame = convertRomToFrontendDetailed(rom.data);
|
||||
return romGame;
|
||||
}
|
||||
|
||||
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 status("Not Found", rom.response);
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
}, {
|
||||
params: z.object({ source: z.string(), id: z.coerce.number() })
|
||||
params: z.object({ source: z.string(), id: z.string() })
|
||||
})
|
||||
.get('/status/:source/:id', async ({ params: { source, id }, set }) =>
|
||||
{
|
||||
|
|
@ -239,7 +311,7 @@ export default new Elysia()
|
|||
return buildStatusResponse(source, id);
|
||||
}, {
|
||||
response: z.any(),
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
query: z.object({ isLocal: z.boolean().optional() })
|
||||
})
|
||||
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||
|
|
@ -253,36 +325,51 @@ export default new Elysia()
|
|||
|
||||
return status(deleted.length > 0 ? 'OK' : 'Not Modified');
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
})
|
||||
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||
{
|
||||
if (!taskQueue.hasActive())
|
||||
{
|
||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
|
||||
return status(200);
|
||||
if (source === 'romm' || source === 'store')
|
||||
{
|
||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
|
||||
return status(200);
|
||||
}
|
||||
|
||||
return status('Not Implemented');
|
||||
} else
|
||||
{
|
||||
return status('Not Implemented');
|
||||
}
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
response: z.any()
|
||||
})
|
||||
.post('/game/:source/:id/play', async ({ params: { id, source }, set }) =>
|
||||
.post('/game/:source/:id/play', async ({ params: { id, source }, query, set }) =>
|
||||
{
|
||||
const validCommand = await getValidLaunchCommandsForGame(source, id);
|
||||
if (validCommand)
|
||||
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
||||
if (validCommands)
|
||||
{
|
||||
if (validCommand instanceof Error)
|
||||
if (validCommands instanceof Error)
|
||||
{
|
||||
return errorToResponse(validCommand, set);
|
||||
return errorToResponse(validCommands, set);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
await launchCommand(validCommand.command.command, source, id, validCommand.gameId);
|
||||
const validCommand = query.command_id ? validCommands.commands.find(c => c.id === query.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);
|
||||
return { type: 'application', command: null };
|
||||
} else
|
||||
{
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
|
|
@ -291,5 +378,27 @@ export default new Elysia()
|
|||
}
|
||||
}
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
query: 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 ({ }) =>
|
||||
{
|
||||
if (activeGame)
|
||||
{
|
||||
events.emit('activegameexit', {
|
||||
source: 'local', id: String(activeGame.gameId),
|
||||
exitCode: null,
|
||||
signalCode: null
|
||||
});
|
||||
}
|
||||
})
|
||||
.get('/emulatorjs/data/cores/*', async ({ params }) =>
|
||||
{
|
||||
const res = await fetch(`https://cdn.emulatorjs.org/latest/data/cores/${params['*']}`);
|
||||
return res;
|
||||
})
|
||||
.get('/emulatorjs/data/*', async ({ params }) =>
|
||||
{
|
||||
return status("Not Found");
|
||||
});
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import z from "zod";
|
||||
import { count, eq, getTableColumns, notInArray } from "drizzle-orm";
|
||||
import { count, eq, getTableColumns } from "drizzle-orm";
|
||||
import { db } from "../app";
|
||||
import { FrontEndPlatformType } from "@shared/constants";
|
||||
import * as schema from "../schema/app";
|
||||
import * as schema from "@schema/app";
|
||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||
|
||||
export default new Elysia()
|
||||
.get('/platforms', async () =>
|
||||
{
|
||||
const platforms: FrontEndPlatformType[] = [];
|
||||
let rommPlatformsSet: Set<string> | undefined;
|
||||
const { data: rommPlatforms } = await getPlatformsApiPlatformsGet();
|
||||
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e));
|
||||
|
||||
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
|
||||
.from(schema.platforms)
|
||||
|
|
@ -32,7 +33,7 @@ export default new Elysia()
|
|||
path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`,
|
||||
game_count: p.rom_count,
|
||||
updated_at: new Date(p.updated_at),
|
||||
id: { source: 'romm', id: p.id },
|
||||
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}`) ?? []
|
||||
};
|
||||
|
|
@ -46,7 +47,13 @@ export default new Elysia()
|
|||
|
||||
platforms.push(...await Promise.all(localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(async p =>
|
||||
{
|
||||
const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id), with: { screenshots: true }, columns: {} });
|
||||
const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id) });
|
||||
let screenshots: { id: number; }[] = [];
|
||||
if (game)
|
||||
{
|
||||
screenshots = await db.query.screenshots.findMany({ where: eq(schema.screenshots.game_id, game.id), columns: { id: true } });
|
||||
}
|
||||
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
|
|
@ -54,9 +61,9 @@ export default new Elysia()
|
|||
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 },
|
||||
id: { source: 'local', id: String(p.id) },
|
||||
hasLocal: true,
|
||||
paths_screenshots: game?.screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? []
|
||||
paths_screenshots: screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? []
|
||||
|
||||
};
|
||||
|
||||
|
|
@ -66,13 +73,52 @@ export default new Elysia()
|
|||
return { platforms };
|
||||
}).get('/platforms/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
const rommPlatform = await getPlatformApiPlatformsIdGet({ path: { id } });
|
||||
if (rommPlatform.data)
|
||||
if (source === 'romm')
|
||||
{
|
||||
return rommPlatform.data;
|
||||
const { data: rommPlatform, response } = await getPlatformApiPlatformsIdGet({ path: { id } });
|
||||
if (rommPlatform)
|
||||
{
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: rommPlatform.slug,
|
||||
name: rommPlatform.display_name,
|
||||
family_name: rommPlatform.family_name,
|
||||
path_cover: `/api/romm/image/romm/assets/platforms/${rommPlatform.slug}.svg`,
|
||||
game_count: rommPlatform.rom_count,
|
||||
updated_at: new Date(rommPlatform.updated_at),
|
||||
id: { source: 'romm', id: String(rommPlatform.id) },
|
||||
paths_screenshots: [],
|
||||
hasLocal: false
|
||||
};
|
||||
|
||||
return platform;
|
||||
}
|
||||
|
||||
return status("Not Found", response);
|
||||
}
|
||||
else if (source === 'local')
|
||||
{
|
||||
const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, id) });
|
||||
if (localPlatform)
|
||||
{
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: localPlatform.slug,
|
||||
name: localPlatform.name,
|
||||
family_name: localPlatform.family_name,
|
||||
path_cover: `/api/romm/platform/local/${localPlatform.id}/cover`,
|
||||
game_count: 0,
|
||||
updated_at: localPlatform.created_at,
|
||||
id: { source: 'local', id: String(localPlatform.id) },
|
||||
hasLocal: true,
|
||||
paths_screenshots: []
|
||||
};
|
||||
|
||||
return platform;
|
||||
}
|
||||
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
return status("Not Found", rommPlatform.response);
|
||||
return status("Not Implemented");
|
||||
}, { 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({
|
||||
|
|
|
|||
|
|
@ -2,26 +2,19 @@ import path from 'node:path';
|
|||
import { which } from 'bun';
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import * as schema from '../../schema/emulators';
|
||||
import * as appSchema from "../../schema/app";
|
||||
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 os from 'node:os';
|
||||
import { $ } from 'bun';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
|
||||
import { CommandEntry } from '@/shared/constants';
|
||||
|
||||
export const varRegex = /%([^%]+)%/g;
|
||||
|
||||
interface CommandEntry
|
||||
{
|
||||
label?: string;
|
||||
command: string;
|
||||
valid: boolean;
|
||||
emulator?: string;
|
||||
}
|
||||
|
||||
export async function launchCommand (validCommand: string, source: string, sourceId: number, id: number)
|
||||
export async function launchCommand (validCommand: string, source: string, sourceId: string, id: number)
|
||||
{
|
||||
if (activeGame && activeGame.process?.killed === false)
|
||||
{
|
||||
|
|
@ -69,13 +62,12 @@ export async function launchCommand (validCommand: string, source: string, sourc
|
|||
|
||||
if (source === 'romm')
|
||||
{
|
||||
updateRommProps(sourceId);
|
||||
updateRommProps(Number(sourceId));
|
||||
}
|
||||
else if (localGame?.source === 'romm' && localGame.source_id)
|
||||
{
|
||||
updateRommProps(localGame.source_id);
|
||||
updateRommProps(Number(localGame.source_id));
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/* Old spawn lanching, cases issues, needs to be ran as shell
|
||||
|
|
@ -117,7 +109,10 @@ export async function getValidLaunchCommands (data: {
|
|||
}): Promise<CommandEntry[]>
|
||||
{
|
||||
|
||||
const system = await emulatorsDb.query.systems.findFirst({ with: { commands: true }, where: eq(schema.systems.name, data.systemSlug) });
|
||||
const system = await emulatorsDb.query.systems.findFirst({
|
||||
with: { commands: true },
|
||||
where: eq(schema.systems.name, data.systemSlug)
|
||||
});
|
||||
|
||||
if (!system)
|
||||
{
|
||||
|
|
@ -165,7 +160,7 @@ export async function getValidLaunchCommands (data: {
|
|||
}
|
||||
}
|
||||
|
||||
const formattedCommands = await Promise.all(system.commands.map(async command =>
|
||||
const formattedCommands = await Promise.all(system.commands.map(async (command, index) =>
|
||||
{
|
||||
const label = command.label;
|
||||
let cmd = command.command;
|
||||
|
|
@ -213,14 +208,14 @@ export async function getValidLaunchCommands (data: {
|
|||
if (value.startsWith("%EMULATOR_"))
|
||||
{
|
||||
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
||||
let exec = await findExec(emulatorName);
|
||||
let exec = await findExecByName(emulatorName);
|
||||
if (data.customEmulatorConfig.has(emulatorName))
|
||||
{
|
||||
exec = data.customEmulatorConfig.get(emulatorName);
|
||||
exec = { path: data.customEmulatorConfig.get(emulatorName)!, type: 'custom' };
|
||||
}
|
||||
|
||||
emulator = emulatorName;
|
||||
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec)) : undefined]];
|
||||
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec.path)) : undefined]];
|
||||
}
|
||||
|
||||
const key = value[0].substring(1, value.length - 1);
|
||||
|
|
@ -237,6 +232,7 @@ export async function getValidLaunchCommands (data: {
|
|||
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
|
||||
|
||||
return {
|
||||
id: index,
|
||||
label: label ?? undefined,
|
||||
command: formattedCommand,
|
||||
valid: !invalid, emulator
|
||||
|
|
@ -246,13 +242,18 @@ export async function getValidLaunchCommands (data: {
|
|||
return formattedCommands.filter(c => !!c);
|
||||
}
|
||||
|
||||
export async function findExec (emulatorName: string)
|
||||
export async function findExecByName (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);
|
||||
}
|
||||
|
||||
export async function findExec (emulator: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
|
||||
{
|
||||
if (os.platform() === 'win32')
|
||||
{
|
||||
const regValues = emulator.winregistrypath;
|
||||
|
|
@ -263,7 +264,7 @@ export async function findExec (emulatorName: string)
|
|||
const registryValue = await readRegistryValue(node);
|
||||
if (registryValue)
|
||||
{
|
||||
return registryValue;
|
||||
return { path: registryValue, type: 'registry' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,7 +277,7 @@ export async function findExec (emulatorName: string)
|
|||
const systemPath = await resolveSystemPath(systempaths);
|
||||
if (systemPath)
|
||||
{
|
||||
return systemPath;
|
||||
return { path: systemPath, type: 'system' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,7 +287,7 @@ export async function findExec (emulatorName: string)
|
|||
const staticPath = await resolveStaticPath(staticPaths);
|
||||
if (staticPath)
|
||||
{
|
||||
return staticPath;
|
||||
return { path: staticPath, type: 'static' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { GameInstallProgress, GameStatusType, } from "@shared/constants";
|
||||
import { GameInstallProgress, GameStatusType, RPC_URL, } from "@shared/constants";
|
||||
import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app";
|
||||
import { getValidLaunchCommands } from "./launchGameService";
|
||||
import * as schema from '../../schema/app';
|
||||
import * as schema from '@schema/app';
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { getLocalGameMatch } from "./utils";
|
||||
import { getRomApiRomsIdGet } from "@/clients/romm";
|
||||
import fs from 'node:fs/promises';
|
||||
import { ErrorLike } from "elysia/universal";
|
||||
import { getStoreGameFromId } from "../../store/services/gamesService";
|
||||
import { cores } from "../../emulatorjs/emulatorjs";
|
||||
import { host } from "@/bun/utils/host";
|
||||
|
||||
class CommandSearchError extends Error
|
||||
{
|
||||
|
|
@ -18,7 +21,7 @@ class CommandSearchError extends Error
|
|||
}
|
||||
}
|
||||
|
||||
export async function getLocalGame (source: string, id: number)
|
||||
export async function getLocalGame (source: string, id: string)
|
||||
{
|
||||
const localGames = await db.select({ id: schema.games.id, path_fs: schema.games.path_fs, platform_slug: schema.platforms.es_slug })
|
||||
.from(schema.games)
|
||||
|
|
@ -33,7 +36,7 @@ export async function getLocalGame (source: string, id: number)
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export async function getValidLaunchCommandsForGame (source: string, id: number)
|
||||
export async function getValidLaunchCommandsForGame (source: string, id: string)
|
||||
{
|
||||
const localGame = await getLocalGame(source, id);
|
||||
if (localGame)
|
||||
|
|
@ -42,18 +45,28 @@ export async function getValidLaunchCommandsForGame (source: string, id: number)
|
|||
{
|
||||
if (localGame.path_fs)
|
||||
{
|
||||
|
||||
try
|
||||
{
|
||||
const commands = await getValidLaunchCommands({ systemSlug: localGame.platform_slug, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs });
|
||||
|
||||
if (cores[localGame.platform_slug])
|
||||
{
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
const validCommand = commands.find(c => c.valid);
|
||||
if (validCommand)
|
||||
{
|
||||
return { command: validCommand, gameId: localGame.id, source: source, sourceId: id };
|
||||
|
||||
return { commands: commands.filter(c => c.valid), 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(',')}`);
|
||||
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
|
||||
}
|
||||
} catch (error)
|
||||
{
|
||||
|
|
@ -76,7 +89,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: number)
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export default async function buildStatusResponse (source: string, id: number)
|
||||
export default async function buildStatusResponse (source: string, id: string)
|
||||
{
|
||||
let cleanup: (() => void) | undefined;
|
||||
let closed = false;
|
||||
|
|
@ -87,6 +100,7 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
|
||||
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping')
|
||||
{
|
||||
if (closed) return;
|
||||
const evntString = event ? `event: ${event}\n` : '';
|
||||
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
||||
}
|
||||
|
|
@ -136,13 +150,14 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
}
|
||||
else
|
||||
{
|
||||
enqueue({ status: 'installed', details: validCommand.command.label });
|
||||
enqueue({ status: 'installed', details: validCommand.commands[0].label, commands: validCommand.commands });
|
||||
}
|
||||
|
||||
} else if (source === 'romm')
|
||||
}
|
||||
else if (source === 'romm')
|
||||
{
|
||||
// TODO: Add Caching
|
||||
const remoteGame = await getRomApiRomsIdGet({ path: { id } });
|
||||
const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(id) } });
|
||||
const stats = await fs.statfs(config.get('downloadPath'));
|
||||
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
|
||||
{
|
||||
|
|
@ -152,6 +167,20 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
enqueue({ status: 'install', details: 'Install' });
|
||||
}
|
||||
|
||||
} else if (source === 'store')
|
||||
{
|
||||
const storeGame = await getStoreGameFromId(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" });
|
||||
} else
|
||||
{
|
||||
enqueue({ status: 'install', details: 'Install' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -190,7 +219,7 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
{
|
||||
enqueue({
|
||||
status: 'error',
|
||||
error: error
|
||||
error: getErrorMessage(error)
|
||||
}, 'error');
|
||||
}
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import getFolderSize from "get-folder-size";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { config } from "../../app";
|
||||
import { config, db, emulatorsDb } from "../../app";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as schema from "../../schema/app";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
|
||||
import * as schema from "@schema/app";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants";
|
||||
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
|
||||
export async function calculateSize (installPath: string | null)
|
||||
{
|
||||
|
|
@ -19,15 +20,15 @@ export async function checkInstalled (installPath: string | null)
|
|||
return fs.exists(path.join(config.get('downloadPath'), installPath));
|
||||
}
|
||||
|
||||
export function getLocalGameMatch (id: number, source: string)
|
||||
export function getLocalGameMatch (id: string, source: string)
|
||||
{
|
||||
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, id);
|
||||
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id));
|
||||
}
|
||||
|
||||
export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
|
||||
{
|
||||
const game: FrontEndGameType = {
|
||||
id: { id: rom.id, source: 'romm' },
|
||||
id: { id: String(rom.id), source: 'romm' },
|
||||
path_cover: `/api/romm/image/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),
|
||||
|
|
@ -40,11 +41,131 @@ export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
|
|||
source: null,
|
||||
source_id: null,
|
||||
paths_screenshots: rom.merged_screenshots.map(s => `/api/romm/image/romm/${s}`),
|
||||
platform_slug: rom.platform_slug
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
|
||||
platform?: typeof schema.platforms.$inferSelect | null;
|
||||
screenshotIds?: number[];
|
||||
})
|
||||
{
|
||||
const game: FrontEndGameType = {
|
||||
platform_display_name: g.platform?.name ?? "Local",
|
||||
id: { id: String(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`,
|
||||
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
|
||||
path_fs: g.path_fs,
|
||||
last_played: g.last_played,
|
||||
slug: g.slug,
|
||||
name: g.name,
|
||||
platform_id: g.platform_id,
|
||||
platform_slug: g.platform?.slug ?? null
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
|
||||
platform?: typeof schema.platforms.$inferSelect | null;
|
||||
screenshotIds?: number[];
|
||||
})
|
||||
{
|
||||
const game: FrontEndGameTypeDetailed = {
|
||||
platform_display_name: g.platform?.name ?? "Local",
|
||||
id: { id: String(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`,
|
||||
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
|
||||
path_fs: g.path_fs,
|
||||
last_played: g.last_played,
|
||||
slug: g.slug,
|
||||
name: g.name,
|
||||
platform_id: g.platform_id,
|
||||
platform_slug: g.platform?.slug ?? null,
|
||||
summary: g.summary,
|
||||
fs_size_bytes: 0,
|
||||
missing: false,
|
||||
local: true
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
|
||||
{
|
||||
let size: number | null = null;
|
||||
try
|
||||
{
|
||||
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||
size = Number(fileResponse.headers.get('content-length'));
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
}
|
||||
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
|
||||
where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm'))
|
||||
});
|
||||
|
||||
const platformDef = await emulatorsDb.query.systems.findFirst({
|
||||
where: eq(emulatorSchema.systems.name, system),
|
||||
columns: { fullname: true }
|
||||
});
|
||||
|
||||
const gameId = `${system}@${id}`;
|
||||
|
||||
const game: FrontEndGameType = {
|
||||
platform_display_name: platformDef?.fullname ?? system,
|
||||
path_platform_cover: `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`,
|
||||
id: { source: 'store', id: gameId },
|
||||
source: null,
|
||||
source_id: null,
|
||||
path_fs: null,
|
||||
path_cover: `/api/romm/image?url=${encodeURIComponent(storeGame.pictures.titlescreens?.[0])}`,
|
||||
last_played: null,
|
||||
updated_at: new Date(),
|
||||
slug: null,
|
||||
name: storeGame.title,
|
||||
platform_id: null,
|
||||
platform_slug: system,
|
||||
paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? []
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export async function convertStoreToFrontendDetailed (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameTypeDetailed>
|
||||
{
|
||||
let size: number | null = null;
|
||||
try
|
||||
{
|
||||
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||
size = Number(fileResponse.headers.get('content-length'));
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const detailed: FrontEndGameTypeDetailed = {
|
||||
...await convertStoreToFrontend(system, id, storeGame),
|
||||
summary: storeGame.description,
|
||||
fs_size_bytes: size,
|
||||
missing: false,
|
||||
local: false,
|
||||
};
|
||||
|
||||
return detailed;
|
||||
}
|
||||
|
||||
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
|
||||
{
|
||||
const detailed: FrontEndGameTypeDetailed = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue