feat: implemented a basic store and emulatorjs
This commit is contained in:
parent
2f32cbc730
commit
7286541822
121 changed files with 5900 additions and 1092 deletions
|
|
@ -10,10 +10,10 @@ import Conf from "conf";
|
|||
import projectPackage from '~/package.json';
|
||||
import { Notification, SettingsSchema, SettingsType } from "@shared/constants";
|
||||
import { client } from "@clients/romm/client.gen";
|
||||
import * as schema from "./schema/app";
|
||||
import * as emulatorSchema from "./schema/emulators";
|
||||
import * as schema from "@schema/app";
|
||||
import cacheSchema from "@schema/cache";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import { login, logout } from "./auth";
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { ActiveGame } from "../types/types";
|
||||
import EventEmitter from "node:events";
|
||||
|
|
@ -21,6 +21,7 @@ import { ErrorLike } from "bun";
|
|||
import { appPath, getErrorMessage } from "../utils";
|
||||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||
import { ensureDir } from "fs-extra";
|
||||
import UpdateStoreJob from "./jobs/update-store";
|
||||
|
||||
export const config = new Conf<SettingsType>({
|
||||
projectName: projectPackage.name,
|
||||
|
|
@ -50,7 +51,10 @@ const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path),
|
|||
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
||||
export const jar = new CookieJar(fileCookieStore);
|
||||
let sqlite: Database;
|
||||
export const cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
|
||||
let cacheSqlite: Database;
|
||||
export let db: DrizzleSqliteDODatabase<typeof schema>;
|
||||
export let cache: DrizzleSqliteDODatabase<typeof cacheSchema>;
|
||||
await reloadDatabase();
|
||||
const emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true });
|
||||
export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
||||
|
|
@ -73,6 +77,7 @@ events.addListener('activegameexit', ({ error }) =>
|
|||
}
|
||||
});
|
||||
config.onDidChange('downloadPath', () => reloadDatabase());
|
||||
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
|
||||
|
||||
export async function cleanup ()
|
||||
{
|
||||
|
|
@ -86,13 +91,25 @@ export async function reloadDatabase ()
|
|||
{
|
||||
await ensureDir(config.get('downloadPath'));
|
||||
sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true });
|
||||
await ensureDir(path.join(os.tmpdir(), 'gameflow'));
|
||||
console.log("Loaded Cache from: ", cachePath);
|
||||
cacheSqlite = new Database(cachePath, { create: true, readwrite: true });
|
||||
db = drizzle(sqlite, { schema });
|
||||
cache = drizzle(cacheSqlite, { schema: cacheSchema });
|
||||
migrate(db!, { migrationsFolder: appPath("./drizzle") });
|
||||
cache.run(`
|
||||
CREATE TABLE IF NOT EXISTS item_cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
expire_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
interface AppEventMap
|
||||
{
|
||||
activegameexit: [{ source: string, id: number, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
||||
activegameexit: [{ source: string, id: string, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
||||
exitapp: [];
|
||||
notification: [Notification];
|
||||
}
|
||||
|
|
@ -1,45 +1,117 @@
|
|||
import Elysia, { sse, status } from "elysia";
|
||||
import { config, jar, taskQueue } from "./app";
|
||||
import { config, events, jar, taskQueue } from "./app";
|
||||
import z from "zod";
|
||||
import { client } from "@clients/romm/client.gen";
|
||||
import { loginApiLoginPost, logoutApiLogoutPost } from "@clients/romm";
|
||||
import secrets from '../api/secrets';
|
||||
import { LoginJob } from "./jobs/login-job";
|
||||
import TwitchLoginJob from "./jobs/twitch-login-job";
|
||||
|
||||
export default new Elysia()
|
||||
.post('/login/remote/start', async () =>
|
||||
.post('/login/twitch', async ({ body: { openInBrowser } }) =>
|
||||
{
|
||||
if (taskQueue.hasActiveOfType(TwitchLoginJob))
|
||||
{
|
||||
return status("Conflict", `Twitch Authentication already in progress`);
|
||||
}
|
||||
|
||||
if (!process.env.TWITCH_CLIENT_ID)
|
||||
{
|
||||
return status("Not Found", "Twitch Client ID not set");
|
||||
}
|
||||
|
||||
return taskQueue.enqueue(TwitchLoginJob.id, new TwitchLoginJob(process.env.TWITCH_CLIENT_ID, openInBrowser ?? false));
|
||||
},
|
||||
{ body: z.object({ openInBrowser: z.boolean().optional() }) })
|
||||
.post('/logout/twitch', async () =>
|
||||
{
|
||||
if (!process.env.TWITCH_CLIENT_ID)
|
||||
{
|
||||
return status("Not Found", "Twitch Client ID not set");
|
||||
}
|
||||
|
||||
const res = await fetch('https://id.twitch.tv/oauth2/revoke', {
|
||||
method: "POST", headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.TWITCH_CLIENT_ID
|
||||
})
|
||||
});
|
||||
|
||||
await secrets.delete({ service: 'gamflow_twitch', name: 'access_token' });
|
||||
await secrets.delete({ service: 'gamflow_twitch', name: 'refresh_token' });
|
||||
await secrets.delete({ service: 'gamflow_twitch', name: 'expires_in' });
|
||||
|
||||
return status(res.status, res.statusText);
|
||||
})
|
||||
.get('/login/twitch', async () =>
|
||||
{
|
||||
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
|
||||
if (!access_token)
|
||||
{
|
||||
return status('Not Found', "Not Logged In");
|
||||
}
|
||||
|
||||
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } });
|
||||
if (res.ok)
|
||||
{
|
||||
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
|
||||
}
|
||||
|
||||
if (!process.env.TWITCH_CLIENT_ID)
|
||||
{
|
||||
return status("Not Found", "Twitch Client ID not set");
|
||||
}
|
||||
|
||||
const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" });
|
||||
if (!refresh_token)
|
||||
{
|
||||
return status("Not Found", "Refresh Token Not Found");
|
||||
}
|
||||
|
||||
// refresh token
|
||||
const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', {
|
||||
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({
|
||||
client_id: process.env.TWITCH_CLIENT_ID,
|
||||
access_token,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token
|
||||
})
|
||||
});
|
||||
|
||||
if (refreshResponse.ok)
|
||||
{
|
||||
const data: {
|
||||
access_token: string,
|
||||
refresh_token: string,
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
} = await refreshResponse.json();
|
||||
|
||||
await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
|
||||
await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
|
||||
await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() });
|
||||
|
||||
events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' });
|
||||
|
||||
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } });
|
||||
if (res.ok)
|
||||
{
|
||||
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
|
||||
}
|
||||
}
|
||||
|
||||
return status(400, res.statusText);
|
||||
})
|
||||
.post('/login/romm', async () =>
|
||||
{
|
||||
if (taskQueue.hasActiveOfType(LoginJob))
|
||||
{
|
||||
return status("Conflict", "Login Already Active");
|
||||
}
|
||||
|
||||
const job = new LoginJob();
|
||||
taskQueue.enqueue("login", job);
|
||||
return status("OK");
|
||||
})
|
||||
.get('/login/remote/status', async function* ()
|
||||
{
|
||||
const job = taskQueue.findJob("login");
|
||||
if (job)
|
||||
{
|
||||
const loginJob = job.job as LoginJob;
|
||||
yield sse({ data: { endsAt: loginJob.endsAt, url: loginJob.url } });
|
||||
await taskQueue.waitForJob('login');
|
||||
yield sse({ data: {} });
|
||||
}
|
||||
|
||||
yield sse({ data: {} });
|
||||
})
|
||||
.post('/login/remote/cancel', async () =>
|
||||
{
|
||||
const job = taskQueue.findJob("login");
|
||||
if (job)
|
||||
{
|
||||
job.abort("cancel");
|
||||
await taskQueue.waitForJob('login');
|
||||
}
|
||||
return {};
|
||||
return taskQueue.enqueue(LoginJob.id, new LoginJob());
|
||||
})
|
||||
.post('/login', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
|
||||
.get('/login', async () =>
|
||||
|
|
|
|||
34
src/bun/api/cache.ts
Normal file
34
src/bun/api/cache.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { cache } from "./app";
|
||||
import cacheSchema from "@schema/cache";
|
||||
|
||||
export const CACHE_KEYS = {
|
||||
ROM_PLATFORMS: 'rom-platforms',
|
||||
STORE_GAME: (path: string) => `store-game-${path}`,
|
||||
STORE_GAME_MANIFEST: 'store-game-manifest'
|
||||
} as const;
|
||||
|
||||
export async function getOrCached<T> (key: string, getter: () => Promise<T>, options?: { expireMs?: number; }): Promise<T>
|
||||
{
|
||||
const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) });
|
||||
const updated_at = new Date();
|
||||
|
||||
if (cached && cached.expire_at > updated_at)
|
||||
{
|
||||
return cached.data as T;
|
||||
}
|
||||
|
||||
const data = await getter();
|
||||
|
||||
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
await cache.insert(cacheSchema.item_cache)
|
||||
.values({ key, data, updated_at, expire_at })
|
||||
.onConflictDoUpdate({
|
||||
target: cacheSchema.item_cache.key,
|
||||
set: { data, updated_at, expire_at }
|
||||
})
|
||||
.run();
|
||||
|
||||
return data;
|
||||
}
|
||||
46
src/bun/api/emulatorjs/emulatorjs.ts
Normal file
46
src/bun/api/emulatorjs/emulatorjs.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// ES-DE to emulator JS mapping
|
||||
// TODO: use the retroarch cores based on ES-DE
|
||||
export const cores: Record<string, string> = {
|
||||
"atari5200": "atari5200",
|
||||
"virtualboy": "vb",
|
||||
"nds": "nds",
|
||||
"arcade": "arcade",
|
||||
"nes": "nes",
|
||||
"gb": "gb",
|
||||
"gbc": "gb",
|
||||
"colecovision": "coleco",
|
||||
"mastersystem": "segaMS",
|
||||
"megadrive": "segaMD",
|
||||
"gamegear": "segaGG",
|
||||
"segacd": "segaCD",
|
||||
"sega32x": "sega32x",
|
||||
"genesis": "sega",
|
||||
"mark3": "sega",
|
||||
"megacd": "sega",
|
||||
"megacdjp": "sega",
|
||||
"megadrivejp": "sega",
|
||||
"sg-1000": "sega",
|
||||
"atarilynx": "lynx",
|
||||
"mame": "mame",
|
||||
"ngp": "ngp",
|
||||
"supergrafx": "pce",
|
||||
"pcfx": "pcfx",
|
||||
"psx": "psx",
|
||||
"wonderswan": "ws",
|
||||
"gba": "gba",
|
||||
"n64": "n64",
|
||||
"3do": "3do",
|
||||
"psp": "psp",
|
||||
"atari7800": "atari7800",
|
||||
"snes": "snes",
|
||||
"atari2600": "atari2600",
|
||||
"atarijaguar": "jaguar",
|
||||
"saturn": "segaSaturn",
|
||||
"amiga": "amiga",
|
||||
"c64": "c64",
|
||||
"c128": "c128",
|
||||
"pet": "pet",
|
||||
"plus4": "plus4",
|
||||
"vic20": "vic20",
|
||||
"dos": "dos"
|
||||
};
|
||||
|
|
@ -1,23 +1,32 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { config, db, taskQueue } from "../app";
|
||||
import { activeGame, config, db, events, taskQueue } from "../app";
|
||||
import { and, eq, getTableColumns, sql } from "drizzle-orm";
|
||||
import z from "zod";
|
||||
import * as schema from "../schema/app";
|
||||
import * as schema from "@schema/app";
|
||||
import fs from "node:fs/promises";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants";
|
||||
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import { InstallJob } from "../jobs/install-job";
|
||||
import path from "node:path";
|
||||
import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils";
|
||||
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameMatch } from "./services/utils";
|
||||
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
|
||||
import { errorToResponse } from "elysia/adapter/bun/handler";
|
||||
import { launchCommand } from "./services/launchGameService";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { Jimp } from 'jimp';
|
||||
import { defaultFormats, defaultPlugins } from 'jimp';
|
||||
import { createJimp } from "@jimp/core";
|
||||
import webp from "@jimp/wasm-webp";
|
||||
import { extractStoreGameSourceId, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
|
||||
|
||||
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height }: { blur?: number, width?: number, height?: number; })
|
||||
// A custom jimp that supports webp
|
||||
const Jimp = createJimp({
|
||||
formats: [...defaultFormats, webp],
|
||||
plugins: defaultPlugins,
|
||||
});
|
||||
|
||||
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height, noBlur }: { blur?: number, width?: number, height?: number; noBlur?: boolean; })
|
||||
{
|
||||
if (blur)
|
||||
if (blur && !noBlur)
|
||||
{
|
||||
const jimp = await Jimp.read(img);
|
||||
if (width)
|
||||
|
|
@ -48,6 +57,8 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width,
|
|||
export default new Elysia()
|
||||
.get('/game/local/:id/cover', async ({ params: { id }, query, set }) =>
|
||||
{
|
||||
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||
|
||||
const coverBlob = await db.query.games.findFirst({ columns: { cover: true, cover_type: true }, where: eq(schema.games.id, id) });
|
||||
if (!coverBlob || !coverBlob.cover)
|
||||
{
|
||||
|
|
@ -71,7 +82,7 @@ export default new Elysia()
|
|||
return processImage(`${rommAdress}/${path}`, query);
|
||||
}
|
||||
return status('Not Found');
|
||||
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) })
|
||||
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional(), noBlur: z.coerce.boolean().optional() }) })
|
||||
.get('/image', async ({ query }) =>
|
||||
{
|
||||
return processImage(query.url, query);
|
||||
|
|
@ -106,18 +117,24 @@ export default new Elysia()
|
|||
}, {
|
||||
params: z.object({ id: z.number() }),
|
||||
response: z.object({ installed: z.boolean() })
|
||||
}).get('/games', async ({ query: { platform_source, platform_slug, platform_id, collection_id } }) =>
|
||||
})
|
||||
.get('/games', async ({ query, set }) =>
|
||||
{
|
||||
const where: any[] = [];
|
||||
if (platform_slug)
|
||||
if (query.platform_slug)
|
||||
{
|
||||
where.push(eq(schema.platforms.slug, platform_slug));
|
||||
where.push(eq(schema.platforms.slug, query.platform_slug));
|
||||
}
|
||||
|
||||
if (query.source)
|
||||
{
|
||||
where.push(eq(schema.games.source, query.source));
|
||||
}
|
||||
|
||||
const games: FrontEndGameType[] = [];
|
||||
let localGamesSet: Set<number> | undefined;
|
||||
let localGamesSet: Set<string> | undefined;
|
||||
|
||||
if (!collection_id)
|
||||
if (!query.collection_id)
|
||||
{
|
||||
const localGames = await db.select({
|
||||
...getTableColumns(schema.games),
|
||||
|
|
@ -128,45 +145,87 @@ export default new Elysia()
|
|||
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
|
||||
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
|
||||
.groupBy(schema.games.id)
|
||||
|
||||
.offset(query.offset ?? 0)
|
||||
.limit(query.limit ?? 50)
|
||||
.where(and(...where));
|
||||
|
||||
localGamesSet = new Set(localGames.filter(g => !!g.source_id).map(g => g.source_id!));
|
||||
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
|
||||
games.push(...localGames.map(g =>
|
||||
{
|
||||
const game: FrontEndGameType = {
|
||||
platform_display_name: g.platform?.name ?? "Local",
|
||||
id: { id: g.id, source: 'local' },
|
||||
updated_at: g.created_at,
|
||||
path_cover: `/api/romm/game/local/${g.id}/cover`,
|
||||
source_id: g.source_id,
|
||||
source: g.source,
|
||||
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
||||
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
|
||||
path_fs: g.path_fs,
|
||||
last_played: g.last_played,
|
||||
slug: g.slug,
|
||||
name: g.name,
|
||||
platform_id: g.platform_id
|
||||
};
|
||||
return game;
|
||||
return convertLocalToFrontend(g);
|
||||
}));
|
||||
}
|
||||
|
||||
if ((!platform_source || platform_source === 'romm') || !!collection_id)
|
||||
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
|
||||
{
|
||||
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true });
|
||||
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(g.id)).map(g =>
|
||||
const rommGames = await getRomsApiRomsGet({
|
||||
query: {
|
||||
platform_ids: query.platform_id ? [query.platform_id] : undefined,
|
||||
collection_id: query.collection_id,
|
||||
limit: query.limit,
|
||||
offset: query.offset
|
||||
}, throwOnError: true
|
||||
});
|
||||
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(`romm@${g.id}`)).map(g =>
|
||||
{
|
||||
return convertRomToFrontend(g);
|
||||
}));
|
||||
}
|
||||
|
||||
if (query.source === 'store')
|
||||
{
|
||||
const gamesManifest = await getStoreGameManifest();
|
||||
set.headers['x-max-items'] = gamesManifest.filter(g => g.type === 'blob').length;
|
||||
|
||||
const storeGames = await Promise.all(gamesManifest
|
||||
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), gamesManifest.length))
|
||||
.map(async (e) =>
|
||||
{
|
||||
const system = path.dirname(e.path);
|
||||
const id = path.basename(e.path, path.extname(e.path));
|
||||
|
||||
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
|
||||
|
||||
if (localGame)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const storeGame = await getStoreGameFromPath(e.path);
|
||||
|
||||
return convertStoreToFrontend(system, id, storeGame);
|
||||
}));
|
||||
games.push(...storeGames.filter(g => g !== undefined));
|
||||
}
|
||||
|
||||
return { games };
|
||||
}, {
|
||||
query: GameListFilterSchema,
|
||||
})
|
||||
.get('/rom/:source/:id', async ({ params: { id, source } }) =>
|
||||
{
|
||||
const localGame = await db.query.games.findFirst({
|
||||
where: getLocalGameMatch(id, source),
|
||||
columns: { path_fs: true }
|
||||
});
|
||||
|
||||
if (!localGame?.path_fs)
|
||||
{
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
const downloadPath = config.get('downloadPath');
|
||||
const path_fs = path.join(downloadPath, localGame.path_fs);
|
||||
const stats = await fs.stat(path_fs);
|
||||
if (stats.isDirectory())
|
||||
{
|
||||
return status("Not Found", "Rom is a folder");
|
||||
}
|
||||
|
||||
return Bun.file(path_fs);
|
||||
}, {
|
||||
params: z.object({ source: z.string(), id: z.string() })
|
||||
})
|
||||
.get('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
async function getLocalGameDetailed (match: any)
|
||||
|
|
@ -175,7 +234,7 @@ export default new Elysia()
|
|||
where: match,
|
||||
with: {
|
||||
screenshots: { columns: { id: true } },
|
||||
platform: { columns: { name: true } }
|
||||
platform: { columns: { name: true, slug: true } }
|
||||
}
|
||||
});
|
||||
if (localGame)
|
||||
|
|
@ -185,7 +244,7 @@ export default new Elysia()
|
|||
const game: FrontEndGameTypeDetailed = {
|
||||
path_cover: `/api/romm/game/local/${localGame.id}/cover`,
|
||||
updated_at: localGame.created_at,
|
||||
id: { id: localGame.id, source: 'local' },
|
||||
id: { id: String(localGame.id), source: 'local' },
|
||||
path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`,
|
||||
fs_size_bytes: fileSize ?? null,
|
||||
paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`),
|
||||
|
|
@ -199,7 +258,8 @@ export default new Elysia()
|
|||
last_played: localGame.last_played,
|
||||
slug: localGame.slug,
|
||||
name: localGame.name,
|
||||
platform_id: localGame.platform_id
|
||||
platform_id: localGame.platform_id,
|
||||
platform_slug: localGame.platform.slug
|
||||
};
|
||||
return game;
|
||||
}
|
||||
|
|
@ -209,7 +269,7 @@ export default new Elysia()
|
|||
|
||||
if (source === 'local')
|
||||
{
|
||||
const localGame = await getLocalGameDetailed(eq(schema.games.id, id));
|
||||
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
|
||||
if (localGame) return localGame;
|
||||
return status('Not Found');
|
||||
}
|
||||
|
|
@ -218,18 +278,30 @@ export default new Elysia()
|
|||
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
|
||||
if (localGame) return localGame;
|
||||
|
||||
const rom = await getRomApiRomsIdGet({ path: { id } });
|
||||
if (rom.data)
|
||||
if (source === 'romm')
|
||||
{
|
||||
const romGame = convertRomToFrontendDetailed(rom.data);
|
||||
return romGame;
|
||||
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
|
||||
if (rom.data)
|
||||
{
|
||||
const romGame = convertRomToFrontendDetailed(rom.data);
|
||||
return romGame;
|
||||
}
|
||||
|
||||
return status("Not Found", rom.response);
|
||||
}
|
||||
else if (source === 'store')
|
||||
{
|
||||
const gameId = extractStoreGameSourceId(id);
|
||||
const storeGame = await getStoreGame(gameId.system, gameId.id);
|
||||
if (!storeGame) return status("Not Found");
|
||||
return convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
|
||||
}
|
||||
|
||||
return status("Not Found", rom.response);
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
}, {
|
||||
params: z.object({ source: z.string(), id: z.coerce.number() })
|
||||
params: z.object({ source: z.string(), id: z.string() })
|
||||
})
|
||||
.get('/status/:source/:id', async ({ params: { source, id }, set }) =>
|
||||
{
|
||||
|
|
@ -239,7 +311,7 @@ export default new Elysia()
|
|||
return buildStatusResponse(source, id);
|
||||
}, {
|
||||
response: z.any(),
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
query: z.object({ isLocal: z.boolean().optional() })
|
||||
})
|
||||
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||
|
|
@ -253,36 +325,51 @@ export default new Elysia()
|
|||
|
||||
return status(deleted.length > 0 ? 'OK' : 'Not Modified');
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
})
|
||||
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||
{
|
||||
if (!taskQueue.hasActive())
|
||||
{
|
||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
|
||||
return status(200);
|
||||
if (source === 'romm' || source === 'store')
|
||||
{
|
||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
|
||||
return status(200);
|
||||
}
|
||||
|
||||
return status('Not Implemented');
|
||||
} else
|
||||
{
|
||||
return status('Not Implemented');
|
||||
}
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
response: z.any()
|
||||
})
|
||||
.post('/game/:source/:id/play', async ({ params: { id, source }, set }) =>
|
||||
.post('/game/:source/:id/play', async ({ params: { id, source }, query, set }) =>
|
||||
{
|
||||
const validCommand = await getValidLaunchCommandsForGame(source, id);
|
||||
if (validCommand)
|
||||
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
||||
if (validCommands)
|
||||
{
|
||||
if (validCommand instanceof Error)
|
||||
if (validCommands instanceof Error)
|
||||
{
|
||||
return errorToResponse(validCommand, set);
|
||||
return errorToResponse(validCommands, set);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
await launchCommand(validCommand.command.command, source, id, validCommand.gameId);
|
||||
const validCommand = query.command_id ? validCommands.commands.find(c => c.id === query.command_id) : validCommands.commands[0];
|
||||
if (validCommand)
|
||||
{
|
||||
// launch command waits for the game to exit, we don't want that.
|
||||
launchCommand(validCommand.command, source, id, validCommands.gameId);
|
||||
return { type: 'application', command: null };
|
||||
} else
|
||||
{
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
|
|
@ -291,5 +378,27 @@ export default new Elysia()
|
|||
}
|
||||
}
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
query: z.object({ command_id: z.number().or(z.string()).optional() }),
|
||||
response: z.object({ type: z.enum(['emulatorjs', 'application']), command: z.string().nullable() })
|
||||
})
|
||||
.post("/stop", async ({ }) =>
|
||||
{
|
||||
if (activeGame)
|
||||
{
|
||||
events.emit('activegameexit', {
|
||||
source: 'local', id: String(activeGame.gameId),
|
||||
exitCode: null,
|
||||
signalCode: null
|
||||
});
|
||||
}
|
||||
})
|
||||
.get('/emulatorjs/data/cores/*', async ({ params }) =>
|
||||
{
|
||||
const res = await fetch(`https://cdn.emulatorjs.org/latest/data/cores/${params['*']}`);
|
||||
return res;
|
||||
})
|
||||
.get('/emulatorjs/data/*', async ({ params }) =>
|
||||
{
|
||||
return status("Not Found");
|
||||
});
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import z from "zod";
|
||||
import { count, eq, getTableColumns, notInArray } from "drizzle-orm";
|
||||
import { count, eq, getTableColumns } from "drizzle-orm";
|
||||
import { db } from "../app";
|
||||
import { FrontEndPlatformType } from "@shared/constants";
|
||||
import * as schema from "../schema/app";
|
||||
import * as schema from "@schema/app";
|
||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||
|
||||
export default new Elysia()
|
||||
.get('/platforms', async () =>
|
||||
{
|
||||
const platforms: FrontEndPlatformType[] = [];
|
||||
let rommPlatformsSet: Set<string> | undefined;
|
||||
const { data: rommPlatforms } = await getPlatformsApiPlatformsGet();
|
||||
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e));
|
||||
|
||||
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
|
||||
.from(schema.platforms)
|
||||
|
|
@ -32,7 +33,7 @@ export default new Elysia()
|
|||
path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`,
|
||||
game_count: p.rom_count,
|
||||
updated_at: new Date(p.updated_at),
|
||||
id: { source: 'romm', id: p.id },
|
||||
id: { source: 'romm', id: String(p.id) },
|
||||
hasLocal: localPlatformSet.has(p.slug),
|
||||
paths_screenshots: game.data?.items[0]?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`) ?? []
|
||||
};
|
||||
|
|
@ -46,7 +47,13 @@ export default new Elysia()
|
|||
|
||||
platforms.push(...await Promise.all(localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(async p =>
|
||||
{
|
||||
const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id), with: { screenshots: true }, columns: {} });
|
||||
const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id) });
|
||||
let screenshots: { id: number; }[] = [];
|
||||
if (game)
|
||||
{
|
||||
screenshots = await db.query.screenshots.findMany({ where: eq(schema.screenshots.game_id, game.id), columns: { id: true } });
|
||||
}
|
||||
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
|
|
@ -54,9 +61,9 @@ export default new Elysia()
|
|||
path_cover: `/api/romm/platform/local/${p.id}/cover`,
|
||||
game_count: p.game_count,
|
||||
updated_at: p.created_at,
|
||||
id: { source: 'local', id: p.id },
|
||||
id: { source: 'local', id: String(p.id) },
|
||||
hasLocal: true,
|
||||
paths_screenshots: game?.screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? []
|
||||
paths_screenshots: screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? []
|
||||
|
||||
};
|
||||
|
||||
|
|
@ -66,13 +73,52 @@ export default new Elysia()
|
|||
return { platforms };
|
||||
}).get('/platforms/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
const rommPlatform = await getPlatformApiPlatformsIdGet({ path: { id } });
|
||||
if (rommPlatform.data)
|
||||
if (source === 'romm')
|
||||
{
|
||||
return rommPlatform.data;
|
||||
const { data: rommPlatform, response } = await getPlatformApiPlatformsIdGet({ path: { id } });
|
||||
if (rommPlatform)
|
||||
{
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: rommPlatform.slug,
|
||||
name: rommPlatform.display_name,
|
||||
family_name: rommPlatform.family_name,
|
||||
path_cover: `/api/romm/image/romm/assets/platforms/${rommPlatform.slug}.svg`,
|
||||
game_count: rommPlatform.rom_count,
|
||||
updated_at: new Date(rommPlatform.updated_at),
|
||||
id: { source: 'romm', id: String(rommPlatform.id) },
|
||||
paths_screenshots: [],
|
||||
hasLocal: false
|
||||
};
|
||||
|
||||
return platform;
|
||||
}
|
||||
|
||||
return status("Not Found", response);
|
||||
}
|
||||
else if (source === 'local')
|
||||
{
|
||||
const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, id) });
|
||||
if (localPlatform)
|
||||
{
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: localPlatform.slug,
|
||||
name: localPlatform.name,
|
||||
family_name: localPlatform.family_name,
|
||||
path_cover: `/api/romm/platform/local/${localPlatform.id}/cover`,
|
||||
game_count: 0,
|
||||
updated_at: localPlatform.created_at,
|
||||
id: { source: 'local', id: String(localPlatform.id) },
|
||||
hasLocal: true,
|
||||
paths_screenshots: []
|
||||
};
|
||||
|
||||
return platform;
|
||||
}
|
||||
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
return status("Not Found", rommPlatform.response);
|
||||
return status("Not Implemented");
|
||||
}, { params: z.object({ source: z.string(), id: z.coerce.number() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
|
||||
{
|
||||
const coverBlob = await db.query.platforms.findFirst({
|
||||
|
|
|
|||
|
|
@ -2,26 +2,19 @@ import path from 'node:path';
|
|||
import { which } from 'bun';
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import * as schema from '../../schema/emulators';
|
||||
import * as appSchema from "../../schema/app";
|
||||
import * as schema from '@schema/emulators';
|
||||
import * as appSchema from "@schema/app";
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app';
|
||||
import os from 'node:os';
|
||||
import { $ } from 'bun';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
|
||||
import { CommandEntry } from '@/shared/constants';
|
||||
|
||||
export const varRegex = /%([^%]+)%/g;
|
||||
|
||||
interface CommandEntry
|
||||
{
|
||||
label?: string;
|
||||
command: string;
|
||||
valid: boolean;
|
||||
emulator?: string;
|
||||
}
|
||||
|
||||
export async function launchCommand (validCommand: string, source: string, sourceId: number, id: number)
|
||||
export async function launchCommand (validCommand: string, source: string, sourceId: string, id: number)
|
||||
{
|
||||
if (activeGame && activeGame.process?.killed === false)
|
||||
{
|
||||
|
|
@ -69,13 +62,12 @@ export async function launchCommand (validCommand: string, source: string, sourc
|
|||
|
||||
if (source === 'romm')
|
||||
{
|
||||
updateRommProps(sourceId);
|
||||
updateRommProps(Number(sourceId));
|
||||
}
|
||||
else if (localGame?.source === 'romm' && localGame.source_id)
|
||||
{
|
||||
updateRommProps(localGame.source_id);
|
||||
updateRommProps(Number(localGame.source_id));
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/* Old spawn lanching, cases issues, needs to be ran as shell
|
||||
|
|
@ -117,7 +109,10 @@ export async function getValidLaunchCommands (data: {
|
|||
}): Promise<CommandEntry[]>
|
||||
{
|
||||
|
||||
const system = await emulatorsDb.query.systems.findFirst({ with: { commands: true }, where: eq(schema.systems.name, data.systemSlug) });
|
||||
const system = await emulatorsDb.query.systems.findFirst({
|
||||
with: { commands: true },
|
||||
where: eq(schema.systems.name, data.systemSlug)
|
||||
});
|
||||
|
||||
if (!system)
|
||||
{
|
||||
|
|
@ -165,7 +160,7 @@ export async function getValidLaunchCommands (data: {
|
|||
}
|
||||
}
|
||||
|
||||
const formattedCommands = await Promise.all(system.commands.map(async command =>
|
||||
const formattedCommands = await Promise.all(system.commands.map(async (command, index) =>
|
||||
{
|
||||
const label = command.label;
|
||||
let cmd = command.command;
|
||||
|
|
@ -213,14 +208,14 @@ export async function getValidLaunchCommands (data: {
|
|||
if (value.startsWith("%EMULATOR_"))
|
||||
{
|
||||
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
||||
let exec = await findExec(emulatorName);
|
||||
let exec = await findExecByName(emulatorName);
|
||||
if (data.customEmulatorConfig.has(emulatorName))
|
||||
{
|
||||
exec = data.customEmulatorConfig.get(emulatorName);
|
||||
exec = { path: data.customEmulatorConfig.get(emulatorName)!, type: 'custom' };
|
||||
}
|
||||
|
||||
emulator = emulatorName;
|
||||
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec)) : undefined]];
|
||||
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec.path)) : undefined]];
|
||||
}
|
||||
|
||||
const key = value[0].substring(1, value.length - 1);
|
||||
|
|
@ -237,6 +232,7 @@ export async function getValidLaunchCommands (data: {
|
|||
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
|
||||
|
||||
return {
|
||||
id: index,
|
||||
label: label ?? undefined,
|
||||
command: formattedCommand,
|
||||
valid: !invalid, emulator
|
||||
|
|
@ -246,13 +242,18 @@ export async function getValidLaunchCommands (data: {
|
|||
return formattedCommands.filter(c => !!c);
|
||||
}
|
||||
|
||||
export async function findExec (emulatorName: string)
|
||||
export async function findExecByName (emulatorName: string)
|
||||
{
|
||||
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) });
|
||||
if (!emulator)
|
||||
{
|
||||
throw new Error(`Could not find emulator ${emulatorName}`);
|
||||
}
|
||||
return findExec(emulator);
|
||||
}
|
||||
|
||||
export async function findExec (emulator: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
|
||||
{
|
||||
if (os.platform() === 'win32')
|
||||
{
|
||||
const regValues = emulator.winregistrypath;
|
||||
|
|
@ -263,7 +264,7 @@ export async function findExec (emulatorName: string)
|
|||
const registryValue = await readRegistryValue(node);
|
||||
if (registryValue)
|
||||
{
|
||||
return registryValue;
|
||||
return { path: registryValue, type: 'registry' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,7 +277,7 @@ export async function findExec (emulatorName: string)
|
|||
const systemPath = await resolveSystemPath(systempaths);
|
||||
if (systemPath)
|
||||
{
|
||||
return systemPath;
|
||||
return { path: systemPath, type: 'system' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,7 +287,7 @@ export async function findExec (emulatorName: string)
|
|||
const staticPath = await resolveStaticPath(staticPaths);
|
||||
if (staticPath)
|
||||
{
|
||||
return staticPath;
|
||||
return { path: staticPath, type: 'static' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { GameInstallProgress, GameStatusType, } from "@shared/constants";
|
||||
import { GameInstallProgress, GameStatusType, RPC_URL, } from "@shared/constants";
|
||||
import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app";
|
||||
import { getValidLaunchCommands } from "./launchGameService";
|
||||
import * as schema from '../../schema/app';
|
||||
import * as schema from '@schema/app';
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { getLocalGameMatch } from "./utils";
|
||||
import { getRomApiRomsIdGet } from "@/clients/romm";
|
||||
import fs from 'node:fs/promises';
|
||||
import { ErrorLike } from "elysia/universal";
|
||||
import { getStoreGameFromId } from "../../store/services/gamesService";
|
||||
import { cores } from "../../emulatorjs/emulatorjs";
|
||||
import { host } from "@/bun/utils/host";
|
||||
|
||||
class CommandSearchError extends Error
|
||||
{
|
||||
|
|
@ -18,7 +21,7 @@ class CommandSearchError extends Error
|
|||
}
|
||||
}
|
||||
|
||||
export async function getLocalGame (source: string, id: number)
|
||||
export async function getLocalGame (source: string, id: string)
|
||||
{
|
||||
const localGames = await db.select({ id: schema.games.id, path_fs: schema.games.path_fs, platform_slug: schema.platforms.es_slug })
|
||||
.from(schema.games)
|
||||
|
|
@ -33,7 +36,7 @@ export async function getLocalGame (source: string, id: number)
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export async function getValidLaunchCommandsForGame (source: string, id: number)
|
||||
export async function getValidLaunchCommandsForGame (source: string, id: string)
|
||||
{
|
||||
const localGame = await getLocalGame(source, id);
|
||||
if (localGame)
|
||||
|
|
@ -42,18 +45,28 @@ export async function getValidLaunchCommandsForGame (source: string, id: number)
|
|||
{
|
||||
if (localGame.path_fs)
|
||||
{
|
||||
|
||||
try
|
||||
{
|
||||
const commands = await getValidLaunchCommands({ systemSlug: localGame.platform_slug, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs });
|
||||
|
||||
if (cores[localGame.platform_slug])
|
||||
{
|
||||
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`;
|
||||
commands.push({
|
||||
id: 'emulatorjs',
|
||||
label: "Emulator JS", command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, emulator: 'emulatorjs'
|
||||
});
|
||||
}
|
||||
|
||||
const validCommand = commands.find(c => c.valid);
|
||||
if (validCommand)
|
||||
{
|
||||
return { command: validCommand, gameId: localGame.id, source: source, sourceId: id };
|
||||
|
||||
return { commands: commands.filter(c => c.valid), gameId: localGame.id, source: source, sourceId: id };
|
||||
}
|
||||
else
|
||||
{
|
||||
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator).join(',')}`);
|
||||
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
|
||||
}
|
||||
} catch (error)
|
||||
{
|
||||
|
|
@ -76,7 +89,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: number)
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export default async function buildStatusResponse (source: string, id: number)
|
||||
export default async function buildStatusResponse (source: string, id: string)
|
||||
{
|
||||
let cleanup: (() => void) | undefined;
|
||||
let closed = false;
|
||||
|
|
@ -87,6 +100,7 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
|
||||
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping')
|
||||
{
|
||||
if (closed) return;
|
||||
const evntString = event ? `event: ${event}\n` : '';
|
||||
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
||||
}
|
||||
|
|
@ -136,13 +150,14 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
}
|
||||
else
|
||||
{
|
||||
enqueue({ status: 'installed', details: validCommand.command.label });
|
||||
enqueue({ status: 'installed', details: validCommand.commands[0].label, commands: validCommand.commands });
|
||||
}
|
||||
|
||||
} else if (source === 'romm')
|
||||
}
|
||||
else if (source === 'romm')
|
||||
{
|
||||
// TODO: Add Caching
|
||||
const remoteGame = await getRomApiRomsIdGet({ path: { id } });
|
||||
const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(id) } });
|
||||
const stats = await fs.statfs(config.get('downloadPath'));
|
||||
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
|
||||
{
|
||||
|
|
@ -152,6 +167,20 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
enqueue({ status: 'install', details: 'Install' });
|
||||
}
|
||||
|
||||
} else if (source === 'store')
|
||||
{
|
||||
const storeGame = await getStoreGameFromId(id);
|
||||
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||
const size = Number(fileResponse.headers.get('content-length'));
|
||||
const stats = await fs.statfs(config.get('downloadPath'));
|
||||
|
||||
if (size > stats.bsize * stats.bavail)
|
||||
{
|
||||
enqueue({ status: 'error', error: "Not Enough Free Space" });
|
||||
} else
|
||||
{
|
||||
enqueue({ status: 'install', details: 'Install' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -190,7 +219,7 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
{
|
||||
enqueue({
|
||||
status: 'error',
|
||||
error: error
|
||||
error: getErrorMessage(error)
|
||||
}, 'error');
|
||||
}
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import getFolderSize from "get-folder-size";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { config } from "../../app";
|
||||
import { config, db, emulatorsDb } from "../../app";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as schema from "../../schema/app";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
|
||||
import * as schema from "@schema/app";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants";
|
||||
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
|
||||
export async function calculateSize (installPath: string | null)
|
||||
{
|
||||
|
|
@ -19,15 +20,15 @@ export async function checkInstalled (installPath: string | null)
|
|||
return fs.exists(path.join(config.get('downloadPath'), installPath));
|
||||
}
|
||||
|
||||
export function getLocalGameMatch (id: number, source: string)
|
||||
export function getLocalGameMatch (id: string, source: string)
|
||||
{
|
||||
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, id);
|
||||
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id));
|
||||
}
|
||||
|
||||
export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
|
||||
{
|
||||
const game: FrontEndGameType = {
|
||||
id: { id: rom.id, source: 'romm' },
|
||||
id: { id: String(rom.id), source: 'romm' },
|
||||
path_cover: `/api/romm/image/romm${rom.path_cover_large}`,
|
||||
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
||||
updated_at: new Date(rom.updated_at),
|
||||
|
|
@ -40,11 +41,131 @@ export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
|
|||
source: null,
|
||||
source_id: null,
|
||||
paths_screenshots: rom.merged_screenshots.map(s => `/api/romm/image/romm/${s}`),
|
||||
platform_slug: rom.platform_slug
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
|
||||
platform?: typeof schema.platforms.$inferSelect | null;
|
||||
screenshotIds?: number[];
|
||||
})
|
||||
{
|
||||
const game: FrontEndGameType = {
|
||||
platform_display_name: g.platform?.name ?? "Local",
|
||||
id: { id: String(g.id), source: 'local' },
|
||||
updated_at: g.created_at,
|
||||
path_cover: `/api/romm/game/local/${g.id}/cover`,
|
||||
source_id: g.source_id,
|
||||
source: g.source,
|
||||
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
||||
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
|
||||
path_fs: g.path_fs,
|
||||
last_played: g.last_played,
|
||||
slug: g.slug,
|
||||
name: g.name,
|
||||
platform_id: g.platform_id,
|
||||
platform_slug: g.platform?.slug ?? null
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
|
||||
platform?: typeof schema.platforms.$inferSelect | null;
|
||||
screenshotIds?: number[];
|
||||
})
|
||||
{
|
||||
const game: FrontEndGameTypeDetailed = {
|
||||
platform_display_name: g.platform?.name ?? "Local",
|
||||
id: { id: String(g.id), source: 'local' },
|
||||
updated_at: g.created_at,
|
||||
path_cover: `/api/romm/game/local/${g.id}/cover`,
|
||||
source_id: g.source_id,
|
||||
source: g.source,
|
||||
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
||||
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
|
||||
path_fs: g.path_fs,
|
||||
last_played: g.last_played,
|
||||
slug: g.slug,
|
||||
name: g.name,
|
||||
platform_id: g.platform_id,
|
||||
platform_slug: g.platform?.slug ?? null,
|
||||
summary: g.summary,
|
||||
fs_size_bytes: 0,
|
||||
missing: false,
|
||||
local: true
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
|
||||
{
|
||||
let size: number | null = null;
|
||||
try
|
||||
{
|
||||
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||
size = Number(fileResponse.headers.get('content-length'));
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
}
|
||||
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
|
||||
where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm'))
|
||||
});
|
||||
|
||||
const platformDef = await emulatorsDb.query.systems.findFirst({
|
||||
where: eq(emulatorSchema.systems.name, system),
|
||||
columns: { fullname: true }
|
||||
});
|
||||
|
||||
const gameId = `${system}@${id}`;
|
||||
|
||||
const game: FrontEndGameType = {
|
||||
platform_display_name: platformDef?.fullname ?? system,
|
||||
path_platform_cover: `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`,
|
||||
id: { source: 'store', id: gameId },
|
||||
source: null,
|
||||
source_id: null,
|
||||
path_fs: null,
|
||||
path_cover: `/api/romm/image?url=${encodeURIComponent(storeGame.pictures.titlescreens?.[0])}`,
|
||||
last_played: null,
|
||||
updated_at: new Date(),
|
||||
slug: null,
|
||||
name: storeGame.title,
|
||||
platform_id: null,
|
||||
platform_slug: system,
|
||||
paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? []
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export async function convertStoreToFrontendDetailed (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameTypeDetailed>
|
||||
{
|
||||
let size: number | null = null;
|
||||
try
|
||||
{
|
||||
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||
size = Number(fileResponse.headers.get('content-length'));
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const detailed: FrontEndGameTypeDetailed = {
|
||||
...await convertStoreToFrontend(system, id, storeGame),
|
||||
summary: storeGame.description,
|
||||
fs_size_bytes: size,
|
||||
missing: false,
|
||||
local: false,
|
||||
};
|
||||
|
||||
return detailed;
|
||||
}
|
||||
|
||||
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
|
||||
{
|
||||
const detailed: FrontEndGameTypeDetailed = {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@ import { IJob, JobContext } from "../task-queue";
|
|||
import { mkdir } from 'node:fs/promises';
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
import fs from 'node:fs/promises';
|
||||
import * as schema from "../schema/app";
|
||||
import * as emulatorSchema from "../schema/emulators";
|
||||
import * as schema from "@schema/app";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import path from 'node:path';
|
||||
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm";
|
||||
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 { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
||||
import * as igdb from 'ts-igdb-client';
|
||||
import secrets from "../secrets";
|
||||
|
||||
interface JobConfig
|
||||
{
|
||||
|
|
@ -18,15 +21,15 @@ interface JobConfig
|
|||
|
||||
export class InstallJob implements IJob
|
||||
{
|
||||
public id: number;
|
||||
public gameId: string;
|
||||
public source: string;
|
||||
public sourceId: number;
|
||||
|
||||
public sourceId: string;
|
||||
public config?: JobConfig;
|
||||
static id = "install-job" as const;
|
||||
|
||||
constructor(id: number, source: string, sourceId: number, config?: JobConfig)
|
||||
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
|
||||
{
|
||||
this.id = id;
|
||||
this.gameId = id;
|
||||
this.config = config;
|
||||
this.sourceId = sourceId;
|
||||
this.source = source;
|
||||
|
|
@ -41,6 +44,65 @@ export class InstallJob implements IJob
|
|||
{
|
||||
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)
|
||||
{
|
||||
/*
|
||||
|
|
@ -92,11 +154,10 @@ export class InstallJob implements IJob
|
|||
await fs.rm(zipFilePath);*/
|
||||
|
||||
cx.setProgress(0, 'download');
|
||||
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
|
||||
downloadUrl.searchParams.set('rom_ids', String(this.id));
|
||||
|
||||
const res = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
|
||||
cookie: cookie
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -119,62 +180,99 @@ export class InstallJob implements IJob
|
|||
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
Readable.fromWeb(res.body as any).pipe(progressStream).pipe(unzip.Extract({ path: downloadPath })).on('close', resolve).on('error', 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);
|
||||
});
|
||||
Readable.fromWeb(res.body as any).pipe(progressStream)
|
||||
.pipe(extract)
|
||||
.on('close', resolve)
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const rom = (await getRomApiRomsIdGet({ path: { id: this.id }, throwOnError: true })).data;
|
||||
const romPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
|
||||
|
||||
if (this.config?.dryDownload === true)
|
||||
{
|
||||
rom.files.length;
|
||||
await mkdir(path.join(downloadPath, rom.fs_path, rom.fs_name), { recursive: true });
|
||||
await mkdir(path.join(downloadPath, extract_path), { recursive: true });
|
||||
}
|
||||
|
||||
// pre-fetch screenshots
|
||||
const screenshots = await Promise.all(rom.merged_screenshots.map(s => fetch(`${config.get('rommAddress')}${s}`)));
|
||||
|
||||
const rommAddress = config.get('rommAddress');
|
||||
const coverResponse = await fetch(`${rommAddress}${rom.path_cover_large}`);
|
||||
|
||||
const coverResponse = await fetch(coverUrl);
|
||||
const cover = Buffer.from(await coverResponse.arrayBuffer());
|
||||
|
||||
if (cx.abortSignal.aborted) return;
|
||||
|
||||
await db.transaction(async (tx) =>
|
||||
{
|
||||
// Search for existing platform
|
||||
const platformSearch = [];
|
||||
if (romPlatform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, romPlatform.igdb_id));
|
||||
if (romPlatform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, romPlatform.igdb_slug));
|
||||
if (romPlatform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, romPlatform.ra_id));
|
||||
if (romPlatform.slug) platformSearch.push(eq(schema.platforms.slug, romPlatform.slug));
|
||||
if (romPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, romPlatform.moby_id));
|
||||
const platformSearch = [eq(schema.platforms.slug, system_slug)];
|
||||
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, system_slug)];
|
||||
|
||||
const esPlatform = await emulatorsDb
|
||||
.select({ slug: emulatorSchema.systemMappings.system, romm_slug: emulatorSchema.systemMappings.sourceSlug })
|
||||
.from(emulatorSchema.systemMappings)
|
||||
.where(and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, romPlatform.slug)));
|
||||
if (rommPlatform)
|
||||
{
|
||||
if (rommPlatform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, rommPlatform.igdb_id));
|
||||
if (rommPlatform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, rommPlatform.igdb_slug));
|
||||
if (rommPlatform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, rommPlatform.ra_id));
|
||||
if (rommPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, rommPlatform.moby_id));
|
||||
|
||||
const existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
|
||||
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm'));
|
||||
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform.slug));
|
||||
}
|
||||
|
||||
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
|
||||
with: { system: true },
|
||||
where: and(...esPlatformSearch)
|
||||
});
|
||||
|
||||
if (esPlatform)
|
||||
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
|
||||
|
||||
let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
|
||||
let platformId: number;
|
||||
if (!existingPlatform)
|
||||
{
|
||||
// Create new local platform
|
||||
const platformCover = await fetch(`${rommAddress}/assets/platforms/${romPlatform.slug.toLocaleLowerCase()}.svg`);
|
||||
const platform: typeof schema.platforms.$inferInsert = {
|
||||
slug: romPlatform.slug,
|
||||
igdb_id: romPlatform.igdb_id,
|
||||
igdb_slug: romPlatform.igdb_slug,
|
||||
ra_id: romPlatform.ra_id,
|
||||
cover: Buffer.from(await platformCover.arrayBuffer()),
|
||||
cover_type: platformCover.headers.get('content-type'),
|
||||
name: romPlatform.name,
|
||||
family_name: romPlatform.family_name,
|
||||
es_slug: esPlatform.length > 0 ? esPlatform[0].slug : undefined
|
||||
};
|
||||
// TODO: add ES slug once I have better way to query ES
|
||||
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
|
||||
platformId = id;
|
||||
// TODO: use something else than the romm demo as CDN
|
||||
const platformCover = await fetch(`https://demo.romm.app/assets/platforms/${system_slug}.svg`);
|
||||
|
||||
if (!esPlatform && !rommPlatform)
|
||||
{
|
||||
// go to unknown platform
|
||||
existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
|
||||
|
||||
if (existingPlatform)
|
||||
{
|
||||
platformId = existingPlatform.id;
|
||||
} else
|
||||
{
|
||||
const [{ id }] = await tx.insert(schema.platforms).values({
|
||||
slug: 'unknown',
|
||||
name: "Unknown"
|
||||
}).returning({ id: schema.platforms.id });
|
||||
platformId = id;
|
||||
}
|
||||
} else
|
||||
{
|
||||
// Create new local platform
|
||||
const platform: typeof schema.platforms.$inferInsert = {
|
||||
slug: rommPlatform?.slug ?? esPlatform?.system.name ?? '',
|
||||
igdb_id: rommPlatform?.igdb_id,
|
||||
igdb_slug: rommPlatform?.igdb_slug,
|
||||
ra_id: rommPlatform?.ra_id,
|
||||
cover: Buffer.from(await platformCover.arrayBuffer()),
|
||||
cover_type: platformCover.headers.get('content-type'),
|
||||
name: rommPlatform?.name ?? esPlatform?.system.fullname ?? '',
|
||||
family_name: rommPlatform?.family_name,
|
||||
es_slug: esPlatform?.system.name ?? undefined
|
||||
};
|
||||
|
||||
// TODO: add ES slug once I have better way to query ES
|
||||
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
|
||||
platformId = id;
|
||||
}
|
||||
|
||||
} else
|
||||
{
|
||||
platformId = existingPlatform.id;
|
||||
|
|
@ -182,32 +280,52 @@ export class InstallJob implements IJob
|
|||
|
||||
// create the rom
|
||||
const game: typeof schema.games.$inferInsert = {
|
||||
source_id: rom.id,
|
||||
source: 'romm',
|
||||
slug: rom.slug,
|
||||
path_fs: path.join(rom.fs_path, rom.fs_name),
|
||||
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
||||
source_id,
|
||||
source: this.source,
|
||||
slug,
|
||||
path_fs,
|
||||
last_played: last_played,
|
||||
platform_id: platformId,
|
||||
igdb_id: rom.igdb_id,
|
||||
ra_id: rom.ra_id,
|
||||
summary: rom.summary,
|
||||
name: rom.name,
|
||||
cover: Buffer.from(await coverResponse.arrayBuffer()),
|
||||
igdb_id: igdb_id,
|
||||
ra_id: ra_id,
|
||||
summary: summary,
|
||||
name,
|
||||
cover,
|
||||
cover_type: coverResponse.headers.get('content-type')
|
||||
};
|
||||
|
||||
// Save screenshots and update database
|
||||
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
|
||||
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
|
||||
{
|
||||
const screenshot: typeof schema.screenshots.$inferInsert = {
|
||||
game_id: id,
|
||||
content: Buffer.from(await response.arrayBuffer()),
|
||||
type: response.headers.get('content-type')
|
||||
};
|
||||
|
||||
return screenshot;
|
||||
})));
|
||||
if (screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_ID)
|
||||
{
|
||||
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
|
||||
if (access_token)
|
||||
{
|
||||
const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token);
|
||||
|
||||
const { data } = await client.request('artworks').pipe(igdb.fields(['game', 'url']), igdb.where('game', '=', igdb_id)).execute();
|
||||
|
||||
screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!));
|
||||
}
|
||||
}
|
||||
|
||||
// pre-fetch screenshots
|
||||
const screenshots = await Promise.all(screenshotUrls.map(s => fetch(s)));
|
||||
|
||||
if (screenshots.length > 0)
|
||||
{
|
||||
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
|
||||
{
|
||||
const screenshot: typeof schema.screenshots.$inferInsert = {
|
||||
game_id: id,
|
||||
content: Buffer.from(await response.arrayBuffer()),
|
||||
type: response.headers.get('content-type')
|
||||
};
|
||||
|
||||
return screenshot;
|
||||
})));
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
80
src/bun/api/jobs/jobs.ts
Normal file
80
src/bun/api/jobs/jobs.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import Elysia from "elysia";
|
||||
import z, { } from "zod";
|
||||
import { taskQueue } from "../app";
|
||||
import { LoginJob } from "./login-job";
|
||||
import TwitchLoginJob from "./twitch-login-job";
|
||||
import UpdateStoreJob from "./update-store";
|
||||
|
||||
function registerJob<const Path extends string, TS, T extends { id: Path, dataSchema?: TS; }> (job: T, path: Path, dataSchema: TS)
|
||||
{
|
||||
return new Elysia().ws(path, {
|
||||
body: z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('cancel') })
|
||||
]),
|
||||
response: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(['data', 'started', 'progress']),
|
||||
status: z.string(),
|
||||
progress: z.number(),
|
||||
data: dataSchema
|
||||
}),
|
||||
z.object({ type: z.literal(['completed', 'ended']) }),
|
||||
z.object({ type: z.literal('error'), error: z.unknown() })
|
||||
]),
|
||||
open (ws)
|
||||
{
|
||||
const job = taskQueue.findJob(path);
|
||||
if (job)
|
||||
{
|
||||
ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
|
||||
(ws.data as any).cleanup = [
|
||||
taskQueue.on('started', ({ id, job }) =>
|
||||
{
|
||||
if (id === path)
|
||||
{
|
||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('progress', ({ id, job }) =>
|
||||
{
|
||||
if (id === path)
|
||||
{
|
||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('completed', ({ id }) =>
|
||||
{
|
||||
if (id === path)
|
||||
{
|
||||
ws.send({ type: 'completed' });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('error', ({ id, error }) =>
|
||||
{
|
||||
if (id === path)
|
||||
{
|
||||
ws.send({ type: 'error', error: error });
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
close (ws)
|
||||
{
|
||||
(ws.data as any).cleanup.forEach((d: Function) => d());
|
||||
},
|
||||
message (ws, message)
|
||||
{
|
||||
if (message.type === 'cancel')
|
||||
{
|
||||
taskQueue.findJob(path)?.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));
|
||||
|
|
@ -4,20 +4,27 @@ import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
|||
import { host, localIp } from "@/bun/utils/host";
|
||||
import cors from "@elysiajs/cors";
|
||||
import { tryLoginAndSave } from "../auth";
|
||||
import z from "zod";
|
||||
import { config } from "../app";
|
||||
import z from "zod";
|
||||
import { delay } from "@/shared/utils";
|
||||
|
||||
export class LoginJob implements IJob
|
||||
{
|
||||
endsAt: Date;
|
||||
startedAt: Date;
|
||||
url: string;
|
||||
static id = "login-job" as const;
|
||||
static dataSchema = z.object({ endsAt: z.date(), startedAt: z.date(), url: z.url() });
|
||||
|
||||
constructor()
|
||||
{
|
||||
this.endsAt = new Date();
|
||||
this.endsAt = new Date(new Date().getTime() + 300000);
|
||||
this.startedAt = new Date();
|
||||
this.url = `http://${localIp}:${LOGIN_PORT}/`;
|
||||
}
|
||||
|
||||
exposeData = (): z.infer<typeof LoginJob.dataSchema> => ({ endsAt: this.endsAt, startedAt: this.startedAt, url: this.url });
|
||||
|
||||
async start (context: JobContext): Promise<any>
|
||||
{
|
||||
const loginServer = new Elysia({ serve: { hostname: localIp, port: LOGIN_PORT } })
|
||||
|
|
@ -44,12 +51,7 @@ export class LoginJob implements IJob
|
|||
try
|
||||
{
|
||||
loginServer.listen({});
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
this.endsAt = new Date(new Date().getTime() + 300000);
|
||||
context.abortSignal.addEventListener('abort', () => reject());
|
||||
setTimeout(() => { reject('timeout'); }, 300000); // auto close after 5 minutes
|
||||
});
|
||||
await delay(this.endsAt, context.abortSignal);
|
||||
} catch
|
||||
{
|
||||
} finally
|
||||
|
|
|
|||
110
src/bun/api/jobs/twitch-login-job.ts
Normal file
110
src/bun/api/jobs/twitch-login-job.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { IJob, JobContext } from "../task-queue";
|
||||
import secrets from "../secrets";
|
||||
import open from "open";
|
||||
import z from "zod";
|
||||
import { delay } from "@/shared/utils";
|
||||
|
||||
|
||||
interface TwitchDevice
|
||||
{
|
||||
device_code: string,
|
||||
expires_in: number,
|
||||
expires_at: Date,
|
||||
started_at: Date,
|
||||
interval: number,
|
||||
user_code: string,
|
||||
verification_uri: string;
|
||||
}
|
||||
|
||||
export default class TwitchLoginJob implements IJob
|
||||
{
|
||||
twitchScopes = "analytics:read:extensions analytics:read:games user:read:email";
|
||||
device?: TwitchDevice;
|
||||
clientId: string;
|
||||
openInBrowser: boolean;
|
||||
static id = 'twitch-login-job' as const;
|
||||
static dataSchema = z.object({ expires_at: z.date(), started_at: z.date(), url: z.url(), user_code: z.string() }).or(z.undefined());
|
||||
|
||||
constructor(clientId: string, openInBrowser: boolean)
|
||||
{
|
||||
this.clientId = clientId;
|
||||
this.openInBrowser = openInBrowser;
|
||||
}
|
||||
|
||||
exposeData = (): z.infer<typeof TwitchLoginJob.dataSchema> => this.device ? ({
|
||||
expires_at: this.device.expires_at,
|
||||
started_at: this.device.started_at,
|
||||
url: this.device.verification_uri,
|
||||
user_code: this.device.user_code
|
||||
}) : undefined;
|
||||
|
||||
async start (context: JobContext): Promise<any>
|
||||
{
|
||||
context.setProgress(0, "Retrieving Device");
|
||||
let res = await fetch("https://id.twitch.tv/oauth2/device", {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
scopes: this.twitchScopes
|
||||
}),
|
||||
signal: context.abortSignal
|
||||
});
|
||||
|
||||
const device: TwitchDevice = await res.json();
|
||||
const expiredTimeout = setTimeout(() => context.abort('expired'), device.expires_in * 1000);
|
||||
device.expires_at = new Date(new Date().getTime() + device.expires_in * 1000);
|
||||
device.started_at = new Date();
|
||||
this.device = device;
|
||||
|
||||
try
|
||||
{
|
||||
if (this.openInBrowser)
|
||||
open(device.verification_uri);
|
||||
this.device = device;
|
||||
context.setProgress(50, "Waiting For Authentication");
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (context.abortSignal.aborted) break;
|
||||
await delay(device.interval * 1000, context.abortSignal);
|
||||
|
||||
res = await fetch("https://id.twitch.tv/oauth2/token", {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
scopes: this.twitchScopes,
|
||||
device_code: this.device.device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
||||
}),
|
||||
signal: context.abortSignal
|
||||
});
|
||||
|
||||
if (res.status === 200)
|
||||
{
|
||||
const data: {
|
||||
access_token: string,
|
||||
expires_in: number,
|
||||
refresh_token: string,
|
||||
scope: string[],
|
||||
token_type: string;
|
||||
} = await res.json();
|
||||
|
||||
secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
|
||||
secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
|
||||
secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() });
|
||||
break;
|
||||
}
|
||||
else if (res.status !== 400)
|
||||
{
|
||||
console.error(res.statusText);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} finally
|
||||
{
|
||||
clearTimeout(expiredTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
76
src/bun/api/jobs/update-store.ts
Normal file
76
src/bun/api/jobs/update-store.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { ensureDir } from "fs-extra";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { getStoreFolder } from "../store/store";
|
||||
|
||||
export default class UpdateStoreJob implements IJob
|
||||
{
|
||||
static id = "update-store" as const;
|
||||
static origin = "https://github.com/simeonradivoev/gameflow-store.git";
|
||||
static branch = "master";
|
||||
|
||||
async gitCommand (commands: string[], dir: string)
|
||||
{
|
||||
const proc = Bun.spawn(['git', ...commands], {
|
||||
cwd: dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [output] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
return output.trim();
|
||||
}
|
||||
|
||||
async isGitRepo (dir: string)
|
||||
{
|
||||
return (await this.gitCommand(["rev-parse", "--is-inside-work-tree"], dir)) === 'true';
|
||||
}
|
||||
|
||||
async getOrigin (dir: string)
|
||||
{
|
||||
const origin = await this.gitCommand(["remote", "get-url", "origin"], dir);
|
||||
return origin;
|
||||
}
|
||||
|
||||
async hasChanges (dir: string)
|
||||
{
|
||||
return (await this.gitCommand(["status", "--porcelain"], dir)).length > 0;
|
||||
}
|
||||
|
||||
async start (context: JobContext)
|
||||
{
|
||||
const storeFolder = getStoreFolder();
|
||||
await ensureDir(storeFolder);
|
||||
context.setProgress(10);
|
||||
if (await this.isGitRepo(storeFolder))
|
||||
{
|
||||
const existingOrigin = await this.getOrigin(storeFolder);
|
||||
if (existingOrigin !== UpdateStoreJob.origin)
|
||||
{
|
||||
throw new Error(`Git Repo in downloads is not valid. It has origin of ${existingOrigin}. Repo must be of ${UpdateStoreJob.origin}`);
|
||||
}
|
||||
|
||||
// check for uncommitted changes
|
||||
const status = await this.gitCommand([" status", "--porcelain"], storeFolder);
|
||||
if (status.length > 0)
|
||||
{
|
||||
console.log("Cleaning local changes...");
|
||||
await this.gitCommand(["reset", "--hard"], storeFolder);
|
||||
await this.gitCommand(["clean", "-fd"], storeFolder);
|
||||
}
|
||||
|
||||
// fetch & reset to remote
|
||||
await this.gitCommand(["fetch", "origin"], storeFolder);
|
||||
await this.gitCommand(["reset", "--hard", `origin/${UpdateStoreJob.branch}`], storeFolder);
|
||||
console.log("Shop Repo updated");
|
||||
} else
|
||||
{
|
||||
context.setProgress(50);
|
||||
await this.gitCommand(["clone", "--depth", "1", "--branch", UpdateStoreJob.branch, UpdateStoreJob.origin, '.'], storeFolder);
|
||||
context.setProgress(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,21 @@
|
|||
import { cors } from "@elysiajs/cors";
|
||||
import Elysia from "elysia";
|
||||
import { RPC_PORT } from "../../shared/constants";
|
||||
import { RPC_PORT } from "@shared/constants";
|
||||
import clients from "./clients";
|
||||
import { settings } from "./settings";
|
||||
import { settings } from "./settings/settings";
|
||||
import { system } from "./system";
|
||||
import { store } from "./store/store";
|
||||
import { host } from "../utils/host";
|
||||
import { jobs } from "./jobs/jobs";
|
||||
|
||||
const api = new Elysia({ serve: {} })
|
||||
.use([cors(), clients, settings, system]);
|
||||
.use([cors(), clients, settings, system, store, jobs]);
|
||||
|
||||
export type RommAPIType = typeof clients;
|
||||
export type SettingsAPIType = typeof settings;
|
||||
export type SystemAPIType = typeof system;
|
||||
export type StoreAPIType = typeof store;
|
||||
export type JobsAPIType = typeof jobs;
|
||||
|
||||
export function RunAPIServer ()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core";
|
|||
|
||||
export const games = sqliteTable('games', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
source_id: integer('source_id').unique(),
|
||||
source_id: text('source_id'),
|
||||
source: text("source"),
|
||||
igdb_id: integer("igdb_id").unique(),
|
||||
name: text("name"),
|
||||
|
|
|
|||
10
src/bun/api/schema/cache.ts
Normal file
10
src/bun/api/schema/cache.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export default {
|
||||
item_cache: sqliteTable('item_cache', {
|
||||
key: text('key').primaryKey(),
|
||||
data: text('data', { mode: 'json' }).notNull(),
|
||||
expire_at: integer("expire_at", { mode: 'timestamp' }).notNull(),
|
||||
updated_at: integer("updated_at", { mode: 'timestamp' }).notNull(),
|
||||
})
|
||||
};
|
||||
|
|
@ -29,6 +29,10 @@ export const systemMappings = sqliteTable('systemMappings', {
|
|||
system: text().notNull().references(() => systems.name)
|
||||
});
|
||||
|
||||
export const systemMappingsRelations = relations(systemMappings, ({ one }) => ({
|
||||
system: one(systems, { fields: [systemMappings.system], references: [systems.name] })
|
||||
}));
|
||||
|
||||
export const commands = sqliteTable('commands', {
|
||||
system: text().references(() => systems.name, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
label: text(),
|
||||
|
|
@ -36,7 +40,7 @@ export const commands = sqliteTable('commands', {
|
|||
});
|
||||
|
||||
export const commandsRelations = relations(commands, ({ one }) => ({
|
||||
author: one(systems, {
|
||||
system: one(systems, {
|
||||
fields: [commands.system],
|
||||
references: [systems.name],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
import z from "zod";
|
||||
import { LOGIN_PORT, SettingsSchema } from "@shared/constants";
|
||||
import Elysia, { status } from "elysia";
|
||||
import { config, customEmulators, db, emulatorsDb, taskQueue } from "./app";
|
||||
import * as appSchema from './schema/app';
|
||||
import { findExec } from "./games/services/launchGameService";
|
||||
import * as emulatorSchema from "./schema/emulators";
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync } from "node:fs";
|
||||
import { InstallJob } from "./jobs/install-job";
|
||||
import { move } from "fs-extra";
|
||||
|
||||
export const settings = new Elysia({ prefix: '/api/settings' })
|
||||
.get('/emulators/automatic', async () =>
|
||||
{
|
||||
const localGames = await db.select({ es_slug: appSchema.platforms.es_slug, platform_id: appSchema.platforms.id })
|
||||
.from(appSchema.games)
|
||||
.leftJoin(appSchema.platforms, eq(appSchema.games.platform_id, appSchema.platforms.id))
|
||||
.groupBy(appSchema.platforms.es_slug);
|
||||
|
||||
const platformLookup = new Map(localGames.map(g => [g.es_slug, g.platform_id]));
|
||||
|
||||
const commands = await emulatorsDb
|
||||
.select({ command: emulatorSchema.commands.command, system_slug: emulatorSchema.systems.name })
|
||||
.from(emulatorSchema.commands).where(inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.es_slug).map(s => s.es_slug!)))))
|
||||
.leftJoin(emulatorSchema.systems, eq(emulatorSchema.systems.name, emulatorSchema.commands.system));
|
||||
|
||||
|
||||
const emulatorCounts: Record<string, number> = {};
|
||||
const emulators = commands
|
||||
.flatMap(command =>
|
||||
{
|
||||
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
|
||||
if (!matches)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
matches.forEach(m =>
|
||||
{
|
||||
emulatorCounts[m] = (emulatorCounts[m] ?? 0) + 1;
|
||||
});
|
||||
|
||||
return matches?.map(m => [m, command.system_slug] as [string, string]);
|
||||
}
|
||||
).filter(c => !!c);
|
||||
const uniqueEmulators = new Map(emulators);
|
||||
|
||||
return await Promise.all(Array.from(uniqueEmulators.entries()).map(async ([emulator, system_slug]) =>
|
||||
{
|
||||
let execPath: string | undefined;
|
||||
if (customEmulators.has(emulator))
|
||||
{
|
||||
execPath = customEmulators.get(emulator);
|
||||
} else
|
||||
{
|
||||
execPath = await findExec(emulator);
|
||||
}
|
||||
|
||||
let platform: number | null | undefined = null;
|
||||
if (emulatorCounts[emulator] <= 1)
|
||||
{
|
||||
platform = platformLookup.get(system_slug);
|
||||
}
|
||||
|
||||
return { emulator: emulator, path: execPath, exists: !!execPath && await fs.exists(execPath), path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null };
|
||||
}));
|
||||
}, {
|
||||
response: z.array(z.object({ emulator: z.string(), path: z.string().optional(), exists: z.boolean(), path_cover: z.string().nullable() }))
|
||||
})
|
||||
.put('/emulators/custom/:id', async ({ params: { id }, body: { value } }) =>
|
||||
{
|
||||
return customEmulators.set(id, value);
|
||||
},
|
||||
{
|
||||
body: z.object({ value: z.string() })
|
||||
})
|
||||
.delete('/emulators/custom/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
return customEmulators.delete(id);
|
||||
})
|
||||
.get('/emulators/custom/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
return customEmulators.get(id);
|
||||
},
|
||||
{
|
||||
response: z.string()
|
||||
})
|
||||
.get('/emulators/custom', async () =>
|
||||
{
|
||||
return Object.keys(customEmulators.store);
|
||||
}, {
|
||||
response: z.array(z.string())
|
||||
})
|
||||
.put('/path/download', async ({ body: { manualPath, drive } }) =>
|
||||
{
|
||||
if (taskQueue.hasActiveOfType(InstallJob))
|
||||
{
|
||||
return status("Forbidden", "Installation in progress");
|
||||
}
|
||||
|
||||
const oldDownloadPath = config.get('downloadPath');
|
||||
if (!existsSync(oldDownloadPath))
|
||||
{
|
||||
return status("Not Found", "Old download path doesn't exist");
|
||||
}
|
||||
|
||||
async function isDirEmpty (dirname: string)
|
||||
{
|
||||
const files = await fs.readdir(dirname);
|
||||
return files.length === 0;
|
||||
}
|
||||
|
||||
const path = manualPath ?? drive;
|
||||
|
||||
if (!path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(path) && !isDirEmpty(path))
|
||||
{
|
||||
return status("Conflict", "New location already exists and is not empty");
|
||||
}
|
||||
|
||||
await move(oldDownloadPath, path);
|
||||
config.set('downloadPath', manualPath);
|
||||
return manualPath;
|
||||
}, {
|
||||
body: z.object({
|
||||
manualPath: z.string().optional(),
|
||||
drive: z.string().optional()
|
||||
})
|
||||
})
|
||||
.get("/:id", async ({ params: { id } }) =>
|
||||
{
|
||||
const value = config.get(id);
|
||||
return { value: value };
|
||||
}, {
|
||||
params: z.object({ id: z.keyof(SettingsSchema) }),
|
||||
}).post('/:id',
|
||||
async ({ params: { id }, body: { value }, }) =>
|
||||
{
|
||||
config.set(id, value);
|
||||
}, {
|
||||
params: z.object({ id: z.keyof(SettingsSchema) }),
|
||||
body: z.object({ value: z.any() }),
|
||||
});
|
||||
|
||||
193
src/bun/api/settings/services.ts
Normal file
193
src/bun/api/settings/services.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
|
||||
import * as appSchema from '@schema/app';
|
||||
import { findExec, findExecByName } from "../games/services/launchGameService";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { customEmulators, db, emulatorsDb } from '../app';
|
||||
import fs from 'node:fs/promises';
|
||||
import { cores } from '../emulatorjs/emulatorjs';
|
||||
|
||||
/**
|
||||
* Get emulators based on local games. Only the ones we probably need.
|
||||
* */
|
||||
export async function getRelevantEmulators ()
|
||||
{
|
||||
const localGames = await db.select({ es_slug: appSchema.platforms.es_slug, platform_id: appSchema.platforms.id, platform_name: appSchema.platforms.name })
|
||||
.from(appSchema.games)
|
||||
.leftJoin(appSchema.platforms, eq(appSchema.games.platform_id, appSchema.platforms.id))
|
||||
.groupBy(appSchema.platforms.es_slug);
|
||||
|
||||
const platformLookup = new Map(localGames.filter(g => g.es_slug).map(g => [g.es_slug!, g]));
|
||||
const platformViability = new Map(localGames.filter(g => g.es_slug).map(g => [g.es_slug!, false]));
|
||||
|
||||
// check emulator js
|
||||
for (const platform of platformLookup)
|
||||
{
|
||||
if (cores[platform[0]])
|
||||
platformViability.set(platform[0], true);
|
||||
}
|
||||
|
||||
// all commands based on the local games
|
||||
const commands = await emulatorsDb
|
||||
.select({ command: emulatorSchema.commands.command, system_slug: emulatorSchema.systems.name })
|
||||
.from(emulatorSchema.commands).where(inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.es_slug).map(s => s.es_slug!)))))
|
||||
.leftJoin(emulatorSchema.systems, eq(emulatorSchema.systems.name, emulatorSchema.commands.system));
|
||||
|
||||
|
||||
// get all emulators in said commands
|
||||
const emulators = commands
|
||||
.flatMap(command =>
|
||||
{
|
||||
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
|
||||
if (!matches)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return matches?.map(m => ({ emulator: m, system: command.system_slug }));
|
||||
}
|
||||
).filter(c => !!c);
|
||||
|
||||
// Group them by name
|
||||
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
|
||||
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
|
||||
{
|
||||
let execPath: { path: string; type: string, } | undefined;
|
||||
if (customEmulators.has(emulator))
|
||||
{
|
||||
execPath = { path: customEmulators.get(emulator), type: 'custom' };
|
||||
} else
|
||||
{
|
||||
execPath = await findExecByName(emulator);
|
||||
}
|
||||
|
||||
let platform: number | null | undefined = null;
|
||||
const validSystemSlug = system_slug.find(s => s.system);
|
||||
if (validSystemSlug?.system)
|
||||
{
|
||||
platform = platformLookup.get(validSystemSlug.system)?.platform_id;
|
||||
}
|
||||
|
||||
// check if automatic or custom path found existing binary.
|
||||
// This might not be the actual emulator but I don't care.
|
||||
const exists = !!execPath && await fs.exists(execPath.path);
|
||||
const systems = Array.from(new Set(system_slug.filter(s => s.system).map(s => s.system!)));
|
||||
if (exists)
|
||||
{
|
||||
systems.forEach(s => platformViability.set(s, true));
|
||||
}
|
||||
|
||||
return {
|
||||
emulator: emulator,
|
||||
path: execPath,
|
||||
exists: exists,
|
||||
isCritical: false,
|
||||
path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null,
|
||||
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s)
|
||||
};
|
||||
}));
|
||||
|
||||
finalEmulators.push({
|
||||
emulator: 'emulatorjs',
|
||||
exists: true,
|
||||
path: { path: 'localhost', type: 'js' },
|
||||
path_cover: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
||||
isCritical: false,
|
||||
systems: []
|
||||
});
|
||||
|
||||
return finalEmulators.map(e =>
|
||||
{
|
||||
e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!));
|
||||
return e;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Only emulators we strictly need based on local games. Emulator JS is included as bundled.
|
||||
* If there is even single emulator for a system don't include emulators for that system.
|
||||
*/
|
||||
/*export async function getMissingEmulators ()
|
||||
{
|
||||
const localGames = await db.query.games.findMany({
|
||||
columns: {
|
||||
platform_id: true,
|
||||
slug: true
|
||||
},
|
||||
with: {
|
||||
platform: {
|
||||
columns: {
|
||||
name: true,
|
||||
es_slug: true
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
const platformLookup = new Map(localGames.map(g => [g.platform.es_slug, g]));
|
||||
const platformViability = new Map(localGames.map(g => [g.platform.es_slug, false]));
|
||||
|
||||
// all commands based on the local games
|
||||
const commands = await emulatorsDb.query.commands.findMany({
|
||||
columns: { command: true },
|
||||
where: inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.platform.es_slug).map(s => s.platform.es_slug!)))),
|
||||
with: { system: { columns: { name: true } } }
|
||||
});
|
||||
|
||||
// get all emulators in said commands
|
||||
const emulators = commands
|
||||
.flatMap(command =>
|
||||
{
|
||||
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
|
||||
if (!matches)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return matches?.map(m => ({ emulator: m, system: command.system?.name }));
|
||||
}
|
||||
).filter(c => !!c);
|
||||
|
||||
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
|
||||
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
|
||||
{
|
||||
let execPath: { path: string; type: string, } | undefined;
|
||||
if (customEmulators.has(emulator))
|
||||
{
|
||||
execPath = { path: customEmulators.get(emulator), type: 'custom' };
|
||||
} else
|
||||
{
|
||||
execPath = await findExecByName(emulator);
|
||||
}
|
||||
|
||||
let platform: number | null | undefined = null;
|
||||
if (system_slug.length <= 1)
|
||||
{
|
||||
platform = platformLookup.get(system_slug[0].system)?.platform_id;
|
||||
}
|
||||
|
||||
// check if automatic or custom path found existing binary.
|
||||
// This might not be the actual emulator but I don't care.
|
||||
const exists = !!execPath && await fs.exists(execPath.path);
|
||||
const systems = Array.from(new Set(system_slug.map(s => s.system)));
|
||||
if (exists)
|
||||
{
|
||||
systems.forEach(s => platformViability.set(s, true));
|
||||
}
|
||||
|
||||
return {
|
||||
emulator: emulator,
|
||||
path: execPath,
|
||||
exists: exists,
|
||||
isCritical: false,
|
||||
path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null,
|
||||
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s)
|
||||
};
|
||||
}));
|
||||
|
||||
return finalEmulators.map(e =>
|
||||
{
|
||||
e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!));
|
||||
return e;
|
||||
});
|
||||
}*/
|
||||
94
src/bun/api/settings/settings.ts
Normal file
94
src/bun/api/settings/settings.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import z from "zod";
|
||||
import { SettingsSchema } from "@shared/constants";
|
||||
import Elysia, { status } from "elysia";
|
||||
import { config, customEmulators, taskQueue } from "../app";
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync } from "node:fs";
|
||||
import { InstallJob } from "../jobs/install-job";
|
||||
import { move } from "fs-extra";
|
||||
import { getRelevantEmulators } from "./services";
|
||||
|
||||
export const settings = new Elysia({ prefix: '/api/settings' })
|
||||
.get('/emulators/automatic', async () =>
|
||||
{
|
||||
return getRelevantEmulators();
|
||||
})
|
||||
.put('/emulators/custom/:id', async ({ params: { id }, body: { value } }) =>
|
||||
{
|
||||
return customEmulators.set(id, value);
|
||||
},
|
||||
{
|
||||
body: z.object({ value: z.string() })
|
||||
})
|
||||
.delete('/emulators/custom/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
return customEmulators.delete(id);
|
||||
})
|
||||
.get('/emulators/custom/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
return customEmulators.get(id);
|
||||
},
|
||||
{
|
||||
response: z.string()
|
||||
})
|
||||
.get('/emulators/custom', async () =>
|
||||
{
|
||||
return Object.keys(customEmulators.store);
|
||||
}, {
|
||||
response: z.array(z.string())
|
||||
})
|
||||
.put('/path/download', async ({ body: { manualPath, drive } }) =>
|
||||
{
|
||||
if (taskQueue.hasActiveOfType(InstallJob))
|
||||
{
|
||||
return status("Forbidden", "Installation in progress");
|
||||
}
|
||||
|
||||
const oldDownloadPath = config.get('downloadPath');
|
||||
if (!existsSync(oldDownloadPath))
|
||||
{
|
||||
return status("Not Found", "Old download path doesn't exist");
|
||||
}
|
||||
|
||||
async function isDirEmpty (dirname: string)
|
||||
{
|
||||
const files = await fs.readdir(dirname);
|
||||
return files.length === 0;
|
||||
}
|
||||
|
||||
const path = manualPath ?? drive;
|
||||
|
||||
if (!path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(path) && !isDirEmpty(path))
|
||||
{
|
||||
return status("Conflict", "New location already exists and is not empty");
|
||||
}
|
||||
|
||||
await move(oldDownloadPath, path);
|
||||
config.set('downloadPath', manualPath);
|
||||
return manualPath;
|
||||
}, {
|
||||
body: z.object({
|
||||
manualPath: z.string().optional(),
|
||||
drive: z.string().optional()
|
||||
})
|
||||
})
|
||||
.get("/:id", async ({ params: { id } }) =>
|
||||
{
|
||||
const value = config.get(id);
|
||||
return { value: value };
|
||||
}, {
|
||||
params: z.object({ id: z.keyof(SettingsSchema) }),
|
||||
}).post('/:id',
|
||||
async ({ params: { id }, body: { value }, }) =>
|
||||
{
|
||||
config.set(id, value);
|
||||
}, {
|
||||
params: z.object({ id: z.keyof(SettingsSchema) }),
|
||||
body: z.object({ value: z.any() }),
|
||||
});
|
||||
|
||||
59
src/bun/api/store/services/gamesService.ts
Normal file
59
src/bun/api/store/services/gamesService.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { GithubManifestSchema, StoreGameSchema } from "@/shared/constants";
|
||||
import { CACHE_KEYS, getOrCached } from "../../cache";
|
||||
|
||||
export async function getStoreGameManifest ()
|
||||
{
|
||||
return getOrCached(CACHE_KEYS.STORE_GAME_MANIFEST, async () =>
|
||||
{
|
||||
const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json()).then(data => GithubManifestSchema.parseAsync(data));
|
||||
|
||||
return store.tree.filter((e: any) =>
|
||||
{
|
||||
if (e.type === 'blob' && e.path !== "featured.json")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; })
|
||||
{
|
||||
const offset = filter?.offset ?? 0;
|
||||
const limit = Math.min(50, filter?.limit ?? 10);
|
||||
|
||||
const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) =>
|
||||
{
|
||||
return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, "")))));
|
||||
}));
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
export function extractStoreGameSourceId (id: string)
|
||||
{
|
||||
const gameId = id.split('@');
|
||||
if (gameId.length !== 2)
|
||||
throw new Error("Store ID should include platform and name with @ separator");
|
||||
return { system: gameId[0], id: gameId[1] };
|
||||
}
|
||||
|
||||
export function getStoreGameFromId (id: string)
|
||||
{
|
||||
const data = extractStoreGameSourceId(id);
|
||||
return getStoreGame(data.system, data.id);
|
||||
}
|
||||
|
||||
export async function getStoreGame (system: string, id: string)
|
||||
{
|
||||
return getStoreGameFromPath(`${system}/${encodeURIComponent(id)}.json`);
|
||||
}
|
||||
|
||||
export async function getStoreGameFromPath (path: string)
|
||||
{
|
||||
const game = await getOrCached(CACHE_KEYS.STORE_GAME(path), () => fetch(`https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/${path}`)
|
||||
.then(e => e.json())
|
||||
.then(g => StoreGameSchema.parseAsync(g)));
|
||||
return game;
|
||||
}
|
||||
201
src/bun/api/store/store.ts
Normal file
201
src/bun/api/store/store.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
|
||||
import Elysia from "elysia";
|
||||
import { config, customEmulators, db } 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 * 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;
|
||||
}
|
||||
|
||||
export const store = new Elysia({ prefix: '/api/store' })
|
||||
.get('/emulators', async ({ query }) =>
|
||||
{
|
||||
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e =>
|
||||
{
|
||||
console.error(e);
|
||||
return undefined;
|
||||
});
|
||||
const emulatesParsed = await getAllStoreEmulatorPackages();
|
||||
let frontEndEmulators = await Promise.all(emulatesParsed
|
||||
.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 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));
|
||||
if (romPlatform)
|
||||
{
|
||||
return romPlatform.rom_count;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
}));
|
||||
|
||||
const gameCount = gameCounts.reduce((a, c) => a + c);
|
||||
|
||||
return { ...emulator, exists, systems, gameCount } satisfies FrontEndEmulator;
|
||||
}));
|
||||
|
||||
if (query.missing)
|
||||
{
|
||||
frontEndEmulators = frontEndEmulators.filter(e => !e.exists);
|
||||
}
|
||||
|
||||
if (query.orderBy === 'importance')
|
||||
{
|
||||
frontEndEmulators.sort((a, b) =>
|
||||
{
|
||||
const gameCountDiff = b.gameCount - a.gameCount;
|
||||
if (gameCountDiff !== 0) return gameCountDiff;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
if (query.limit)
|
||||
{
|
||||
frontEndEmulators = frontEndEmulators.splice(0, query.limit);
|
||||
}
|
||||
|
||||
return frontEndEmulators;
|
||||
},
|
||||
{
|
||||
query: z.object({
|
||||
limit: z.coerce.number().optional(),
|
||||
missing: z.stringbool().optional().describe("Show Only Non Installed emulators"),
|
||||
orderBy: z.enum(['name', 'recently_updated', 'importance']).optional()
|
||||
})
|
||||
})
|
||||
.get('/games/featured', async () =>
|
||||
{
|
||||
const response = await fetch('https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/featured.json');
|
||||
const games = await z.object({ featured: z.array(StoreGameSchema) }).parseAsync(await response.json());
|
||||
return Promise.all(games.featured.map(async g =>
|
||||
{
|
||||
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(`${g.system}@${g.title}`, 'store') });
|
||||
if (localGame) return convertLocalToFrontendDetailed(localGame);
|
||||
return convertStoreToFrontendDetailed(g.system, g.title, g);
|
||||
}));
|
||||
})
|
||||
.get('/stats', async () =>
|
||||
{
|
||||
const emulatesParsed = await getAllStoreEmulatorPackages();
|
||||
const storeEmulatorCount = emulatesParsed.filter(e => e.os.includes(process.platform as any)).length;
|
||||
const gameCount = await db.$count(appSchema.games);
|
||||
return {
|
||||
storeEmulatorCount,
|
||||
gameCount
|
||||
};
|
||||
})
|
||||
.get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) =>
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
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 } }) =>
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
const emulatorPath = path.join(downlodDir, "store", "buckets", "emulators", `${id}.json`);
|
||||
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 emulator: FrontEndEmulatorDetailed = {
|
||||
...emulatorPackage,
|
||||
systems,
|
||||
exists,
|
||||
status: {
|
||||
source: execPath?.type,
|
||||
location: execPath?.path
|
||||
},
|
||||
screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`)
|
||||
};
|
||||
|
||||
return emulator;
|
||||
}, { params: z.object({ id: z.string() }) });
|
||||
|
|
@ -2,7 +2,7 @@ import Elysia from "elysia";
|
|||
import open from 'open';
|
||||
import z from "zod";
|
||||
import os from 'node:os';
|
||||
import { config, events } from "./app";
|
||||
import { cachePath, config, events } from "./app";
|
||||
import { isSteamDeck, openExternal } from "../utils";
|
||||
import fs from 'node:fs/promises';
|
||||
import buildNotificationsStream from "./notifications";
|
||||
|
|
@ -11,6 +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";
|
||||
|
||||
export const system = new Elysia({ prefix: '/api/system' })
|
||||
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
|
||||
|
|
@ -48,7 +49,9 @@ export const system = new Elysia({ prefix: '/api/system' })
|
|||
hostname: os.hostname(),
|
||||
steamDeck: process.env.SteamDeck,
|
||||
machine: os.machine(),
|
||||
source
|
||||
source,
|
||||
cacheSize: (await fs.stat(cachePath)).size,
|
||||
storeSize: (await getFolderSize(getStoreFolder())).size
|
||||
};
|
||||
})
|
||||
.get('/notifications', ({ set }) =>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export class TaskQueue
|
|||
setTimeout(this.processQueue);
|
||||
});
|
||||
return promise;
|
||||
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
|
@ -91,8 +92,10 @@ export class TaskQueue
|
|||
|
||||
export interface EventsList
|
||||
{
|
||||
started: [e: BaseEvent];
|
||||
progress: [e: ProgressEvent];
|
||||
abort: [e: AbortEvent];
|
||||
/** Called when the job successfully completes */
|
||||
completed: [e: CompletedEvent];
|
||||
error: [e: ErrorEvent];
|
||||
ended: [e: BaseEvent];
|
||||
|
|
@ -101,7 +104,7 @@ export interface EventsList
|
|||
interface BaseEvent
|
||||
{
|
||||
id: string;
|
||||
job: IJob;
|
||||
job: IPublicJob;
|
||||
}
|
||||
|
||||
interface ErrorEvent extends BaseEvent
|
||||
|
|
@ -128,6 +131,7 @@ interface CompletedEvent extends BaseEvent
|
|||
export interface IJob
|
||||
{
|
||||
start (context: JobContext): Promise<any>;
|
||||
exposeData?(): any;
|
||||
}
|
||||
|
||||
export type JobStatus = 'completed' | 'error' | 'running' | 'waiting' | 'aborted';
|
||||
|
|
@ -137,7 +141,7 @@ export interface IPublicJob
|
|||
progress: number;
|
||||
state?: string;
|
||||
status: JobStatus;
|
||||
job: any;
|
||||
job: IJob;
|
||||
abort: (reason?: any) => void;
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +156,7 @@ export class JobContext implements IPublicJob
|
|||
private error?: any;
|
||||
private events: EventEmitter<EventsList>;
|
||||
private abortController: AbortController;
|
||||
private m_job: IJob;
|
||||
private readonly m_job: IJob;
|
||||
|
||||
constructor(id: string, events: EventEmitter<EventsList>, job: IJob)
|
||||
{
|
||||
|
|
@ -162,7 +166,7 @@ export class JobContext implements IPublicJob
|
|||
this.abortController.signal.addEventListener('abort', () =>
|
||||
{
|
||||
this.aborted = true;
|
||||
this.events.emit('abort', { id: this.m_id, reason: this.abortController.signal.reason, job: this.m_job } satisfies AbortEvent);
|
||||
this.events.emit('abort', { id: this.m_id, reason: this.abortController.signal.reason, job: this } satisfies AbortEvent);
|
||||
});
|
||||
this.events = events;
|
||||
}
|
||||
|
|
@ -171,19 +175,24 @@ export class JobContext implements IPublicJob
|
|||
{
|
||||
try
|
||||
{
|
||||
this.events.emit('started', { id: this.m_id, job: this });
|
||||
await this.m_job.start(this);
|
||||
this.completed = true;
|
||||
this.events.emit('completed', { id: this.m_id, job: this.m_job });
|
||||
this.events.emit('completed', { id: this.m_id, job: this });
|
||||
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
this.events.emit('error', { id: this.m_id, job: this.m_job, error });
|
||||
if (error !== 'cancel')
|
||||
{
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
this.events.emit('error', { id: this.m_id, job: this, error });
|
||||
this.error = error;
|
||||
} finally
|
||||
{
|
||||
this.running = false;
|
||||
this.events.emit('ended', { id: this.m_id, job: this.m_job });
|
||||
this.events.emit('ended', { id: this.m_id, job: this });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -211,7 +220,7 @@ export class JobContext implements IPublicJob
|
|||
this.m_progress = progress;
|
||||
if (state)
|
||||
this.m_state = state;
|
||||
this.events.emit('progress', { id: this.m_id, progress, state: state ?? this.m_state, job: this.m_job });
|
||||
this.events.emit('progress', { id: this.m_id, progress, state: state ?? this.m_state, job: this });
|
||||
}
|
||||
|
||||
public abort (reason?: any)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue