From 4806f3487a577ab8e7c66907e5b640d95ab8a46c Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 10 Apr 2026 02:00:11 +0300 Subject: [PATCH] feat: Added way to update the local games from romm when IDs change based on IGDB or Retro Achievement ID Fixes #2 --- scripts/dev.ts | 10 +-- src/bun/api/games/games.ts | 11 ++- src/bun/api/games/services/statusService.ts | 51 +++++++++---- src/bun/api/hooks/games.ts | 5 ++ .../com.simeonradivoev.gameflow.romm/romm.ts | 11 ++- .../components/game/ActionButtons.tsx | 71 ++++++++++++------- src/mainview/components/game/Details.tsx | 16 ++++- src/mainview/routes/game/$source.$id.tsx | 3 + src/mainview/scripts/queries/romm.ts | 17 ++++- 9 files changed, 143 insertions(+), 52 deletions(-) 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