mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
Merge pull request #149 from Pandipipas/migrate-assets-to-local-server-1rhwip
Switch game assets to HTTP server (remove GitHub API) and require assetsBaseUrl
This commit is contained in:
@@ -29,3 +29,42 @@ NodeCG bundle for producing fighting game overlays.
|
|||||||
## Version
|
## Version
|
||||||
|
|
||||||
Initial project version: `0.1.0`.
|
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"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -4,7 +4,8 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"exampleProperty": {
|
"exampleProperty": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"default": ""
|
||||||
},
|
},
|
||||||
"startggClientId": {
|
"startggClientId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -39,9 +40,12 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 65535,
|
"maximum": 65535,
|
||||||
"description": "Puerto local para callback OAuth de Challonge"
|
"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": [
|
"required": []
|
||||||
"exampleProperty"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,8 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { nodecg } from './util/nodecg.js';
|
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';
|
const CHARACTER_NAMES_FILE = 'fighting-characters.json';
|
||||||
|
const LOCAL_MANIFEST_FILE = 'manifest.json';
|
||||||
let cachedDefaultBranch: string | null = null;
|
|
||||||
|
|
||||||
const gameCatalog = [
|
const gameCatalog = [
|
||||||
{ title: 'Street Fighter 6', slug: 'street-fighter-6', repoFolder: 'street-fighter-6' },
|
{ 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' },
|
{ title: 'THE KING OF FIGHTERS XV', slug: 'the-king-of-fighters-xv', repoFolder: 'the-king-of-fighters-xv' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type GitHubContentEntry = {
|
type AssetFileEntry = {
|
||||||
type: 'file' | 'dir';
|
|
||||||
path: string;
|
path: string;
|
||||||
size: number;
|
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));
|
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);
|
nodecg.mount(`/bundles/${nodecg.bundleName}/game-assets`, assetsRouter);
|
||||||
|
|
||||||
const githubHeaders = {
|
const requestHeaders = {
|
||||||
Accept: 'application/vnd.github+json',
|
|
||||||
'User-Agent': 'scoreko-dev-nodecg-bundle',
|
'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') => {
|
const emitProgress = (title: string, progress: number, status: 'downloading' | 'completed' | 'error') => {
|
||||||
nodecg.sendMessage('scoreko-assets:downloadProgress', {
|
nodecg.sendMessage('scoreko-assets:downloadProgress', {
|
||||||
title,
|
title,
|
||||||
@@ -78,43 +92,53 @@ const emitProgress = (title: string, progress: number, status: 'downloading' | '
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchJson = async <T>(url: string): Promise<T> => {
|
const fetchJson = async <T>(url: string): Promise<T> => {
|
||||||
const response = await fetch(url, { headers: githubHeaders });
|
const response = await fetch(url, { headers: requestHeaders });
|
||||||
if (!response.ok) {
|
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<T>;
|
return response.json() as Promise<T>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDefaultBranch = async () => {
|
const normalizeManifestEntry = (entry: HttpManifestEntry, gameTitle: string) => {
|
||||||
if (cachedDefaultBranch) {
|
if (typeof entry === 'string') {
|
||||||
return cachedDefaultBranch;
|
return {
|
||||||
|
path: entry,
|
||||||
|
size: 0,
|
||||||
|
explicitUrl: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const repoMetadata = await fetchJson<{ default_branch?: string }>(`https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}`);
|
if (typeof entry === 'object' && entry !== null && typeof entry.path === 'string') {
|
||||||
cachedDefaultBranch = repoMetadata.default_branch || 'main';
|
return {
|
||||||
return cachedDefaultBranch;
|
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<GitHubContentEntry[]> => {
|
const listHttpFiles = async (game: (typeof gameCatalog)[number]): Promise<AssetFileEntry[]> => {
|
||||||
const githubRef = await getDefaultBranch();
|
const baseUrl = getConfiguredAssetsBaseUrl();
|
||||||
const url = `${GITHUB_API_BASE}/${repoPath}?ref=${encodeURIComponent(githubRef)}`;
|
const manifestUrl = `${baseUrl}/games/${game.repoFolder}/${LOCAL_MANIFEST_FILE}`;
|
||||||
const entries = await fetchJson<GitHubContentEntry[]>(url);
|
const entries = await fetchJson<HttpManifestEntry[]>(manifestUrl);
|
||||||
const files: GitHubContentEntry[] = [];
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
if (!Array.isArray(entries) || entries.length === 0) {
|
||||||
if (entry.type === 'file') {
|
throw new Error(`No se encontraron archivos en ${manifestUrl}.`);
|
||||||
files.push(entry);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === 'dir') {
|
return entries.map((rawEntry) => {
|
||||||
const nested = await listRepoFilesRecursively(entry.path);
|
const normalized = normalizeManifestEntry(rawEntry, game.title);
|
||||||
files.push(...nested);
|
const cleanPath = normalized.path.replace(/^\/+/, '');
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
return {
|
||||||
|
path: `games/${game.repoFolder}/${cleanPath}`,
|
||||||
|
size: Math.max(0, normalized.size),
|
||||||
|
downloadUrl: normalized.explicitUrl ?? `${baseUrl}/games/${game.repoFolder}/${cleanPath}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const listInstalledGames = async () => {
|
const listInstalledGames = async () => {
|
||||||
@@ -160,17 +184,16 @@ const downloadGameAssets = async (gameTitle: string) => {
|
|||||||
throw new Error('Juego no soportado en el catálogo.');
|
throw new Error('Juego no soportado en el catálogo.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const repoFolderPath = `games/${game.repoFolder}`;
|
|
||||||
emitProgress(game.title, 0, 'downloading');
|
emitProgress(game.title, 0, 'downloading');
|
||||||
|
|
||||||
const files = await listRepoFilesRecursively(repoFolderPath);
|
const files = await listHttpFiles(game);
|
||||||
if (!files.length) {
|
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) {
|
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);
|
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 });
|
await rm(destinationFolder, { recursive: true, force: true });
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (!file.download_url) {
|
const relativePath = file.path.replace(`games/${game.repoFolder}/`, '');
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const relativePath = file.path.replace(`${repoFolderPath}/`, '');
|
|
||||||
const targetPath = path.join(destinationFolder, relativePath);
|
const targetPath = path.join(destinationFolder, relativePath);
|
||||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`No se pudo descargar ${file.path} (status ${response.status}).`);
|
throw new Error(`No se pudo descargar ${file.path} (status ${response.status}).`);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+5
-1
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface Configschema {
|
export interface Configschema {
|
||||||
exampleProperty: string;
|
exampleProperty?: string;
|
||||||
/**
|
/**
|
||||||
* Client ID de tu OAuth app de start.gg
|
* Client ID de tu OAuth app de start.gg
|
||||||
*/
|
*/
|
||||||
@@ -32,4 +32,8 @@ export interface Configschema {
|
|||||||
* Puerto local para callback OAuth de Challonge
|
* Puerto local para callback OAuth de Challonge
|
||||||
*/
|
*/
|
||||||
challongeOAuthPort?: number;
|
challongeOAuthPort?: number;
|
||||||
|
/**
|
||||||
|
* URL base para descargar assets por HTTP (ej: http://192.168.1.50:8080). Por defecto usa http://localhost.
|
||||||
|
*/
|
||||||
|
assetsBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user