import { config, db, plugins, taskQueue } from "../../app"; import { eq } from "drizzle-orm"; import { getErrorMessage } from "@/bun/utils"; import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils"; import fs from 'node:fs/promises'; import Elysia from "elysia"; import z from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; import * as appSchema from "@schema/app"; import { RPC_URL } from "@/shared/constants"; import { DownloadSourceSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { host } from "@/bun/utils/host"; import { CommandEntry, FrontEndId, GameLookup, GameStatusType, LocalDownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared"; export class CommandSearchError extends Error { constructor(status: GameStatusType, message: string) { super(message); this.name = status; } } export async function getLocalGame (source: string, id: string) { const localGame = await db.query.games.findFirst({ columns: { id: true, path_fs: true, source: true, source_id: true, igdb_id: true, ra_id: true, main_glob: true }, where: getLocalGameMatch(id, source), with: { platform: { columns: { slug: true } } } }); return localGame; } /** Update local game's metadata from custom source, not the actual source of the game. Say from metadata providers like IGDB */ export async function customUpdate (source: string, id: string, destination: string, destinationId: string) { const localGame = await getLocalGame(source, id); if (!localGame) throw new Error("Could not find Local Game"); const matchesMap = new Map(); await plugins.hooks.games.gameLookup.promise(matchesMap, { source: destination, id: destinationId }); const matches = matchesMap.values().next().value; if (!matches || matches?.length <= 0) throw new Error("Could not find destination"); const match = matches[0]; await db.transaction(async (tx) => { await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id)); // pre-fetch screenshots const screenshots = await Promise.all(match.screenshotUrls.map(s => fetch(s))); if (screenshots.length > 0) { await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) => { const screenshot: typeof appSchema.screenshots.$inferInsert = { game_id: localGame.id, content: Buffer.from(await response.arrayBuffer()), type: response.headers.get('content-type') }; return screenshot; }))); } let cover: Buffer | undefined = undefined; if (match.coverUrl) { const coverResponse = await fetch(match.coverUrl); if (coverResponse.ok) { cover = Buffer.from(await coverResponse.arrayBuffer()); } } await tx.update(appSchema.games).set({ cover, metadata: { age_ratings: match.age_ratings, genres: match.genres, player_count: match.player_count ?? undefined, companies: match.companies, game_modes: match.game_modes, average_rating: match.average_rating ?? undefined, first_release_date: match.first_release_date, } }).where(eq(appSchema.games.id, localGame.id)); }); } export async function update (source: string, id: string) { const localGame = await getLocalGame(source, id); if (!localGame) throw new Error("Could not find Local Game"); if (!localGame.source || !localGame.source_id) throw new Error("Game has not source defined"); const sourceGame = await getSourceGameDetailed(localGame.source, localGame.source_id, { sourceOnly: true }); if (!sourceGame) throw new Error("Could not find source game"); await db.transaction(async (tx) => { await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id)); const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)]; if (paths_screenshots.length <= 0 && sourceGame.igdb_id) { const matches = new Map(); await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(sourceGame.igdb_id) }); if (matches.size > 0) { const firstMatches = matches.values().next().value; if (firstMatches && firstMatches.length > 0) { paths_screenshots.push(...firstMatches[0].screenshotUrls); } } } // pre-fetch screenshots const screenshots = await Promise.all(paths_screenshots.map(s => fetch(s))); if (screenshots.length > 0) { await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) => { const screenshot: typeof appSchema.screenshots.$inferInsert = { game_id: localGame.id, content: Buffer.from(await response.arrayBuffer()), type: response.headers.get('content-type') }; return screenshot; }))); } await tx.update(appSchema.games).set({ metadata: { age_ratings: sourceGame.metadata.age_ratings, genres: sourceGame.metadata.genres, player_count: sourceGame.metadata.player_count ?? undefined, companies: sourceGame.metadata.companies, game_modes: sourceGame.metadata.game_modes, average_rating: sourceGame.metadata.average_rating ?? undefined, first_release_date: sourceGame.metadata.first_release_date?.getTime() ?? undefined, } }).where(eq(appSchema.games.id, localGame.id)); }); } export async function fixSource (source: string, id: string) { const valid = await validateGameSource(source, id); if (!valid.valid) { if (!valid.localGame) throw new Error("No Local Game"); if (!valid.localGame.source) throw new Error("No Valid Source"); const foundGame = await plugins.hooks.games.searchGame.promise({ igdb_id: valid.localGame.igdb_id ?? undefined, ra_id: valid.localGame.ra_id ?? undefined, source: valid.localGame.source }); if (foundGame) { await db.update(appSchema.games).set({ source: foundGame.id.source, source_id: foundGame.id.id, metadata: { age_ratings: foundGame.metadata.age_ratings, genres: foundGame.metadata.genres, player_count: foundGame.metadata.player_count ?? undefined, companies: foundGame.metadata.companies, game_modes: foundGame.metadata.game_modes, average_rating: foundGame.metadata.average_rating ?? undefined, first_release_date: foundGame.metadata.first_release_date?.getTime() ?? undefined, } }).where(eq(appSchema.games.id, valid.localGame.id)); return true; } else { throw new Error("Could not find Source Game"); } } else { throw new Error("Game Source Already Valid"); } } export async function validateGameSource (source: string, id: string): Promise<{ valid: boolean, localGame?: { id: number; igdb_id: number | null; ra_id: number | null; source: string | null; }, reason?: string; }> { const localGame = await getLocalGame(source, id); if (!localGame) return { valid: true }; if (localGame.source && localGame.source_id) { const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); if (!sourceGame) return { valid: false, reason: "Source Missing", localGame }; // Store should be immutable if (localGame.source !== 'store' && sourceGame.igdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) { return { valid: false, reason: "Metadata Missmatch", localGame }; } } return { valid: true, localGame }; } export async function updateLocalLastPlayed (id: number) { await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(id))); } export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined> { const localGame = await getLocalGame(source, id); if (localGame) { const commands = await plugins.hooks.games.buildLaunchCommands.promise({ source: localGame.source, sourceId: localGame.source_id, id: { source: 'local', id: String(localGame.id) }, systemSlug: localGame.platform.slug, gamePath: localGame.path_fs, mainGlob: localGame.main_glob, }); if (commands instanceof Error || !commands) return commands; const validCommand = commands.find(c => c.valid); if (validCommand) { return { commands: commands.filter(c => c.valid), gameId: { id: String(localGame.id), source: 'local' }, source: localGame.source ?? source, sourceId: localGame.source_id ? String(localGame.source_id) : id, }; } else { 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(', ')}`); } } else if (source === 'emulator') { const commands = await plugins.hooks.games.buildLaunchCommands.promise({ source, sourceId: id, id: { source: source, id: id }, systemSlug: "", gamePath: null }); if (commands instanceof Error || !commands) return commands; const validCommand = commands.find(c => c.valid); if (validCommand) { return { commands: commands.filter(c => c.valid), gameId: { id, source } }; } else { 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(', ')}`); } } return undefined; } export default function buildStatusResponse () { return new Elysia().ws('/status/:source/:id', { response: z.discriminatedUnion('status', [ z.object({ status: z.literal('error'), error: z.unknown() }), z.object({ status: z.literal('installed'), commands: z.array(z.any()), details: z.string().optional() }), z.object({ status: z.literal('refresh'), localId: z.number().optional() }), z.object({ status: z.literal(['queued']) }), z.object({ status: z.literal('playing'), details: z.string() }), z.object({ status: z.literal('install'), details: z.string(), sources: DownloadSourceSchema.array() }), z.object({ status: z.literal('present'), details: z.string() }), z.object({ status: z.literal(['download', 'extract']), progress: z.number() }), ]), message (ws, data) { if (data === 'cancel') { const activeTask = taskQueue.findJob(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob); activeTask?.abort('cancel'); } }, async open (ws) { sendLatests().catch(e => ws.send({ status: 'error', error: JSON.stringify(e) })); const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }); async function sendLatests () { if (ws.readyState > 1) return; const activeTask = taskQueue.findJob(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob); if (activeTask) { if (activeTask.status === 'queued') { ws.send({ status: 'queued' }); } else { ws.send({ status: activeTask.state as InstallJobStates, progress: activeTask.progress }); } } else if (taskQueue.hasActiveOfType(LaunchGameJob)) { ws.send({ status: 'playing', details: 'Playing' }); } else { const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source) }); const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id); if (validCommand) { if (validCommand instanceof Error) { ws.send({ status: 'error', error: validCommand.message }); } else { ws.send({ status: 'installed', details: validCommand.commands[0].label, commands: validCommand.commands }); } } else if (!localGame && ws.data.params.source === 'store') { const downloads = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, id: ws.data.params.id }); const sources = downloads?.map(d => ({ id: d.id, name: d.id })) ?? []; /*const storeGame = await getStoreGame(ws.data.params.id); const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); const size = Number(fileResponse.headers.get('content-length')); const stats = await fs.statfs(config.get('downloadPath')); if (size > stats.bsize * stats.bavail) { ws.send({ status: 'error', error: "Not Enough Free Space" }); } else { ws.send({ status: 'install', details: 'Install' }); }*/ ws.send({ status: 'install', details: 'Install', sources }); } else if (!localGame) { const files = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, id: ws.data.params.id }); const sources = files?.map(d => ({ id: d.id, name: d.id })) ?? []; let filesChecked: LocalDownloadFileEntry[] | undefined; if (files && files.length) { filesChecked = await checkFiles(files[0].files, !!files[0].extract_path); } if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false)) { ws.send({ status: 'present', details: "Files Exist On Disk, Import" }); } else { const size = filesChecked?.filter(f => f.exists !== true || f.matches !== true).reduce((p, f) => p += f.size ?? 0, 0); const stats = await fs.statfs(config.get('downloadPath')); if (size && size > stats.bsize * stats.bavail) { ws.send({ status: 'error', error: "Not Enough Free Space" }); } else if (filesChecked?.some(f => f.exists === true && f.matches === false)) { ws.send({ status: 'install', details: 'Some Files Present, Install', sources }); } else { ws.send({ status: 'install', details: 'Install', sources }); } } } else { ws.send({ status: 'error', error: "No Way To Launch" }); } } } const dispose: Function[] = []; const handleActiveExit = async (data: { error?: unknown; }) => { if (data.error) { ws.send({ status: 'error', error: data.error }); } await sendLatests(); }; dispose.push(taskQueue.on('progress', (data) => { if (data.id === installJobId) { ws.send({ status: data.job.state as InstallJobStates, progress: data.progress }); } })); dispose.push(taskQueue.on('queued', (data) => { if (data.id === installJobId) { ws.send({ status: 'queued' }); } })); dispose.push(taskQueue.on('ended', (data) => { if (data.id === installJobId) { ws.send({ status: 'refresh', localId: (data.job.job as InstallJob).localGameId }); } else if (data.job.job instanceof LaunchGameJob) { handleActiveExit({}); } })); dispose.push(taskQueue.on('error', (data) => { if (data.id === installJobId) { ws.send({ status: 'error', error: getErrorMessage(data.error) }); } else if (data.job.job instanceof LaunchGameJob) { handleActiveExit({ error: data.error }); } })); (ws.data as any).cleanup = () => { dispose.forEach(f => f()); }; }, close (ws, code, reason) { (ws.data as any).cleanup?.(); }, }); }