import Elysia, { status } from "elysia"; import { config, db, taskQueue } from "../app"; import { and, eq, getTableColumns, sql } from "drizzle-orm"; import z from "zod"; import * as schema from "../schema/app"; import fs from "node:fs/promises"; import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants"; import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm"; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils"; import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; import { launchCommand } from "./services/launchGameService"; import { getErrorMessage } from "@/bun/utils"; import { Jimp } from 'jimp'; async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height }: { blur?: number, width?: number, height?: number; }) { if (blur) { const jimp = await Jimp.read(img); if (width) { jimp.resize({ w: width, h: height }); } if (height) { jimp.resize({ w: width, h: height }); } if (blur) { jimp.blur(blur); } return jimp.getBuffer('image/png'); } 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 }) => { 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 }) => { if (source === 'romm') { 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() }) }) .get('/image', async ({ query }) => { 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 }) => { 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: { platform_source, platform_slug, platform_id, collection_id } }) => { const where: any[] = []; if (platform_slug) { where.push(eq(schema.platforms.slug, platform_slug)); } const games: FrontEndGameType[] = []; let localGamesSet: Set | undefined; if (!collection_id) { const localGames = await db.select({ ...getTableColumns(schema.games), platform: schema.platforms, screenshotIds: sql`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).map(g => 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; })); } if ((!platform_source || platform_source === 'romm') || !!collection_id) { const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true }); games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(g.id)).map(g => { return convertRomToFrontend(g); })); } return { games }; }, { query: GameListFilterSchema, }) .get('/game/:source/:id', async ({ params: { source, id } }) => { async function getLocalGameDetailed (match: any) { const localGame = await db.query.games.findFirst({ where: match, with: { screenshots: { columns: { id: true } }, platform: { columns: { name: true } } } }); if (localGame) { const exists = await checkInstalled(localGame.path_fs); const fileSize = await calculateSize(localGame.path_fs); const game: FrontEndGameTypeDetailed = { path_cover: `/api/romm/game/local/${localGame.id}/cover`, updated_at: localGame.created_at, id: { id: localGame.id, source: 'local' }, path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`, fs_size_bytes: fileSize ?? null, paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`), local: true, missing: !exists, platform_display_name: localGame.platform?.name, summary: localGame.summary, source: localGame.source, source_id: localGame.source_id, path_fs: localGame.path_fs, last_played: localGame.last_played, slug: localGame.slug, name: localGame.name, platform_id: localGame.platform_id }; return game; } return undefined; } if (source === 'local') { const localGame = await getLocalGameDetailed(eq(schema.games.id, id)); if (localGame) return localGame; return status('Not Found'); } else { const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source)); if (localGame) return localGame; const rom = await getRomApiRomsIdGet({ path: { id } }); if (rom.data) { const romGame = convertRomToFrontendDetailed(rom.data); return romGame; } return status("Not Found", rom.response); } }, { params: z.object({ source: z.string(), id: z.coerce.number() }) }) .get('/status/:source/:id', async ({ params: { source, id }, set }) => { set.headers["content-type"] = 'text/event-stream'; set.headers["cache-control"] = 'no-cache'; set.headers['connection'] = 'keep-alive'; return buildStatusResponse(source, id); }, { response: z.any(), params: z.object({ id: z.coerce.number(), source: z.string() }), query: z.object({ isLocal: z.boolean().optional() }) }) .delete('/game/:source/:id', async ({ params: { source, id } }) => { const deleted = await db.delete(schema.games).where(getLocalGameMatch(id, source)).returning({ path_fs: schema.games.path_fs }); const downloadPath = config.get('downloadPath'); await Promise.all(deleted.filter(d => !!d.path_fs).map(async d => { await fs.rm(path.join(downloadPath, d.path_fs!), { recursive: true, force: true }); })); return status(deleted.length > 0 ? 'OK' : 'Not Modified'); }, { params: z.object({ id: z.coerce.number(), source: z.string() }), }) .post('/game/:source/:id/install', async ({ params: { id, source } }) => { if (!taskQueue.hasActive()) { taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id)); return status(200); } else { return status('Not Implemented'); } }, { params: z.object({ id: z.coerce.number(), source: z.string() }), response: z.any() }) .post('/game/:source/:id/play', async ({ params: { id, source }, set }) => { const validCommand = await getValidLaunchCommandsForGame(source, id); if (validCommand) { if (validCommand instanceof Error) { return errorToResponse(validCommand, set); } else { try { await launchCommand(validCommand.command.command, source, id, validCommand.gameId); } catch (error) { console.error(error); return status('Internal Server Error', getErrorMessage(error)); } } } }, { params: z.object({ id: z.coerce.number(), source: z.string() }), });