fix: Made store downloads extract in their own folder

feat: Implemented cemu integration
This commit is contained in:
Simeon Radivoev 2026-04-05 12:46:50 +03:00
parent 09b8b9c6f8
commit 764691fc86
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
11 changed files with 156 additions and 52 deletions

View file

@ -10,7 +10,7 @@ import path from "node:path";
import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler"; import { errorToResponse } from "elysia/adapter/bun/handler";
import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService"; import { getEmulatorsForSystem, getRomFilePaths, launchCommand } from "./services/launchGameService";
import { getErrorMessage, SeededRandom } from "@/bun/utils"; import { getErrorMessage, SeededRandom } from "@/bun/utils";
import { defaultFormats, defaultPlugins } from 'jimp'; import { defaultFormats, defaultPlugins } from 'jimp';
import { createJimp } from "@jimp/core"; import { createJimp } from "@jimp/core";
@ -255,7 +255,8 @@ export default new Elysia()
{ {
const localGame = await db.query.games.findFirst({ const localGame = await db.query.games.findFirst({
where: getLocalGameMatch(id, source), where: getLocalGameMatch(id, source),
columns: { path_fs: true } columns: { path_fs: true },
with: { platform: { columns: { es_slug: true } } }
}); });
if (!localGame?.path_fs) if (!localGame?.path_fs)
@ -265,13 +266,15 @@ export default new Elysia()
const downloadPath = config.get('downloadPath'); const downloadPath = config.get('downloadPath');
const path_fs = path.join(downloadPath, localGame.path_fs); const path_fs = path.join(downloadPath, localGame.path_fs);
const stats = await fs.stat(path_fs);
if (stats.isDirectory()) const filesPaths = await getRomFilePaths(path_fs, localGame.platform.es_slug ?? undefined);
if (filesPaths.length <= 0)
{ {
return status("Not Found", "Rom is a folder"); throw new Error("No Valid Roms Found");
} }
return Bun.file(path_fs); return Bun.file(filesPaths[0]);
}, { }, {
params: z.object({ source: z.string(), id: z.string() }) params: z.object({ source: z.string(), id: z.string() })
}) })

View file

@ -67,6 +67,70 @@ export async function getEmulatorsForSystem (systemSlug: string)
return Array.from(emulators); return Array.from(emulators);
} }
export async function getRomFilePaths (gamePath: string, systemSlug?: string)
{
if (!existsSync(gamePath))
{
throw new Error(`Provided rom path is missing: '${gamePath}'`);
}
const gamePathStat = await fs.stat(gamePath);
const validFiles: string[] = [];
if (gamePathStat.isDirectory())
{
if (!systemSlug) throw new Error("Needs system to find valid file");
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(schema.systems.name, systemSlug)
});
if (!system)
{
throw new Error(`Could not find system '${systemSlug}'`);
}
const extensionList = system.extension.join(',');
for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`)))
{
validFiles.push(file);
}
if (validFiles.length <= 0)
{
throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`);
}
} else if (systemSlug)
{
const system = await emulatorsDb.query.systems.findFirst({
with: { commands: true },
where: eq(schema.systems.name, systemSlug)
});
if (!system)
{
throw new Error(`Could not find system '${systemSlug}'`);
}
if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase())))
{
validFiles.push(gamePath);
}
else
{
const extensionList = system.extension.join(',');
throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`);
}
} else
{
validFiles.push(gamePath);
}
return validFiles;
}
/** /**
* *
* @param data Uses es-de system slug * @param data Uses es-de system slug
@ -96,38 +160,7 @@ export async function getValidLaunchCommands (data: {
const downloadPath = config.get('downloadPath'); const downloadPath = config.get('downloadPath');
const gamePath = path.join(downloadPath, data.gamePath); const gamePath = path.join(downloadPath, data.gamePath);
const validFiles: string[] = []; const validFiles: string[] = await getRomFilePaths(gamePath, data.systemSlug);
if (!existsSync(gamePath))
{
throw new Error(`Provided rom path is missing: '${gamePath}'`);
}
const gamePathStat = await fs.stat(gamePath);
const extensionList = system.extension.join(',');
if (gamePathStat.isDirectory())
{
for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`)))
{
validFiles.push(file);
}
if (validFiles.length <= 0)
{
throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`);
}
} else
{
if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase())))
{
validFiles.push(gamePath);
}
else
{
throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`);
}
}
function escapeWindowsArg (arg: string): string function escapeWindowsArg (arg: string): string
{ {

View file

@ -70,7 +70,8 @@ export class InstallJob implements IJob<never, InstallJobStates>
name: game.title, name: game.title,
summary: game.description, summary: game.description,
system_slug: gameId.system, system_slug: gameId.system,
extract_path: path.join('roms', gameId.system), path_fs: path.join('roms', gameId.system, game.title),
extract_path: path.join('roms', gameId.system, game.title),
}; };
break; break;
@ -218,7 +219,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
source_id: info.source_id, source_id: info.source_id,
source: this.source, source: this.source,
slug: info.slug, slug: info.slug,
path_fs: info.path_fs, path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined),
last_played: info.last_played, last_played: info.last_played,
platform_id: platformId, platform_id: platformId,
igdb_id: info.igdb_id, igdb_id: info.igdb_id,

View file

