gameflow-deck/src/bun/api/games/games.ts
Simeon Radivoev 816d50ae4d
fix: Fixed romm login, now uses token
feat: Moved romm to internal plugin
fix: Made focusing and navigation more reliable
fix: Loading errors on first time launch
2026-03-28 17:32:51 +02:00

591 lines
No EOL
23 KiB
TypeScript

import Elysia, { status } from "elysia";
import { config, db, emulatorsDb, plugins, taskQueue } from "../app";
import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm";
import z from "zod";
import * as schema from "@schema/app";
import fs from "node:fs/promises";
import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler";
import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService";
import { getErrorMessage, SeededRandom } from "@/bun/utils";
import { defaultFormats, defaultPlugins } from 'jimp';
import { createJimp } from "@jimp/core";
import webp from "@jimp/wasm-webp";
import * as emulatorSchema from '@schema/emulators';
import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
import { host } from "@/bun/utils/host";
import { LaunchGameJob } from "../jobs/launch-game-job";
// 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; })
{
try
{
if ((blur && !noBlur))
{
const jimp = await Jimp.read(img);
if (blur && !noBlur)
{
jimp.blur(blur);
}
if (width)
{
jimp.resize({ w: width, h: height });
} else if (height)
{
jimp.resize({ w: width, h: height });
}
return jimp.getBuffer('image/png');
}
} catch (e)
{
}
if (typeof img === 'string')
{
const rommFetch = await fetch(img);
return rommFetch;
}
return img;
}
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)
{
return status(404);
}
if (coverBlob.cover_type)
{
set.headers["content-type"] = coverBlob.cover_type;
}
return processImage(coverBlob.cover, query);
}, {
params: z.object({ id: z.coerce.number() }),
query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() })
})
.get('/image/:source/*', async ({ params: { source, "*": path }, query, set }) =>
{
if (source === 'romm')
{
set.headers["cross-origin-resource-policy"] = 'cross-origin';
const rommAdress = config.get('rommAddress');
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(), noBlur: z.coerce.boolean().optional() }) })
.get('/image', async ({ query, set }) =>
{
set.headers["cross-origin-resource-policy"] = 'cross-origin';
return processImage(query.url, query);
}, { query: z.object({ url: z.url(), blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) })
.get('/screenshot/:id', async ({ params: { id }, query, set }) =>
{
set.headers["cross-origin-resource-policy"] = 'cross-origin';
const screenshot = await db.query.screenshots.findFirst({ where: eq(schema.screenshots.id, id), columns: { content: true, type: true } });
if (screenshot)
{
if (screenshot.type)
{
set.headers["content-type"] = screenshot.type;
}
return processImage(screenshot.content, query);
}
return status(404);
}, {
params: z.object({ id: z.coerce.number() }),
query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() })
})
.get("/game/local/:id/installed", async ({ params: { id } }) =>
{
const data = await db.query.games.findFirst({ where: eq(schema.games.id, id) });
if (data && data.path_fs)
{
return { installed: await fs.exists(data.path_fs) };
}
return { installed: false };
}, {
params: z.object({ id: z.number() }),
response: z.object({ installed: z.boolean() })
})
.get('/games', async ({ query, set }) =>
{
const games: FrontEndGameType[] = [];
if (query.source === 'store')
{
const shuffledGames = await getShuffledStoreGames();
set.headers['x-max-items'] = shuffledGames.length;
const storeGames = await Promise.all(shuffledGames
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length))
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const localGame = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.where(and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)));
if (localGame.length > 0) return convertLocalToFrontend(localGame[0]);
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
games.push(...storeGames.filter(g => g !== undefined));
} else
{
const where: any[] = [];
let localGamesSet: Set<string> | undefined;
if (query.platform_slug)
{
where.push(eq(schema.platforms.slug, query.platform_slug));
} else if (query.platform_id && query.platform_source === 'local')
{
where.push(eq(schema.platforms.id, query.platform_id));
}
else if (query.platform_id && query.platform_source)
{
const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: String(query.platform_id) });
if (platform)
{
where.push(eq(schema.platforms.slug, platform?.slug));
}
}
if (query.source)
{
where.push(eq(schema.games.source, query.source));
}
const localGames = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.where(and(...where));
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
if (!query.collection_id)
{
games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g =>
{
return convertLocalToFrontend(g);
}));
const remoteGames: FrontEndGameType[] = [];
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`)));
} else
{
const remoteGames: FrontEndGameType[] = [];
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
games.push(...remoteGames.map(g =>
{
if (localGamesSet?.has(`${g.id.source}@${g.id.id}`))
{
return convertLocalToFrontend(localGames.find(l => l.source === g.id.source && l.source_id === g.id.id)!);
} else
{
return g;
}
}));
}
}
if (query.orderBy)
{
switch (query.orderBy)
{
case 'added':
games.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime());
break;
case 'activity':
games.sort((a, b) => Math.max(b.updated_at.getTime(), b.last_played?.getTime() ?? 0) - Math.max(a.updated_at.getTime(), a.last_played?.getTime() ?? 0));
break;
case 'name':
games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
break;
}
}
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 } }) =>
{
const sourceData = await getSourceGameDetailed(source, id);
if (sourceData)
{
if (sourceData.platform_slug)
{
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) });
if (systemMapping)
{
const emulatorNames = await getEmulatorsForSystem(systemMapping.system);
const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e }))));
sourceData.emulators = await Promise.all(emulators.map(async ({ name, data }) =>
{
if (data)
{
const systems = await buildStoreFrontendEmulatorSystems(data);
return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true };
}
else if (name === 'EMULATORJS')
{
return {
name: 'EMULATORJS',
validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }],
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
systems: [],
gameCount: 0
} satisfies FrontEndGameTypeDetailedEmulator;
}
else
{
return {
name: name,
logo: "",
systems: [],
gameCount: 0,
validSources: []
} satisfies FrontEndGameTypeDetailedEmulator;
}
}));
}
}
return sourceData;
} else
{
return status("Not Found");
}
}, {
params: z.object({ source: z.string(), id: z.string() })
})
.use(buildStatusResponse())
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
{
const deleted = await db.delete(schema.games).where(getLocalGameMatch(id, source)).returning({ path_fs: schema.games.path_fs });
const downloadPath = config.get('downloadPath');
await Promise.all(deleted.filter(d => !!d.path_fs).map(async d =>
{
await fs.rm(path.join(downloadPath, d.path_fs!), { recursive: true, force: true });
}));
return status(deleted.length > 0 ? 'OK' : 'Not Modified');
}, {
params: z.object({ id: z.string(), source: z.string() }),
})
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
{
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
{
if (source === 'romm' || source === 'store')
{
taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source));
return status(200);
}
return status('Not Implemented');
} else
{
return status('Not Implemented');
}
}, {
params: z.object({ id: z.string(), source: z.string() }),
response: z.any()
})
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
{
const job = taskQueue.findJob(InstallJob.query({ source, id }), InstallJob);
if (job)
{
job.abort('cancel');
return status('OK');
}
return status('Not Found');
}, {
params: z.object({ id: z.string(), source: z.string() }),
response: z.any()
})
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
{
const validCommands = await getValidLaunchCommandsForGame(source, id);
if (validCommands)
{
if (validCommands instanceof Error)
{
return errorToResponse(validCommands, set);
}
else
{
try
{
const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0];
if (validCommand)
{
// launch command waits for the game to exit, we don't want that.
await launchCommand(validCommand, source, id, validCommands.gameId);
return { type: 'application', command: null };
} else
{
return status("Not Found");
}
} catch (error)
{
console.error(error);
return status('Internal Server Error', getErrorMessage(error));
}
}
}
}, {
params: z.object({ id: z.string(), source: z.string() }),
body: z.object({ command_id: z.number().or(z.string()).optional() }),
response: z.object({ type: z.enum(['emulatorjs', 'application']), command: z.string().nullable() })
})
.post("/stop", async ({ }) =>
{
const job = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
if (job)
{
job.abort('cancel');
}
})
.get('/emulatorjs/data/cores/*', async ({ params }) =>
{
const res = await fetch(`https://cdn.emulatorjs.org/latest/data/cores/${params['*']}`);
return res;
})
.get('/emulatorjs/data/*', async () =>
{
return status("Not Found");
})
.get('/recommended/games/emulator/:id', async ({ params: { id } }) =>
{
const emulator = await getStoreEmulatorPackage(id);
if (!emulator) return status("Not Found");
const systems = await buildStoreFrontendEmulatorSystems(emulator);
const systemsIdSet = new Set(systems.map(s => s.id));
const games: FrontEndGameType[] = [];
let localGamesSet: Set<string> | undefined;
const localGames = await db.select({
...getTableColumns(schema.games),
platform: schema.platforms,
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
})
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
.groupBy(schema.games.id)
.where(inArray(schema.platforms.slug, systems.map(s => s.id)));
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g =>
{
return convertLocalToFrontend(g);
}).slice(0, 3));
const remoteGames: FrontEndGameType[] = [];
await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames });
games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`)));
const gamesManifest = await getStoreGameManifest();
const storeGames = await Promise.all(gamesManifest
.filter(g => systemsIdSet.has(path.dirname(g.path)))
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
if (localGame)
{
return undefined;
}
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
games.push(...storeGames.filter(g => g !== undefined).slice(0, 3));
return games;
})
.get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) =>
{
const sourceData = await getSourceGameDetailed(source, id);
if (!sourceData) return status("Not Found");
const sourceCompaniesSet = new Set(sourceData.companies);
const sourceGenresSet = new Set(sourceData.genres);
const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined;
const games: (FrontEndGameType & { metadata?: any; })[] = [];
const localGames = await db.select({ ...getTableColumns(schema.games), platform: schema.platforms })
.from(schema.games)
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
.groupBy(schema.games.id);
const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`));
games.push(...localGames.map(g => ({ ...convertLocalToFrontend(g), metadata: g.metadata })));
const shuffledGames = await getShuffledStoreGames();
const storeGames = await Promise.all(shuffledGames
.filter(g =>
{
const system = path.dirname(g.path);
const id = path.basename(g.path, path.extname(g.path));
if (localGamesSourceSet.has(`${system}@${id}`))
return false;
if (esSystem)
{
if (path.dirname(g.path) === esSystem.system) return true;
}
return false;
})
.map(async (e) =>
{
const system = path.dirname(e.path);
const id = path.basename(e.path, path.extname(e.path));
const storeGame = await getStoreGameFromPath(e.path);
return convertStoreToFrontend(system, id, storeGame);
}));
if (storeGames)
{
games.push(...storeGames.slice(0, 3));
}
const remoteGames: (FrontEndGameType & { metadata?: any; })[] = [];
plugins.hooks.games.fetchRecommendedGamesForGame.promise({
game: sourceData, games: remoteGames
});
games.push(...remoteGames.filter(g => !localGamesSourceSet.has(`${g.id.source}@${g.id.id}`)));
const random = new SeededRandom(Math.round(new Date().getTime() / 1000 / 60 / 60));
const rankedGames = games.filter(g =>
{
if (sourceData.source && g.id.id === sourceData.source_id && g.id.source === sourceData.source)
{
return false;
}
if (g.id.id === sourceData.id.id && g.id.source === sourceData.id.source)
{
return false;
}
return true;
}).map(g =>
{
let rank = random.next();
if (g.platform_slug === sourceData.platform_slug)
rank += 1;
if (g.id.source === 'local')
rank -= 0.2;
if (g.metadata)
{
if (g.metadata.companies instanceof Array && g.metadata.companies.some((c: string) => sourceCompaniesSet.has(c)))
{
rank += 1;
}
if (g.metadata.genres instanceof Array && g.metadata.genres.some((g: string) => sourceGenresSet.has(g)))
{
rank += 1;
}
}
return { rank: rank, game: g };
});
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
return rankedGames.map(g => g.game).slice(0, 10);
});