import { getStoreFolder } from "@/bun/api/store/services/gamesService"; import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants"; import os from 'node:os'; import path from "node:path"; import * as appSchema from '@schema/app'; import * as emulatorSchema from '@schema/emulators'; import { config, db, emulatorsDb, plugins } from "@/bun/api/app"; import { and, eq } from "drizzle-orm"; import { getOrCached } from "@/bun/api/cache"; import { Glob } from "bun"; import { shuffleInPlace } from "@/bun/utils"; import mustache from "mustache"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import fs from "node:fs/promises"; import { CommandEntry, EmulatorSourceEntryType, EmulatorSystem, FrontEndEmulator, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, SaveFileChange } from "@/shared/types"; export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) { const offset = filter?.offset ?? 0; const limit = Math.min(50, filter?.limit ?? 10); const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) => { return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, ""))))); })); return games; } export async function getStoreGame (id: string) { const file = Bun.file(path.join(getStoreFolder(), 'buckets', 'games', `${id}.json`)); if (!(await file.exists())) return undefined; const game = file .json() .then(g => StoreGameSchema.parseAsync(g)) .then(g => ({ ...g, id })); return game; } function convertStoreMediaToPath (c: string) { if (c.startsWith('http')) { return `/api/romm/image?url=${encodeURIComponent(c)}`; } else { return `/api/store/media/${c}`; } } export async function convertStoreToFrontend (id: string, storeGame: StoreGameType): Promise { const validDownloads = getValidDownloads(storeGame); let platform_slug: string | null = null; let platform_id: number | null = null; let platform_display_name: string | null = null; let path_platform_cover: string | null = null; if (validDownloads.length > 0 && validDownloads[0].system) { let system = validDownloads[0].system.split(':')[0]; if (system === 'win32') system = 'win'; const localPlatform = await db.query.platforms.findFirst({ where: eq(appSchema.platforms.slug, system), columns: { id: true, slug: true, name: true } }); if (localPlatform) { platform_id = localPlatform.id; platform_slug = localPlatform.slug; path_platform_cover = `/api/romm/platform/local/${localPlatform.id}/cover`; platform_display_name = localPlatform.name; } if (platform_slug === null) { const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, system), eq(emulatorSchema.systemMappings.source, 'romm')) }); if (rommSystem?.system) { const platformDef = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.systems.name, rommSystem?.system), columns: { fullname: true } }); platform_slug = rommSystem.system; platform_display_name = platformDef?.fullname ?? null; path_platform_cover = `/api/romm/image/romm/assets/platforms/${rommSystem.sourceSlug}.svg`; } else { const platformDef = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.systems.name, system), columns: { fullname: true } }); platform_slug = system; platform_display_name = platformDef?.fullname ?? null; } platform_slug ??= system; } } const game: FrontEndGameType = { platform_display_name, path_platform_cover, id: { source: 'store', id: id }, source: null, source_id: null, path_fs: null, path_covers: storeGame.covers?.map(convertStoreMediaToPath) ?? [], last_played: null, updated_at: new Date(), slug: id, name: storeGame.name, platform_id, platform_slug, paths_screenshots: storeGame.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [], metadata: { first_release_date: typeof storeGame.first_release_date === 'number' ? new Date(storeGame.first_release_date) : storeGame.first_release_date ?? null } }; return game; } export async function convertStoreToFrontendDetailed (id: string, storeGame: StoreGameType): Promise { const validDownloads = getValidDownloads(storeGame); let size: number | null = null; if (validDownloads.length > 0 && validDownloads[0].url) { try { const fileResponse = await fetch(validDownloads[0]?.url, { method: 'HEAD' }); size = Number(fileResponse.headers.get('content-length')); } catch (error) { console.error(error); } } const detailed: FrontEndGameTypeDetailed = { ...await convertStoreToFrontend(id, storeGame), summary: storeGame.description, fs_size_bytes: size, missing: false, local: false, version: storeGame.version, igdb_id: storeGame.igdb_id ?? null, ra_id: storeGame.ra_id ?? null, metadata: { genres: storeGame.genres ?? [], companies: storeGame.companies ?? [], game_modes: [], age_ratings: [], player_count: storeGame.player_count ?? null, average_rating: null, first_release_date: typeof storeGame.first_release_date === 'number' ? new Date(storeGame.first_release_date) : storeGame.first_release_date ?? null } }; return detailed; } export function getValidDownloads (game: StoreGameType, downloadId?: string) { const downloads = Object.entries(game.downloads).map(([k, d]) => ({ id: k, ...d })); const supportedDownloads = downloads.filter(d => d.type === 'direct'); if (downloadId) { return supportedDownloads.filter(d => d.id === downloadId); } else { return supportedDownloads.filter(d => { if (d.system === `${process.platform}:${process.arch}`) return true; // TODO: Add linux proton support //if (process.platform === 'linux' && d.system === `win32:${process.arch}`) return true; // emulator fallback return !d.system.includes(':'); }).toSorted((a, b) => { const bScore = b.system.includes(':') ? 0 : 1; const aScore = a.system.includes(':') ? 0 : 1; return bScore - aScore; }); } } export async function getShuffledStoreGames () { return getOrCached('shuffled-store-games', async () => { const files = new Glob(path.join(getStoreFolder(), 'buckets', 'games', '*.json')).scan(); const allGamePaths = await Array.fromAsync(files); const allStoreGames = await Promise.all(allGamePaths.map(p => Bun.file(p).json().then(g => StoreGameSchema.parseAsync(g)).then(g => ({ ...g, id: path.basename(p, '.json') })))); shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60)); return allStoreGames; }, { expireMs: 1000 / 60 / 60 }); } export async function buildFilters (filters: FrontEndFilterSets) { const filtersFile = Bun.file(path.join(getStoreFolder(), 'manifests', 'filters.json')); if (!await filtersFile.exists()) return; const storeFilters = await filtersFile.json(); storeFilters.genres?.forEach((g: string) => filters.genres.add(g)); storeFilters.age_ratings?.forEach((g: string) => filters.age_ratings.add(g)); if (storeFilters.player_count) filters.player_counts.add(storeFilters.player_count); storeFilters.companies?.forEach((g: string) => filters.companies.add(g)); } function getAppData () { if (process.platform === "win32") return process.env.APPDATA!; if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support"); // linux return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); } function getLocalAppData () { if (process.platform === "win32") return process.env.LOCALAPPDATA!; if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Caches"); // Linux / Unix return process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"); } export function buildSaves (command: CommandEntry, storeGame: StoreGameType, download?: StoreDownloadType) { let saveFileGlobs: Record | undefined = undefined; if (download && download.saves) { saveFileGlobs = download.saves; } else if (storeGame.saves) { const platformSaves = storeGame.saves[`${process.platform}:${process.arch}`]; if (platformSaves) { saveFileGlobs = platformSaves; } } const view = { GAMEDIR: command.startDir, HOMEDIR: os.homedir(), TMPDIR: os.tmpdir(), APPDATA: getAppData(), LOCALAPPDATA: getLocalAppData(), }; if (!saveFileGlobs) return; return Object.entries(saveFileGlobs).map(([slot, save]) => { const cwd = mustache.render(save.cwd, view); const change: SaveFileChange = { cwd, shared: false, isGlob: true, subPath: save.globs }; return [slot, change] as [string, SaveFileChange]; }); } export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, systems: EmulatorSystem[]) { const execPaths: EmulatorSourceEntryType[] = []; await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: emulator.name, sources: execPaths }); const em: FrontEndEmulator = { name: emulator.name, logo: emulator.logo, systems, gameCount: 0, validSources: execPaths, integrations: [], source: "store" }; return em; } export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined> { const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`; if (await fs.exists(existingPackagePath)) { const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json()); const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined); if (!download) return { ...existingPackage, hasUpdate: false }; if (download.info.version) { if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true }; } else if (existingPackage.id !== download.info.id) { return { ...existingPackage, hasUpdate: true }; } return { ...existingPackage, hasUpdate: false }; } // this should only happen if download info is missing maybe manually deleted or wasn't saved. return undefined; } export async function buildLaunchCommand (ctx: { gamePath: string; systemSlug: string; mainGlob?: string | null; }): Promise { if (ctx.systemSlug !== 'win' && ctx.systemSlug !== 'linux' && ctx.systemSlug !== 'mac') return; const downloadPath = config.get('downloadPath'); const gamePathAbsolute = path.join(downloadPath, ctx.gamePath); if (!(await fs.exists(gamePathAbsolute))) return; const gamePathStat = await fs.stat(gamePathAbsolute); if (gamePathStat.isDirectory()) { let mainGlob = ctx.mainGlob; if (!mainGlob && ctx.systemSlug === 'win') mainGlob = '**/*.exe'; if (!mainGlob) return; const fileGlob = new Glob(mainGlob); for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, ctx.gamePath) })) { return { startDir: path.join(downloadPath, ctx.gamePath, path.dirname(file)), command: [`./${path.basename(file)}`], id: `store-${process.platform}`, shell: false, valid: true, metadata: { romPath: path.join(downloadPath, ctx.gamePath, file) } }; } } else { return { startDir: path.join(downloadPath, path.dirname(ctx.gamePath)), command: [`./${path.basename(ctx.gamePath)}`], id: `store-${process.platform}`, valid: true, shell: false, metadata: { romPath: path.join(downloadPath, ctx.gamePath), } }; } }