mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
326 lines
9.9 KiB
TypeScript
326 lines
9.9 KiB
TypeScript
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { nodecg } from './util/nodecg.js';
|
|
|
|
const CHARACTER_NAMES_FILE = 'fighting-characters.json';
|
|
const LOCAL_MANIFEST_FILE = 'manifest.json';
|
|
|
|
const gameCatalog = [
|
|
{ title: 'Street Fighter 6', slug: 'street-fighter-6', repoFolder: 'street-fighter-6' },
|
|
{ title: 'TEKKEN 8', slug: 'tekken-8', repoFolder: 'tekken-8' },
|
|
{ title: '2XKO', slug: '2xko', repoFolder: '2xko' },
|
|
{ title: 'Guilty Gear -Strive-', slug: 'guilty-gear-strive', repoFolder: 'guilty-gear-strive' },
|
|
{ title: 'Mortal Kombat 1', slug: 'mortal-kombat-1', repoFolder: 'mortal-kombat-1' },
|
|
{ title: 'THE KING OF FIGHTERS XV', slug: 'the-king-of-fighters-xv', repoFolder: 'the-king-of-fighters-xv' },
|
|
] as const;
|
|
|
|
type AssetFileEntry = {
|
|
path: string;
|
|
size: number;
|
|
downloadUrl: string;
|
|
};
|
|
|
|
type HttpManifestEntry = string | {
|
|
path?: unknown;
|
|
size?: unknown;
|
|
url?: unknown;
|
|
};
|
|
|
|
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const bundleRoot = path.resolve(extensionDir, '..');
|
|
const assetsRoot = path.join(bundleRoot, 'game-assets');
|
|
|
|
const assetsRouter = nodecg.Router();
|
|
|
|
assetsRouter.get('/*', async (req, res) => {
|
|
const wildcardParam = (req.params as Record<string, unknown>)['0']
|
|
?? (req.params as Record<string, unknown>)[''];
|
|
const requestedPath = Array.isArray(wildcardParam)
|
|
? String(wildcardParam[0] ?? '')
|
|
: typeof wildcardParam === 'string'
|
|
? wildcardParam
|
|
: '';
|
|
const normalizedPath = path.normalize(requestedPath).replace(/^(\.\.(?:[\\/]|$))+/, '');
|
|
const filePath = path.resolve(assetsRoot, normalizedPath);
|
|
|
|
if (!filePath.startsWith(assetsRoot)) {
|
|
res.status(400).send('Invalid asset path.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const fileStats = await stat(filePath);
|
|
if (!fileStats.isFile()) {
|
|
res.status(404).send('Asset not found.');
|
|
return;
|
|
}
|
|
|
|
res.type(path.extname(filePath));
|
|
res.send(await readFile(filePath));
|
|
} catch {
|
|
res.status(404).send('Asset not found.');
|
|
}
|
|
});
|
|
|
|
nodecg.mount(`/bundles/${nodecg.bundleName}/game-assets`, assetsRouter);
|
|
|
|
const requestHeaders = {
|
|
'User-Agent': 'scoreko-dev-nodecg-bundle',
|
|
};
|
|
|
|
const getConfiguredAssetsBaseUrl = () => {
|
|
const configuredValue = nodecg.bundleConfig.assetsBaseUrl;
|
|
if (typeof configuredValue !== 'string') {
|
|
return 'http://localhost';
|
|
}
|
|
|
|
const trimmed = configuredValue.trim();
|
|
if (!trimmed) {
|
|
return 'http://localhost';
|
|
}
|
|
|
|
return trimmed.replace(/\/+$/, '');
|
|
};
|
|
|
|
const emitProgress = (title: string, progress: number, status: 'downloading' | 'completed' | 'error') => {
|
|
nodecg.sendMessage('scoreko-assets:downloadProgress', {
|
|
title,
|
|
progress: Math.max(0, Math.min(100, progress)),
|
|
status,
|
|
});
|
|
};
|
|
|
|
const fetchJson = async <T>(url: string): Promise<T> => {
|
|
const response = await fetch(url, { headers: requestHeaders });
|
|
if (!response.ok) {
|
|
throw new Error(`Error HTTP (${response.status}) al solicitar ${url}`);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
};
|
|
|
|
const normalizeManifestEntry = (entry: HttpManifestEntry, gameTitle: string) => {
|
|
if (typeof entry === 'string') {
|
|
return {
|
|
path: entry,
|
|
size: 0,
|
|
explicitUrl: null,
|
|
};
|
|
}
|
|
|
|
if (typeof entry === 'object' && entry !== null && typeof entry.path === 'string') {
|
|
return {
|
|
path: entry.path,
|
|
size: typeof entry.size === 'number' ? entry.size : 0,
|
|
explicitUrl: typeof entry.url === 'string' ? entry.url : null,
|
|
};
|
|
}
|
|
|
|
throw new Error(`El ${LOCAL_MANIFEST_FILE} de ${gameTitle} contiene entradas inválidas.`);
|
|
};
|
|
|
|
const listHttpFiles = async (game: (typeof gameCatalog)[number]): Promise<AssetFileEntry[]> => {
|
|
const baseUrl = getConfiguredAssetsBaseUrl();
|
|
const manifestUrl = `${baseUrl}/games/${game.repoFolder}/${LOCAL_MANIFEST_FILE}`;
|
|
const entries = await fetchJson<HttpManifestEntry[]>(manifestUrl);
|
|
|
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
throw new Error(`No se encontraron archivos en ${manifestUrl}.`);
|
|
}
|
|
|
|
return entries.map((rawEntry) => {
|
|
const normalized = normalizeManifestEntry(rawEntry, game.title);
|
|
const cleanPath = normalized.path.replace(/^\/+/, '');
|
|
|
|
return {
|
|
path: `games/${game.repoFolder}/${cleanPath}`,
|
|
size: Math.max(0, normalized.size),
|
|
downloadUrl: normalized.explicitUrl ?? `${baseUrl}/games/${game.repoFolder}/${cleanPath}`,
|
|
};
|
|
});
|
|
};
|
|
|
|
const listInstalledGames = async () => {
|
|
const entries = await readdir(assetsRoot, { withFileTypes: true }).catch(() => []);
|
|
const installedSlugs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
return gameCatalog.filter((game) => installedSlugs.includes(game.slug)).map((game) => game.title);
|
|
};
|
|
|
|
const parseCharacterNames = (content: string, gameTitle: string) => {
|
|
const parsed = JSON.parse(content) as unknown;
|
|
const names = Array.isArray(parsed)
|
|
? parsed
|
|
: typeof parsed === 'object' && parsed !== null && Array.isArray((parsed as { characters?: unknown }).characters)
|
|
? (parsed as { characters: unknown[] }).characters
|
|
: null;
|
|
|
|
if (!names || names.some((name) => typeof name !== 'string')) {
|
|
throw new Error(`El archivo ${CHARACTER_NAMES_FILE} de ${gameTitle} no tiene un formato válido.`);
|
|
}
|
|
|
|
return names;
|
|
};
|
|
|
|
const listInstalledCharacterNamesByGame = async () => {
|
|
const charactersByGame = await Promise.all(gameCatalog.map(async (game) => {
|
|
const sourcePath = path.join(assetsRoot, game.slug, CHARACTER_NAMES_FILE);
|
|
|
|
try {
|
|
const fileContent = await readFile(sourcePath, 'utf8');
|
|
const names = parseCharacterNames(fileContent, game.title);
|
|
return [game.title, names] as const;
|
|
} catch {
|
|
return [game.title, []] as const;
|
|
}
|
|
}));
|
|
|
|
return Object.fromEntries(charactersByGame) as Record<string, string[]>;
|
|
};
|
|
|
|
const downloadGameAssets = async (gameTitle: string) => {
|
|
const game = gameCatalog.find((entry) => entry.title === gameTitle);
|
|
if (!game) {
|
|
throw new Error('Juego no soportado en el catálogo.');
|
|
}
|
|
|
|
emitProgress(game.title, 0, 'downloading');
|
|
|
|
const files = await listHttpFiles(game);
|
|
if (!files.length) {
|
|
throw new Error(`No se encontraron archivos para ${game.title}.`);
|
|
}
|
|
|
|
const hasCharacterNamesFile = files.some((file) => file.path.endsWith(`/${CHARACTER_NAMES_FILE}`));
|
|
if (!hasCharacterNamesFile) {
|
|
throw new Error(`No se encontró ${CHARACTER_NAMES_FILE} para ${game.title}.`);
|
|
}
|
|
|
|
const totalBytes = files.reduce((acc, file) => acc + (file.size || 0), 0);
|
|
let downloadedBytes = 0;
|
|
|
|
const destinationFolder = path.join(assetsRoot, game.slug);
|
|
await rm(destinationFolder, { recursive: true, force: true });
|
|
|
|
for (const file of files) {
|
|
const relativePath = file.path.replace(`games/${game.repoFolder}/`, '');
|
|
const targetPath = path.join(destinationFolder, relativePath);
|
|
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
|
|
const response = await fetch(file.downloadUrl, { headers: requestHeaders });
|
|
if (!response.ok) {
|
|
throw new Error(`No se pudo descargar ${file.path} (status ${response.status}).`);
|
|
}
|
|
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
await writeFile(targetPath, Buffer.from(arrayBuffer));
|
|
|
|
downloadedBytes += file.size || 0;
|
|
const progress = totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 100;
|
|
emitProgress(game.title, progress, 'downloading');
|
|
}
|
|
|
|
emitProgress(game.title, 100, 'completed');
|
|
|
|
return {
|
|
title: game.title,
|
|
slug: game.slug,
|
|
};
|
|
};
|
|
|
|
const removeGameAssets = async (gameTitle: string) => {
|
|
const game = gameCatalog.find((entry) => entry.title === gameTitle);
|
|
if (!game) {
|
|
throw new Error('Juego no soportado en el catálogo.');
|
|
}
|
|
|
|
const destinationFolder = path.join(assetsRoot, game.slug);
|
|
await rm(destinationFolder, { recursive: true, force: true });
|
|
|
|
return {
|
|
title: game.title,
|
|
slug: game.slug,
|
|
};
|
|
};
|
|
|
|
nodecg.listenFor('scoreko-assets:listInstalled', async (_payload: unknown, ack) => {
|
|
if (typeof ack !== 'function') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ack(null, await listInstalledGames());
|
|
} catch (error) {
|
|
ack((error as Error).message);
|
|
}
|
|
});
|
|
|
|
nodecg.listenFor('scoreko-assets:listCharactersByGame', async (_payload: unknown, ack) => {
|
|
if (typeof ack !== 'function') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ack(null, await listInstalledCharacterNamesByGame());
|
|
} catch (error) {
|
|
ack((error as Error).message);
|
|
}
|
|
});
|
|
|
|
nodecg.listenFor('scoreko-assets:getAssetsBaseUrl', async (_payload: unknown, ack) => {
|
|
if (typeof ack !== 'function') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ack(null, { assetsBaseUrl: getConfiguredAssetsBaseUrl() });
|
|
} catch (error) {
|
|
ack((error as Error).message);
|
|
}
|
|
});
|
|
|
|
nodecg.listenFor('scoreko-assets:downloadGame', async (payload: unknown, ack) => {
|
|
if (typeof ack !== 'function') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const title = typeof payload === 'object' && payload !== null ? (payload as { title?: unknown }).title : undefined;
|
|
if (typeof title !== 'string') {
|
|
throw new Error('Título de juego inválido.');
|
|
}
|
|
|
|
const downloaded = await downloadGameAssets(title);
|
|
ack(null, {
|
|
downloaded,
|
|
installedGames: await listInstalledGames(),
|
|
});
|
|
} catch (error) {
|
|
if (typeof payload === 'object' && payload !== null && typeof (payload as { title?: unknown }).title === 'string') {
|
|
emitProgress((payload as { title: string }).title, 0, 'error');
|
|
}
|
|
ack((error as Error).message);
|
|
}
|
|
});
|
|
|
|
nodecg.listenFor('scoreko-assets:removeGame', async (payload: unknown, ack) => {
|
|
if (typeof ack !== 'function') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const title = typeof payload === 'object' && payload !== null ? (payload as { title?: unknown }).title : undefined;
|
|
if (typeof title !== 'string') {
|
|
throw new Error('Título de juego inválido.');
|
|
}
|
|
|
|
const removed = await removeGameAssets(title);
|
|
ack(null, {
|
|
removed,
|
|
installedGames: await listInstalledGames(),
|
|
});
|
|
} catch (error) {
|
|
ack((error as Error).message);
|
|
}
|
|
});
|