From 890c3385e6544ec9f5f516a578722fa7764f9e56 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:56:48 +0200 Subject: [PATCH 1/3] feat: Add get beatmap by filename --- server/src/controllers/api/index.ts | 42 ++++++++++++++++++ .../abstracts/client/base-client.types.ts | 2 + .../core/domains/osu.ppy.sh/bancho.client.ts | 29 +++++++++++++ .../core/managers/mirrors/mirrors.manager.ts | 9 ++-- .../managers/storage/storage-cache.service.ts | 43 +++++++++++++++++-- .../core/managers/storage/storage.manager.ts | 11 +++++ server/src/types/redis.ts | 1 + 7 files changed, 130 insertions(+), 7 deletions(-) diff --git a/server/src/controllers/api/index.ts b/server/src/controllers/api/index.ts index 3e642ee..9b99e6a 100644 --- a/server/src/controllers/api/index.ts +++ b/server/src/controllers/api/index.ts @@ -52,6 +52,48 @@ export default (app: App) => { tags: ["v2"], }, ) + .get( + "v2/filename/:filename", + async ({ + BeatmapsManagerInstance, + params: { filename }, + query: { full }, + set, + }) => { + const beatmap = await BeatmapsManagerInstance.getBeatmap({ + beatmapFilename: filename, + allowMissingNonBeatmapValues: full, + }); + + if (beatmap.source) { + set.headers["X-Data-Source"] = beatmap.source; + } + + const { source: _, ...responseBeatmap } = beatmap; + if (!full || !beatmap.data) + return responseBeatmap?.data ?? responseBeatmap; + + const beatmapset = await BeatmapsManagerInstance.getBeatmapSet({ + beatmapSetId: beatmap.data.beatmapset_id, + }); + + if (beatmapset.source) { + set.headers["X-Data-Source"] = beatmapset.source; + } + + const { source: __, ...responseBeatmapset } = beatmapset; + return responseBeatmapset?.data ?? responseBeatmapset; + }, + { + params: t.Object({ + filename: t.String(), + }), + query: t.Object({ + full: t.Optional(t.BooleanString()), + }), + tags: ["v2"], + }, + ) .get( "v2/md5/:hash", async ({ diff --git a/server/src/core/abstracts/client/base-client.types.ts b/server/src/core/abstracts/client/base-client.types.ts index 6bd7a97..0026317 100644 --- a/server/src/core/abstracts/client/base-client.types.ts +++ b/server/src/core/abstracts/client/base-client.types.ts @@ -41,6 +41,7 @@ export type DownloadOsuBeatmap = { export type GetBeatmapOptions = { beatmapId?: number; beatmapHash?: string; + beatmapFilename?: string; allowMissingNonBeatmapValues?: boolean; }; @@ -61,6 +62,7 @@ export enum ClientAbilities { GetBeatmapsetsByBeatmapIds = 1 << 11, // 2048 GetBeatmapByIdWithSomeNonBeatmapValues = 1 << 12, // 4096 GetBeatmapByHashWithSomeNonBeatmapValues = 1 << 13, // 8192 + GetBeatmapByFilename = 1 << 14, // 16384 } export type MirrorClient = { diff --git a/server/src/core/domains/osu.ppy.sh/bancho.client.ts b/server/src/core/domains/osu.ppy.sh/bancho.client.ts index fc392b0..f66597c 100644 --- a/server/src/core/domains/osu.ppy.sh/bancho.client.ts +++ b/server/src/core/domains/osu.ppy.sh/bancho.client.ts @@ -32,6 +32,7 @@ export class BanchoClient extends BaseClient { abilities: [ ClientAbilities.GetBeatmapById, ClientAbilities.GetBeatmapByHash, + ClientAbilities.GetBeatmapByFilename, ClientAbilities.GetBeatmapSetById, ClientAbilities.GetBeatmaps, ClientAbilities.DownloadOsuBeatmap, @@ -49,6 +50,7 @@ export class BanchoClient extends BaseClient { abilities: [ ClientAbilities.GetBeatmapById, ClientAbilities.GetBeatmapByHash, + ClientAbilities.GetBeatmapByFilename, ClientAbilities.GetBeatmapSetById, ClientAbilities.GetBeatmaps, ClientAbilities.DownloadOsuBeatmap, @@ -85,6 +87,9 @@ export class BanchoClient extends BaseClient { else if (ctx.beatmapHash) { return await this.getBeatmapByHash(ctx.beatmapHash); } + else if (ctx.beatmapFilename) { + return await this.getBeatmapByFilename(ctx.beatmapFilename); + } throw new Error("Invalid arguments"); } @@ -293,6 +298,30 @@ export class BanchoClient extends BaseClient { }; } + private async getBeatmapByFilename( + beatmapHash: string, + ): Promise> { + const result = await this.api.get(`api/v2/beatmaps/lookup`, { + config: { + headers: { + Authorization: `Bearer ${await this.osuApiKey}`, + }, + params: { + filename: beatmapHash, + }, + }, + }); + + if (!result || result.status !== 200 || !result.data) { + return { result: null, status: result?.status ?? 500 }; + } + + return { + result: this.convertService.convertBeatmap(result.data), + status: result.status, + }; + } + private get osuApiKey() { return this.banchoService.getBanchoClientToken(); } diff --git a/server/src/core/managers/mirrors/mirrors.manager.ts b/server/src/core/managers/mirrors/mirrors.manager.ts index 63415d9..2977b8f 100644 --- a/server/src/core/managers/mirrors/mirrors.manager.ts +++ b/server/src/core/managers/mirrors/mirrors.manager.ts @@ -125,8 +125,8 @@ export class MirrorsManager { async getBeatmap( ctx: GetBeatmapOptions, ): Promise> { - if (!ctx.beatmapId && !ctx.beatmapHash) { - throw new Error("Either beatmapId or beatmapHash is required"); + if (!ctx.beatmapId && !ctx.beatmapHash && !ctx.beatmapFilename) { + throw new Error("Either beatmapId, beatmapHash or beatmapFilename is required"); } let criteria: ClientAbilities; @@ -135,11 +135,14 @@ export class MirrorsManager { ? ClientAbilities.GetBeatmapByIdWithSomeNonBeatmapValues : ClientAbilities.GetBeatmapById; } - else { + else if (ctx.beatmapHash) { criteria = ctx.allowMissingNonBeatmapValues ? ClientAbilities.GetBeatmapByHashWithSomeNonBeatmapValues : ClientAbilities.GetBeatmapByHash; } + else { + criteria = ClientAbilities.GetBeatmapByFilename; + } return await this.useMirror(ctx, criteria, "getBeatmap"); } diff --git a/server/src/core/managers/storage/storage-cache.service.ts b/server/src/core/managers/storage/storage-cache.service.ts index 37bd774..0b07bdc 100644 --- a/server/src/core/managers/storage/storage-cache.service.ts +++ b/server/src/core/managers/storage/storage-cache.service.ts @@ -23,11 +23,28 @@ export class StorageCacheService { async getBeatmap( ctx: GetBeatmapOptions, ): Promise { + if (!ctx.beatmapId && !ctx.beatmapHash && !ctx.beatmapFilename) { + throw new Error("Either beatmapId, beatmapHash or beatmapFilename is required"); + } + let { beatmapId } = ctx; if (ctx.beatmapHash) { const cachedId = await this.redis.get( - `${RedisKeys.BEATMAP_ID_BY_HASH}${ctx.beatmapHash}`, + `${RedisKeys.BEATMAP_ID_BY_HASH}${ctx.beatmapHash}`, + ); + + if (!cachedId) + return undefined; + if (cachedId === "null") + return null; + + beatmapId = Number(cachedId); + } + + if (ctx.beatmapFilename) { + const cachedId = await this.redis.get( + `${RedisKeys.BEATMAP_ID_BY_FILENAME}${ctx.beatmapFilename}`, ); if (!cachedId) @@ -46,9 +63,18 @@ export class StorageCacheService { } async insertEmptyBeatmap(ctx: GetBeatmapOptions) { - const key = ctx?.beatmapId - ? `${RedisKeys.BEATMAP_BY_ID}${ctx?.beatmapId}` - : `${RedisKeys.BEATMAP_ID_BY_HASH}${ctx?.beatmapHash}`; + let key = ""; + + if (ctx?.beatmapId) { + key = `${RedisKeys.BEATMAP_BY_ID}${ctx?.beatmapId}`; + } + else if (ctx?.beatmapHash) { + key = `${RedisKeys.BEATMAP_ID_BY_HASH}${ctx?.beatmapHash}`; + } + else if (ctx?.beatmapFilename) { + key = `${RedisKeys.BEATMAP_ID_BY_FILENAME}${ctx?.beatmapFilename}`; + } + await this.redis.set( key, "null", @@ -73,6 +99,15 @@ export class StorageCacheService { ); } + async insertBeatmapByFilename(beatmap: Beatmap, filename: string) { + await this.redis.set( + `${RedisKeys.BEATMAP_ID_BY_FILENAME}${filename}`, + beatmap.id, + "PX", + this.getRedisTTLBasedOnStatus(beatmap.status), + ); + } + async insertEmptyBeatmapset(ctx: GetBeatmapSetOptions) { const key = `${RedisKeys.BEATMAPSET_BY_ID}${ctx?.beatmapSetId}`; await this.redis.set( diff --git a/server/src/core/managers/storage/storage.manager.ts b/server/src/core/managers/storage/storage.manager.ts index 6db0f82..f52c33a 100644 --- a/server/src/core/managers/storage/storage.manager.ts +++ b/server/src/core/managers/storage/storage.manager.ts @@ -49,6 +49,10 @@ export class StorageManager { async getBeatmap( ctx: GetBeatmapOptions, ): Promise { + if (!ctx.beatmapId && !ctx.beatmapHash && !ctx.beatmapFilename) { + throw new Error("Either beatmapId, beatmapHash or beatmapFilename is required"); + } + let entity = await this.cacheService.getBeatmap(ctx); if (entity !== undefined) { @@ -61,6 +65,9 @@ export class StorageManager { else if (ctx.beatmapHash) { entity = await getBeatmapByHash(ctx.beatmapHash); } + else if (ctx.beatmapFilename) { + // We don't store filenames in database, skip + } if (entity) { this.cacheService.insertBeatmap(entity); @@ -145,6 +152,10 @@ export class StorageManager { if (beatmap) { await createBeatmap(beatmap); await this.cacheService.insertBeatmap(beatmap); + + if (ctx.beatmapFilename) { + await this.cacheService.insertBeatmapByFilename(beatmap, ctx.beatmapFilename); + } } else { await this.cacheService.insertEmptyBeatmap(ctx); diff --git a/server/src/types/redis.ts b/server/src/types/redis.ts index 59bbc71..9dc10aa 100644 --- a/server/src/types/redis.ts +++ b/server/src/types/redis.ts @@ -1,6 +1,7 @@ export enum RedisKeys { BEATMAP_BY_ID = "BEATMAP:ID:", BEATMAP_ID_BY_HASH = "BEATMAP_ID:HASH:", + BEATMAP_ID_BY_FILENAME = "BEATMAP_ID:FILENAME:", BEATMAPSET_BY_ID = "BEATMAPSET:ID:", BEATMAPSET_FILE_BY_ID = "BEATMAPSET_FILE:ID:", BEATMAP_OSU_FILE = "BEATMAP_OSU_FILE:ID:", From 333a0bc0fb3e86c303356de1df04628196bf72d9 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:57:14 +0200 Subject: [PATCH 2/3] fix: flanky test --- server/tests/mirrors.manager.test.ts | 41 ++++++++++++++++------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/server/tests/mirrors.manager.test.ts b/server/tests/mirrors.manager.test.ts index a15f0fb..87a70d0 100644 --- a/server/tests/mirrors.manager.test.ts +++ b/server/tests/mirrors.manager.test.ts @@ -40,6 +40,20 @@ function getMirrorsWithAbility(ability: ClientAbilities) { ); } +async function waitForCapacityChange( + client: BaseClient, + ability: ClientAbilities, + timeout = 1000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + const { remaining, limit } = client.getCapacity(ability); + if (remaining < limit) + return; + await new Promise(r => setTimeout(r, 5)); + } +} + describe("MirrorsManager", () => { let mirrorsManager: MirrorsManager; let mockStorageManager: StorageManager; @@ -162,8 +176,7 @@ describe("MirrorsManager", () => { beatmapSetId, }); - // Skip a tick to check if is on cooldown - await new Promise(r => setTimeout(r, 0)); + await waitForCapacityChange(client, ClientAbilities.GetBeatmapSetById); let capacity = client.getCapacity( ClientAbilities.GetBeatmapSetById, @@ -316,8 +329,7 @@ describe("MirrorsManager", () => { beatmapId, }); - // Skip a tick to check if is on cooldown - await new Promise(r => setTimeout(r, 0)); + await waitForCapacityChange(client, ClientAbilities.GetBeatmapById); let capacity = client.getCapacity( ClientAbilities.GetBeatmapById, @@ -467,8 +479,7 @@ describe("MirrorsManager", () => { beatmapHash, }); - // Skip a tick to check if is on cooldown - await new Promise(r => setTimeout(r, 0)); + await waitForCapacityChange(client, ClientAbilities.GetBeatmapByHash); let capacity = client.getCapacity( ClientAbilities.GetBeatmapByHash, @@ -614,8 +625,7 @@ describe("MirrorsManager", () => { beatmapSetId, }); - // Skip a tick to check if is on cooldown - await new Promise(r => setTimeout(r, 0)); + await waitForCapacityChange(client, ClientAbilities.DownloadBeatmapSetById); let capacity = client.getCapacity( ClientAbilities.DownloadBeatmapSetById, @@ -769,8 +779,7 @@ describe("MirrorsManager", () => { noVideo: true, }); - // Skip a tick to check if is on cooldown - await new Promise(r => setTimeout(r, 0)); + await waitForCapacityChange(client, ClientAbilities.DownloadBeatmapSetByIdNoVideo); let capacity = client.getCapacity( ClientAbilities.DownloadBeatmapSetByIdNoVideo, @@ -967,8 +976,7 @@ describe("MirrorsManager", () => { beatmapIds, }); - // Skip a tick to check if is on cooldown - await new Promise(r => setTimeout(r, 0)); + await waitForCapacityChange(client, ClientAbilities.GetBeatmapsetsByBeatmapIds); let capacity = client.getCapacity( ClientAbilities.GetBeatmapsetsByBeatmapIds, @@ -1117,8 +1125,7 @@ describe("MirrorsManager", () => { beatmapId, }); - // Skip a tick to check if is on cooldown - await new Promise(r => setTimeout(r, 0)); + await waitForCapacityChange(client, ClientAbilities.DownloadOsuBeatmap); let capacity = client.getCapacity( ClientAbilities.DownloadOsuBeatmap, @@ -1273,8 +1280,7 @@ describe("MirrorsManager", () => { allowMissingNonBeatmapValues: true, }); - // Skip a tick to check if is on cooldown - await new Promise(r => setTimeout(r, 0)); + await waitForCapacityChange(client, ClientAbilities.GetBeatmapById); let capacity = client.getCapacity( ClientAbilities.GetBeatmapById, @@ -1428,8 +1434,7 @@ describe("MirrorsManager", () => { allowMissingNonBeatmapValues: true, }); - // Skip a tick to check if is on cooldown - await new Promise(r => setTimeout(r, 0)); + await waitForCapacityChange(client, ClientAbilities.GetBeatmapByHash); let capacity = client.getCapacity( ClientAbilities.GetBeatmapByHash, From 2472c9c54268fd61dddd7da228f1ee397537c3d3 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:04:55 +0200 Subject: [PATCH 3/3] feat: add tests --- server/tests/mirrors.manager.test.ts | 152 +++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/server/tests/mirrors.manager.test.ts b/server/tests/mirrors.manager.test.ts index 87a70d0..9fde0ff 100644 --- a/server/tests/mirrors.manager.test.ts +++ b/server/tests/mirrors.manager.test.ts @@ -567,6 +567,158 @@ describe("MirrorsManager", () => { ); }); + describe("GetBeatmapByFilename", () => { + const mirrors = getMirrorsWithAbility( + ClientAbilities.GetBeatmapByFilename, + ); + + test.each(mirrors)( + `$name: Should successfully fetch a beatmap by filename`, + async (mirror) => { + const client = getMirrorClient(mirror); + + const beatmapId = faker.number.int({ + min: 1, + max: 1000000, + }); + + const beatmapFilename = `${faker.music.artist()} - ${faker.music.songName()} (${faker.internet.username()}) [${faker.string.alpha(6)}].osu`; + + const { mockBeatmap } = Mocker.getClientMockMethods(client); + + mockBeatmap({ + data: { + id: beatmapId, + }, + }); + + const result = await mirrorsManager.getBeatmap({ + beatmapFilename, + }); + + expect(result.status).toBe(200); + expect(result.result).not.toBeNull(); + expect(result.result?.id).toBe(beatmapId); + }, + ); + + test.each(mirrors)( + `$name: Should successfully update ratelimit during get beatmap by filename request`, + async (mirror) => { + const client = getMirrorClient(mirror); + + const beatmapId = faker.number.int({ + min: 1, + max: 1000000, + }); + + const beatmapFilename = `${faker.music.artist()} - ${faker.music.songName()} (${faker.internet.username()}) [${faker.string.alpha(6)}].osu`; + + const { generateBeatmap } + = Mocker.getClientGenerateMethods(client); + + const mockApiGet = Mocker.mockRequest( + client, + "baseApi", + "get", + { + data: generateBeatmap({ id: beatmapId }), + status: 200, + headers: {}, + }, + ); + + const request = mirrorsManager.getBeatmap({ + beatmapFilename, + }); + + await waitForCapacityChange(client, ClientAbilities.GetBeatmapByFilename); + + let capacity = client.getCapacity( + ClientAbilities.GetBeatmapByFilename, + ); + + expect(capacity.remaining).toBeLessThan(capacity.limit); + + const awaitedResult = await request; + + expect(mockApiGet).toHaveBeenCalledTimes(1); + + capacity = client.getCapacity( + ClientAbilities.GetBeatmapByFilename, + ); + + expect(awaitedResult.status).toBe(200); + expect(awaitedResult.result).not.toBeNull(); + expect(awaitedResult.result?.id).toBe(beatmapId); + + expect(capacity.remaining).toBeLessThan(capacity.limit); + }, + ); + + test.each(mirrors)( + `$name: Should successfully return 404 when beatmap is not found`, + async (mirror) => { + const client = getMirrorClient(mirror); + + const beatmapFilename = `${faker.music.artist()} - ${faker.music.songName()} (${faker.internet.username()}) [${faker.string.alpha(6)}].osu`; + + const mockApiGet = Mocker.mockRequest( + client, + "baseApi", + "get", + { + data: null, + status: 404, + headers: {}, + }, + ); + + const request = mirrorsManager.getBeatmap({ + beatmapFilename, + }); + + const awaitedResult = await request; + + expect(mockApiGet).toHaveBeenCalledTimes(1); + + expect(awaitedResult.status).toBe(404); + expect(awaitedResult.result).toBeNull(); + }, + ); + + test.each(mirrors)( + `$name: Should successfully return 502 when API request fails and no other mirrors are available`, + async (mirror) => { + const client = getMirrorClient(mirror); + + const beatmapFilename = `${faker.music.artist()} - ${faker.music.songName()} (${faker.internet.username()}) [${faker.string.alpha(6)}].osu`; + + const mockApiGet = Mocker.mockRequest( + client, + "baseApi", + "get", + { + data: null, + status: 500, + headers: {}, + }, + ); + + const request = mirrorsManager.getBeatmap({ + beatmapFilename, + }); + + const awaitedResult = await request; + + expect(mockApiGet).toHaveBeenCalledTimes(1); + + expect(awaitedResult.status).toBe(502); + expect(awaitedResult.result).toBeNull(); + }, + ); + }); + describe("DownloadBeatmapSetById", () => { const mirrors = getMirrorsWithAbility( ClientAbilities.DownloadBeatmapSetById,