feat: Implemented local game import (with a wizard)

feat: Implemented a radial virtual gamepad keyboard.
fix: Fixed shortcuts for file explorer
This commit is contained in:
Simeon Radivoev 2026-05-04 14:59:43 +03:00
parent e54a6ac8f0
commit 06b7e4074d
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
66 changed files with 2216 additions and 416 deletions

View file

@ -8,7 +8,7 @@ import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService";
import buildStatusResponse, { customUpdate, fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler";
import { launchCommand } from "./services/launchGameService";
import { getErrorMessage, SeededRandom } from "@/bun/utils";
@ -21,6 +21,7 @@ import { host } from "@/bun/utils/host";
import { LaunchGameJob } from "../jobs/launch-game-job";
import { cores } from "../emulatorjs/emulatorjs";
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
import { ImportJob } from "../jobs/import-job";
// A custom jimp that supports webp
const Jimp = createJimp({
@ -491,6 +492,24 @@ export default new Elysia()
{
return update(source, id);
})
.post('/game/:source/:id/update', async ({ params: { id, source }, body }) =>
{
return customUpdate(source, id, body.source, body.id);
}, { body: z.object({ source: z.string(), id: z.string() }) })
.get('/lookup', async ({ query: { search } }) =>
{
const matches: GameLookup[] = [];
await plugins.hooks.games.gameLookup.promise({ search, matches });
return matches;
}, {
query: z.object({ search: z.string() })
})
.get('/lookup/:source/:id', async ({ params: { source, id } }) =>
{
const matches: GameLookup[] = [];
await plugins.hooks.games.gameLookup.promise({ source, id, matches });
return matches;
})
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
{
const validCommands = await getValidLaunchCommandsForGame(source, id);
@ -651,4 +670,17 @@ export default new Elysia()
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
return rankedGames.map(g => g.game).slice(0, 10);
})
.post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) =>
{
if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running");
const data = await taskQueue.enqueue(ImportJob.id, new ImportJob(source, id, gamePath, platformId), true);
return { source: 'local', id: data.localId };
}, {
body: z.object({
source: z.string(),
id: z.string(),
gamePath: z.string(),
platformId: z.number()
})
});

View file

@ -1,8 +1,9 @@
import Elysia, { status } from "elysia";
import z from "zod";
import { and, count, eq, getTableColumns, not, notExists } from "drizzle-orm";
import { db, plugins } from "../app";
import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm";
import { config, db, plugins } from "../app";
import * as schema from "@schema/app";
import { findPlatform } from "./services/utils";
export default new Elysia()
.get('/platforms', async () =>
@ -91,7 +92,8 @@ export default new Elysia()
{
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
if (!remotePlatform) return status("Not Found");
return remotePlatform;
const local = await db.query.platforms.findFirst({ where: or(eq(schema.platforms.slug, remotePlatform?.slug), eq(schema.platforms.name, remotePlatform?.name)) });
return { ...remotePlatform, hasLocal: !!local };
}
}, { params: z.object({ source: z.string(), id: z.string() }) })
.get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
@ -114,15 +116,31 @@ export default new Elysia()
}
return status(200, coverBlob.cover);
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) })
.post('/platform/local/:id/update', async ({ params: { id } }) =>
.post('/platform/:source/:id/update', async ({ params: { source, id } }) =>
{
const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, Number(id)) });
const where: any[] = [];
if (source === 'local')
{
where.push(eq(schema.platforms.id, Number(id)));
} else
{
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
if (remotePlatform)
{
where.push(eq(schema.platforms.slug, remotePlatform.slug));
}
}
const localPlatform = await db.query.platforms.findFirst({
where: or(...where)
});
if (!localPlatform) return status("Not Found");
const platformLookup = await plugins.hooks.games.platformLookup.promise({
slug: localPlatform.slug
});
let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${localPlatform.slug}.svg`);
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${localPlatform.slug}.svg`);
if (!platformCover.ok && platformLookup?.url_logo)
{
platformCover = await fetch(platformLookup.url_logo);
@ -144,4 +162,23 @@ export default new Elysia()
.where(eq(schema.games.platform_id, Number(id)))
))).returning();
if (deleted.length <= 0) return status("Not Found");
})
.get('/platform/lookup/match/:source/:id', async ({ params: { source, id } }) =>
{
const platformLookup = await plugins.hooks.games.platformLookup.promise({ source, id });
if (!platformLookup) return status("Not Found");
const match = await findPlatform({
system_slug: platformLookup.slug,
platform: {
source_slug: platformLookup.slug,
source_id: Number(id),
source: source,
name: platformLookup.name
}
});
return { details: platformLookup, match };
}, {
detail: {
description: "Find matches of remote platform lookups. Returns the operations for each platform if it were to be imported. If platform locally exists. Will a new local platform be created from say romm. Unknown is returned if no match is found."
}
});

