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:
parent
cf6fff6fac
commit
3750e9ed8f
103 changed files with 4888 additions and 1632 deletions
|
|
@ -22,6 +22,7 @@ import { appPath, getErrorMessage } from "../utils";
|
|||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||
import { ensureDir } from "fs-extra";
|
||||
import UpdateStoreJob from "./jobs/update-store";
|
||||
import { getStoreFolder } from "./store/services/gamesService";
|
||||
|
||||
export const config = new Conf<SettingsType>({
|
||||
projectName: projectPackage.name,
|
||||
|
|
@ -47,6 +48,8 @@ export const customEmulators = new Conf<Record<string, string>>({
|
|||
console.log("Config Path Located At: ", config.path);
|
||||
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
||||
console.log("App Directory is ", process.env.APPDIR);
|
||||
console.log("Store Directory is ", getStoreFolder());
|
||||
|
||||
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
||||
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
||||
export const jar = new CookieJar(fileCookieStore);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { cache } from "./app";
|
||||
import cacheSchema from "@schema/cache";
|
||||
import { GithubReleaseSchema } from "@/shared/constants";
|
||||
|
||||
export const CACHE_KEYS = {
|
||||
ROM_PLATFORMS: 'rom-platforms',
|
||||
|
|
@ -31,4 +32,14 @@ export async function getOrCached<T> (key: string, getter: () => Promise<T>, opt
|
|||
.run();
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getOrCachedGithubRelease (path: string)
|
||||
{
|
||||
return getOrCached(`github-release-${path}`, async () =>
|
||||
{
|
||||
const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, { method: "GET" });
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
return GithubReleaseSchema.parseAsync(await response.json());
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
},
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
105
src/bun/api/jobs/emulator-download-job.ts
Normal file
105
src/bun/api/jobs/emulator-download-job.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { EmulatorPackageType } from "@/shared/constants";
|
||||
import { getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import z from "zod";
|
||||
import { Glob } from "bun";
|
||||
import { config } from "../app";
|
||||
import path from 'node:path';
|
||||
import { getOrCachedGithubRelease } from "../cache";
|
||||
import _7z from '7zip-min';
|
||||
import fs from "node:fs/promises";
|
||||
import { Downloader } from "@/bun/utils/downloader";
|
||||
import { move } from "fs-extra";
|
||||
|
||||
type EmulatorDownloadStates = "download" | "extract";
|
||||
|
||||
export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>
|
||||
{
|
||||
static id = "download-emulator" as const;
|
||||
static dataSchema = z.object({ emulator: z.string() });
|
||||
emulator: string;
|
||||
downloadSource: string;
|
||||
emulatorPackage?: EmulatorPackageType;
|
||||
|
||||
constructor(emulator: string, downloadSource: string)
|
||||
{
|
||||
this.emulator = emulator;
|
||||
this.downloadSource = downloadSource;
|
||||
}
|
||||
|
||||
async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>)
|
||||
{
|
||||
this.emulatorPackage = await getStoreEmulatorPackage(this.emulator);
|
||||
if (!this.emulatorPackage) throw new Error("Emulator not found");
|
||||
if (!this.emulatorPackage.downloads) throw new Error("Emulator has no downloads");
|
||||
|
||||
const validDownloads = this.emulatorPackage.downloads[`${process.platform}:${process.arch}`];
|
||||
if (!validDownloads) throw new Error(`Now downloads in ${this.emulatorPackage.name} for platform ${process.platform}:${process.arch}`);
|
||||
|
||||
const validDownload = validDownloads.find(d => d.type === this.downloadSource);
|
||||
if (!validDownload || !validDownload.path) throw new Error(`Download type ${this.downloadSource} not found`);
|
||||
|
||||
console.log("Trying To Download from ", `https://api.github.com/repos/${validDownload.path}/releases/latest`);
|
||||
const latestRelease = await getOrCachedGithubRelease(validDownload.path);
|
||||
const glob = new Glob(validDownload.pattern);
|
||||
const validAsset = latestRelease.assets.find(a => glob.match(a.name));
|
||||
if (!validAsset) throw new Error("Could Not Find Valid Asset");
|
||||
const downloadUrl = validAsset.browser_download_url;
|
||||
const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator);
|
||||
|
||||
const isArchive = validAsset.content_type === 'application/x-7z-compressed' || validAsset.name.endsWith('.7z') || validAsset.content_type === 'application/zip' || validAsset.name.endsWith('.zip');
|
||||
|
||||
const isAppImage = validAsset.name.endsWith(".AppImage");
|
||||
|
||||
if (!isArchive && !isAppImage)
|
||||
{
|
||||
throw new Error("Invalid Download Type");
|
||||
}
|
||||
|
||||
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
||||
const downloader = new Downloader(this.emulator,
|
||||
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl), file_path: this.emulator }],
|
||||
tmpFolder,
|
||||
{
|
||||
onProgress (stats)
|
||||
{
|
||||
context.setProgress(stats.progress, 'download');
|
||||
},
|
||||
});
|
||||
|
||||
const destinationPaths = await downloader.start();
|
||||
if (destinationPaths)
|
||||
{
|
||||
if (isArchive)
|
||||
{
|
||||
if (await downloader.start() && destinationPaths[0])
|
||||
{
|
||||
let destinationPath = destinationPaths[0];
|
||||
await _7z.unpack(destinationPath, emulatorsFolder);
|
||||
await fs.rm(destinationPath, { recursive: true });
|
||||
|
||||
// check if 1 root folder we need to get rid of
|
||||
const contents = await fs.readdir(emulatorsFolder);
|
||||
if (contents.length === 1)
|
||||
{
|
||||
const stat = await fs.stat(path.join(emulatorsFolder, contents[0]));
|
||||
if (stat.isDirectory())
|
||||
{
|
||||
console.log("Found 1 root folder, using that instead");
|
||||
const tmpEmulatorsFolder = `${emulatorsFolder} (1)`;
|
||||
await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true });
|
||||
await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exposeData ()
|
||||
{
|
||||
return { emulator: this.emulator };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -6,12 +6,14 @@ import * as schema from "@schema/app";
|
|||
import * as emulatorSchema from "@schema/emulators";
|
||||
import path from 'node:path';
|
||||
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm";
|
||||
import { config, db, emulatorsDb, jar } from "../app";
|
||||
import unzip from 'unzip-stream';
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { config, db, emulatorsDb, events, jar } from "../app";
|
||||
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
||||
import * as igdb from 'ts-igdb-client';
|
||||
import secrets from "../secrets";
|
||||
import { hashFile } from "@/bun/utils";
|
||||
import { Downloader } from "@/bun/utils/downloader";
|
||||
import { sleep } from "bun";
|
||||
import _7z from '7zip-min';
|
||||
|
||||
interface JobConfig
|
||||
{
|
||||
|
|
@ -19,13 +21,16 @@ interface JobConfig
|
|||
dryDownload?: boolean;
|
||||
}
|
||||
|
||||
export class InstallJob implements IJob
|
||||
export type InstallJobStates = 'download' | 'extract';
|
||||
|
||||
export class InstallJob implements IJob<never, InstallJobStates>
|
||||
{
|
||||
public gameId: string;
|
||||
public source: string;
|
||||
public sourceId: string;
|
||||
public config?: JobConfig;
|
||||
static id = "install-job" as const;
|
||||
public group = InstallJob.id;
|
||||
|
||||
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
|
||||
{
|
||||
|
|
@ -35,162 +40,124 @@ export class InstallJob implements IJob
|
|||
this.source = source;
|
||||
}
|
||||
|
||||
public async start (cx: JobContext)
|
||||
public async start (cx: JobContext<InstallJob, never, InstallJobStates>)
|
||||
{
|
||||
cx.setProgress(0, 'download');
|
||||
fs.mkdir(config.get('downloadPath'), { recursive: true });
|
||||
|
||||
const downloadPath = config.get('downloadPath');
|
||||
|
||||
let files: {
|
||||
url: URL,
|
||||
file_path: string;
|
||||
file_name: string;
|
||||
size?: number;
|
||||
}[] = [];
|
||||
let cookie: string = '';
|
||||
let screenshotUrls: string[];
|
||||
let coverUrl: string;
|
||||
let rommPlatform: PlatformSchema | undefined;
|
||||
let slug: string | null;
|
||||
let path_fs: string | undefined;
|
||||
let summary: string | null;
|
||||
let name: string | null;
|
||||
let last_played: Date | null;
|
||||
let igdb_id: number | null;
|
||||
let ra_id: number | null;
|
||||
let source_id: string;
|
||||
let system_slug: string;
|
||||
let extract_path: string;
|
||||
let metadata: any | undefined;
|
||||
|
||||
switch (this.source)
|
||||
{
|
||||
case 'romm':
|
||||
|
||||
const rom = (await getRomApiRomsIdGet({ path: { id: Number(this.gameId) }, throwOnError: true })).data;
|
||||
rommPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
|
||||
|
||||
const rommAddress = config.get('rommAddress');
|
||||
coverUrl = `${rommAddress}${rom.path_cover_large}`;
|
||||
screenshotUrls = rom.merged_screenshots.map(s => `${config.get('rommAddress')}${s}`);
|
||||
last_played = rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null;
|
||||
igdb_id = rom.igdb_id;
|
||||
ra_id = rom.ra_id;
|
||||
summary = rom.summary;
|
||||
name = rom.name;
|
||||
path_fs = path.join(rom.fs_path, rom.fs_name);
|
||||
source_id = String(rom.id);
|
||||
slug = rom.slug;
|
||||
system_slug = rommPlatform.slug;
|
||||
extract_path = '';
|
||||
metadata = rom.metadatum;
|
||||
|
||||
const rommFiles = await Promise.all(rom.files.map(async f =>
|
||||
{
|
||||
const localPath = path.join(config.get('downloadPath'), f.full_path);
|
||||
if (f.md5_hash && await fs.exists(localPath))
|
||||
{
|
||||
const existingHash = await hashFile(localPath, 'sha1');
|
||||
if (existingHash === f.md5_hash)
|
||||
{
|
||||
console.log("File Already Present: ", f.full_path);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
console.warn("File ", f.full_path, 'with hash', existingHash, 'has different hash than', f.sha1_hash);
|
||||
}
|
||||
|
||||
return {
|
||||
url: new URL(`${config.get('rommAddress')}/api/romsfiles/${f.id}/content/${f.file_name}`),
|
||||
file_name: f.file_name,
|
||||
file_path: path.join(config.get('downloadPath'), f.file_path),
|
||||
size: f.file_size_bytes
|
||||
};
|
||||
}));
|
||||
|
||||
files.push(...rommFiles.filter(f => f !== undefined));
|
||||
cookie = await jar.getCookieString(config.get('rommAddress') ?? '');
|
||||
break;
|
||||
case 'store':
|
||||
const game = await getStoreGameFromId(this.gameId);
|
||||
const gameId = extractStoreGameSourceId(this.gameId);
|
||||
coverUrl = game.pictures.titlescreens[0];
|
||||
screenshotUrls = game.pictures.screenshots;
|
||||
files.push({ url: new URL(game.file), file_path: `roms/${game.system}`, file_name: path.basename(decodeURI(game.file)) });
|
||||
slug = this.gameId;
|
||||
source_id = this.gameId;
|
||||
name = game.title;
|
||||
summary = game.description;
|
||||
system_slug = gameId.system;
|
||||
extract_path = path.join('roms', gameId.system);
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unsupported source");
|
||||
}
|
||||
|
||||
if (this.config?.dryRun !== true)
|
||||
{
|
||||
const downloadPath = config.get('downloadPath');
|
||||
|
||||
let downloadUrl: URL;
|
||||
let cookie: string = '';
|
||||
let screenshotUrls: string[];
|
||||
let coverUrl: string;
|
||||
let rommPlatform: PlatformSchema | undefined;
|
||||
let slug: string | null;
|
||||
let path_fs: string | undefined;
|
||||
let summary: string | null;
|
||||
let name: string | null;
|
||||
let last_played: Date | null;
|
||||
let igdb_id: number | null;
|
||||
let ra_id: number | null;
|
||||
let source_id: string;
|
||||
let system_slug: string;
|
||||
let extract_path: string;
|
||||
|
||||
switch (this.source)
|
||||
{
|
||||
case 'romm':
|
||||
|
||||
const rom = (await getRomApiRomsIdGet({ path: { id: Number(this.gameId) }, throwOnError: true })).data;
|
||||
rommPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
|
||||
|
||||
const rommAddress = config.get('rommAddress');
|
||||
coverUrl = `${rommAddress}${rom.path_cover_large}`;
|
||||
screenshotUrls = rom.merged_screenshots.map(s => `${config.get('rommAddress')}${s}`);
|
||||
last_played = rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null;
|
||||
igdb_id = rom.igdb_id;
|
||||
ra_id = rom.ra_id;
|
||||
summary = rom.summary;
|
||||
name = rom.name;
|
||||
path_fs = path.join(rom.fs_path, rom.fs_name);
|
||||
source_id = String(rom.id);
|
||||
slug = rom.slug;
|
||||
system_slug = rommPlatform.slug;
|
||||
extract_path = '';
|
||||
|
||||
downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
|
||||
downloadUrl.searchParams.set('rom_ids', String(this.gameId));
|
||||
cookie = await jar.getCookieString(config.get('rommAddress') ?? '');
|
||||
break;
|
||||
case 'store':
|
||||
const game = await getStoreGameFromId(this.gameId);
|
||||
const gameId = extractStoreGameSourceId(this.gameId);
|
||||
coverUrl = game.pictures.titlescreens[0];
|
||||
screenshotUrls = game.pictures.screenshots;
|
||||
downloadUrl = new URL(game.file);
|
||||
slug = this.gameId;
|
||||
source_id = this.gameId;
|
||||
name = game.title;
|
||||
summary = game.description;
|
||||
system_slug = gameId.system;
|
||||
extract_path = 'roms', gameId.system;
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unsupported source");
|
||||
}
|
||||
|
||||
if (this.config?.dryDownload !== true)
|
||||
{
|
||||
/*
|
||||
// download files for rom
|
||||
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
|
||||
downloadUrl.searchParams.set('rom_ids', String(this.id));
|
||||
const downloader = new DownloaderHelper(downloadUrl.href, downloadPath, {
|
||||
headers: {
|
||||
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
|
||||
},
|
||||
fileName: `${this.id}.zip`,
|
||||
// Romm doesn't support resume download
|
||||
override: true
|
||||
});
|
||||
|
||||
cx.abortSignal.addEventListener('abort', downloader.stop);
|
||||
|
||||
downloader.on('progress.throttled', e =>
|
||||
{
|
||||
cx.setProgress(e.progress, 'download');
|
||||
});
|
||||
|
||||
downloader.on('error', (e) =>
|
||||
{
|
||||
cx.abort(e);
|
||||
});
|
||||
const finishPromise = new Promise<string>(resolve =>
|
||||
{
|
||||
downloader.on("end", ({ filePath }) => resolve(filePath));
|
||||
});
|
||||
|
||||
await downloader.start().catch(err => console.error(err));
|
||||
const zipFilePath = await finishPromise;
|
||||
|
||||
cx.setProgress(0, 'extract');
|
||||
|
||||
const zip = new StreamZip.async({ file: zipFilePath });
|
||||
const totalCount = await zip.entriesCount;
|
||||
let extractCount = 0;
|
||||
zip.on('extract', async (entry, file) =>
|
||||
{
|
||||
console.log(`Extracted ${entry.name} to ${file}`);
|
||||
cx.setProgress(extractCount / totalCount * 100, 'extract');
|
||||
extractCount++;
|
||||
});
|
||||
await zip.extract(null, downloadPath);
|
||||
await zip.close();
|
||||
|
||||
await fs.rm(zipFilePath);*/
|
||||
|
||||
cx.setProgress(0, 'download');
|
||||
|
||||
const res = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
cookie: cookie
|
||||
},
|
||||
});
|
||||
|
||||
const totalBytes = Number(res.headers.get("content-length")) || 0;
|
||||
let bytesReceived = 0;
|
||||
|
||||
const progressStream = new Transform({
|
||||
transform (chunk, _, callback)
|
||||
const downloader = new Downloader(`game-${this.source}-${this.gameId}`,
|
||||
files,
|
||||
config.get('downloadPath'),
|
||||
{
|
||||
bytesReceived += chunk.length;
|
||||
if (totalBytes > 0)
|
||||
signal: cx.abortSignal,
|
||||
onProgress (stats)
|
||||
{
|
||||
const percent = (bytesReceived / totalBytes) * 100;
|
||||
cx.setProgress(percent, 'download');
|
||||
}
|
||||
this.push(chunk);
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
const extract = unzip.Extract({ path: path.join(downloadPath, extract_path), });
|
||||
(extract as any).unzipStream.on('entry', (entry: any) =>
|
||||
{
|
||||
if (!path_fs)
|
||||
path_fs = path.join(extract_path, entry.path);
|
||||
cx.setProgress(stats.progress, 'download');
|
||||
},
|
||||
});
|
||||
Readable.fromWeb(res.body as any).pipe(progressStream)
|
||||
.pipe(extract)
|
||||
.on('close', resolve)
|
||||
.on('error', reject);
|
||||
});
|
||||
|
||||
const downloadedFiles = await downloader.start();
|
||||
if (extract_path && downloadedFiles)
|
||||
{
|
||||
for (const path of downloadedFiles)
|
||||
{
|
||||
await _7z.unpack(path, extract_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config?.dryDownload === true)
|
||||
|
|
@ -198,8 +165,6 @@ export class InstallJob implements IJob
|
|||
await mkdir(path.join(downloadPath, extract_path), { recursive: true });
|
||||
}
|
||||
|
||||
|
||||
|
||||
const coverResponse = await fetch(coverUrl);
|
||||
const cover = Buffer.from(await coverResponse.arrayBuffer());
|
||||
|
||||
|
|
@ -291,7 +256,8 @@ export class InstallJob implements IJob
|
|||
summary: summary,
|
||||
name,
|
||||
cover,
|
||||
cover_type: coverResponse.headers.get('content-type')
|
||||
cover_type: coverResponse.headers.get('content-type'),
|
||||
metadata
|
||||
};
|
||||
|
||||
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
|
||||
|
|
@ -327,7 +293,17 @@ export class InstallJob implements IJob
|
|||
}
|
||||
|
||||
});
|
||||
} else
|
||||
{
|
||||
for (let i = 0; i < 10; i++)
|
||||
{
|
||||
cx.setProgress(i * 10, "download");
|
||||
if (cx.abortSignal.aborted) return;
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
events.emit('notification', { message: `${name}: Installed`, type: 'success', duration: 8000 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,21 @@
|
|||
import Elysia from "elysia";
|
||||
import z, { } from "zod";
|
||||
import z, { _ZodType, ZodAny, ZodObject, ZodTypeAny } from "zod";
|
||||
import { taskQueue } from "../app";
|
||||
import { LoginJob } from "./login-job";
|
||||
import TwitchLoginJob from "./twitch-login-job";
|
||||
import UpdateStoreJob from "./update-store";
|
||||
import { EmulatorDownloadJob } from "./emulator-download-job";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { IJob } from "../task-queue";
|
||||
|
||||
function registerJob<const Path extends string, TS, T extends { id: Path, dataSchema?: TS; }> (_job: T, path: Path, dataSchema: TS)
|
||||
function registerJob<
|
||||
const Path extends string,
|
||||
const Schema extends ZodTypeAny,
|
||||
const States extends string,
|
||||
T extends IJob<z.infer<Schema>, States>
|
||||
> (_job: { id: Path; dataSchema: Schema; } & (new (...args: any[]) => T))
|
||||
{
|
||||
return new Elysia().ws(path, {
|
||||
return new Elysia().ws(_job.id, {
|
||||
body: z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('cancel') })
|
||||
]),
|
||||
|
|
@ -16,14 +24,14 @@ function registerJob<const Path extends string, TS, T extends { id: Path, dataSc
|
|||
type: z.literal(['data', 'started', 'progress']),
|
||||
status: z.string(),
|
||||
progress: z.number(),
|
||||
data: dataSchema
|
||||
data: _job.dataSchema
|
||||
}),
|
||||
z.object({ type: z.literal(['completed', 'ended']) }),
|
||||
z.object({ type: z.literal('error'), error: z.unknown() })
|
||||
z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }),
|
||||
z.object({ type: z.literal('error'), error: z.string() })
|
||||
]),
|
||||
open (ws)
|
||||
{
|
||||
const job = taskQueue.findJob(path);
|
||||
const job = taskQueue.findJob(_job.id, _job);
|
||||
if (job)
|
||||
{
|
||||
ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
|
|
@ -32,30 +40,37 @@ function registerJob<const Path extends string, TS, T extends { id: Path, dataSc
|
|||
(ws.data as any).cleanup = [
|
||||
taskQueue.on('started', ({ id, job }) =>
|
||||
{
|
||||
if (id === path)
|
||||
if (id === _job.id)
|
||||
{
|
||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('progress', ({ id, job }) =>
|
||||
{
|
||||
if (id === path)
|
||||
if (id === _job.id)
|
||||
{
|
||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('completed', ({ id }) =>
|
||||
taskQueue.on('completed', ({ id, job }) =>
|
||||
{
|
||||
if (id === path)
|
||||
if (id === _job.id)
|
||||
{
|
||||
ws.send({ type: 'completed' });
|
||||
ws.send({ type: 'completed', data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('ended', ({ id, job }) =>
|
||||
{
|
||||
if (id === _job.id)
|
||||
{
|
||||
ws.send({ type: 'ended', data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('error', ({ id, error }) =>
|
||||
{
|
||||
if (id === path)
|
||||
if (id === _job.id)
|
||||
{
|
||||
ws.send({ type: 'error', error: error });
|
||||
ws.send({ type: 'error', error: getErrorMessage(error) });
|
||||
}
|
||||
})
|
||||
];
|
||||
|
|
@ -68,13 +83,14 @@ function registerJob<const Path extends string, TS, T extends { id: Path, dataSc
|
|||
{
|
||||
if (message.type === 'cancel')
|
||||
{
|
||||
taskQueue.findJob(path)?.abort('cancel');
|
||||
taskQueue.findJob(_job.id, _job)?.abort('cancel');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const jobs = new Elysia({ prefix: '/api/jobs' })
|
||||
.use(registerJob(LoginJob, LoginJob.id, LoginJob.dataSchema))
|
||||
.use(registerJob(TwitchLoginJob, TwitchLoginJob.id, TwitchLoginJob.dataSchema))
|
||||
.use(registerJob(UpdateStoreJob, UpdateStoreJob.id, undefined));
|
||||
.use(registerJob(LoginJob))
|
||||
.use(registerJob(TwitchLoginJob))
|
||||
.use(registerJob(UpdateStoreJob))
|
||||
.use(registerJob(EmulatorDownloadJob));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { IJob, JobBase, JobContext, JobContextFromClass } from "../task-queue";
|
||||
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
||||
import { host, localIp } from "@/bun/utils/host";
|
||||
import cors from "@elysiajs/cors";
|
||||
|
|
@ -8,7 +8,7 @@ import { config } from "../app";
|
|||
import z from "zod";
|
||||
import { delay } from "@/shared/utils";
|
||||
|
||||
export class LoginJob implements IJob
|
||||
export class LoginJob implements IJob<z.infer<typeof LoginJob.dataSchema>, "base">
|
||||
{
|
||||
endsAt: Date;
|
||||
startedAt: Date;
|
||||
|
|
@ -25,7 +25,7 @@ export class LoginJob implements IJob
|
|||
|
||||
exposeData = (): z.infer<typeof LoginJob.dataSchema> => ({ endsAt: this.endsAt, startedAt: this.startedAt, url: this.url });
|
||||
|
||||
async start (context: JobContext): Promise<any>
|
||||
async start (context: JobContext<LoginJob, z.infer<typeof LoginJob.dataSchema>, "base">): Promise<void>
|
||||
{
|
||||
const loginServer = new Elysia({ serve: { hostname: localIp, port: LOGIN_PORT } })
|
||||
.use(cors())
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ interface TwitchDevice
|
|||
verification_uri: string;
|
||||
}
|
||||
|
||||
export default class TwitchLoginJob implements IJob
|
||||
type States = "Retrieving Device" | "Waiting For Authentication";
|
||||
|
||||
export default class TwitchLoginJob implements IJob<z.infer<typeof TwitchLoginJob.dataSchema>, States>
|
||||
{
|
||||
twitchScopes = "analytics:read:extensions analytics:read:games user:read:email";
|
||||
device?: TwitchDevice;
|
||||
|
|
@ -38,7 +40,7 @@ export default class TwitchLoginJob implements IJob
|
|||
user_code: this.device.user_code
|
||||
}) : undefined;
|
||||
|
||||
async start (context: JobContext): Promise<any>
|
||||
async start (context: JobContext<TwitchLoginJob, z.infer<typeof TwitchLoginJob.dataSchema>, States>): Promise<any>
|
||||
{
|
||||
context.setProgress(0, "Retrieving Device");
|
||||
let res = await fetch("https://id.twitch.tv/oauth2/device", {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { ensureDir } from "fs-extra";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { getStoreFolder } from "../store/store";
|
||||
import { getStoreFolder } from "../store/services/gamesService";
|
||||
import z from "zod";
|
||||
|
||||
export default class UpdateStoreJob implements IJob
|
||||
export default class UpdateStoreJob implements IJob<never, never>
|
||||
{
|
||||
static id = "update-store" as const;
|
||||
static origin = "https://github.com/simeonradivoev/gameflow-store.git";
|
||||
static branch = "master";
|
||||
static dataSchema = z.never();
|
||||
|
||||
async gitCommand (commands: string[], dir: string)
|
||||
{
|
||||
|
|
@ -40,8 +42,10 @@ export default class UpdateStoreJob implements IJob
|
|||
return (await this.gitCommand(["status", "--porcelain"], dir)).length > 0;
|
||||
}
|
||||
|
||||
async start (context: JobContext)
|
||||
async start (context: JobContext<UpdateStoreJob, never, never>)
|
||||
{
|
||||
if (process.env.CUSTOM_STORE_PATH) return;
|
||||
|
||||
const storeFolder = getStoreFolder();
|
||||
await ensureDir(storeFolder);
|
||||
context.setProgress(10);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|||
|
||||
export const emulators = sqliteTable('emulators', {
|
||||
name: text().primaryKey().unique(),
|
||||
fullname: text(),
|
||||
systempath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||
staticpath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||
corepath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
|
||||
import * as appSchema from '@schema/app';
|
||||
import { 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';
|
||||
import { FrontEndEmulator } from '@/shared/constants';
|
||||
import { FrontEndEmulator, SERVER_URL } from '@/shared/constants';
|
||||
import { findExecsByName } from '../games/services/launchGameService';
|
||||
import { host } from '@/bun/utils/host';
|
||||
|
||||
/**
|
||||
* Get emulators based on local games. Only the ones we probably need.
|
||||
|
|
@ -53,14 +54,8 @@ export async function getRelevantEmulators ()
|
|||
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);
|
||||
}
|
||||
const execPaths = await findExecsByName(emulator);
|
||||
const validExecPath = execPaths.find(e => e.exists);
|
||||
|
||||
let platform: number | null | undefined = null;
|
||||
const validSystemSlug = system_slug.find(s => s.system);
|
||||
|
|
@ -68,45 +63,31 @@ export async function getRelevantEmulators ()
|
|||
{
|
||||
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)
|
||||
if (validExecPath)
|
||||
{
|
||||
systems.forEach(s => platformViability.set(s, true));
|
||||
}
|
||||
|
||||
const em: FrontEndEmulator & { isCritical: boolean; path?: { path: string, type: string; }; } = {
|
||||
const em: FrontEndEmulator & { isCritical: boolean; } = {
|
||||
name: emulator,
|
||||
exists: exists,
|
||||
logo: platform ? `/api/romm/platform/local/${platform}/cover` : '',
|
||||
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ icon: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })),
|
||||
gameCount: 0,
|
||||
description: '',
|
||||
homepage: '',
|
||||
type: 'emulator',
|
||||
os: [process.platform as any],
|
||||
isCritical: false,
|
||||
path: execPath,
|
||||
validSource: validExecPath
|
||||
};
|
||||
|
||||
return em;
|
||||
}));
|
||||
|
||||
finalEmulators.push({
|
||||
name: 'emulatorjs',
|
||||
exists: true,
|
||||
path: { path: 'localhost', type: 'js' },
|
||||
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,
|
||||
type: 'emulator',
|
||||
description: '',
|
||||
homepage: '',
|
||||
os: [process.platform as any],
|
||||
isCritical: false
|
||||
isCritical: false,
|
||||
});
|
||||
|
||||
return finalEmulators.map(e =>
|
||||
|
|
|
|||
31
src/bun/api/store/services/emulatorsService.ts
Normal file
31
src/bun/api/store/services/emulatorsService.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { EmulatorPackageType, EmulatorSourceType, FrontEndEmulator } from "@/shared/constants";
|
||||
import { emulatorsDb } from "../../app";
|
||||
import * as emulatorSchema from '@schema/emulators';
|
||||
import { findExecs } from "../../games/services/launchGameService";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}[])
|
||||
{
|
||||
let execPath: EmulatorSourceType | undefined;
|
||||
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) });
|
||||
|
||||
if (esEmulator)
|
||||
{
|
||||
const allExecs = await findExecs(emulator.name, esEmulator);
|
||||
if (allExecs.length > 0) execPath = allExecs[0];
|
||||
}
|
||||
|
||||
const em: FrontEndEmulator = {
|
||||
name: emulator.name,
|
||||
logo: emulator.logo,
|
||||
systems,
|
||||
gameCount,
|
||||
validSource: execPath
|
||||
};
|
||||
|
||||
return em;
|
||||
}
|
||||
|
|
@ -1,5 +1,22 @@
|
|||
import { GithubManifestSchema, StoreGameSchema } from "@/shared/constants";
|
||||
import { EmulatorPackageSchema, EmulatorPackageType, GithubManifestSchema, StoreGameSchema } from "@/shared/constants";
|
||||
import { CACHE_KEYS, getOrCached } from "../../cache";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { config, emulatorsDb } from '../../app';
|
||||
import path from "node:path";
|
||||
import fs from 'node:fs/promises';
|
||||
import * as emulatorSchema from '@schema/emulators';
|
||||
import { shuffleInPlace } from "@/bun/utils";
|
||||
|
||||
export async function getShuffledStoreGames ()
|
||||
{
|
||||
return getOrCached('shuffled-store-games', async () =>
|
||||
{
|
||||
const gamesManifest = await getStoreGameManifest();
|
||||
const allStoreGames = gamesManifest.filter(g => g.type === 'blob');
|
||||
shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60));
|
||||
return allStoreGames;
|
||||
}, { expireMs: 1000 / 60 / 60 });
|
||||
}
|
||||
|
||||
export async function getStoreGameManifest ()
|
||||
{
|
||||
|
|
@ -56,4 +73,55 @@ export async function getStoreGameFromPath (path: string)
|
|||
.then(e => e.json())
|
||||
.then(g => StoreGameSchema.parseAsync(g)));
|
||||
return game;
|
||||
}
|
||||
|
||||
export function getStoreFolder ()
|
||||
{
|
||||
if (process.env.CUSTOM_STORE_PATH) return process.env.CUSTOM_STORE_PATH;
|
||||
const downlodDir = config.get('downloadPath');
|
||||
return path.join(downlodDir, "store");
|
||||
}
|
||||
|
||||
export async function getStoreEmulatorPackage (id: string)
|
||||
{
|
||||
const emulatorPath = path.join(getStoreFolder(), "buckets", "emulators", `${id}.json`);
|
||||
if (await fs.exists(emulatorPath))
|
||||
return EmulatorPackageSchema.parseAsync(JSON.parse(await fs.readFile(emulatorPath, 'utf-8')));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getAllStoreEmulatorPackages ()
|
||||
{
|
||||
const emulatorsBucket = path.join(getStoreFolder(), "buckets", "emulators");
|
||||
const emulators = await fs.readdir(emulatorsBucket);
|
||||
const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8')));
|
||||
|
||||
const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e =>
|
||||
{
|
||||
if (e.error)
|
||||
{
|
||||
console.error(e.error);
|
||||
}
|
||||
return e.data;
|
||||
}).map(e => e.data!);
|
||||
|
||||
return emulatesParsed;
|
||||
}
|
||||
|
||||
export async function buildStoreFrontendEmulatorSystems (emulator: EmulatorPackageType)
|
||||
{
|
||||
const systems = await Promise.all(emulator.systems.map(async system =>
|
||||
{
|
||||
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
|
||||
where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system))
|
||||
});
|
||||
|
||||
const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } });
|
||||
|
||||
let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`;
|
||||
|
||||
return { id: system, romm_slug: rommSystem?.sourceSlug, name: esSystem?.fullname ?? system, icon: icon };
|
||||
}));
|
||||
|
||||
return systems;
|
||||
}
|
||||
|
|
@ -1,61 +1,19 @@
|
|||
|
||||
import Elysia from "elysia";
|
||||
import { config, customEmulators, db } from "../app";
|
||||
import Elysia, { status } from "elysia";
|
||||
import { config, db, taskQueue } from "../app";
|
||||
import path from "node:path";
|
||||
import fs from 'node:fs/promises';
|
||||
import { EmulatorPackageSchema, EmulatorPackageType, FrontEndEmulator, FrontEndEmulatorDetailed, StoreGameSchema } from "@/shared/constants";
|
||||
import { findExec } from "../games/services/launchGameService";
|
||||
import { emulatorsDb } from '../app';
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as emulatorSchema from '@schema/emulators';
|
||||
import { FrontEndEmulatorDetailed, FrontEndEmulatorDetailedDownload, StoreGameSchema } from "@/shared/constants";
|
||||
import { findExecsByName } from "../games/services/launchGameService";
|
||||
import * as appSchema from '@schema/app';
|
||||
import z from "zod";
|
||||
import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
|
||||
import { getPlatformsApiPlatformsGet } from "@/clients/romm";
|
||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||
|
||||
export function getStoreFolder ()
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
return path.join(downlodDir, "store");
|
||||
}
|
||||
|
||||
async function getAllStoreEmulatorPackages ()
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
const emulatorsBucket = path.join(downlodDir, "store", "buckets", "emulators");
|
||||
const emulators = await fs.readdir(emulatorsBucket);
|
||||
const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8')));
|
||||
|
||||
const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e =>
|
||||
{
|
||||
if (e.error)
|
||||
{
|
||||
console.error(e.error);
|
||||
}
|
||||
return e.data;
|
||||
}).map(e => e.data!);
|
||||
|
||||
return emulatesParsed;
|
||||
}
|
||||
|
||||
async function buildSystems (emulator: EmulatorPackageType)
|
||||
{
|
||||
const systems = await Promise.all(emulator.systems.map(async system =>
|
||||
{
|
||||
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
|
||||
where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system))
|
||||
});
|
||||
|
||||
const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } });
|
||||
|
||||
let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`;
|
||||
|
||||
return { id: system, name: esSystem?.fullname ?? system, icon: icon };
|
||||
}));
|
||||
|
||||
return systems;
|
||||
}
|
||||
import { CACHE_KEYS, getOrCached, getOrCachedGithubRelease } from "../cache";
|
||||
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage } from "./services/gamesService";
|
||||
import { EmulatorDownloadJob } from "../jobs/emulator-download-job";
|
||||
import { Glob } from "bun";
|
||||
import { convertStoreEmulatorToFrontend } from "./services/emulatorsService";
|
||||
|
||||
export const store = new Elysia({ prefix: '/api/store' })
|
||||
.get('/emulators', async ({ query }) =>
|
||||
|
|
@ -70,27 +28,10 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
.filter(e => e.os.includes(process.platform as any))
|
||||
.map(async (emulator) =>
|
||||
{
|
||||
let execPath: { path: string; type: string; } | undefined;
|
||||
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) });
|
||||
|
||||
if (esEmulator)
|
||||
{
|
||||
if (customEmulators.has(emulator?.name))
|
||||
{
|
||||
execPath = { path: customEmulators.get(emulator.name), type: 'custom' };
|
||||
} else
|
||||
{
|
||||
execPath = await findExec(esEmulator);
|
||||
}
|
||||
}
|
||||
|
||||
const exists = !!execPath && await fs.exists(execPath.path);
|
||||
const systems = await buildSystems(emulator);
|
||||
|
||||
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||
const gameCounts = await Promise.all(systems.map(async (s) =>
|
||||
{
|
||||
const rommMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, s.id)) });
|
||||
const romPlatform = rommPlatforms?.find(p => p.slug === (rommMapping?.sourceSlug ?? s.id));
|
||||
const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id));
|
||||
if (romPlatform)
|
||||
{
|
||||
return romPlatform.rom_count;
|
||||
|
|
@ -101,13 +42,12 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
}));
|
||||
|
||||
const gameCount = gameCounts.reduce((a, c) => a + c);
|
||||
|
||||
return { ...emulator, exists, systems, gameCount } satisfies FrontEndEmulator;
|
||||
return convertStoreEmulatorToFrontend(emulator, gameCount, systems);
|
||||
}));
|
||||
|
||||
if (query.missing)
|
||||
{
|
||||
frontEndEmulators = frontEndEmulators.filter(e => !e.exists);
|
||||
frontEndEmulators = frontEndEmulators.filter(e => !e.validSource);
|
||||
}
|
||||
|
||||
if (query.orderBy === 'importance')
|
||||
|
|
@ -161,42 +101,65 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
return Bun.file(path.join(downlodDir, "store", "media", "screenshots", id, name));
|
||||
},
|
||||
{ params: z.object({ id: z.string(), name: z.string() }) })
|
||||
.get('/details/emulator/:id', async ({ params: { id } }) =>
|
||||
.get('/emulator/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
const emulatorPath = path.join(downlodDir, "store", "buckets", "emulators", `${id}.json`);
|
||||
const emulatorPackage = await getStoreEmulatorPackage(id);
|
||||
if (!emulatorPackage) return status("Not Found");
|
||||
|
||||
const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage);
|
||||
|
||||
const execPaths = await findExecsByName(emulatorPackage.name);
|
||||
|
||||
const emulatorScreenshotsPath = path.join(downlodDir, "store", "media", "screenshots", id);
|
||||
const emulatorPackage = await EmulatorPackageSchema.parseAsync(JSON.parse(await fs.readFile(emulatorPath, 'utf-8')));
|
||||
|
||||
const systems = await buildSystems(emulatorPackage);
|
||||
let execPath: { path: string; type: string; } | undefined;
|
||||
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorPackage.name) });
|
||||
|
||||
if (esEmulator)
|
||||
{
|
||||
if (customEmulators.has(emulatorPackage?.name))
|
||||
{
|
||||
execPath = { path: customEmulators.get(emulatorPackage.name), type: 'custom' };
|
||||
} else
|
||||
{
|
||||
execPath = await findExec(esEmulator);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : [];
|
||||
const exists = !!execPath && await fs.exists(execPath.path);
|
||||
const validExec = execPaths.find(p => p.exists);
|
||||
const emulator: FrontEndEmulatorDetailed = {
|
||||
...emulatorPackage,
|
||||
name: emulatorPackage.name,
|
||||
description: emulatorPackage.description,
|
||||
systems,
|
||||
exists,
|
||||
status: {
|
||||
source: execPath?.type,
|
||||
location: execPath?.path
|
||||
},
|
||||
validSource: validExec,
|
||||
screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`),
|
||||
gameCount: 0
|
||||
gameCount: 0,
|
||||
homepage: emulatorPackage.homepage,
|
||||
downloads: await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d =>
|
||||
{
|
||||
if (d.type === 'github' && d.path)
|
||||
{
|
||||
const release = await getOrCachedGithubRelease(d.path);
|
||||
const glob = new Glob(d.pattern);
|
||||
const download: FrontEndEmulatorDetailedDownload = {
|
||||
name: d.type,
|
||||
type: release.assets.find(a => glob.match(a.name))?.content_type
|
||||
};
|
||||
return download;
|
||||
};
|
||||
|
||||
return { name: d.type, type: "Unknown" };
|
||||
}) ?? []),
|
||||
logo: emulatorPackage.logo,
|
||||
sources: execPaths
|
||||
};
|
||||
|
||||
return emulator;
|
||||
}, { params: z.object({ id: z.string() }) });
|
||||
}, { params: z.object({ id: z.string() }) })
|
||||
.post('/install/emulator/:id/:source', async ({ params: { source, id } }) =>
|
||||
{
|
||||
if (taskQueue.hasActiveOfType(EmulatorDownloadJob))
|
||||
{
|
||||
return status("Conflict", "Installation already running");
|
||||
}
|
||||
const job = new EmulatorDownloadJob(id, source);
|
||||
return taskQueue.enqueue(EmulatorDownloadJob.id, job);
|
||||
})
|
||||
.delete('/emulator/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
|
||||
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
||||
if (await fs.exists(storeEmulatorFolder))
|
||||
{
|
||||
fs.rm(storeEmulatorFolder, { recursive: true });
|
||||
return status("OK");
|
||||
}
|
||||
return status("Not Found");
|
||||
});
|
||||
|
|
@ -11,7 +11,7 @@ import { DirSchema, DownloadsDrive } from "@/shared/constants";
|
|||
import { getDevices, getDevicesCurated } from "./drives";
|
||||
import getFolderSize from "get-folder-size";
|
||||
import si from 'systeminformation';
|
||||
import { getStoreFolder } from "./store/store";
|
||||
import { getStoreFolder } from "./store/services/gamesService";
|
||||
|
||||
export const system = new Elysia({ prefix: '/api/system' })
|
||||
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
|
||||
|
|
|
|||
|
|
@ -1,40 +1,44 @@
|
|||
|
||||
import { JobStatus } from '@/shared/constants';
|
||||
import EventEmitter from 'node:events';
|
||||
import z, { ZodTypeAny } from 'zod';
|
||||
|
||||
export class TaskQueue
|
||||
{
|
||||
private activeQueue: { context: JobContext, promise?: Promise<void>; }[] = [];
|
||||
private queue?: { context: JobContext, promise?: Promise<void>; }[] = [];
|
||||
private activeQueue: { context: JobContext<any, string, any>, promise?: Promise<void>; }[] = [];
|
||||
private queue?: { context: JobContext<any, string, any>, promise?: Promise<void>; }[] = [];
|
||||
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
|
||||
|
||||
public enqueue (id: string, job: IJob): Promise<void>
|
||||
public enqueue<TData, TState extends string, T extends IJob<TData, TState>> (id: string, job: T)
|
||||
{
|
||||
this.disposeSafeguard();
|
||||
if (!this.queue || !this.events) throw new Error("Queue disposed");
|
||||
const context = new JobContext(id, this.events, job);
|
||||
this.queue.push({ context });
|
||||
this.events?.emit('queued', { id: context.id, job: context });
|
||||
return this.processQueue();
|
||||
}
|
||||
|
||||
private processQueue (): Promise<void>
|
||||
private processQueue ()
|
||||
{
|
||||
if (!this.queue) return Promise.resolve();
|
||||
const top = this.queue.pop();
|
||||
if (top)
|
||||
|
||||
const next = this.queue.filter(j => !j.context.job.group || !this.activeQueue.some(a => a.context.job.group === j.context.job.group)).map((job, i) => ({ i, job }));
|
||||
|
||||
next.reverse().forEach(({ i }) => this.queue!.splice(i, 1));
|
||||
|
||||
next.forEach(job =>
|
||||
{
|
||||
const promise = top.context.start();
|
||||
top.promise = promise;
|
||||
const index = this.queue.length;
|
||||
this.activeQueue.push(top);
|
||||
const promise = job.job.context.start();
|
||||
job.job.promise = promise;
|
||||
this.activeQueue.push(job.job);
|
||||
promise.finally(() =>
|
||||
{
|
||||
const index = this.activeQueue.indexOf(job.job);
|
||||
this.activeQueue.splice(index, 1);
|
||||
setTimeout(this.processQueue);
|
||||
setTimeout(() => this.processQueue(), 0);
|
||||
});
|
||||
return promise;
|
||||
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
private disposeSafeguard ()
|
||||
|
|
@ -65,10 +69,15 @@ export class TaskQueue
|
|||
return job?.promise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public findJob (id: string): IPublicJob | undefined
|
||||
|
||||
public findJob<const TData, const TState extends string, const T extends IJob<TData, TState>> (id: string, type: new (...args: any[]) => T): IPublicJob<TData, TState, T> | undefined
|
||||
{
|
||||
const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id);
|
||||
return job?.context;
|
||||
if (job?.context.job instanceof type)
|
||||
{
|
||||
return job?.context;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void
|
||||
|
|
@ -99,12 +108,13 @@ export interface EventsList
|
|||
completed: [e: CompletedEvent];
|
||||
error: [e: ErrorEvent];
|
||||
ended: [e: BaseEvent];
|
||||
queued: [e: BaseEvent];
|
||||
}
|
||||
|
||||
interface BaseEvent
|
||||
{
|
||||
id: string;
|
||||
job: IPublicJob;
|
||||
job: IPublicJob<any, string, any>;
|
||||
}
|
||||
|
||||
interface ErrorEvent extends BaseEvent
|
||||
|
|
@ -128,37 +138,50 @@ interface CompletedEvent extends BaseEvent
|
|||
|
||||
}
|
||||
|
||||
export interface IJob
|
||||
export interface IJob<TData, TState extends string>
|
||||
{
|
||||
start (context: JobContext): Promise<any>;
|
||||
exposeData?(): any;
|
||||
group?: string;
|
||||
start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>;
|
||||
exposeData?(): TData;
|
||||
}
|
||||
|
||||
export type JobStatus = 'completed' | 'error' | 'running' | 'waiting' | 'aborted';
|
||||
|
||||
export interface IPublicJob
|
||||
export interface IPublicJob<TData, TState extends string, T extends IJob<TData, TState>>
|
||||
{
|
||||
progress: number;
|
||||
state?: string;
|
||||
status: JobStatus;
|
||||
job: IJob;
|
||||
job: T;
|
||||
abort: (reason?: any) => void;
|
||||
}
|
||||
|
||||
export class JobContext implements IPublicJob
|
||||
type JobClass = new (...args: any[]) => IJob<any, any>;
|
||||
type JobClassWithStatics = JobClass & {
|
||||
id: string;
|
||||
dataSchema?: any;
|
||||
};
|
||||
export type JobContextFromClass<C extends JobClassWithStatics> =
|
||||
JobContext<
|
||||
InstanceType<C>,
|
||||
C extends { dataSchema: ZodTypeAny; }
|
||||
? z.infer<C['dataSchema']>
|
||||
: never,
|
||||
C['id']
|
||||
>;
|
||||
|
||||
export class JobContext<T extends IJob<TData, TState>, TData, TState extends string> implements IPublicJob<TData, TState, T>
|
||||
{
|
||||
private m_id: string;
|
||||
private m_progress: number = 0;
|
||||
private m_state?: string;
|
||||
private m_state?: TState;
|
||||
private running: boolean = false;
|
||||
private aborted: boolean = false;
|
||||
private completed: boolean = false;
|
||||
private error?: any;
|
||||
private events: EventEmitter<EventsList>;
|
||||
private abortController: AbortController;
|
||||
private readonly m_job: IJob;
|
||||
private readonly m_job: T;
|
||||
|
||||
constructor(id: string, events: EventEmitter<EventsList>, job: IJob)
|
||||
constructor(id: string, events: EventEmitter<EventsList>, job: T)
|
||||
{
|
||||
this.m_id = id;
|
||||
this.m_job = job;
|
||||
|
|
@ -202,7 +225,7 @@ export class JobContext implements IPublicJob
|
|||
if (this.error) return 'error';
|
||||
if (this.aborted) return 'aborted';
|
||||
if (this.running) return 'running';
|
||||
return 'waiting';
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
public get id () { return this.m_id; }
|
||||
|
|
@ -215,7 +238,11 @@ export class JobContext implements IPublicJob
|
|||
|
||||
public get state () { return this.m_state; }
|
||||
|
||||
public setProgress (progress: number, state?: string)
|
||||
/**
|
||||
* @param progress The 0 to 100 progress
|
||||
* @param state what type of progress is this. Is it really progress. I humanity even advancing.
|
||||
*/
|
||||
public setProgress (progress: number, state?: TState)
|
||||
{
|
||||
this.m_progress = progress;
|
||||
if (state)
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import { createInterface } from 'readline';
|
|||
const api = RunAPIServer();
|
||||
let bunServer: { stop: () => void; } | undefined;
|
||||
|
||||
if (!Bun.env.PUBLIC_ACCESS)
|
||||
if (!process.env.PUBLIC_ACCESS)
|
||||
{
|
||||
bunServer = RunBunServer();
|
||||
bunServer = await RunBunServer();
|
||||
}
|
||||
|
||||
async function cleanup ()
|
||||
|
|
@ -24,7 +24,7 @@ async function cleanup ()
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
if (Bun.env.HEADLESS)
|
||||
if (process.env.HEADLESS)
|
||||
{
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import staticPlugin from "@elysiajs/static";
|
|||
export function RunBunServer ()
|
||||
{
|
||||
console.log("Launching Server on port ", SERVER_PORT);
|
||||
return new Elysia()
|
||||
const server = new Elysia()
|
||||
.use(cors())
|
||||
.headers({
|
||||
'cross-origin-embedder-policy': 'credentialless',
|
||||
|
|
@ -28,33 +28,11 @@ export function RunBunServer ()
|
|||
assets: appPath("./dist"),
|
||||
prefix: "/",
|
||||
alwaysStatic: true
|
||||
})).listen({ port: SERVER_PORT, hostname: host, development: true }, console.log);
|
||||
/*return Bun.serve({
|
||||
port: SERVER_PORT,
|
||||
hostname: host,
|
||||
routes: {
|
||||
"/": Bun.file(appPath("./dist/index.html")),
|
||||
// Serve a file by lazily loading it into memory
|
||||
"/favicon.ico": Bun.file(appPath("./dist/favicon.ico")),
|
||||
"/emulatorjs/": Bun.file(appPath("./dist/emulatorjs/index.html")),
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": new Response(
|
||||
JSON.stringify({
|
||||
name: appInfo.name,
|
||||
version: appInfo.version,
|
||||
debuggable: true,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
fetch: async (req) =>
|
||||
{
|
||||
const url = new URL(req.url);
|
||||
return new Response(Bun.file(appPath(`./${path.join('dist', url.pathname)}`)));
|
||||
},
|
||||
});*/
|
||||
}));
|
||||
|
||||
return new Promise<typeof server>((resolve) =>
|
||||
{
|
||||
server.onStart(() => resolve(server))
|
||||
.listen({ port: SERVER_PORT, hostname: host, development: true }, console.log);
|
||||
});
|
||||
}
|
||||
2
src/bun/types/types.d.ts
vendored
2
src/bun/types/types.d.ts
vendored
|
|
@ -6,7 +6,7 @@ export type ActiveGame = {
|
|||
process?: ChildProcess;
|
||||
gameId: number;
|
||||
name: string;
|
||||
command: string;
|
||||
command: { command: string, startDir?: string; };
|
||||
};
|
||||
|
||||
interface ObjectConstructor
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { $ } from 'bun';
|
||||
import path from 'node:path';
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
|
||||
export function checkRunning (pid: number)
|
||||
{
|
||||
|
|
@ -68,4 +70,44 @@ export async function openExternal (target: string)
|
|||
{
|
||||
return $`open ${target}`.throws(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hashFile (path: string, algorithm: "sha1" | "md5"): Promise<string>
|
||||
{
|
||||
return new Promise((resolve, reject) =>
|
||||
{
|
||||
const hash = createHash(algorithm);
|
||||
const stream = createReadStream(path);
|
||||
|
||||
stream.on("data", (data) => hash.update(data));
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
export class SeededRandom
|
||||
{
|
||||
seed: number;
|
||||
|
||||
constructor(seed?: number)
|
||||
{
|
||||
this.seed = seed ?? new Date().getTime();
|
||||
}
|
||||
|
||||
next ()
|
||||
{
|
||||
var x = Math.sin(this.seed++) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
}
|
||||
|
||||
export function shuffleInPlace (array: any[], startSeed?: number)
|
||||
{
|
||||
const random = new SeededRandom(startSeed);
|
||||
|
||||
for (let i = array.length - 1; i > 0; i--)
|
||||
{
|
||||
const j = Math.floor(random.next() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
222
src/bun/utils/downloader.ts
Normal file
222
src/bun/utils/downloader.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { ensureDir, move } from "fs-extra";
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { config, jar } from "../api/app";
|
||||
import { file } from "bun";
|
||||
|
||||
export interface FileEntry
|
||||
{
|
||||
url: URL;
|
||||
file_path: string;
|
||||
file_name: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface ProgressStats
|
||||
{
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface TmpDownloadMetadata
|
||||
{
|
||||
files: FileEntry[];
|
||||
}
|
||||
|
||||
export class Downloader
|
||||
{
|
||||
files: FileEntry[];
|
||||
headers?: Record<string, string>;
|
||||
onProgress?: (stats: ProgressStats) => void;
|
||||
signal?: AbortSignal;
|
||||
activeFile?: FileEntry;
|
||||
downloadPath: string;
|
||||
id: string;
|
||||
tmpPath: string;
|
||||
tmpPathMeta: string;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
files: FileEntry[],
|
||||
downloadPath: string, init?: {
|
||||
headers?: Record<string, string>,
|
||||
onProgress?: (stats: ProgressStats) => void;
|
||||
signal?: AbortSignal;
|
||||
})
|
||||
{
|
||||
this.files = files;
|
||||
this.headers = init?.headers;
|
||||
this.onProgress = init?.onProgress;
|
||||
this.signal = init?.signal;
|
||||
this.downloadPath = downloadPath;
|
||||
this.id = id;
|
||||
this.tmpPath = path.join(config.get('downloadPath'), 'downloads', this.id);
|
||||
this.tmpPathMeta = path.join(config.get('downloadPath'), 'downloads', `${this.id}.json`);
|
||||
}
|
||||
|
||||
async updateTmpDownload ()
|
||||
{
|
||||
const meta: TmpDownloadMetadata = {
|
||||
files: this.files
|
||||
};
|
||||
|
||||
await ensureDir(path.join(config.get('downloadPath'), 'downloads'));
|
||||
await fs.writeFile(this.tmpPathMeta, JSON.stringify(meta));
|
||||
}
|
||||
|
||||
async start ()
|
||||
{
|
||||
const totalSize = this.files.reduce((accum, current) => accum += current.size ?? 0, 0);
|
||||
let bytesReceived = 0;
|
||||
|
||||
if (this.files.some(f => path.isAbsolute(f.file_path)))
|
||||
{
|
||||
throw new Error("Only Relative Paths Supported");
|
||||
}
|
||||
|
||||
await this.updateTmpDownload();
|
||||
|
||||
for (let i = 0; i < this.files.length; i++)
|
||||
{
|
||||
const file = this.files[i];
|
||||
this.activeFile = file;
|
||||
const cookie = await jar.getCookieString(file.url.href);
|
||||
|
||||
await ensureDir(path.join(this.tmpPath, file.file_path));
|
||||
|
||||
const filePath = path.join(this.tmpPath, file.file_path, file.file_name);
|
||||
let start = 0;
|
||||
|
||||
// 1. Check existing file
|
||||
if (await fs.exists(filePath))
|
||||
{
|
||||
start = ((await fs.stat(filePath)).size);
|
||||
}
|
||||
|
||||
// 2. Request remaining bytes
|
||||
let res = await fetch(file.url, {
|
||||
headers: {
|
||||
...this.headers,
|
||||
...(start > 0
|
||||
? { Range: `bytes=${start}-` }
|
||||
: undefined),
|
||||
cookie
|
||||
}
|
||||
});
|
||||
|
||||
const resSize = Number(res.headers.get("content-length") ?? 0);
|
||||
|
||||
if (start > 0)
|
||||
{
|
||||
if (res.status === 206)
|
||||
{
|
||||
console.log("Resume supported, continuing download");
|
||||
} else if (res.status === 200)
|
||||
{
|
||||
console.log("Server ignored Range, restarting download from beginning");
|
||||
start = 0;
|
||||
|
||||
// Must make a new request from the beginning
|
||||
res = await fetch(file.url, { headers: { ...this.headers, cookie } });
|
||||
|
||||
if (!res.ok)
|
||||
{
|
||||
throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} else if (res.status === 416)
|
||||
{
|
||||
const localSize = (await fs.stat(filePath)).size;
|
||||
if (resSize && localSize === resSize)
|
||||
{
|
||||
console.log("File already fully downloaded, skipping");
|
||||
break;
|
||||
} else
|
||||
{
|
||||
console.log("Partial file corrupt or changed, redownloading");
|
||||
start = 0;
|
||||
res = await fetch(file.url, { headers: { ...this.headers, cookie } }); // full download
|
||||
|
||||
if (!res.ok)
|
||||
{
|
||||
throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} else
|
||||
{
|
||||
if (!res.ok) throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
// 3. Append or overwrite
|
||||
const stream = createWriteStream(filePath, {
|
||||
flags: start > 0 ? "a" : "w",
|
||||
highWaterMark: 64 * 1024
|
||||
});
|
||||
|
||||
const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0;
|
||||
if (totalSize <= 0)
|
||||
bytesReceived = 0;
|
||||
else
|
||||
bytesReceived += start;
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
|
||||
let lastUpdate = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
bytesReceived += value.length;
|
||||
if (totalBytes > 0 && this.onProgress)
|
||||
{
|
||||
const percent = (bytesReceived / totalBytes) * 100;
|
||||
|
||||
if (Date.now() - lastUpdate > 100)
|
||||
{
|
||||
this.onProgress({ progress: percent });
|
||||
lastUpdate = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.signal?.aborted)
|
||||
{
|
||||
if (this.signal.reason === 'cancel')
|
||||
{
|
||||
console.log("Canceling Download and cleaning up files");
|
||||
await fs.rm(this.tmpPath, { recursive: true });
|
||||
await fs.rm(this.tmpPathMeta);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Aborting Download: ", this.signal.reason);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!stream.write(value))
|
||||
{
|
||||
await new Promise((resolve) => stream.once("drain", () => resolve(true)));
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
stream.end(() => resolve(undefined));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
await move(this.tmpPath, this.downloadPath, { overwrite: true });
|
||||
if (await fs.exists(this.tmpPath))
|
||||
await fs.rm(this.tmpPath, { recursive: true });
|
||||
await fs.rm(this.tmpPathMeta);
|
||||
|
||||
return this.files.map(f => path.join(this.downloadPath, f.file_path, f.file_name));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue