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'; 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; }; 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)['0'] ?? (req.params as Record)['']; 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 (url: string): Promise => { 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; }; 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 listRemoteGames = async (): Promise => { const baseUrl = getConfiguredAssetsBaseUrl(); const gamesIndexUrl = `${baseUrl}/games/`; 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: titleFromSlug(slug), logoFile: `${slug}.png`, })); }; const listHttpFiles = async (game: RemoteGame): Promise => { const baseUrl = getConfiguredAssetsBaseUrl(); const manifestUrl = `${baseUrl}/games/${game.repoFolder}/${LOCAL_MANIFEST_FILE}`; const entries = await fetchJson(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; }; const downloadGameAssets = async (gameSlug: string) => { const game: RemoteGame = { slug: gameSlug, repoFolder: gameSlug, title: 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 destinationFolder = path.join(assetsRoot, gameSlug); await rm(destinationFolder, { recursive: true, force: true }); return { title: 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); } });