View file

@ -4,7 +4,7 @@ import { getErrorMessage } from "@/bun/utils";
import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
import fs from 'node:fs/promises';
import Elysia from "elysia";
import z, { string } from "zod";
import z from "zod";
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
import { LaunchGameJob } from "../../jobs/launch-game-job";
import * as appSchema from "@schema/app";
@ -41,6 +41,63 @@ export async function getLocalGame (source: string, id: string)
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 matches: GameLookup[] = [];
await plugins.hooks.games.gameLookup.promise({ source: destination, id: destinationId, matches });
if (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);
@ -56,10 +113,11 @@ export async function update (source: string, id: string)
const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)];
if (paths_screenshots.length <= 0 && sourceGame.igdb_id)
{
const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id) });
if (igdbLookup)
const matches: GameLookup[] = [];
await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id), matches });
if (matches.length > 0)
{
paths_screenshots.push(...igdbLookup.screenshotUrls);
paths_screenshots.push(...matches[0].screenshotUrls);
}
}

View file

@ -2,23 +2,25 @@ import getFolderSize from "get-folder-size";
import fs from "node:fs/promises";
import path from "node:path";
import { config, db, emulatorsDb, plugins } from "../../app";
import { and, eq } from "drizzle-orm";
import { and, eq, or } from "drizzle-orm";
import * as schema from "@schema/app";
import { RPC_URL, StoreGameType } from "@shared/constants";
import { RPC_URL } from "@shared/constants";
import { hashFile } from "@/bun/utils";
import { host } from "@/bun/utils/host";
import secrets from "../../secrets";
import * as emulatorSchema from "@schema/emulators";
export async function calculateSize (installPath: string | null)
{
if (!installPath) return null;
return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size;
const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
return (await getFolderSize(finalPath)).size;
}
export async function checkInstalled (installPath: string | null)
{
if (!installPath) return false;
return fs.exists(path.join(config.get('downloadPath'), installPath));
const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
return fs.exists(finalPath);
}
export function getScreenshotLocalGameMatch (id: string, source: string)
@ -171,4 +173,297 @@ export async function checkFiles (files: DownloadFileEntry[], isArchive: boolean
}
return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry;
}));
}
export async function findPlatform (info: {
system_slug: string; platform: {
igdb_id?: number;
igdb_slug?: string;
ra_id?: number;
moby_id?: number;
source: string;
source_id?: number;
source_slug?: string;
family_name?: string;
name?: string;
} | undefined;
}):
Promise<{
type: string | null;
slug?: string | null;
name?: string | null;
family_name?: string | null;
es_slug?: string | null;
coverUrl?: string | null;
}>
{
// Search for existing platform
const platformSearch = [eq(schema.platforms.slug, info.system_slug)];
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)];
if (info.platform)
{
if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id));
if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug));
if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id));
if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id));
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, info.platform.source));
if (info.platform.source_slug)
{
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug));
} else if (info.platform.source_id)
{
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id));
} else
{
throw new Error("Must Provide at least one source id or slug");
}
}
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
with: { system: true },
where: and(...esPlatformSearch)
});
if (esPlatform)
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
let existingPlatform = await db.query.platforms.findFirst({ where: or(...platformSearch) });
if (!existingPlatform)
{
// TODO: use something else than the romm demo as CDN
const platformLookup = await plugins.hooks.games.platformLookup.promise({
slug: info.platform?.source_slug ?? info.system_slug
});
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`, { method: "HEAD" });
if (!platformCover.ok && platformLookup?.url_logo)
{
platformCover = await fetch(platformLookup.url_logo, { method: "HEAD" });
}
if (!esPlatform && !info.platform)
{
// go to unknown platform
existingPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
if (existingPlatform)
{
return {
type: "existing",
slug: existingPlatform.slug,
name: existingPlatform.name,
family_name: existingPlatform.family_name,
es_slug: existingPlatform.es_slug,
coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover`
};
} else
{
return { type: "unknown" };
}
} else
{
return {
type: "new",
slug: info.platform?.source_slug ?? esPlatform?.system.name ?? '',
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
family_name: info.platform?.family_name,
es_slug: esPlatform?.system.name ?? undefined,
coverUrl: platformCover.url
};
}
} else
{
return {
type: "existing",
slug: existingPlatform.slug,
name: existingPlatform.name,
family_name: existingPlatform.family_name,
es_slug: existingPlatform.es_slug,
coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover`
};
}
}
export async function createLocalGame (info: {
name: string;
system_slug: string | undefined;
source: string | undefined;
source_id: string | undefined;
slug: string | null | undefined;
path_fs: string | null | undefined;
summary: string | null | undefined;
igdb_id: number | undefined;
ra_id: number | undefined;
main_glob: string | undefined;
cover: Buffer<ArrayBufferLike> | undefined;
coverType: string | null | undefined;
version: string | undefined;
version_source: string | undefined;
screenshotUrls: string[];
version_system: string | undefined;
last_played?: Date;
metadata: LocalGameMetadata | undefined,
platform: {
igdb_id?: number;
igdb_slug?: string;
ra_id?: number;
moby_id?: number;
source: string;
source_id?: number;
source_slug?: string;
family_name?: string;
name?: string;
} | undefined;
})
{
const id = await db.transaction(async (tx) =>
{
// Search for existing platform
const platformSearch = [];
const esPlatformSearch = [];
if (info.system_slug)
{
platformSearch.push(eq(schema.platforms.slug, info.system_slug));
esPlatformSearch.push(eq(emulatorSchema.systemMappings.system, info.system_slug));
}
if (info.platform)
{
if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id));
if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug));
if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id));
if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id));
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, info.platform.source));
if (info.platform.source_slug)
{
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug));
} else if (info.platform.source_id)
{
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id));
} else
{
throw new Error("Must Provide at least one source id or slug");
}
}
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
with: { system: true },
where: and(...esPlatformSearch)
});
if (esPlatform)
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
let platformId: number;
if (!existingPlatform)
{
// TODO: use something else than the romm demo as CDN
const platformLookup = await plugins.hooks.games.platformLookup.promise({
slug: info.platform?.source_slug ?? info.system_slug
});
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`);
if (!platformCover.ok && platformLookup?.url_logo)
{
platformCover = await fetch(platformLookup.url_logo);
}
if (!esPlatform && !info.platform)
{
// go to unknown platform
existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
if (existingPlatform)
{
platformId = existingPlatform.id;
} else
{
const [{ id }] = await tx.insert(schema.platforms).values({
slug: 'unknown',
name: "Unknown"
}).returning({ id: schema.platforms.id });
platformId = id;
}
} else
{
// Create new local platform
const platform: typeof schema.platforms.$inferInsert = {
slug: info.platform?.source_slug ?? esPlatform?.system.name ?? '',
igdb_id: info.platform?.igdb_id,
igdb_slug: info.platform?.igdb_slug,
ra_id: info.platform?.ra_id,
cover: Buffer.from(await platformCover.arrayBuffer()),
cover_type: platformCover.headers.get('content-type'),
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
family_name: info.platform?.family_name,
es_slug: esPlatform?.system.name ?? undefined,
};
// TODO: add ES slug once I have better way to query ES
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
platformId = id;
}
} else
{
platformId = existingPlatform.id;
}
// create the rom
const game: typeof schema.games.$inferInsert = {
source_id: info.source_id,
source: info.source,
slug: info.slug,
path_fs: info.path_fs,
last_played: info.last_played,
platform_id: platformId,
igdb_id: info.igdb_id,
ra_id: info.ra_id,
summary: info.summary,
name: info.name,
cover: info.cover,
cover_type: info.coverType,
metadata: info.metadata,
main_glob: info.main_glob,
version: info.version,
version_source: info.version_source,
version_system: info.version_system
};
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
if (info.screenshotUrls.length <= 0 && info.igdb_id)
{
const matches: GameLookup[] = [];
await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(info.igdb_id), matches });
info.screenshotUrls.push(...matches[0].screenshotUrls);
}
// pre-fetch screenshots
const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s)));
if (screenshots.length > 0)
{
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
{
const screenshot: typeof schema.screenshots.$inferInsert = {
game_id: id,
content: Buffer.from(await response.arrayBuffer()),
type: response.headers.get('content-type')
};
return screenshot;
})));
}
return id;
});
return id;
}

