diff --git a/README.md b/README.md index 0b18bc3..de0ec9f 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,42 @@ NodeCG bundle for producing fighting game overlays. ## Version Initial project version: `0.1.0`. + +## Assets por HTTP (sin GitHub API) + +La descarga de assets usa **únicamente HTTP**. Debes configurar un servidor propio. + +1. En `cfg/scoreko-dev.json`, configura `assetsBaseUrl` (opcional, por defecto `http://localhost`): + +```json +{ + "scoreko-dev": { + "assetsBaseUrl": "http://localhost" + } +} +``` + +2. Sirve por HTTP esta estructura: + +```text +games/ + street-fighter-6/ + manifest.json + fighting-characters.json + characters/... + tekken-8/ + manifest.json + ... +``` + +3. Cada `manifest.json` debe ser un array con rutas relativas, o con objetos `{ "path", "size", "url" }`. + +Ejemplo mínimo: + +```json +[ + "fighting-characters.json", + "characters/ryu.png" +] +``` + diff --git a/configschema.json b/configschema.json index d0a093b..2c61e83 100644 --- a/configschema.json +++ b/configschema.json @@ -4,7 +4,8 @@ "additionalProperties": false, "properties": { "exampleProperty": { - "type": "string" + "type": "string", + "default": "" }, "startggClientId": { "type": "string", @@ -39,9 +40,12 @@ "minimum": 1, "maximum": 65535, "description": "Puerto local para callback OAuth de Challonge" + }, + "assetsBaseUrl": { + "type": "string", + "default": "http://localhost", + "description": "URL base para descargar assets por HTTP (ej: http://192.168.1.50:8080). Por defecto usa http://localhost." } }, - "required": [ - "exampleProperty" - ] + "required": [] } diff --git a/src/extension/game-assets.ts b/src/extension/game-assets.ts index 439d316..cb451bf 100644 --- a/src/extension/game-assets.ts +++ b/src/extension/game-assets.ts @@ -3,12 +3,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { nodecg } from './util/nodecg.js'; -const GITHUB_OWNER = 'Pandipipas'; -const GITHUB_REPO = 'scoreko-assets'; -const GITHUB_API_BASE = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/contents`; const CHARACTER_NAMES_FILE = 'fighting-characters.json'; - -let cachedDefaultBranch: string | null = null; +const LOCAL_MANIFEST_FILE = 'manifest.json'; const gameCatalog = [ { title: 'Street Fighter 6', slug: 'street-fighter-6', repoFolder: 'street-fighter-6' }, @@ -19,11 +15,16 @@ const gameCatalog = [ { title: 'THE KING OF FIGHTERS XV', slug: 'the-king-of-fighters-xv', repoFolder: 'the-king-of-fighters-xv' }, ] as const; -type GitHubContentEntry = { - type: 'file' | 'dir'; +type AssetFileEntry = { path: string; size: number; - download_url: string | null; + downloadUrl: string; +}; + +type HttpManifestEntry = string | { + path?: unknown; + size?: unknown; + url?: unknown; }; const extensionDir = path.dirname(fileURLToPath(import.meta.url)); @@ -64,11 +65,24 @@ assetsRouter.get('/*', async (req, res) => { nodecg.mount(`/bundles/${nodecg.bundleName}/game-assets`, assetsRouter); -const githubHeaders = { - Accept: 'application/vnd.github+json', +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, @@ -78,43 +92,53 @@ const emitProgress = (title: string, progress: number, status: 'downloading' | ' }; const fetchJson = async (url: string): Promise => { - const response = await fetch(url, { headers: githubHeaders }); + const response = await fetch(url, { headers: requestHeaders }); if (!response.ok) { - throw new Error(`GitHub API error (${response.status}) while requesting ${url}`); + throw new Error(`Error HTTP (${response.status}) al solicitar ${url}`); } return response.json() as Promise; }; -const getDefaultBranch = async () => { - if (cachedDefaultBranch) { - return cachedDefaultBranch; +const normalizeManifestEntry = (entry: HttpManifestEntry, gameTitle: string) => { + if (typeof entry === 'string') { + return { + path: entry, + size: 0, + explicitUrl: null, + }; } - const repoMetadata = await fetchJson<{ default_branch?: string }>(`https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}`); - cachedDefaultBranch = repoMetadata.default_branch || 'main'; - return cachedDefaultBranch; + 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 listRepoFilesRecursively = async (repoPath: string): Promise => { - const githubRef = await getDefaultBranch(); - const url = `${GITHUB_API_BASE}/${repoPath}?ref=${encodeURIComponent(githubRef)}`; - const entries = await fetchJson(url); - const files: GitHubContentEntry[] = []; +const listHttpFiles = async (game: (typeof gameCatalog)[number]): Promise => { + const baseUrl = getConfiguredAssetsBaseUrl(); + const manifestUrl = `${baseUrl}/games/${game.repoFolder}/${LOCAL_MANIFEST_FILE}`; + const entries = await fetchJson(manifestUrl); - for (const entry of entries) { - if (entry.type === 'file') { - files.push(entry); - continue; - } - - if (entry.type === 'dir') { - const nested = await listRepoFilesRecursively(entry.path); - files.push(...nested); - } + if (!Array.isArray(entries) || entries.length === 0) { + throw new Error(`No se encontraron archivos en ${manifestUrl}.`); } - return files; + 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 () => { @@ -160,17 +184,16 @@ const downloadGameAssets = async (gameTitle: string) => { throw new Error('Juego no soportado en el catálogo.'); } - const repoFolderPath = `games/${game.repoFolder}`; emitProgress(game.title, 0, 'downloading'); - const files = await listRepoFilesRecursively(repoFolderPath); + const files = await listHttpFiles(game); if (!files.length) { - throw new Error(`No se encontraron archivos en ${repoFolderPath} dentro de scoreko-assets.`); + throw new Error(`No se encontraron archivos para ${game.title}.`); } - const hasCharacterNamesFile = files.some((file) => file.path === `${repoFolderPath}/${CHARACTER_NAMES_FILE}`); + const hasCharacterNamesFile = files.some((file) => file.path.endsWith(`/${CHARACTER_NAMES_FILE}`)); if (!hasCharacterNamesFile) { - throw new Error(`No se encontró ${CHARACTER_NAMES_FILE} en ${repoFolderPath} dentro de scoreko-assets.`); + throw new Error(`No se encontró ${CHARACTER_NAMES_FILE} para ${game.title}.`); } const totalBytes = files.reduce((acc, file) => acc + (file.size || 0), 0); @@ -180,15 +203,11 @@ const downloadGameAssets = async (gameTitle: string) => { await rm(destinationFolder, { recursive: true, force: true }); for (const file of files) { - if (!file.download_url) { - continue; - } - - const relativePath = file.path.replace(`${repoFolderPath}/`, ''); + 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.download_url, { headers: githubHeaders }); + const response = await fetch(file.downloadUrl, { headers: requestHeaders }); if (!response.ok) { throw new Error(`No se pudo descargar ${file.path} (status ${response.status}).`); } diff --git a/src/types/schemas/configschema.d.ts b/src/types/schemas/configschema.d.ts index eb4b9ad..213ce90 100644 --- a/src/types/schemas/configschema.d.ts +++ b/src/types/schemas/configschema.d.ts @@ -7,7 +7,7 @@ */ export interface Configschema { - exampleProperty: string; + exampleProperty?: string; /** * Client ID de tu OAuth app de start.gg */ @@ -32,4 +32,8 @@ export interface Configschema { * Puerto local para callback OAuth de Challonge */ challongeOAuthPort?: number; + /** + * URL base para descargar assets por HTTP (ej: http://192.168.1.50:8080). Por defecto usa http://localhost. + */ + assetsBaseUrl?: string; }