feat: Implemented romm saves for dolphin and xenia
feat: Implemented save backups for emulatorjs fix: Added support for rar archives fix: Moved to individual ini adjustments for pcsx2 and ppsspp to allow for user editing of configs
This commit is contained in:
parent
54dd9256e3
commit
7948bd24fa
36 changed files with 1103 additions and 243 deletions
|
|
@ -3,7 +3,7 @@ import { and, eq, or } from 'drizzle-orm';
|
|||
import fs from 'node:fs/promises';
|
||||
import * as schema from "@schema/app";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import path from 'node:path';
|
||||
import path, { join } from 'node:path';
|
||||
import { config, db, emulatorsDb, events, plugins } from "../app";
|
||||
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
||||
import * as igdb from 'ts-igdb-client';
|
||||
|
|
@ -13,9 +13,12 @@ import { Downloader } from "@/bun/utils/downloader";
|
|||
import Seven from 'node-7z';
|
||||
import z from "zod";
|
||||
import { checkFiles } from "../games/services/utils";
|
||||
import { ensureDir } from "fs-extra";
|
||||
import { ensureDir, existsSync } from "fs-extra";
|
||||
import { path7za } from "7zip-bin";
|
||||
import slugify from 'slugify';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
import { createExtractorFromFile } from 'node-unrar-js';
|
||||
import { which } from "bun";
|
||||
|
||||
interface JobConfig
|
||||
{
|
||||
|
|
@ -116,23 +119,62 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
|||
for (const filePath of downloadedFiles)
|
||||
{
|
||||
const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path);
|
||||
await new Promise((resolve, reject) =>
|
||||
await new Promise(async (resolve, reject) =>
|
||||
{
|
||||
const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true });
|
||||
let sevenZipPath = process.env.ZIP7_PATH ?? path7za;
|
||||
|
||||
if (filePath.endsWith('.rar'))
|
||||
{
|
||||
let newPath: string | undefined;
|
||||
if (process.platform === 'win32' && await fs.exists("C:\\Program Files\\7-Zip\\7z.exe"))
|
||||
{
|
||||
newPath = "C:\\Program Files\\7-Zip\\7z.exe";
|
||||
} else
|
||||
{
|
||||
newPath = which('7z') ?? undefined;
|
||||
}
|
||||
|
||||
if (!newPath)
|
||||
{
|
||||
await fs.rm(filePath);
|
||||
reject(new Error("No RAR Support"));
|
||||
return;
|
||||
}
|
||||
|
||||
sevenZipPath = newPath;
|
||||
}
|
||||
|
||||
let rejected = false;
|
||||
const seven = Seven.extractFull(filePath, extractPath, { $bin: sevenZipPath, $progress: true });
|
||||
seven.on('progress', p =>
|
||||
{
|
||||
cx.setProgress(progress + p.percent * progressDelta, "extract");
|
||||
});
|
||||
|
||||
seven.on('error', e =>
|
||||
{
|
||||
reject(e);
|
||||
rejected = true;
|
||||
});
|
||||
seven.on('end', async () =>
|
||||
{
|
||||
if (rejected) return;
|
||||
await fs.rm(filePath);
|
||||
resolve(true);
|
||||
});
|
||||
}).catch(async e =>
|
||||
{
|
||||
if (filePath.endsWith('.zip'))
|
||||
{
|
||||
console.warn("Could not extract", filePath, "with 7zip trying zip extractor");
|
||||
await ensureDir(extractPath);
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const count = await zip.extract(null, extractPath);
|
||||
console.log(`Extracted ${count} entries`);
|
||||
await zip.close();
|
||||
} else
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
progress += progressDelta * 100;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import { db, events, plugins } from "../app";
|
|||
import * as appSchema from "@schema/app";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { spawn } from 'node:child_process';
|
||||
import { watch } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { updateLocalLastPlayed } from "../games/services/statusService";
|
||||
|
||||
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">
|
||||
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>
|
||||
{
|
||||
static id = "launch-game" as const;
|
||||
static dataSchema = z.nullable(ActiveGameSchema);
|
||||
|
|
@ -16,6 +19,8 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
validCommand: CommandEntry;
|
||||
gameSource?: string;
|
||||
gameSourceId?: string;
|
||||
changedSaveFiles: Map<string, SaveFileChange>;
|
||||
saveFolderPath?: string;
|
||||
|
||||
constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string)
|
||||
{
|
||||
|
|
@ -24,11 +29,46 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
this.gameSource = source;
|
||||
this.gameSourceId = sourceId;
|
||||
this.activeGame = null;
|
||||
this.changedSaveFiles = new Map();
|
||||
}
|
||||
|
||||
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">, z.infer<typeof LaunchGameJob.dataSchema>, "playing">)
|
||||
async postPlay (gameInfo: { platformSlug?: string; })
|
||||
{
|
||||
let gameInfo: { name?: string, source_id?: string, source?: string; };
|
||||
if (this.gameId.source === 'local')
|
||||
{
|
||||
await updateLocalLastPlayed(Number(this.gameId.id));
|
||||
}
|
||||
|
||||
const source = this.gameSource ?? this.gameId.source;
|
||||
const id = this.gameSourceId ?? this.gameId.id;
|
||||
|
||||
await plugins.hooks.games.postPlay.promise(
|
||||
{
|
||||
source,
|
||||
id,
|
||||
command: this.validCommand,
|
||||
saveFolderPath: this.saveFolderPath,
|
||||
changedSaveFiles: Array.from(this.changedSaveFiles.values()),
|
||||
validChangedSaveFiles: [],
|
||||
gameInfo
|
||||
}).catch(e => console.error(e));
|
||||
}
|
||||
|
||||
prePlay (setProgress: (progress: number, state: string) => void, gameInfo: { platformSlug?: string; })
|
||||
{
|
||||
return plugins.hooks.games.prePlay.promise({
|
||||
source: this.gameSource ?? this.gameId.source,
|
||||
id: this.gameSourceId ?? this.gameId.id,
|
||||
saveFolderPath: this.saveFolderPath,
|
||||
command: this.validCommand,
|
||||
setProgress: setProgress,
|
||||
gameInfo
|
||||
});
|
||||
}
|
||||
|
||||
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>, z.infer<typeof LaunchGameJob.dataSchema>, string>)
|
||||
{
|
||||
let gameInfo: { name?: string, source_id?: string, source?: string; platformSlug?: string; } | undefined = undefined;
|
||||
if (this.gameId.source === 'emulator')
|
||||
{
|
||||
gameInfo = { name: this.gameId.id };
|
||||
|
|
@ -38,125 +78,140 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
where: eq(appSchema.games.id, Number(this.gameId.id)), columns: {
|
||||
name: true,
|
||||
source_id: true,
|
||||
source: true
|
||||
source: true,
|
||||
},
|
||||
with: {
|
||||
platform: {
|
||||
columns: {
|
||||
es_slug: true,
|
||||
slug: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (localGame)
|
||||
gameInfo = { name: localGame.name ?? undefined, source_id: localGame.source_id ?? undefined, source: localGame.source ?? undefined };
|
||||
gameInfo = {
|
||||
name: localGame.name ?? undefined,
|
||||
source_id: localGame.source_id ?? undefined,
|
||||
source: localGame.source ?? undefined,
|
||||
platformSlug: localGame.platform.es_slug ?? localGame.platform.slug
|
||||
};
|
||||
}
|
||||
|
||||
const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({
|
||||
autoValidCommand: this.validCommand,
|
||||
game: { source: this.gameSource, sourceId: this.gameSourceId, id: this.gameId },
|
||||
game: {
|
||||
source: this.gameSource,
|
||||
sourceId: this.gameSourceId,
|
||||
id: this.gameId,
|
||||
platformSlug: gameInfo?.platformSlug
|
||||
},
|
||||
dryRun: false
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) =>
|
||||
await new Promise(async (resolve, reject) =>
|
||||
{
|
||||
let game: any;
|
||||
if (!commandArgs)
|
||||
try
|
||||
{
|
||||
// ES-DE commands require shell execution. Some emulators fail otherwise.
|
||||
const spawnGame = spawn(this.validCommand.command, {
|
||||
shell: true,
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
env: {
|
||||
let game: any;
|
||||
if (!commandArgs)
|
||||
{
|
||||
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }).catch(e => reject(e));
|
||||
|
||||
// ES-DE commands require shell execution. Some emulators fail otherwise.
|
||||
const spawnGame = spawn(this.validCommand.command, {
|
||||
shell: true,
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
env: {
|
||||
}
|
||||
});
|
||||
|
||||
context.setProgress(0, "playing");
|
||||
|
||||
spawnGame.stdout.on('data', data => console.log(data));
|
||||
spawnGame.on('close', (code) =>
|
||||
{
|
||||
resolve(code);
|
||||
});
|
||||
spawnGame.on('error', e =>
|
||||
{
|
||||
console.error(e);
|
||||
reject(e);
|
||||
});
|
||||
|
||||
game = spawnGame;
|
||||
}
|
||||
else if (this.validCommand.metadata.emulatorBin)
|
||||
{
|
||||
this.saveFolderPath = commandArgs.savesPath;
|
||||
|
||||
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
|
||||
|
||||
// We have full control over launching integrated emulators better to use bun spawn
|
||||
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs.args], {
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
env: {
|
||||
}
|
||||
});
|
||||
|
||||
context.setProgress(0, "playing");
|
||||
|
||||
if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath))
|
||||
{
|
||||
const savesWatcher = watch(commandArgs.savesPath, { recursive: true, signal: context.abortSignal });
|
||||
console.log("Starting To Watch", commandArgs.savesPath, "for save file changes");
|
||||
savesWatcher.on('change', (type, filename) =>
|
||||
{
|
||||
if (typeof filename === 'string')
|
||||
{
|
||||
console.log("Save File Changed", filename);
|
||||
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! });
|
||||
}
|
||||
});
|
||||
|
||||
bunGame.exited.then(() =>
|
||||
{
|
||||
savesWatcher.close();
|
||||
console.log("Closing Save File Watching for", commandArgs.savesPath);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
spawnGame.stdout.on('data', data => console.log(data));
|
||||
spawnGame.on('close', (code) =>
|
||||
bunGame.exited.then(e =>
|
||||
{
|
||||
resolve(true);
|
||||
}).catch(e =>
|
||||
{
|
||||
console.error(e);
|
||||
reject(e);
|
||||
});
|
||||
|
||||
game = bunGame;
|
||||
|
||||
} else
|
||||
{
|
||||
resolve(code);
|
||||
});
|
||||
spawnGame.on('error', e =>
|
||||
{
|
||||
console.error(e);
|
||||
reject(e);
|
||||
});
|
||||
|
||||
game = spawnGame;
|
||||
}
|
||||
else if (this.validCommand.metadata.emulatorBin)
|
||||
{
|
||||
// We have full control over launching integrated emulators better to use bun spawn
|
||||
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], {
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
env: {
|
||||
}
|
||||
});
|
||||
|
||||
context.abortSignal.addEventListener('abort', reject);
|
||||
|
||||
bunGame.exited.then(e =>
|
||||
{
|
||||
resolve(true);
|
||||
}).catch(e =>
|
||||
{
|
||||
console.error(e);
|
||||
reject(e);
|
||||
});
|
||||
game = bunGame;
|
||||
} else
|
||||
{
|
||||
reject(new Error("No Emulator Bin"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeGame = {
|
||||
process: game,
|
||||
name: gameInfo?.name ?? "Unknown",
|
||||
gameId: this.gameId,
|
||||
source: this.gameSource,
|
||||
sourceId: this.gameSourceId,
|
||||
command: this.validCommand
|
||||
};
|
||||
|
||||
const updatePlayed = async (id: FrontEndId, source?: string, sourceId?: string) =>
|
||||
{
|
||||
if (this.gameId.source === 'local')
|
||||
{
|
||||
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(this.gameId.id)));
|
||||
reject(new Error("No Emulator Bin"));
|
||||
return;
|
||||
}
|
||||
|
||||
await plugins.hooks.games.updatePlayed.promise({ source: source ?? id.source, id: sourceId ?? id.id }).then(v =>
|
||||
{
|
||||
if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' });
|
||||
});
|
||||
};
|
||||
|
||||
updatePlayed(this.gameId, this.gameSource, this.gameSourceId);
|
||||
this.activeGame = {
|
||||
process: game,
|
||||
name: gameInfo?.name ?? "Unknown",
|
||||
gameId: this.gameId,
|
||||
source: this.gameSource,
|
||||
sourceId: this.gameSourceId,
|
||||
command: this.validCommand
|
||||
};
|
||||
} catch (e)
|
||||
{
|
||||
context.abort(e);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
/* Old spawn lanching, cases issues, needs to be ran as shell
|
||||
|
||||
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
|
||||
const game = setActiveGame({
|
||||
process: Bun.spawn({
|
||||
cmd,
|
||||
env: {
|
||||
...process.env
|
||||
},
|
||||
onExit (subprocess, exitCode, signalCode, error)
|
||||
{
|
||||
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
|
||||
},
|
||||
stdin: "ignore",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}),
|
||||
name: localGame?.name ?? "Unknown",
|
||||
gameId: validCommand.gameId,
|
||||
command: validCommand.command.command
|
||||
});
|
||||
|
||||
await game.process.exited;
|
||||
if (game.process.exitCode && game.process.exitCode > 0)
|
||||
{
|
||||
return status('Internal Server Error');
|
||||
}*/
|
||||
await this.postPlay({ platformSlug: gameInfo?.platformSlug });
|
||||
}
|
||||
|
||||
exposeData ()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue