Files
scoreko-dev/src/extension/game-assets.ts
T

435 lines
13 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 GAME_TITLES_FILE = 'games.json';
type RemoteGame = {
title: string;
slug: string;
repoFolder: string;
logoFile: string;
};
type AssetFileEntry = {
path: string;
size: number;
downloadUrl: string;
};
type HttpManifestEntry = string | {
path?: unknown;
size?: unknown;
url?: unknown;
};
type HttpGameTitleEntry = {
slug?: unknown;
title?: unknown;
};
type HttpGameTitlesFile = Record<string, unknown> | HttpGameTitleEntry[];
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 titleFromSlug = (slug: string) => slug
.split('-')
.filter(Boolean)
.map((segment) => segment[0].toUpperCase() + segment.slice(1))
.join(' ');
const parseGameTitlesMap = (payload: unknown): Map<string, string> => {
const map = new Map<string, string>();
if (Array.isArray(payload)) {
for (const entry of payload) {
const parsedEntry = entry as HttpGameTitleEntry;
if (
typeof entry === 'object'
&& entry !== null
&& typeof parsedEntry.slug === 'string'
&& typeof parsedEntry.title === 'string'
) {
const slug = parsedEntry.slug.trim();
const title = parsedEntry.title.trim();
if (slug && title) {
map.set(slug, title);
}
}
}
return map;
}
if (typeof payload === 'object' && payload !== null) {
for (const [slug, value] of Object.entries(payload)) {
if (typeof value !== 'string') {
continue;
}
const normalizedSlug = slug.trim();
const title = value.trim();
if (normalizedSlug && title) {
map.set(normalizedSlug, title);
}
}
}
return map;
};
const fetchCustomGameTitles = async (): Promise<Map<string, string>> => {
const baseUrl = getConfiguredAssetsBaseUrl();
const url = `${baseUrl}/games/${GAME_TITLES_FILE}`;
try {
const payload = await fetchJson<HttpGameTitlesFile>(url);
return parseGameTitlesMap(payload);
} catch {
return new Map<string, string>();
}
};
const listRemoteGames = async (): Promise<RemoteGame[]> => {
const baseUrl = getConfiguredAssetsBaseUrl();
const gamesIndexUrl = `${baseUrl}/games/`;
const customTitles = await fetchCustomGameTitles();
const response = await fetch(gamesIndexUrl, { headers: requestHeaders });
if (!response.ok) {
throw new Error(`Error HTTP (${response.status}) al solicitar ${gamesIndexUrl}`);
}
const html = await response.text();
const hrefMatches = [...html.matchAll(/href=["']([^"']+)["']/gi)].map((match) => match[1]);
const slugs = hrefMatches
.map((href) => {
const withoutQuery = href.split('?')[0]?.split('#')[0] ?? '';
if (!withoutQuery.endsWith('/')) {
return null;
}
const decoded = decodeURIComponent(withoutQuery);
const trimmed = decoded.replace(/^\/+|\/+$/g, '');
if (!trimmed || trimmed.includes('/') || trimmed === '.' || trimmed === '..') {
return null;
}
return trimmed;
})
.filter((slug): slug is string => slug !== null);
const uniqueSlugs = [...new Set(slugs)].sort((left, right) => left.localeCompare(right));
return uniqueSlugs.map((slug) => ({
slug,
repoFolder: slug,
title: customTitles.get(slug) ?? titleFromSlug(slug),
logoFile: `${slug}.png`,
}));
};
const listHttpFiles = async (game: RemoteGame): 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(() => []);
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
};
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 installedGames = await listInstalledGames();
const charactersByGame = await Promise.all(installedGames.map(async (slug) => {
const sourcePath = path.join(assetsRoot, slug, CHARACTER_NAMES_FILE);
try {
const fileContent = await readFile(sourcePath, 'utf8');
const names = parseCharacterNames(fileContent, slug);
return [slug, names] as const;
} catch {
return [slug, []] as const;
}
}));
return Object.fromEntries(charactersByGame) as Record<string, string[]>;
};
const downloadGameAssets = async (gameSlug: string) => {
const customTitles = await fetchCustomGameTitles();
const game: RemoteGame = {
slug: gameSlug,
repoFolder: gameSlug,
title: customTitles.get(gameSlug) ?? titleFromSlug(gameSlug),
logoFile: `${gameSlug}.png`,
};
emitProgress(game.slug, 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.slug, progress, 'downloading');
}
emitProgress(game.slug, 100, 'completed');
return {
title: game.title,
slug: game.slug,
};
};
const removeGameAssets = async (gameSlug: string) => {
const customTitles = await fetchCustomGameTitles();
const destinationFolder = path.join(assetsRoot, gameSlug);
await rm(destinationFolder, { recursive: true, force: true });
return {
title: customTitles.get(gameSlug) ?? titleFromSlug(gameSlug),
slug: gameSlug,
};
};
nodecg.listenFor('scoreko-assets:listRemoteGames', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
ack(null, await listRemoteGames());
} catch (error) {
ack((error as Error).message);
}
});
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 slug = typeof payload === 'object' && payload !== null ? (payload as { slug?: unknown }).slug : undefined;
if (typeof slug !== 'string') {
throw new Error('Slug de juego inválido.');
}
const downloaded = await downloadGameAssets(slug);
ack(null, {
downloaded,
installedGames: await listInstalledGames(),
});
} catch (error) {
if (typeof payload === 'object' && payload !== null && typeof (payload as { slug?: unknown }).slug === 'string') {
emitProgress((payload as { slug: string }).slug, 0, 'error');
}
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:removeGame', async (payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
const slug = typeof payload === 'object' && payload !== null ? (payload as { slug?: unknown }).slug : undefined;
if (typeof slug !== 'string') {
throw new Error('Slug de juego inválido.');
}
const removed = await removeGameAssets(slug);
ack(null, {
removed,
installedGames: await listInstalledGames(),
});
} catch (error) {
ack((error as Error).message);
}
});