gameflow-deck/src/bun/api/games/services/statusService.ts
Simeon Radivoev 62f16cbcc1
feat: Moved to stream zip downloading.
feat: Implemented Shortcuts.
feat: Ensured it works on steam deck
2026-02-21 18:28:07 +02:00

191 lines
No EOL
6.6 KiB
TypeScript

import { GameInstallProgress, GameStatusType, } from "@shared/constants";
import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app";
import { getValidLaunchCommands } from "./launchGameService";
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";
class CommandSearchError extends Error
{
constructor(status: GameStatusType, message: string)
{
super(message);
this.name = status;
}
}
export async function getLocalGame (source: string, id: number)
{
const localGames = await db.select({ id: schema.games.id, path_fs: schema.games.path_fs, platform_slug: schema.platforms.es_slug })
.from(schema.games)
.where(getLocalGameMatch(id, source))
.leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id));
if (localGames.length > 0)
{
return localGames[0];
}
return undefined;
}
export async function getValidLaunchCommandsForGame (source: string, id: number)
{
const localGame = await getLocalGame(source, id);
if (localGame)
{
if (localGame.platform_slug)
{
if (localGame.path_fs)
{
try
{
const commands = await getValidLaunchCommands({ systemSlug: localGame.platform_slug, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs });
const validCommand = commands.find(c => c.valid);
if (validCommand)
{
return { command: validCommand, 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(',')}`);
}
} catch (error)
{
console.error(error);
return new CommandSearchError('error', getErrorMessage(error));
}
} else
{
return new CommandSearchError('error', 'Missing Path');
}
}
else
{
return new CommandSearchError('error', 'Missing Platform');
}
}
return undefined;
}
export default async function buildStatusResponse (source: string, id: number)
{
let cleanup: (() => void) | undefined;
return new Response(new ReadableStream({
async start (controller)
{
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh')
{
const evntString = event ? `event: ${event}\n` : '';
controller.enqueue(`${evntString}data: ${JSON.stringify(data)}\n\n`);
}
const sourceId = `${source}-${id}`;
async function sendLatests ()
{
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`);
if (activeTask)
{
enqueue({
progress: activeTask.progress,
status: activeTask.state as any
});
} else if (activeGame && activeGame.gameId === localGame?.id)
{
enqueue({ status: 'playing' as GameStatusType, details: 'Playing' });
}
else
{
const validCommand = await getValidLaunchCommandsForGame(source, id);
if (validCommand)
{
if (validCommand instanceof Error)
{
enqueue({ status: validCommand.name as GameStatusType, error: validCommand.message });
}
else
{
enqueue({ status: 'installed', details: validCommand.command.label });
}
} else if (source === 'romm')
{
// TODO: Add Caching
const remoteGame = await getRomApiRomsIdGet({ path: { id } });
const stats = await fs.statfs(config.get('downloadPath'));
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
{
enqueue({ status: 'error', error: "Not Enough Free Space" });
} else
{
enqueue({ status: 'install', details: 'Install' });
}
}
}
}
await sendLatests();
const dispose: Function[] = [];
const handleActiveExit = async (data: { error?: ErrorLike; }) =>
{
if (data.error)
{
enqueue({
status: 'error',
error: data.error
}, 'error');
}
await sendLatests();
};
events.on('activegameexit', handleActiveExit);
dispose.push(() => events.off('activegameexit', handleActiveExit));
dispose.push(taskQueue.on('progress', ({ id, progress, state }) =>
{
if (id.endsWith(sourceId))
{
enqueue({ progress, status: state as any });
}
}));
dispose.push(taskQueue.on('completed', ({ id }) =>
{
if (id.endsWith(sourceId))
{
enqueue({}, 'refresh');
}
}));
dispose.push(taskQueue.on('error', ({ id, error }) =>
{
if (id.endsWith(sourceId))
{
enqueue({
status: 'error',
error: error
}, 'error');
}
}));
cleanup = () =>
{
dispose.forEach(f => f());
};
},
cancel (reason)
{
cleanup?.();
cleanup = undefined;
},
}));
}