Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/providers/generic/image/GameImageProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type GameImageProvider = {
init(): Promise<void>;
readonly placeholderUrl: string;
resolve(iconUrl: string): Promise<string>;
prefetchAll(iconUrls: string[]): Promise<void>;
};

let implementation: (() => GameImageProvider) | undefined;
Expand All @@ -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;
26 changes: 26 additions & 0 deletions src/r2mm/ecosystem/EcosystemSchema.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<void> {
const bundledSchema = loadBundledSchema();
const currentSchema = await loadSavedEcosystemSchema();
Expand All @@ -164,6 +186,10 @@ export async function updateLatestEcosystemSchema(): Promise<void> {
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(
Expand Down
18 changes: 18 additions & 0 deletions src/r2mm/image/GameImageProviderImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ class GameImageProviderImpl implements GameImageProvider {
return this.placeholderUrl;
}

public async prefetchAll(iconUrls: string[]): Promise<void> {
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<void> {
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}`);
}
Expand Down
45 changes: 45 additions & 0 deletions test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -54,6 +55,7 @@ async function writeCacheFile(schema: Partial<VersionedThunderstoreEcosystem>) {
describe('EcosystemSchema', () => {

let spyLogger: MockInstance;
let mockPrefetchAll: ReturnType<typeof vi.fn>;

beforeEach(async () => {
mockJsonSchema = {};
Expand All @@ -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);
});

Expand Down Expand Up @@ -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();
});

});

});
33 changes: 33 additions & 0 deletions test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading