feat: Implemented external ryujinx integration plugin refactor: moved sdk types and schemas to own workspace package fix: Fixed emulator launch with no game
470 lines
No EOL
19 KiB
TypeScript
470 lines
No EOL
19 KiB
TypeScript
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<string, GameLookup[]>();
|
|
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<ArrayBuffer> | 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<string, GameLookup[]>();
|
|
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?.();
|
|
},
|
|
});
|
|
} |