View file

@ -1,5 +1,5 @@
import { EmulatorPackageType, GameListFilterType } from '@/shared/constants';
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable';
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook } from 'tapable';
export class GameHooks
{
@ -95,7 +95,12 @@ export class GameHooks
name?: string;
family_name?: string;
} | undefined>(['ctx']);
gameLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], { screenshotUrls: string[]; } | undefined>(['ctx']);
gameLookup = new AsyncSeriesHook<[ctx: {
source?: string,
id?: string;
search?: string;
matches: GameLookup[];
}]>(['ctx']);
fetchPlatforms = new AsyncSeriesHook<[ctx: {
platforms: FrontEndPlatformType[];
}]>(['ctx']);

View file

@ -0,0 +1,96 @@
import { eq, or } from "drizzle-orm";
import { db, plugins } from "../app";
import { createLocalGame } from "../games/services/utils";
import { IJob, JobContext } from "../task-queue";
import * as schema from "@schema/app";
import z from "zod";
export class ImportJob implements IJob<z.infer<typeof ImportJob.dataSchema>, string>
{
static id = "import-job" as const;
static dataSchema = z.object({ localId: z.number().nullable() });
group?: 'import-job';
gamePath: string;
source: string;
id: string;
platformId: number;
localId: number | null = null;
constructor(source: string, id: string, gamePath: string, platformId: number)
{
this.gamePath = gamePath;
this.source = source;
this.id = id;
this.platformId = platformId;
}
exposeData (): z.infer<typeof ImportJob.dataSchema>
{
return { localId: this.localId };
}
async start (context: JobContext<IJob<z.infer<typeof ImportJob.dataSchema>, string>, z.infer<typeof ImportJob.dataSchema>, string>): Promise<any>
{
const matches: GameLookup[] = [];
await plugins.hooks.games.gameLookup.promise({ source: this.source, id: this.id, matches });
if (matches.length <= 0) throw Error("Could not Find Game");
const match = matches[0];
let cover: Buffer<ArrayBufferLike> | undefined = undefined;
let coverType: string | undefined = undefined;
if (match.coverUrl)
{
const coverResponse = await fetch(match.coverUrl);
if (coverResponse.ok)
{
cover = Buffer.from(await coverResponse.arrayBuffer());
coverType = coverResponse.headers.get('content-type') ?? undefined;
}
}
const localSearchFilters: any[] = [];
if (match.igdb_id) localSearchFilters.push(eq(schema.games.igdb_id, match.igdb_id));
if (match.slug) localSearchFilters.push(eq(schema.games.slug, match.slug));
localSearchFilters.push(eq(schema.games.name, match.name));
localSearchFilters.push(eq(schema.games.path_fs, this.gamePath));
const existingLocalGame = await db.query.games.findFirst({ where: or(...localSearchFilters) });
if (existingLocalGame) throw new Error("Game Already Exists");
const platformMatch = match.platforms.find(p => p.id === this.platformId);
this.localId = await createLocalGame({
name: match.name,
system_slug: platformMatch?.slug,
source: undefined,
source_id: undefined,
slug: match.slug,
path_fs: this.gamePath,
summary: match.summary,
igdb_id: match.igdb_id,
ra_id: undefined,
main_glob: undefined,
cover,
coverType,
version: undefined,
version_source: undefined,
screenshotUrls: match.screenshotUrls,
version_system: undefined,
platform: platformMatch ? {
source_slug: platformMatch.slug,
source_id: platformMatch.id,
source: this.source,
name: platformMatch.displayName
} : undefined,
metadata: {
game_modes: match.game_modes,
companies: match.companies,
first_release_date: match.first_release_date ?? undefined,
player_count: match.player_count,
age_ratings: match.age_ratings,
average_rating: match.average_rating,
genres: match.genres,
}
});
}
}

