diff --git a/src/providers/generic/image/GameImageProvider.ts b/src/providers/generic/image/GameImageProvider.ts index 15c5d5295..09277c038 100644 --- a/src/providers/generic/image/GameImageProvider.ts +++ b/src/providers/generic/image/GameImageProvider.ts @@ -4,6 +4,7 @@ export type GameImageProvider = { init(): Promise; readonly placeholderUrl: string; resolve(iconUrl: string): Promise; + prefetchAll(iconUrls: string[]): Promise; }; let implementation: (() => GameImageProvider) | undefined; @@ -23,6 +24,7 @@ const gameImage: GameImageProvider = { init: () => getImplementation().init(), get placeholderUrl() { return getImplementation().placeholderUrl; }, resolve: (iconUrl) => getImplementation().resolve(iconUrl), + prefetchAll: (iconUrls) => getImplementation().prefetchAll(iconUrls), }; export default gameImage; diff --git a/src/r2mm/ecosystem/EcosystemSchema.ts b/src/r2mm/ecosystem/EcosystemSchema.ts index 9be3884c4..010ea308f 100644 --- a/src/r2mm/ecosystem/EcosystemSchema.ts +++ b/src/r2mm/ecosystem/EcosystemSchema.ts @@ -1,5 +1,6 @@ import bundledEcosystem from "../../assets/data/ecosystem.json"; import {R2Modman, ThunderstoreEcosystem} from "../../assets/data/ecosystemTypes"; +import GameImageProvider from "../../providers/generic/image/GameImageProvider"; import jsonSchema from "../../assets/data/ecosystemJsonSchema.json"; import R2Error from "../../model/errors/R2Error"; import Ajv from "ajv"; @@ -144,6 +145,27 @@ async function fetchLatestSchema( } } +function getNonBundledIconUrls( + mergedSchema: ThunderstoreEcosystem, + bundledSchema: ThunderstoreEcosystem +): string[] { + const bundledKeys = new Set(Object.keys(bundledSchema.games)); + const urls: string[] = []; + for (const [key, game] of Object.entries(mergedSchema.games)) { + if (bundledKeys.has(key)) { + continue; + } + if (game.r2modman) { + for (const entry of game.r2modman) { + if (entry.meta.iconUrl) { + urls.push(entry.meta.iconUrl) + } + } + } + } + return urls; +} + export async function updateLatestEcosystemSchema(): Promise { const bundledSchema = loadBundledSchema(); const currentSchema = await loadSavedEcosystemSchema(); @@ -164,6 +186,10 @@ export async function updateLatestEcosystemSchema(): Promise { const mergedSchema = mergeSchemas(bundledSchema, result.schema); await writeLatestEcosystemSchema(mergedSchema, result.lastModified); await internalUpdateEcosystemReactives(mergedSchema); + const nonBundledIconUrls = getNonBundledIconUrls(mergedSchema, bundledSchema); + if (nonBundledIconUrls.length > 0) { + void GameImageProvider.prefetchAll(nonBundledIconUrls); + } } async function writeLatestEcosystemSchema( diff --git a/src/r2mm/image/GameImageProviderImpl.ts b/src/r2mm/image/GameImageProviderImpl.ts index 59788b5e9..4335ecb71 100644 --- a/src/r2mm/image/GameImageProviderImpl.ts +++ b/src/r2mm/image/GameImageProviderImpl.ts @@ -65,6 +65,24 @@ class GameImageProviderImpl implements GameImageProvider { return this.placeholderUrl; } + public async prefetchAll(iconUrls: string[]): Promise { + if (!this.cacheRoot || this.cdnBreakerTripped) { + return; + } + const unique = [...new Set(iconUrls.filter(Boolean))]; + await Promise.allSettled(unique.map(iconUrl => this.prefetchOne(iconUrl))); + } + + private async prefetchOne(iconUrl: string): Promise { + if (this.cdnBreakerTripped) { + return; + } + const cachePath = this.cachePathFor(iconUrl); + if (cachePath) { + await this.fetchFromCdnAndCache(iconUrl, cachePath); + } + } + private bundledUrlFor(iconUrl: string): string { return ProtocolProvider.getPublicAssetUrl(`${BUNDLED_ASSET_DIR}/${iconUrl}`); } diff --git a/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts index 351d94056..18a4292bc 100644 --- a/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts +++ b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts @@ -18,6 +18,7 @@ import {TestPathProvider} from '../../../stubs/providers/node/Node.Path.Provider import ManagerInformation from '../../../../../src/_managerinf/ManagerInformation'; import LoggerProvider from '../../../../../src/providers/ror2/logging/LoggerProvider'; import StubLoggerProvider from '../../../stubs/providers/stub.LoggerProvider'; +import { provideGameImageImplementation } from '../../../../../src/providers/generic/image/GameImageProvider'; const mockAxiosGet = vi.fn(); @@ -54,6 +55,7 @@ async function writeCacheFile(schema: Partial) { describe('EcosystemSchema', () => { let spyLogger: MockInstance; + let mockPrefetchAll: ReturnType; beforeEach(async () => { mockJsonSchema = {}; @@ -68,6 +70,13 @@ describe('EcosystemSchema', () => { spyLogger = vi.spyOn(LoggerProvider.instance, 'Log').mockImplementation(() => {}); updateModLoaderExports(); mockAxiosGet.mockReset(); + mockPrefetchAll = vi.fn().mockResolvedValue(undefined); + provideGameImageImplementation(() => ({ + init: vi.fn().mockResolvedValue(undefined), + get placeholderUrl() { return ''; }, + resolve: vi.fn().mockResolvedValue(''), + prefetchAll: mockPrefetchAll, + })); await FsProvider.instance.mkdirs(TEST_ROOT); }); @@ -284,6 +293,42 @@ describe('EcosystemSchema', () => { expect(testLoaders[0]!.rootFolder).toBe('test-updated'); }); + test('passes icon URLs for new games to prefetchAll', async () => { + const newGameIconUrl = 'test-new-game-xyz/test-new-game-xyz-cover.webp'; + mockAxiosGet.mockResolvedValue({ + status: 200, + data: { + ...createMinimalSchema(), + games: { + 'test-new-game-xyz': { + distributions: [], + label: 'Test New Game', + meta: { displayName: 'Test New Game', iconUrl: null }, + r2modman: [{ meta: { iconUrl: newGameIconUrl } }], + uuid: 'test-uuid', + }, + }, + }, + headers: {}, + }); + + await updateLatestEcosystemSchema(); + + expect(mockPrefetchAll).toHaveBeenCalledWith([newGameIconUrl]); + }); + + test('does not call prefetchAll when all games are already bundled', async () => { + mockAxiosGet.mockResolvedValue({ + status: 200, + data: createMinimalSchema(), + headers: {}, + }); + + await updateLatestEcosystemSchema(); + + expect(mockPrefetchAll).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts b/test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts index 28a420623..62251018b 100644 --- a/test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts +++ b/test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts @@ -248,4 +248,37 @@ describe('GameImageProviderImpl', () => { expect(result).toBe(PLACEHOLDER_URL); }); }); + + describe('prefetchAll()', () => { + test('does nothing without init() (no cacheRoot)', async () => { + await GameImageProviderImplementation.prefetchAll(['game/icon.webp']); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('fetches each unique URL from CDN and writes to cache', async () => { + await GameImageProviderImplementation.init(); + const body = new Uint8Array([1, 2, 3]).buffer; + mockFetch + .mockResolvedValueOnce(makeFetchResponse(body, 200)) + .mockResolvedValueOnce(makeFetchResponse(body, 200)); + + await GameImageProviderImplementation.prefetchAll([ + 'game-a/game-a-cover.webp', + 'game-b/game-b-cover.webp', + ]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(await FsProvider.instance.exists(cachePathFor('game-a/game-a-cover.webp'))).toBe(true); + expect(await FsProvider.instance.exists(cachePathFor('game-b/game-b-cover.webp'))).toBe(true); + }); + + test('does nothing when the circuit breaker is tripped', async () => { + await GameImageProviderImplementation.init(); + (GameImageProviderImplementation as any).cdnBreakerTripped = true; + + await GameImageProviderImplementation.prefetchAll(['game/icon.webp']); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); });