@ -1,10 +1,11 @@
import z from "zod"; import z from "zod";
import { IJob, JobContext } from "../task-queue"; import { IJob, JobContext } from "../task-queue";
import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema"; import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema";
import { db, events, plugins } from "../app"; import { config, db, events, plugins } from "../app";
import * as appSchema from "@schema/app"; import * as appSchema from "@schema/app";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import path from "node:path";
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing"> export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">
{ {
@ -60,7 +61,9 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
const spawnGame = spawn(this.validCommand.command, { const spawnGame = spawn(this.validCommand.command, {
shell: true, shell: true,
cwd: this.validCommand.startDir, cwd: this.validCommand.startDir,
signal: context.abortSignal signal: context.abortSignal,
env: {
}
}); });
spawnGame.stdout.on('data', data => console.log(data)); spawnGame.stdout.on('data', data => console.log(data));
@ -82,6 +85,8 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], { const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], {
cwd: this.validCommand.startDir, cwd: this.validCommand.startDir,
signal: context.abortSignal, signal: context.abortSignal,
env: {
}
}); });
context.abortSignal.addEventListener('abort', reject); context.abortSignal.addEventListener('abort', reject);

View file

@ -0,0 +1,35 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import path from 'node:path';
import { config } from "@/bun/api/app";
export default class DOLPHINIntegration implements PluginType
{
emulator = 'CEMU';
load (ctx: PluginContextType)
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] };
});
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
{
const args: string[] = [];
args.push(`--fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`);
const savesPath = path.join(config.get('downloadPath'), "saves", 'DOLPHIN');
args.push(`--mlc=${savesPath}`);
if (ctx.autoValidCommand.metadata.romPath)
{
args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`);
}
return args;
});
}
}

View file

@ -0,0 +1,14 @@
{
"name": "com.simeonradivoev.gameflow.cemu",
"displayName": "CEMU Integration",
"version": "0.0.1",
"description": "CEMU Emulator Integration",
"main": "./cemu.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Cemu_Emulator_Official_Logo.png",
"keywords": [
"integration",
"emulator",
"wiiu",
"cemu"
]
}

View file

@ -25,7 +25,7 @@ export default class DOLPHINIntegration implements PluginType
{ {
const args: string[] = []; const args: string[] = [];
const storageFolder = path.join(config.get('downloadPath'), "storage", 'DOLPHIN'); const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator);
args.push(`--user=${storageFolder}`); args.push(`--user=${storageFolder}`);
args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`);
@ -35,7 +35,7 @@ export default class DOLPHINIntegration implements PluginType
args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`);
args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); args.push(`--config=Dolphin.Analytics.PermissionAsked=True`);
const savesPath = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator);
args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`); args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`);
args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`); args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`);

View file

@ -8,7 +8,7 @@
"keywords": [ "keywords": [
"integration", "integration",
"emulator", "emulator",
"wiiu", "wii",
"gc", "gc",
"dolphin" "dolphin"
] ]

View file

@ -49,9 +49,9 @@ export default class PCSX2Integration implements PluginType
{ {
const configFileContents = await Bun.file(configFile).text(); const configFileContents = await Bun.file(configFile).text();
const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator);
const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); const savesFolder = path.join(config.get('downloadPath'), "saves", this.emulator);
const view = { const view = {
BIOS_PATH: biosFolder, BIOS_PATH: biosFolder,
@ -70,7 +70,7 @@ export default class PCSX2Integration implements PluginType
if (process.platform === 'win32') if (process.platform === 'win32')
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
else else
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis');
await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view));
} }

View file

@ -191,6 +191,18 @@ export default class RommIntegration implements PluginType
return file; return file;
})); }));
let extract_path: string | undefined = undefined;
let path_fs = path.join(rom.fs_path, rom.fs_name);
if (files.length === 1)
{
const name = files[0].file_name.toLocaleLowerCase();
if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar'))
{
extract_path = rom.name ?? path.parse(name).name;
path_fs = path.join(rom.fs_path, extract_path);
}
}
const info: DownloadInfo = { const info: DownloadInfo = {
platform: { platform: {
slug: rommPlatform.slug, slug: rommPlatform.slug,
@ -204,13 +216,14 @@ export default class RommIntegration implements PluginType
ra_id: rom.ra_id ?? undefined, ra_id: rom.ra_id ?? undefined,
summary: rom.summary ?? undefined, summary: rom.summary ?? undefined,
name: rom.name ?? "Unknown", name: rom.name ?? "Unknown",
path_fs: path.join(rom.fs_path, rom.fs_name), path_fs,
source_id: String(rom.id), source_id: String(rom.id),
slug: rom.slug ?? undefined, slug: rom.slug ?? undefined,
system_slug: rommPlatform.slug, system_slug: rommPlatform.slug,
metadata: rom.metadatum, metadata: rom.metadatum,
files, files,
auth: await this.getAuthToken() auth: await this.getAuthToken(),
extract_path
}; };
return info; return info;

View file

@ -28,7 +28,7 @@ window.addEventListener('message', (e) =>
}); });
window.EJS_threads = true; window.EJS_threads = !__PUBLIC__;
window.EJS_player = "#game"; window.EJS_player = "#game";
window.EJS_lightgun = false; window.EJS_lightgun = false;
window.EJS_startOnLoaded = true; window.EJS_startOnLoaded = true;