View file

@ -1,17 +1,12 @@
import { IJob, JobContext } from "../task-queue";
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, { join } from 'node:path';
import { config, db, emulatorsDb, events, plugins } from "../app";
import * as igdb from 'ts-igdb-client';
import secrets from "../secrets";
import path from 'node:path';
import { config, events, plugins } from "../app";
import { simulateProgress } from "@/bun/utils";
import { Downloader } from "@/bun/utils/downloader";
import Seven from 'node-7z';
import z from "zod";
import { checkFiles } from "../games/services/utils";
import { checkFiles, createLocalGame } from "../games/services/utils";
import { ensureDir, move } from "fs-extra";
import { path7za } from "7zip-bin";
import StreamZip from 'node-stream-zip';
@ -37,6 +32,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
// The local game ID of newly created entry, if successful
public localGameId?: number;
public group = InstallJob.id;
public localPath?: string;
constructor(id: string, source: string, config?: JobConfig)
{
@ -51,18 +47,19 @@ export class InstallJob implements IJob<never, InstallJobStates>
await fs.mkdir(config.get('downloadPath'), { recursive: true });
const downloadPath = config.get('downloadPath');
let info: DownloadInfo | undefined;
const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
info = allDownloads?.[0];
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
const files = await checkFiles(info.files, !!info.extract_path);
const finalFiles: string[] = [];
let info: DownloadInfo | undefined;
if (this.config?.dryRun !== true)
{
const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
info = allDownloads?.[0];
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
const files = await checkFiles(info.files, !!info.extract_path);
if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches))
{
const headers: Record<string, string> = {};
@ -197,143 +194,32 @@ export class InstallJob implements IJob<never, InstallJobStates>
if (cx.abortSignal.aborted) return;
await db.transaction(async (tx) =>
{
// Search for existing platform
const platformSearch = [eq(schema.platforms.slug, info.system_slug)];
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)];
if (info.platform)
{
if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id));
if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug));
if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id));
if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id));
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm'));
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.slug));
}
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
with: { system: true },
where: and(...esPlatformSearch)
});
if (esPlatform)
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
let platformId: number;
if (!existingPlatform)
{
// TODO: use something else than the romm demo as CDN
const platformLookup = await plugins.hooks.games.platformLookup.promise({
slug: info.platform?.slug ?? info.system_slug
});
let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.platform?.slug ?? info.system_slug}.svg`);
if (!platformCover.ok && platformLookup?.url_logo)
{
platformCover = await fetch(platformLookup.url_logo);
}
if (!esPlatform && !info.platform)
{
// go to unknown platform
existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
if (existingPlatform)
{
platformId = existingPlatform.id;
} else
{
const [{ id }] = await tx.insert(schema.platforms).values({
slug: 'unknown',
name: "Unknown"
}).returning({ id: schema.platforms.id });
platformId = id;
}
} else
{
// Create new local platform
const platform: typeof schema.platforms.$inferInsert = {
slug: info.platform?.slug ?? esPlatform?.system.name ?? '',
igdb_id: info.platform?.igdb_id,
igdb_slug: info.platform?.igdb_slug,
ra_id: info.platform?.ra_id,
cover: Buffer.from(await platformCover.arrayBuffer()),
cover_type: platformCover.headers.get('content-type'),
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
family_name: info.platform?.family_name,
es_slug: esPlatform?.system.name ?? undefined,
};
// TODO: add ES slug once I have better way to query ES
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
platformId = id;
}
} else
{
platformId = existingPlatform.id;
}
// create the rom
const game: typeof schema.games.$inferInsert = {
source_id: info.source_id,
source: this.source,
slug: info.slug,
path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined),
last_played: info.last_played,
platform_id: platformId,
igdb_id: info.igdb_id,
ra_id: info.ra_id,
summary: info.summary,
name: info.name,
cover,
cover_type: coverResponse.headers.get('content-type'),
metadata: info.metadata,
main_glob: info.main_glob,
version: info.version,
version_source: info.version_source,
version_system: info.version_system
};
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
if (info.screenshotUrls.length <= 0 && info.igdb_id)
{
const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(info.igdb_id) });
if (igdbLookup) return igdbLookup.screenshotUrls;
return [];
}
// pre-fetch screenshots
const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s)));
if (screenshots.length > 0)
{
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
{
const screenshot: typeof schema.screenshots.$inferInsert = {
game_id: id,
content: Buffer.from(await response.arrayBuffer()),
type: response.headers.get('content-type')
};
return screenshot;
})));
}
this.localGameId = id;
this.localGameId = await createLocalGame({
cover,
coverType: coverResponse.headers.get('content-type'),
system_slug: info.system_slug,
source_id: info.source_id,
source: this.source,
slug: info.slug,
path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined),
summary: info.summary,
igdb_id: info.igdb_id,
ra_id: info.ra_id,
name: info.name,
main_glob: info.main_glob,
version: info.version,
version_source: info.version_source,
screenshotUrls: info.screenshotUrls,
version_system: info.version_system,
metadata: info.metadata,
platform: info.platform
});
if (this.source && this.gameId) await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info });
events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 });
} else
{
await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal);
}
await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info });
events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 });
}
}

View file

@ -288,7 +288,7 @@ export default class IgdbIntegration implements PluginType
}
const downloadPath = config.get('downloadPath');
const gamePath = path.join(downloadPath, data.gamePath);
const gamePath = path.isAbsolute(data.gamePath) ? data.gamePath : path.join(downloadPath, data.gamePath);
const validFiles: string[] = await this.getRomFilePaths(gamePath, { systemSlug: data.systemSlug, mainGlob: data.mainGlob });
@ -449,7 +449,7 @@ export default class IgdbIntegration implements PluginType
}
const downloadPath = config.get('downloadPath');
const path_fs = path.join(downloadPath, localGame.path_fs);
const path_fs = path.isAbsolute(localGame.path_fs) ? localGame.path_fs : path.join(downloadPath, localGame.path_fs);
return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob });
});

View file

@ -4,7 +4,7 @@
"version": "0.0.1",
"description": "Rclone integration for syncing saves",
"main": "./rclone.ts",
"icon": "https://forum.rclone.org/uploads/default/original/2X/8/8a14ccd453604987a64820f56c6afa75c229aa17.png",
"icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3ERclone%3C%2Ftitle%3E%3Cpath%20d%3D%22M11.842.6258C9.3647.6813%206.9754%201.9906%205.646%204.2933c-.7593%201.3144-1.0647%202.7662-.966%204.1745a7.99%207.99%200%200%201%202.6568-.4541l1.4705-.0013c-.0093-.5594.1245-1.1284.4245-1.6482.8827-1.5284%202.837-2.0522%204.3654-1.1695%201.5284.8824%202.0519%202.8366%201.1695%204.365l-1.4782%202.5647%201.1955%202.0714%202.3914-.0004%201.4775-2.5655c2.0262-3.5088.8239-7.9959-2.6853-10.0217C14.4614.9118%2013.1396.5967%2011.842.6258m-1.5451%208.073-2.9605.0029C3.2844%208.7017%200%2011.9867%200%2016.0383c0%204.052%203.2844%207.3367%207.3364%207.3367%201.5174%200%202.9267-.4609%204.0967-1.2497a8%208%200%200%201-1.72-2.0748l-.7368-1.273c-.4799.288-1.0392.4565-1.6395.4565-1.765%200-3.1958-1.4307-3.1958-3.1958%200-1.7647%201.4307-3.1954%203.1958-3.1954l2.96-.0022%201.1962-2.0708zm9.587.7475a7.99%207.99%200%200%201-.935%202.5278l-.7344%201.2745c.4892.2717.915.6719%201.2153%201.192.8823%201.528.3585%203.4826-1.1699%204.365-1.528.8823-3.4828.3588-4.3651-1.1696l-1.482-2.5628h-2.3915L8.8256%2017.144l1.483%202.5626c2.0262%203.5091%206.513%204.7112%2010.022%202.685%203.5089-2.0257%204.7112-6.5125%202.6853-10.0216-.7588-1.3144-1.863-2.3052-3.132-2.9237%22%20%2F%3E%3C%2Fsvg%3E",
"category": "saves",
"keywords": [
"integration",

View file

@ -42,17 +42,53 @@ export default class IgdbIntegration implements PluginType
{
await checkLoginAndRefreshTwitch();
ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id }) =>
ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id, search, matches }) =>
{
if (!process.env.TWITCH_CLIENT_ID) return;
if (source !== 'igdb') return;
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
if (access_token)
if (!access_token)
{
return;
}
if ((source === 'igdb' && id) || search)
{
const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token);
const { data } = await client.request('screenshots').pipe(igdb.fields(['game', 'url', 'image_id']), igdb.where('game', '=', Number(id))).execute();
return { screenshotUrls: data.filter(s => s.url).map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) };
const { data: games } = await this.queue.add(() => client.request('games')
.pipe(...(search ? [igdb.search(search)] : []),
igdb.fields(['id', 'name', 'summary', 'screenshots.image_id', 'slug', 'first_release_date', 'rating', 'genres.name', 'involved_companies.company.name', 'keywords.name', 'game_modes.name', 'cover.image_id', 'age_ratings.rating_category.rating', 'platforms.name', 'platforms.abbreviation', 'platforms.slug']),
...(source === 'igdb' && id ? [igdb.where('id', '=', Number(id))] : []),
igdb.limit(10)).execute());
matches.push(...games.filter(g => !!g.name)
.map(g =>
{
const lookup: GameLookup = {
source: 'igdb',
id: String(g.id),
coverUrl: g.cover ? `https://images.igdb.com/igdb/image/upload/t_720p/${g.cover.image_id}.webp` : undefined,
screenshotUrls: g.screenshots?.map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) ?? [],
name: g.name!,
summary: g.summary,
genres: g.genres?.map(g => g.name!) ?? [],
companies: g.involved_companies?.filter(c => c.company?.name).map(c => c.company?.name!) ?? [],
game_modes: g.game_modes?.map(m => m.name!) ?? [],
age_ratings: g.age_ratings?.map(r => r.rating_category?.rating!) ?? [],
player_count: undefined,
// UNIX date, needs to be converted
first_release_date: g.first_release_date ? g.first_release_date * 1000 : undefined,
average_rating: g.rating ?? undefined,
keywords: g.keywords?.map(k => k.name!) ?? [],
igdb_id: g.id,
platforms: g.platforms?.map(p => ({ id: p.id!, name: p.abbreviation, displayName: p.name!, slug: p.slug! })) ?? [],
slug: g.slug
};
return lookup;
}));
return;
}
});

