diff --git a/scripts/dev.ts b/scripts/dev.ts
index ef3ad70..b7c07f5 100644
--- a/scripts/dev.ts
+++ b/scripts/dev.ts
@@ -26,15 +26,7 @@ function spawnServer ()
killSignal: 'SIGUSR1',
onExit (subprocess, exitCode, signalCode)
{
- if (exitCode === 1 && retries <= 3)
- {
- server = spawnServer();
- retries++;
- } else
- {
- process.exit();
- }
-
+ process.exit();
}
});
const rl = createInterface({ input: Readable.fromWeb(s.stdout as any) });
diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts
index 531dc93..6d4f6b2 100644
--- a/src/bun/api/games/games.ts
+++ b/src/bun/api/games/games.ts
@@ -8,7 +8,7 @@ import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
-import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
+import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, validateGameSource } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler";
import { getEmulatorsForSystem, getRomFilePaths, launchCommand } from "./services/launchGameService";
import { getErrorMessage, SeededRandom } from "@/bun/utils";
@@ -412,6 +412,15 @@ export default new Elysia()
params: z.object({ id: z.string(), source: z.string() }),
response: z.any()
})
+ .get('/game/:source/:id/validate', async ({ params: { id, source } }) =>
+ {
+ const valid = await validateGameSource(source, id);
+ return { valid: valid.valid, reason: valid.reason };
+ })
+ .post('/game/:source/:id/fix_source', async ({ params: { id, source } }) =>
+ {
+ return fixSource(source, id);
+ })
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
{
const validCommands = await getValidLaunchCommandsForGame(source, id);
diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts
index 97f0d8a..0255d7b 100644
--- a/src/bun/api/games/services/statusService.ts
+++ b/src/bun/api/games/services/statusService.ts
@@ -44,26 +44,53 @@ export async function getLocalGame (source: string, id: string)
return localGame;
}
-export async function validateGameSource (source: string, id: string): Promise<{ valid: boolean, reason?: string; }>
+export async function fixSource (source: string, id: string)
+{
+ const valid = await validateGameSource(source, id);
+ if (!valid.valid)
+ {
+ if (!valid.localGame) throw new Error("No Local Game");
+ if (!valid.localGame.source) throw new Error("No Valid Source");
+
+ const foundGame = await plugins.hooks.games.searchGame.promise({
+ igdb_id: valid.localGame.igdb_id ?? undefined,
+ ra_id: valid.localGame.ra_id ?? undefined,
+ source: valid.localGame.source
+ });
+
+ if (foundGame)
+ {
+ await db.update(appSchema.games).set({ source: foundGame.id.source, source_id: foundGame.id.id }).where(eq(appSchema.games.id, valid.localGame.id));
+ return true;
+ } else
+ {
+ throw new Error("Could not find Source Game");
+ }
+ } else
+ {
+ throw new Error("Game Source Already Valid");
+ }
+}
+
+export async function validateGameSource (source: string, id: string): Promise<{
+ valid: boolean,
+ localGame?: { id: number; igdb_id: number | null; ra_id: number | null; source: string | null; },
+ reason?: string;
+}>
{
const localGame = await getLocalGame(source, id);
- if (!localGame) throw new Error("Could not find local game");
+ if (!localGame) return { valid: true };
if (localGame.source && localGame.source_id)
{
const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id });
- if (!sourceGame) return { valid: false, reason: "Source Missing" };
- if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined))
+ if (!sourceGame) return { valid: false, reason: "Source Missing", localGame };
+ if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined))
{
- return { valid: false, reason: "IGDB Miss Match" };
- }
-
- if (sourceGame.ra_id !== (localGame.ra_id ?? undefined))
- {
- return { valid: false, reason: "RA Miss Match" };
+ return { valid: false, reason: "Metadata Missmatch", localGame };
}
}
- return { valid: true };
+ return { valid: true, localGame };
}
export async function updateLocalLastPlayed (id: number)
@@ -174,7 +201,7 @@ export default function buildStatusResponse ()
},
async open (ws)
{
- sendLatests();
+ sendLatests().catch(e => ws.send({ status: 'error', error: JSON.stringify(e) }));
const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id });
async function sendLatests ()
diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts
index 38016aa..b53a00f 100644
--- a/src/bun/api/hooks/games.ts
+++ b/src/bun/api/hooks/games.ts
@@ -43,6 +43,11 @@ export class GameHooks
localGame?: FrontEndGameTypeDetailed;
id: string;
}], FrontEndGameTypeDetailed | undefined>(['ctx']);
+ searchGame = new AsyncSeriesBailHook<[ctx: {
+ source: string;
+ igdb_id?: number;
+ ra_id?: number;
+ }], FrontEndGameTypeDetailed | undefined>(['ctx']);
/** Get download file URLs
* @param ctx.checksum Check if file already exists using checksums
*/
diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts
index 46c64db..8ec3a62 100644
--- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts
+++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts
@@ -2,7 +2,7 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
-import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
+import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
import { config, events } from "@/bun/api/app";
import path from 'node:path';
import fs from 'node:fs/promises';
@@ -557,5 +557,14 @@ export default class RommIntegration implements PluginType
const platforms = await this.getAllRommPlatforms();
return platforms.find(p => p.id === Number(id));
});
+
+ ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) =>
+ {
+ if (source !== 'romm') return;
+ const roms = await getRomByMetadataProviderApiRomsByMetadataProviderGet({ query: { igdb_id, ra_id } });
+ if (roms.error) throw roms.error;
+ if (!roms.data) return;
+ return this.convertRomToFrontendDetailed(roms.data);
+ });
}
}
\ No newline at end of file
diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx
index d931fa6..8730779 100644
--- a/src/mainview/components/game/ActionButtons.tsx
+++ b/src/mainview/components/game/ActionButtons.tsx
@@ -1,10 +1,10 @@
-import { deleteGameMutation, gameInvalidationQuery } from "@/mainview/scripts/queries/romm";
+import { deleteGameMutation, fixSourceMutation, gameInvalidationQuery, validateSourceQuery } from "@/mainview/scripts/queries/romm";
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
-import { useMutation } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
import { getErrorMessage } from "react-error-boundary";
import toast from "react-hot-toast";
-import { Settings, Trash, Trophy } from "lucide-react";
+import { Hammer, Settings, Trash, Trophy } from "lucide-react";
import MainActions from "./MainActions";
import ActionButton from "./ActionButton";
import { useLocalStorage } from "usehooks-ts";
@@ -33,6 +33,18 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
{
const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots');
+ const fixMutation = useMutation({
+ ...fixSourceMutation, onSuccess (data, variables, onMutateResult, context)
+ {
+ if (onMutateResult) toast.success("Updated Source");
+ context.client.invalidateQueries(gameInvalidationQuery(variables.id, variables.source)).then(() => router.history.back());
+ },
+ onError (error)
+ {
+ toast.error(getErrorMessage(error) ?? "Error While Trying To Fix");
+ }
+ });
+ const { data: validation } = useQuery(validateSourceQuery(data.source, data.id));
const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' });
const router = useRouter();
const deleteMutation = useMutation({
@@ -47,32 +59,41 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
}
});
- useBlocker({ shouldBlockFn: () => deleteMutation.isPending });
+ useBlocker({
+ shouldBlockFn: () =>
+ {
+ return deleteMutation.isPending || fixMutation.isPending;
+ }
+ });
const contextOptions: DialogEntry[] = [];
if (data.game?.local)
{
- if (deleteMutation.isPending)
- {
- contextOptions.push({
- id: 'delete',
- icon: ,
- content: "Deleting",
- type: 'error'
- });
- } else
- {
- contextOptions.push({
- id: 'delete',
- action: () =>
- {
- deleteMutation.mutate();
- },
- icon: ,
- content: "Delete",
- type: 'error'
- });
- }
+ contextOptions.push({
+ id: 'delete',
+ action: () =>
+ {
+ deleteMutation.mutate();
+ },
+ icon: deleteMutation.isPending ? : ,
+ content: deleteMutation.isPending ? "Deleting" : "Delete",
+ type: 'error'
+ });
+ }
+
+ if (!validation?.valid)
+ {
+ contextOptions.push({
+ id: "fix_source",
+ action (ctx)
+ {
+ if (data.game)
+ fixMutation.mutate({ source: data.game.id.source, id: data.game.id.id });
+ },
+ icon: fixMutation.isPending ? : ,
+ content: "Try Fix Source",
+ type: "warning"
+ });
}
const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: , canClose: !deleteMutation.isPending });
diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx
index c3b1fcc..570e670 100644
--- a/src/mainview/components/game/Details.tsx
+++ b/src/mainview/components/game/Details.tsx
@@ -2,11 +2,13 @@ import { scrollIntoViewHandler } from "@/mainview/scripts/utils";
import { RPC_URL } from "@/shared/constants";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
-import { Clock, CloudBackup, CloudDownload, CloudUpload, HardDrive, Store, TriangleAlert } from "lucide-react";
+import { Clock, CloudBackup, CloudDownload, CloudUpload, Gamepad2, HardDrive, Store, TriangleAlert } from "lucide-react";
import prettyBytes from "pretty-bytes";
import { JSX } from "react";
import ActionButtons from "./ActionButtons";
import prettyMilliseconds from 'pretty-ms';
+import { useQuery } from "@tanstack/react-query";
+import { validateSourceQuery } from "@/mainview/scripts/queries/romm";
export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; })
{
@@ -18,6 +20,12 @@ export function DetailElement (data: { icon: JSX.Element; tooltip?: string | nul
);
}
+const sourceIconMap: Record = {
+ store: ,
+ local: ,
+ romm:
+};
+
export default function Details (data: {
game?: FrontEndGameTypeDetailed,
source: string,
@@ -32,6 +40,8 @@ export default function Details (data: {
forceFocus: true
});
+ const { data: validation } = useQuery(validateSourceQuery(data.source, data.id));
+
const platformCoverImg = data.game?.path_platform_cover ? new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`) : undefined;
if (platformCoverImg)
platformCoverImg.searchParams.set("width", "64");
@@ -70,8 +80,8 @@ export default function Details (data: {
}
: } >{data.game?.platform_display_name ?? }
{data.game?.emulators?.some(e => e.integrations.some(i => i.capabilities?.includes('saves'))) && } />}
-
+ :
} >
{data.game?.source ?? data.game?.id.source}
{data.game?.local && local}
diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx
index f77fd9d..42d23f1 100644
--- a/src/mainview/routes/game/$source.$id.tsx
+++ b/src/mainview/routes/game/$source.$id.tsx
@@ -23,6 +23,7 @@ import { GamesSection } from "@/mainview/components/store/GamesSection";
import Details from "@/mainview/components/game/Details";
import { AutoFocus } from "@/mainview/components/AutoFocus";
import SelectMenu from "@/mainview/components/SelectMenu";
+import { stat } from "node:fs";
export const Route = createFileRoute("/game/$source/$id")({
loader: async ({ params, context }) =>
@@ -104,6 +105,8 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; })
stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: });
if (data.game.emulators)
stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) });
+ if (data.game.source)
+ stats.push({ label: "Source", content: `${data.game.source} - ${data.game.source_id}` });
const integrations = new Set(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c));
stats.push({ label: "Integrations", content: Array.from(integrations) });
}
diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts
index 4ecf6ca..3f73bcf 100644
--- a/src/mainview/scripts/queries/romm.ts
+++ b/src/mainview/scripts/queries/romm.ts
@@ -1,6 +1,6 @@
import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants";
import { rommApi, settingsApi } from "../clientApi";
-import { mutationOptions, QueryFilters, queryOptions } from "@tanstack/react-query";
+import { mutationOptions, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query";
import z from "zod";
import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
@@ -155,4 +155,19 @@ export const gameInvalidationQuery = (source: string, id: string): QueryFilters
if (query.queryKey.includes(source) && query.queryKey.includes(id)) return true;
return false;
},
+});
+export const validateSourceQuery = (source: string, id: string) => queryOptions({
+ queryKey: ["game", source, id, "validate"], queryFn: async () =>
+ {
+ const { data, error } = await rommApi.api.romm.game({ source })({ id }).validate.get();
+ return data;
+ }
+});
+export const fixSourceMutation = mutationOptions({
+ mutationKey: ['game', "fix_source"], mutationFn: async ({ source, id }: { source: string, id: string; }) =>
+ {
+ const { data, error } = await rommApi.api.romm.game({ source })({ id }).fix_source.post();
+ if (error) throw error;
+ return data;
+ }
});
\ No newline at end of file