diff --git a/src/dashboard/scoreko-dev/stores/game-assets.ts b/src/dashboard/scoreko-dev/stores/game-assets.ts index e801877..ef3968e 100644 --- a/src/dashboard/scoreko-dev/stores/game-assets.ts +++ b/src/dashboard/scoreko-dev/stores/game-assets.ts @@ -70,8 +70,13 @@ export const useGameAssetsStore = defineStore('game-assets', () => { }; const refreshInstalledGames = async () => { - const availableResponse = await sendNodecgMessage('scoreko-assets:listRemoteGames'); - availableGames.value = Array.isArray(availableResponse) ? availableResponse : []; + try { + const availableResponse = await sendNodecgMessage('scoreko-assets:listRemoteGames'); + availableGames.value = Array.isArray(availableResponse) ? availableResponse : []; + } catch { + availableGames.value = []; + } + const response = await sendNodecgMessage('scoreko-assets:listInstalled'); installedGames.value = Array.isArray(response) ? response : []; const configResponse = await sendNodecgMessage<{ assetsBaseUrl?: string }>('scoreko-assets:getAssetsBaseUrl'); diff --git a/src/extension/game-assets.ts b/src/extension/game-assets.ts index ced286a..32de4ba 100644 --- a/src/extension/game-assets.ts +++ b/src/extension/game-assets.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { nodecg } from './util/nodecg.js'; @@ -6,6 +6,7 @@ import { nodecg } from './util/nodecg.js'; const CHARACTER_NAMES_FILE = 'fighting-characters.json'; const LOCAL_MANIFEST_FILE = 'manifest.json'; const GAME_TITLES_FILE = 'games.json'; +const CACHED_GAME_TITLES_FILE = 'games-cache.json'; type RemoteGame = { title: string; @@ -35,7 +36,35 @@ type HttpGameTitlesFile = Record | HttpGameTitleEntry[]; const extensionDir = path.dirname(fileURLToPath(import.meta.url)); const bundleRoot = path.resolve(extensionDir, '..'); -const assetsRoot = path.join(bundleRoot, 'game-assets'); +const legacyAssetsRoot = path.join(bundleRoot, 'game-assets'); +const assetsRoot = path.join(bundleRoot, 'db', `${nodecg.bundleName}-game-assets`); + +let assetsStorageReady = false; + +const ensureAssetsStorageReady = async () => { + if (assetsStorageReady) { + return; + } + + await mkdir(path.dirname(assetsRoot), { recursive: true }); + + const [currentStats, legacyStats] = await Promise.all([ + stat(assetsRoot).catch(() => null), + stat(legacyAssetsRoot).catch(() => null), + ]); + + if (!currentStats && legacyStats?.isDirectory()) { + await rename(legacyAssetsRoot, assetsRoot).catch(async () => { + await mkdir(assetsRoot, { recursive: true }); + }); + } else { + await mkdir(assetsRoot, { recursive: true }); + } + + assetsStorageReady = true; +}; + +void ensureAssetsStorageReady(); const assetsRouter = nodecg.Router(); @@ -183,6 +212,26 @@ const fetchCustomGameTitles = async (): Promise> => { } }; +const loadCachedGameTitles = async (): Promise> => { + await ensureAssetsStorageReady(); + const cachePath = path.join(assetsRoot, CACHED_GAME_TITLES_FILE); + + try { + const raw = await readFile(cachePath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + return parseGameTitlesMap(parsed); + } catch { + return new Map(); + } +}; + +const saveCachedGameTitles = async (titles: Map) => { + await ensureAssetsStorageReady(); + const cachePath = path.join(assetsRoot, CACHED_GAME_TITLES_FILE); + const payload = Object.fromEntries([...titles.entries()].sort((left, right) => left[0].localeCompare(right[0]))); + await writeFile(cachePath, JSON.stringify(payload, null, 2)); +}; + const listRemoteGames = async (): Promise => { const baseUrl = getConfiguredAssetsBaseUrl(); const gamesIndexUrl = `${baseUrl}/games/`; @@ -240,10 +289,22 @@ const listHttpFiles = async (game: RemoteGame): Promise => { }; const listInstalledGames = async () => { + await ensureAssetsStorageReady(); 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 listInstalledGamesAsRemote = async (): Promise => { + const installedGames = await listInstalledGames(); + const cachedTitles = await loadCachedGameTitles(); + return installedGames.map((slug) => ({ + slug, + repoFolder: slug, + title: cachedTitles.get(slug) ?? titleFromSlug(slug), + logoFile: `${slug}.png`, + })); +}; + const parseCharacterNames = (content: string, gameTitle: string) => { const parsed = JSON.parse(content) as unknown; const names = Array.isArray(parsed) @@ -277,6 +338,7 @@ const listInstalledCharacterNamesByGame = async () => { }; const downloadGameAssets = async (gameSlug: string) => { + await ensureAssetsStorageReady(); const customTitles = await fetchCustomGameTitles(); const game: RemoteGame = { slug: gameSlug, @@ -330,6 +392,7 @@ const downloadGameAssets = async (gameSlug: string) => { }; const removeGameAssets = async (gameSlug: string) => { + await ensureAssetsStorageReady(); const customTitles = await fetchCustomGameTitles(); const destinationFolder = path.join(assetsRoot, gameSlug); await rm(destinationFolder, { recursive: true, force: true }); @@ -346,8 +409,24 @@ nodecg.listenFor('scoreko-assets:listRemoteGames', async (_payload: unknown, ack } try { - ack(null, await listRemoteGames()); + const remoteGames = await listRemoteGames(); + const titlesToCache = new Map(); + remoteGames.forEach((game) => { + titlesToCache.set(game.slug, game.title); + }); + await saveCachedGameTitles(titlesToCache); + ack(null, remoteGames); } catch (error) { + try { + const installedGames = await listInstalledGamesAsRemote(); + if (installedGames.length > 0) { + ack(null, installedGames); + return; + } + } catch { + // noop + } + ack((error as Error).message); } });