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:
parent
e54a6ac8f0
commit
06b7e4074d
66 changed files with 2216 additions and 416 deletions
|
|
@ -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()
|
||||
})
|
||||
});
|
||||
|
|
@ -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."
|
||||
}
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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']);
|
||||
|
|
|
|||
96
src/bun/api/jobs/import-job.ts
Normal file
96
src/bun/api/jobs/import-job.ts
Normal 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,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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')}`,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue