Merge pull request #151 from Pandipipas/update-gameassetsview-to-auto-load-games

feat: descubrir y mostrar juegos desde el servidor HTTP de assets
This commit is contained in:
Pandipipas
2026-03-03 21:22:44 +01:00
committed by GitHub
3 changed files with 134 additions and 91 deletions
+22 -11
View File
@@ -9,6 +9,13 @@ type ProgressPayload = {
status: DownloadStatus; status: DownloadStatus;
}; };
type RemoteGame = {
title: string;
slug: string;
repoFolder: string;
logoFile: string;
};
const sendNodecgMessage = <TResponse>(messageName: string, payload?: unknown) => new Promise<TResponse>((resolve, reject) => { const sendNodecgMessage = <TResponse>(messageName: string, payload?: unknown) => new Promise<TResponse>((resolve, reject) => {
nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => { nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => {
if (error) { if (error) {
@@ -24,6 +31,7 @@ let progressListenerAttached = false;
export const useGameAssetsStore = defineStore('game-assets', () => { export const useGameAssetsStore = defineStore('game-assets', () => {
const installedGames = ref<string[]>([]); const installedGames = ref<string[]>([]);
const availableGames = ref<RemoteGame[]>([]);
const characterNamesByGame = ref<Record<string, string[]>>({}); const characterNamesByGame = ref<Record<string, string[]>>({});
const loadingByTitle = ref<Record<string, boolean>>({}); const loadingByTitle = ref<Record<string, boolean>>({});
const removingByTitle = ref<Record<string, boolean>>({}); const removingByTitle = ref<Record<string, boolean>>({});
@@ -62,6 +70,8 @@ export const useGameAssetsStore = defineStore('game-assets', () => {
}; };
const refreshInstalledGames = async () => { const refreshInstalledGames = async () => {
const availableResponse = await sendNodecgMessage<RemoteGame[]>('scoreko-assets:listRemoteGames');
availableGames.value = Array.isArray(availableResponse) ? availableResponse : [];
const response = await sendNodecgMessage<string[]>('scoreko-assets:listInstalled'); const response = await sendNodecgMessage<string[]>('scoreko-assets:listInstalled');
installedGames.value = Array.isArray(response) ? response : []; installedGames.value = Array.isArray(response) ? response : [];
const configResponse = await sendNodecgMessage<{ assetsBaseUrl?: string }>('scoreko-assets:getAssetsBaseUrl'); const configResponse = await sendNodecgMessage<{ assetsBaseUrl?: string }>('scoreko-assets:getAssetsBaseUrl');
@@ -72,59 +82,60 @@ export const useGameAssetsStore = defineStore('game-assets', () => {
return installedGames.value; return installedGames.value;
}; };
const downloadGame = async (title: string) => { const downloadGame = async (slug: string) => {
loadingByTitle.value = { loadingByTitle.value = {
...loadingByTitle.value, ...loadingByTitle.value,
[title]: true, [slug]: true,
}; };
progressByTitle.value = { progressByTitle.value = {
...progressByTitle.value, ...progressByTitle.value,
[title]: 0, [slug]: 0,
}; };
try { try {
const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:downloadGame', { title }); const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:downloadGame', { slug });
installedGames.value = response.installedGames; installedGames.value = response.installedGames;
await refreshCharacterNamesByGame(); await refreshCharacterNamesByGame();
loadingByTitle.value = { loadingByTitle.value = {
...loadingByTitle.value, ...loadingByTitle.value,
[title]: false, [slug]: false,
}; };
progressByTitle.value = { progressByTitle.value = {
...progressByTitle.value, ...progressByTitle.value,
[title]: 100, [slug]: 100,
}; };
return response; return response;
} catch (error) { } catch (error) {
loadingByTitle.value = { loadingByTitle.value = {
...loadingByTitle.value, ...loadingByTitle.value,
[title]: false, [slug]: false,
}; };
throw error; throw error;
} }
}; };
const removeGame = async (title: string) => { const removeGame = async (slug: string) => {
removingByTitle.value = { removingByTitle.value = {
...removingByTitle.value, ...removingByTitle.value,
[title]: true, [slug]: true,
}; };
try { try {
const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:removeGame', { title }); const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:removeGame', { slug });
installedGames.value = response.installedGames; installedGames.value = response.installedGames;
await refreshCharacterNamesByGame(); await refreshCharacterNamesByGame();
return response; return response;
} finally { } finally {
removingByTitle.value = { removingByTitle.value = {
...removingByTitle.value, ...removingByTitle.value,
[title]: false, [slug]: false,
}; };
} }
}; };
return { return {
installedGames, installedGames,
availableGames,
characterNamesByGame, characterNamesByGame,
loadingByTitle, loadingByTitle,
removingByTitle, removingByTitle,
+22 -37
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useGameAssetsStore } from '../stores/game-assets'; import { useGameAssetsStore } from '../stores/game-assets';
import { fightingGamesCatalog } from '../../../shared/fighting-games';
const gameAssetsStore = useGameAssetsStore(); const gameAssetsStore = useGameAssetsStore();
const errorMessage = ref(''); const errorMessage = ref('');
@@ -18,13 +17,13 @@ const getGameLogoUrl = (repoFolder: string, logoFile: string) => {
const normalizedSearch = computed(() => search.value.trim().toLowerCase()); const normalizedSearch = computed(() => search.value.trim().toLowerCase());
const filteredGames = computed(() => { const filteredGames = computed(() => {
if (!normalizedSearch.value) { if (!normalizedSearch.value) {
return fightingGamesCatalog; return gameAssetsStore.availableGames;
} }
return fightingGamesCatalog.filter((game) => game.title.toLowerCase().includes(normalizedSearch.value)); return gameAssetsStore.availableGames.filter((game) => game.title.toLowerCase().includes(normalizedSearch.value));
}); });
const selectedGame = computed(() => fightingGamesCatalog.find((game) => game.slug === selectedGameSlug.value) ?? null); const selectedGame = computed(() => gameAssetsStore.availableGames.find((game) => game.slug === selectedGameSlug.value) ?? null);
const installedGameSet = computed(() => new Set(gameAssetsStore.installedGames)); const installedGameSet = computed(() => new Set(gameAssetsStore.installedGames));
const openGameDialog = (slug: string) => { const openGameDialog = (slug: string) => {
@@ -35,25 +34,21 @@ const closeGameDialog = () => {
selectedGameSlug.value = null; selectedGameSlug.value = null;
}; };
const openTrailer = (trailerUrl: string) => { const downloadGameBySlug = async (slug: string) => {
window.open(trailerUrl, '_blank', 'noopener,noreferrer');
};
const downloadGameByTitle = async (title: string) => {
errorMessage.value = ''; errorMessage.value = '';
try { try {
await gameAssetsStore.downloadGame(title); await gameAssetsStore.downloadGame(slug);
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'No se pudieron descargar los assets.'; errorMessage.value = error instanceof Error ? error.message : 'No se pudieron descargar los assets.';
} }
}; };
const removeGameByTitle = async (title: string) => { const removeGameBySlug = async (slug: string) => {
errorMessage.value = ''; errorMessage.value = '';
try { try {
await gameAssetsStore.removeGame(title); await gameAssetsStore.removeGame(slug);
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'No se pudieron borrar los assets.'; errorMessage.value = error instanceof Error ? error.message : 'No se pudieron borrar los assets.';
} }
@@ -64,9 +59,9 @@ const onDownloadFromDialog = async () => {
return; return;
} }
const targetTitle = selectedGame.value.title; const targetSlug = selectedGame.value.slug;
closeGameDialog(); closeGameDialog();
await downloadGameByTitle(targetTitle); await downloadGameBySlug(targetSlug);
}; };
onMounted(async () => { onMounted(async () => {
@@ -114,9 +109,9 @@ onMounted(async () => {
/> />
<div <div
v-if="gameAssetsStore.loadingByTitle[game.title]" v-if="gameAssetsStore.loadingByTitle[game.slug]"
class="download-overlay" class="download-overlay"
:style="{ '--progress-width': `${gameAssetsStore.progressByTitle[game.title] ?? 0}%` }" :style="{ '--progress-width': `${gameAssetsStore.progressByTitle[game.slug] ?? 0}%` }"
/> />
<div class="tile-actions"> <div class="tile-actions">
@@ -131,7 +126,7 @@ onMounted(async () => {
<div class="row items-center no-wrap q-gutter-sm"> <div class="row items-center no-wrap q-gutter-sm">
<QBtn <QBtn
v-if="installedGameSet.has(game.title)" v-if="installedGameSet.has(game.slug)"
flat flat
round round
size="md" size="md"
@@ -145,11 +140,11 @@ onMounted(async () => {
flat flat
round round
size="md" size="md"
:icon="gameAssetsStore.loadingByTitle[game.title] ? 'autorenew' : 'download'" :icon="gameAssetsStore.loadingByTitle[game.slug] ? 'autorenew' : 'download'"
:class="{ 'downloading-spin': gameAssetsStore.loadingByTitle[game.title] }" :class="{ 'downloading-spin': gameAssetsStore.loadingByTitle[game.slug] }"
color="white" color="white"
:disable="gameAssetsStore.loadingByTitle[game.title]" :disable="gameAssetsStore.loadingByTitle[game.slug]"
@click="downloadGameByTitle(game.title)" @click="downloadGameBySlug(game.slug)"
/> />
</div> </div>
</div> </div>
@@ -176,34 +171,24 @@ onMounted(async () => {
{{ selectedGame.title }} {{ selectedGame.title }}
</div> </div>
<div class="text-body2 q-mb-sm"> <div class="text-body2 q-mb-sm">
{{ selectedGame.description }} Carpeta en servidor: <strong>{{ selectedGame.repoFolder }}</strong>.
</div>
<div class="text-caption text-weight-medium q-mb-md">
Peso aprox: {{ selectedGame.approxSize }}
</div> </div>
<div class="row q-gutter-sm justify-end"> <div class="row q-gutter-sm justify-end">
<QBtn <QBtn
v-if="installedGameSet.has(selectedGame.title)" v-if="installedGameSet.has(selectedGame.slug)"
flat flat
color="negative" color="negative"
icon="delete" icon="delete"
:loading="gameAssetsStore.removingByTitle[selectedGame.title]" :loading="gameAssetsStore.removingByTitle[selectedGame.slug]"
label="Borrar assets" label="Borrar assets"
@click="removeGameByTitle(selectedGame.title); closeGameDialog()" @click="removeGameBySlug(selectedGame.slug); closeGameDialog()"
/>
<QBtn
flat
color="secondary"
icon="smart_display"
label="Trailer"
@click="openTrailer(selectedGame.trailerUrl)"
/> />
<QBtn <QBtn
color="primary" color="primary"
icon="download" icon="download"
:disable="installedGameSet.has(selectedGame.title)" :disable="installedGameSet.has(selectedGame.slug)"
:label="installedGameSet.has(selectedGame.title) ? 'Descargado' : 'Descargar assets'" :label="installedGameSet.has(selectedGame.slug) ? 'Descargado' : 'Descargar assets'"
@click="onDownloadFromDialog" @click="onDownloadFromDialog"
/> />
</div> </div>
+90 -43
View File
@@ -6,14 +6,12 @@ import { nodecg } from './util/nodecg.js';
const CHARACTER_NAMES_FILE = 'fighting-characters.json'; const CHARACTER_NAMES_FILE = 'fighting-characters.json';
const LOCAL_MANIFEST_FILE = 'manifest.json'; const LOCAL_MANIFEST_FILE = 'manifest.json';
const gameCatalog = [ type RemoteGame = {
{ title: 'Street Fighter 6', slug: 'street-fighter-6', repoFolder: 'street-fighter-6' }, title: string;
{ title: 'TEKKEN 8', slug: 'tekken-8', repoFolder: 'tekken-8' }, slug: string;
{ title: '2XKO', slug: '2xko', repoFolder: '2xko' }, repoFolder: string;
{ title: 'Guilty Gear -Strive-', slug: 'guilty-gear-strive', repoFolder: 'guilty-gear-strive' }, logoFile: string;
{ 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 = { type AssetFileEntry = {
path: string; 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.`); 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 baseUrl = getConfiguredAssetsBaseUrl();
const manifestUrl = `${baseUrl}/games/${game.repoFolder}/${LOCAL_MANIFEST_FILE}`; const manifestUrl = `${baseUrl}/games/${game.repoFolder}/${LOCAL_MANIFEST_FILE}`;
const entries = await fetchJson<HttpManifestEntry[]>(manifestUrl); const entries = await fetchJson<HttpManifestEntry[]>(manifestUrl);
@@ -143,8 +181,7 @@ const listHttpFiles = async (game: (typeof gameCatalog)[number]): Promise<AssetF
const listInstalledGames = async () => { const listInstalledGames = async () => {
const entries = await readdir(assetsRoot, { withFileTypes: true }).catch(() => []); const entries = await readdir(assetsRoot, { withFileTypes: true }).catch(() => []);
const installedSlugs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
return gameCatalog.filter((game) => installedSlugs.includes(game.slug)).map((game) => game.title);
}; };
const parseCharacterNames = (content: string, gameTitle: string) => { const parseCharacterNames = (content: string, gameTitle: string) => {
@@ -163,28 +200,31 @@ const parseCharacterNames = (content: string, gameTitle: string) => {
}; };
const listInstalledCharacterNamesByGame = async () => { const listInstalledCharacterNamesByGame = async () => {
const charactersByGame = await Promise.all(gameCatalog.map(async (game) => { const installedGames = await listInstalledGames();
const sourcePath = path.join(assetsRoot, game.slug, CHARACTER_NAMES_FILE); const charactersByGame = await Promise.all(installedGames.map(async (slug) => {
const sourcePath = path.join(assetsRoot, slug, CHARACTER_NAMES_FILE);
try { try {
const fileContent = await readFile(sourcePath, 'utf8'); const fileContent = await readFile(sourcePath, 'utf8');
const names = parseCharacterNames(fileContent, game.title); const names = parseCharacterNames(fileContent, slug);
return [game.title, names] as const; return [slug, names] as const;
} catch { } catch {
return [game.title, []] as const; return [slug, []] as const;
} }
})); }));
return Object.fromEntries(charactersByGame) as Record<string, string[]>; return Object.fromEntries(charactersByGame) as Record<string, string[]>;
}; };
const downloadGameAssets = async (gameTitle: string) => { const downloadGameAssets = async (gameSlug: string) => {
const game = gameCatalog.find((entry) => entry.title === gameTitle); const game: RemoteGame = {
if (!game) { slug: gameSlug,
throw new Error('Juego no soportado en el catálogo.'); repoFolder: gameSlug,
} title: titleFromSlug(gameSlug),
logoFile: `${gameSlug}.png`,
};
emitProgress(game.title, 0, 'downloading'); emitProgress(game.slug, 0, 'downloading');
const files = await listHttpFiles(game); const files = await listHttpFiles(game);
if (!files.length) { if (!files.length) {
@@ -217,10 +257,10 @@ const downloadGameAssets = async (gameTitle: string) => {
downloadedBytes += file.size || 0; downloadedBytes += file.size || 0;
const progress = totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 100; 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 { return {
title: game.title, title: game.title,
@@ -228,21 +268,28 @@ const downloadGameAssets = async (gameTitle: string) => {
}; };
}; };
const removeGameAssets = async (gameTitle: string) => { const removeGameAssets = async (gameSlug: string) => {
const game = gameCatalog.find((entry) => entry.title === gameTitle); const destinationFolder = path.join(assetsRoot, gameSlug);
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 }); await rm(destinationFolder, { recursive: true, force: true });
return { return {
title: game.title, title: titleFromSlug(gameSlug),
slug: game.slug, 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) => { nodecg.listenFor('scoreko-assets:listInstalled', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') { if (typeof ack !== 'function') {
return; return;
@@ -285,19 +332,19 @@ nodecg.listenFor('scoreko-assets:downloadGame', async (payload: unknown, ack) =>
} }
try { try {
const title = typeof payload === 'object' && payload !== null ? (payload as { title?: unknown }).title : undefined; const slug = typeof payload === 'object' && payload !== null ? (payload as { slug?: unknown }).slug : undefined;
if (typeof title !== 'string') { if (typeof slug !== 'string') {
throw new Error('Título de juego inválido.'); throw new Error('Slug de juego inválido.');
} }
const downloaded = await downloadGameAssets(title); const downloaded = await downloadGameAssets(slug);
ack(null, { ack(null, {
downloaded, downloaded,
installedGames: await listInstalledGames(), installedGames: await listInstalledGames(),
}); });
} catch (error) { } catch (error) {
if (typeof payload === 'object' && payload !== null && typeof (payload as { title?: unknown }).title === 'string') { if (typeof payload === 'object' && payload !== null && typeof (payload as { slug?: unknown }).slug === 'string') {
emitProgress((payload as { title: string }).title, 0, 'error'); emitProgress((payload as { slug: string }).slug, 0, 'error');
} }
ack((error as Error).message); ack((error as Error).message);
} }
@@ -309,12 +356,12 @@ nodecg.listenFor('scoreko-assets:removeGame', async (payload: unknown, ack) => {
} }
try { try {
const title = typeof payload === 'object' && payload !== null ? (payload as { title?: unknown }).title : undefined; const slug = typeof payload === 'object' && payload !== null ? (payload as { slug?: unknown }).slug : undefined;
if (typeof title !== 'string') { if (typeof slug !== 'string') {
throw new Error('Título de juego inválido.'); throw new Error('Slug de juego inválido.');
} }
const removed = await removeGameAssets(title); const removed = await removeGameAssets(slug);
ack(null, { ack(null, {
removed, removed,
installedGames: await listInstalledGames(), installedGames: await listInstalledGames(),