mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
feat: cargar juegos de assets desde servidor HTTP
This commit is contained in:
@@ -6,14 +6,12 @@ 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 RemoteGame = {
|
||||
title: string;
|
||||
slug: string;
|
||||
repoFolder: string;
|
||||
logoFile: string;
|
||||
};
|
||||
|
||||
type AssetFileEntry = {
|
||||
path: string;
|
||||
@@ -120,7 +118,47 @@ const normalizeManifestEntry = (entry: HttpManifestEntry, gameTitle: string) =>
|
||||
throw new Error(`El ${LOCAL_MANIFEST_FILE} de ${gameTitle} contiene entradas inválidas.`);
|
||||
};
|
||||
|
||||
const listHttpFiles = async (game: (typeof gameCatalog)[number]): Promise<AssetFileEntry[]> => {
|
||||
const titleFromSlug = (slug: string) => slug
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment[0].toUpperCase() + segment.slice(1))
|
||||
.join(' ');
|
||||
|
||||
const listRemoteGames = async (): Promise<RemoteGame[]> => {
|
||||
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<AssetFileEntry[]> => {
|
||||
const baseUrl = getConfiguredAssetsBaseUrl();
|
||||
const manifestUrl = `${baseUrl}/games/${game.repoFolder}/${LOCAL_MANIFEST_FILE}`;
|
||||
const entries = await fetchJson<HttpManifestEntry[]>(manifestUrl);
|
||||
@@ -143,8 +181,7 @@ const listHttpFiles = async (game: (typeof gameCatalog)[number]): Promise<AssetF
|
||||
|
||||
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);
|
||||
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
|
||||
};
|
||||
|
||||
const parseCharacterNames = (content: string, gameTitle: string) => {
|
||||
@@ -163,28 +200,31 @@ const parseCharacterNames = (content: string, gameTitle: string) => {
|
||||
};
|
||||
|
||||
const listInstalledCharacterNamesByGame = async () => {
|
||||
const charactersByGame = await Promise.all(gameCatalog.map(async (game) => {
|
||||
const sourcePath = path.join(assetsRoot, game.slug, CHARACTER_NAMES_FILE);
|
||||
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, game.title);
|
||||
return [game.title, names] as const;
|
||||
const names = parseCharacterNames(fileContent, slug);
|
||||
return [slug, names] as const;
|
||||
} catch {
|
||||
return [game.title, []] as const;
|
||||
return [slug, []] 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.');
|
||||
}
|
||||
const downloadGameAssets = async (gameSlug: string) => {
|
||||
const game: RemoteGame = {
|
||||
slug: gameSlug,
|
||||
repoFolder: gameSlug,
|
||||
title: titleFromSlug(gameSlug),
|
||||
logoFile: `${gameSlug}.png`,
|
||||
};
|
||||
|
||||
emitProgress(game.title, 0, 'downloading');
|
||||
emitProgress(game.slug, 0, 'downloading');
|
||||
|
||||
const files = await listHttpFiles(game);
|
||||
if (!files.length) {
|
||||
@@ -217,10 +257,10 @@ const downloadGameAssets = async (gameTitle: string) => {
|
||||
|
||||
downloadedBytes += file.size || 0;
|
||||
const progress = totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 100;
|
||||
emitProgress(game.title, progress, 'downloading');
|
||||
emitProgress(game.slug, progress, 'downloading');
|
||||
}
|
||||
|
||||
emitProgress(game.title, 100, 'completed');
|
||||
emitProgress(game.slug, 100, 'completed');
|
||||
|
||||
return {
|
||||
title: game.title,
|
||||
@@ -228,21 +268,28 @@ const downloadGameAssets = async (gameTitle: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
const removeGameAssets = async (gameSlug: string) => {
|
||||
const destinationFolder = path.join(assetsRoot, gameSlug);
|
||||
await rm(destinationFolder, { recursive: true, force: true });
|
||||
|
||||
return {
|
||||
title: game.title,
|
||||
slug: game.slug,
|
||||
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;
|
||||
@@ -285,19 +332,19 @@ nodecg.listenFor('scoreko-assets:downloadGame', async (payload: unknown, ack) =>
|
||||
}
|
||||
|
||||
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 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(title);
|
||||
const downloaded = await downloadGameAssets(slug);
|
||||
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');
|
||||
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);
|
||||
}
|
||||
@@ -309,12 +356,12 @@ nodecg.listenFor('scoreko-assets:removeGame', async (payload: unknown, ack) => {
|
||||
}
|
||||
|
||||
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 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(title);
|
||||
const removed = await removeGameAssets(slug);
|
||||
ack(null, {
|
||||
removed,
|
||||
installedGames: await listInstalledGames(),
|
||||
|
||||
Reference in New Issue
Block a user