View file

@ -4,7 +4,7 @@
"version": "0.0.1",
"description": "IGDB Metadata Integration",
"main": "./igdb.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/IGDB_logo.svg/1920px-IGDB_logo.svg.png",
"icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EIGDB%3C%2Ftitle%3E%3Cpath%20d%3D%22M24%206.228c-8%20.002-16%200-24%200v11.543a88.875%2088.875%200%200%201%202.271-.333%2074.051%2074.051%200%200%201%2017.038-.28c1.57.153%203.134.363%204.69.614V6.228zm-.706.707v10.013a74.747%2074.747%200%200%200-22.588%200V6.934h22.588ZM7.729%208.84a2.624%202.624%200%200%200-1.857.72%202.55%202.55%200%200%200-.73%201.33c-.098.5-.063%201.03.112%201.51.177.488.515.917.954%201.196.547.354%201.224.472%201.865.401a3.242%203.242%200%200%200%201.786-.777c-.003-.724.002-1.449-.002-2.173-.725.004-1.45-.002-2.174.003.003.317%200%20.634.001.951h1.105c.002.236%200%20.473.002.71-.268.196-.603.286-.932.298-.32.02-.65-.05-.922-.225a1.464%201.464%200%200%201-.59-.744c-.18-.499-.134-1.085.163-1.53.23-.355.619-.61%201.043-.647a1.8%201.8%200%200%201%201.012.206c.152.082.286.192.424.295.228-.281.461-.559.692-.838a3.033%203.033%200%200%200-.595-.403c-.418-.212-.892-.285-1.357-.283Zm11.66.086c-.093%200-.187.002-.28%200-.68.002-1.359-.004-2.038.003.003%201.666%200%203.332.002%204.998h2.497c.239-.002.478-.034.709-.097.276-.076.546-.208.742-.422.194-.208.297-.492.304-.776.016-.278-.032-.572-.195-.804-.175-.252-.453-.408-.734-.514.211-.122.407-.285.521-.505.134-.246.149-.535.117-.807a1.156%201.156%200%200%200-.436-.73c-.264-.207-.599-.304-.93-.334a2.757%202.757%200%200%200-.279-.012Zm-16.715%200v5.002h1.102V8.927c-.368-.002-.735%200-1.102%200zm8.524%200v5.002h2.016a2.87%202.87%200%200%200%201.07-.211%202.445%202.445%200%200%200%201.174-.993c.34-.555.429-1.244.292-1.876a2.367%202.367%200%200%200-.828-1.338c-.478-.387-1.096-.577-1.707-.584h-2.017zm6.949.967c.392.002.784-.001%201.176.002.183.011.38.054.51.19.11.112.136.28.112.43a.436.436%200%200%201-.22.316%201.082%201.082%200%200%201-.483.116c-.365.002-.73-.001-1.094.001-.002-.351%200-.703-.001-1.054zm-5.031.026c.28%200%20.567.053.815.19.274.149.491.396.607.685.113.272.138.574.107.865a1.456%201.456%200%200%201-.335.786%201.425%201.425%200%200%201-.865.466c-.168.031-.34.022-.51.023h-.632V9.92h.813zm5.03%201.948h1.36c.174.006.354.035.505.127.11.066.191.18.212.308.025.15.004.32-.099.44-.102.12-.258.176-.409.2-.172.032-.348.02-.522.022-.35-.001-.698.002-1.047-.001v-1.096z%22%20%2F%3E%3C%2Fsvg%3E",
"category": "sources",
"keywords": [
"integration",

View file

@ -253,6 +253,8 @@ export default class RommIntegration implements PluginType<SettingsType>
const info: DownloadInfo = {
platform: {
source: 'romm',
id: String(rommPlatform.id),
slug: rommPlatform.slug,
name: rommPlatform.name,
family_name: rommPlatform.family_name ?? undefined

View file

@ -1,6 +1,6 @@
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import path, { basename, dirname } from 'node:path';
import path, { } from 'node:path';
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService";
import { Glob, pathToFileURL } from "bun";
import { and, eq } from "drizzle-orm";
@ -12,7 +12,6 @@ import { getSourceGameDetailed } from "@/bun/api/games/services/utils";
import UpdateStoreJob from "@/bun/api/jobs/update-store";
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services";
import { path7za } from "7zip-bin";
export default class RommIntegration implements PluginType
{
@ -314,6 +313,8 @@ export default class RommIntegration implements PluginType
version_system: validDownload.system,
version_source: validDownload.id,
platform: {
source: 'store',
id: system,
slug: system,
name: system
}

View file

@ -12,15 +12,7 @@ export const games = sqliteTable('games', {
main_glob: text("main_glob"),
last_played: integer("last_played", { mode: 'timestamp' }),
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{
genres?: string[],
companies?: string[],
game_modes?: string[],
age_ratings?: string[];
player_count?: string;
first_release_date?: number;
average_rating?: number;
}>().notNull(),
metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<LocalGameMetadata>().notNull(),
slug: text("slug").unique(),
platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(),
cover: blob("cover", { mode: 'buffer' }),

View file

@ -57,15 +57,6 @@ export async function getRelevantEmulators ()
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths });
const integrations = findEmulatorPluginIntegration(emulator, execPaths);
const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator });
if (storeEmulator)
{
storeEmulator.validSources = execPaths;
storeEmulator.integrations = integrations;
return storeEmulator;
}
let platform: number | null | undefined = null;
const validSystemSlug = system_slug.find(s => s.system);
if (validSystemSlug?.system)
@ -78,7 +69,17 @@ export async function getRelevantEmulators ()
systems.forEach(s => platformViability.set(s, true));
}
const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator });
if (storeEmulator)
{
storeEmulator.validSources = execPaths;
storeEmulator.integrations = integrations;
return { ...storeEmulator, isCritical: false };
}
const em: FrontEndEmulator & { isCritical: boolean; } = {
source: 'local',
name: emulator,
logo: platform ? `/api/romm/platform/local/${platform}/cover` : '',
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ iconUrl: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })),
@ -92,6 +93,7 @@ export async function getRelevantEmulators ()
}));
finalEmulators.push({
source: 'local',
name: 'EMULATORJS',
validSources: [{ binPath: `${SERVER_URL(host)}`, type: 'embedded', exists: true }],
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,

View file

@ -2,7 +2,7 @@ import Elysia from "elysia";
import open from 'open';
import z from "zod";
import os from 'node:os';
import { cache, cachePath, config, events, taskQueue } from "./app";
import { cachePath, config, events, taskQueue } from "./app";
import { getAppVersion, isSteamDeck, openExternal } from "../utils";
import fs from 'node:fs/promises';
import buildNotificationsStream from "./notifications";
@ -14,7 +14,7 @@ import si from 'systeminformation';
import { getStoreFolder } from "./store/services/gamesService";
import ReloadPluginsJob from "./jobs/reload-plugins-job";
import { semver } from "bun";
import { getOrCached, getOrCachedGithubRelease, githubRequestQueue } from "./cache";
import { getOrCachedGithubRelease } from "./cache";
import SelfUpdateJob from "./jobs/self-update-job";
async function checkUpdate (force?: boolean)
@ -239,6 +239,10 @@ export const system = new Elysia({ prefix: '/api/system' })
{
currentPath = path.resolve(process.cwd(), currentPath);
}
const currentPathExists = await fs.exists(currentPath);
if (!currentPathExists) currentPath = dirname(process.cwd());
const currentPathStat = await fs.stat(currentPath);
if (!currentPathStat.isDirectory()) currentPath = dirname(currentPath);
const paths = await fs.readdir(currentPath, { withFileTypes: true });
return {
name: path.basename(currentPath),

View file

@ -1,8 +1,5 @@
import { and } from 'drizzle-orm';
import EventEmitter from 'node:events';
import z, { any } from 'zod';
import z from 'zod';
export class TaskQueue
{
@ -10,7 +7,16 @@ export class TaskQueue
private queue?: JobContext<IJob<any, string>, any, string>[] = [];
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
public enqueue<T> (id: string, job: T): T extends IJob<infer TData, infer TState extends string>
constructor()
{
// we need a default error listener or app crashes
this.events?.addListener('error', e =>
{
console.error(e);
});
}
public enqueue<T> (id: string, job: T, throwOnError?: boolean): T extends IJob<infer TData, infer TState extends string>
? Promise<TData>
: never
{
@ -35,7 +41,7 @@ export class TaskQueue
{
job.job.start();
this.activeQueue.push(job.job);
job.job.promise.promise.finally(() =>
job.job.promise.promise.catch(e => { }).finally(() =>
{
const index = this.activeQueue.indexOf(job.job);
this.activeQueue.splice(index, 1);
@ -235,26 +241,21 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
}
} catch (error)
{
try
if (error instanceof Event)
{
if (error instanceof Event)
if (error.target instanceof AbortSignal)
{
if (error.target instanceof AbortSignal)
{
} else
{
console.error(error);
}
this.m_promise.resolve(undefined);
} else
{
console.error(error);
this.events.emit('error', { id: this.m_id, job: this, error });
this.error = error;
this.m_promise.reject(error);
}
} finally
} else
{
this.m_promise.resolve(undefined);
this.events.emit('error', { id: this.m_id, job: this, error });
this.error = error;
this.m_promise.reject(error);
}
} finally