365 lines
No EOL
13 KiB
TypeScript
365 lines
No EOL
13 KiB
TypeScript
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<FrontEndGameType>
|
|
{
|
|
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<FrontEndGameTypeDetailed>
|
|
{
|
|
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<string, {
|
|
cwd: string;
|
|
globs: string[];
|
|
}> | 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<CommandEntry | undefined>
|
|
{
|
|
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),
|
|
}
|
|
};
|
|
}
|
|
} |