Skip to content

Commit 71a46a0

Browse files
authored
feat: support dynamic map file size limits based on song length (#78)
1 parent d10848c commit 71a46a0

18 files changed

Lines changed: 683 additions & 51 deletions

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ This is a website that allows users to host custom maps and songs for a rhythm d
77
Custom maps consist of a zip file, which contains a .rlrr metadata file along with the audio tracks for the song. The audio tracks can either be the song itself, or the audio stems of the song that can allow the game to play the song without any drum track (as the player will be drumming along themselves).
88
The codebase uses Docker to run third-party services locally (Minio for a local S3 instance), the local Supabase CLI for running the Supabase database locally, and the standad Next.js dev mode to run the backend and frontend locally.
99

10+
The official .rlrr schema (fields and types) is at https://raw.githubusercontent.com/emretanirgan/ParadiddleUtilities/refs/heads/master/docs/rlrrschema.json - fetch it if you need to reference what a .rlrr file contains.
11+
1012
# Tech stack
1113

1214
- Typescript, Next.js with App Routing running on Vercel, Postgres running on Supabase

jest.config.unit.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ const config: Config = {
1111
testMatch: ['**/*.unit.test.[jt]s?(x)'],
1212
setupFilesAfterEnv: ['<rootDir>/src/services/jest_setup.unit.ts'],
1313
modulePaths: ['<rootDir>/src'],
14+
moduleNameMapper: {
15+
// Jest's resolver doesn't read the "exports" map, and zip.js' subpaths are ESM-only. The CJS
16+
// bundle is the same native-codec build, just not tree-shaken.
17+
'^@zip\\.js/zip\\.js/lib/zip-core-native\\.js$': '@zip.js/zip.js/index-native.cjs',
18+
},
1419
transform: {
1520
'^.+\\.(t|j)sx?$': [
1621
'@swc/jest',

src/app/api/maps/submit/complete/complete_upload.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { MapValidity } from 'schema/maps';
22
import { actionError } from 'services/helpers';
3+
import { MAX_MAP_FILE_SIZE, formatMaxFileSize } from 'services/maps/map_size';
34
import { submitErrorMap } from 'services/maps/maps_repo';
45
import { getServerContext } from 'services/server_context';
56
import { getUserSession } from 'services/session/session';
@@ -81,11 +82,11 @@ export async function completeMapUpload(id: string, isReupload: boolean) {
8182
});
8283
}
8384
const archive = openMapResult.value;
84-
if (archive.size > 1024 * 1024 * 100) {
85+
// Cheap upfront reject; the length-based limit is applied during validation, once the rlrr is read.
86+
if (archive.size > MAX_MAP_FILE_SIZE) {
8587
await cleanupFailedUpload();
86-
// 100MiB. We use MiB because that's what Windows displays in Explorer and therefore what users will expect.
8788
return actionError({
88-
message: 'File is over the filesize limit (100MB)',
89+
message: `File is over the filesize limit (${formatMaxFileSize(MAX_MAP_FILE_SIZE)})`,
8990
errorBody: {},
9091
});
9192
}

src/services/maps/map_size.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
const MIB = 1024 * 1024;
2+
3+
/** Ceiling regardless of song length. */
4+
export const MAX_MAP_FILE_SIZE = 500 * MIB;
5+
6+
// Rates are for the song+drums pair: FLAC ~13MiB/min, 256kbps Opus ~3.7MiB/min. The base fits FLAC
7+
// for a standard-length song, and the per-minute rate is well under it, so past ~8 minutes a map has
8+
// to be lossy.
9+
const BASE_ALLOWANCE = 80 * MIB;
10+
const ALLOWANCE_PER_MINUTE = 3 * MIB;
11+
// `length` is optional in the rlrr schema, so a map that predates it can't be sized. Falling back to
12+
// the old flat limit keeps those uploadable without making an absent length the most generous
13+
// budget going - which would just be an incentive to drop the field.
14+
const UNKNOWN_LENGTH_ALLOWANCE = 100 * MIB;
15+
16+
export function maxMapFileSize(seconds: number | undefined): number {
17+
if (seconds == null || seconds <= 0 || !Number.isFinite(seconds)) {
18+
return UNKNOWN_LENGTH_ALLOWANCE;
19+
}
20+
return Math.min(MAX_MAP_FILE_SIZE, BASE_ALLOWANCE + (seconds / 60) * ALLOWANCE_PER_MINUTE);
21+
}
22+
23+
/** Longest of the lengths the difficulties declare; they drift when recorded separately. */
24+
export function longestSongLength(lengths: (number | undefined)[]): number | undefined {
25+
const declared = lengths.filter((l): l is number => l != null);
26+
return declared.length === 0 ? undefined : Math.max(...declared);
27+
}
28+
29+
export function overBudgetMessage(limit: number): string {
30+
return `File is over the ${formatMaxFileSize(limit)} limit for a song of this length`;
31+
}
32+
33+
/**
34+
* A limit, as a size the user can compare their file against. Always rounds down, so a file the
35+
* size it names is never over the limit it names.
36+
*/
37+
export function formatMaxFileSize(bytes: number): string {
38+
// Labelled MB but computed as MiB: matches what Windows Explorer shows users.
39+
const mib = bytes / MIB;
40+
// A whole megabyte is a big share of a small limit, so keep a decimal until the limits get big
41+
// enough for it to be noise.
42+
const rounded = mib < 50 ? Math.floor(mib * 10) / 10 : Math.floor(mib);
43+
return `${rounded}MB`;
44+
}

src/services/maps/map_validator.ts

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import { FileEntry, Reader, ZipReader } from '@zip.js/zip.js';
1+
import { FileEntry, ZipReader } from '@zip.js/zip.js';
22
import { PromisedResult, Result, ResultError, wrapError } from 'base/result';
33
import { PDMap } from 'schema/maps';
4+
import { longestSongLength, maxMapFileSize, overBudgetMessage } from 'services/maps/map_size';
5+
import { parseRlrr, rlrrSongLength } from 'services/maps/rlrr';
6+
import { MapArchive } from 'services/maps/s3_handler_types';
47
import { readEntry, zipBasename, zipDirname } from 'services/maps/zip';
58

69
type RawMap = Pick<
@@ -15,6 +18,7 @@ export const enum ValidateMapError {
1518
NO_DATA = 'no_data',
1619
MISSING_ALBUM_ART = 'missing_album_art',
1720
DESCRIPTION_TOO_LONG = 'description_too_long',
21+
FILE_TOO_LARGE = 'file_too_large',
1822
}
1923
export const enum ValidateMapDifficultyError {
2024
INVALID_FORMAT = 'invalid_format',
@@ -24,7 +28,7 @@ export const enum ValidateMapDifficultyError {
2428

2529
export async function validateMap(opts: {
2630
id: string;
27-
reader: Reader<unknown>;
31+
archive: MapArchive;
2832
}): PromisedResult<
2933
RawMap & { albumArtFiles: FileEntry[] },
3034
ValidateMapError | ValidateMapDifficultyError
@@ -33,7 +37,7 @@ export async function validateMap(opts: {
3337
// anything in here can throw. A throw escaping to the caller would strand the map mid-validation:
3438
// its upload only gets rolled back on an error Result.
3539
try {
36-
const entries = await new ZipReader(opts.reader).getEntries();
40+
const entries = await new ZipReader(opts.archive.reader).getEntries();
3741
const files = entries.filter((e): e is FileEntry => !e.directory);
3842
if (files.length === 0) {
3943
return { success: false, errors: [{ type: ValidateMapError.NO_DATA }] };
@@ -47,7 +51,11 @@ export async function validateMap(opts: {
4751
if (mapName == null || !files.every((f) => zipDirname(f.filename) === mapName)) {
4852
return { success: false, errors: [{ type: ValidateMapError.INCORRECT_FOLDER_STRUCTURE }] };
4953
}
50-
return await validateMapFiles({ expectedMapName: mapName, mapFiles: files });
54+
return await validateMapFiles({
55+
expectedMapName: mapName,
56+
mapFiles: files,
57+
archiveSize: opts.archive.size,
58+
});
5159
} catch (e) {
5260
// Corrupted, not a zip at all, or unreadable from storage.
5361
return { success: false, errors: [wrapError(e, ValidateMapError.NO_DATA)] };
@@ -64,6 +72,7 @@ type RawMapMetadata = Pick<
6472
async function validateMapFiles(opts: {
6573
expectedMapName: string;
6674
mapFiles: FileEntry[];
75+
archiveSize: number;
6776
}): PromisedResult<
6877
RawMap & { albumArtFiles: FileEntry[] },
6978
ValidateMapError | ValidateMapDifficultyError
@@ -104,7 +113,8 @@ async function validateMapFiles(opts: {
104113
// Complexity is not, but some existing maps have mismatched complexities between rlrr files,
105114
// and so this check has been skipped temporarily.
106115
// TODO: fix all maps with mismatched complexities
107-
if (key === 'difficultyName' || key === 'complexity') {
116+
// Song length can differ slightly between separately-recorded difficulties.
117+
if (key === 'difficultyName' || key === 'complexity' || key === 'length') {
108118
continue;
109119
}
110120
const expected = validDifficultyResults[0].value[key as keyof RawMapMetadata];
@@ -128,6 +138,21 @@ async function validateMapFiles(opts: {
128138
return { success: false, errors: [{ type: ValidateMapError.DESCRIPTION_TOO_LONG }] };
129139
}
130140

141+
const sizeLimit = maxMapFileSize(
142+
longestSongLength(validDifficultyResults.map((d) => d.value.length))
143+
);
144+
if (opts.archiveSize > sizeLimit) {
145+
return {
146+
success: false,
147+
errors: [
148+
{
149+
type: ValidateMapError.FILE_TOO_LARGE,
150+
userMessage: overBudgetMessage(sizeLimit),
151+
},
152+
],
153+
};
154+
}
155+
131156
const albumArtFiles = validDifficultyResults
132157
.map((v) => v.value.albumArt)
133158
.filter((s): s is string => s != null)
@@ -162,10 +187,13 @@ function validateMapDifficulty(
162187
filename: string,
163188
rlrr: Uint8Array,
164189
getMapFile: (filename: string) => FileEntry | undefined
165-
): Result<RawMapMetadata & { difficultyName: string }, ValidateMapDifficultyError> {
190+
): Result<
191+
RawMapMetadata & { difficultyName: string; length: number | undefined },
192+
ValidateMapDifficultyError
193+
> {
166194
let map: any;
167195
try {
168-
map = parseJson(rlrr);
196+
map = parseRlrr(rlrr);
169197
} catch {
170198
return { success: false, errors: [{ type: ValidateMapDifficultyError.INVALID_FORMAT }] };
171199
}
@@ -245,13 +273,11 @@ function validateMapDifficulty(
245273

246274
return {
247275
success: true,
248-
value: { ...requiredFields, ...optionalFields, difficultyName: difficultyMatch[1] },
276+
value: {
277+
...requiredFields,
278+
...optionalFields,
279+
difficultyName: difficultyMatch[1],
280+
length: rlrrSongLength(map),
281+
},
249282
};
250283
}
251-
252-
function parseJson(bytes: Uint8Array) {
253-
// Paradiddle writes some rlrr files as UTF-16LE with a byte order mark. Both decoders strip the
254-
// mark themselves; leaving one in front would fail the parse.
255-
const isUtf16le = bytes[0] === 0xff && bytes[1] === 0xfe;
256-
return JSON.parse(new TextDecoder(isUtf16le ? 'utf-16le' : 'utf-8').decode(bytes));
257-
}

src/services/maps/maps_repo.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ export class MapsRepo {
413413
existingMap.success && existingMap.value.validity === MapValidity.REUPLOADED;
414414

415415
await this.setValidity(id, MapValidity.VALIDATING);
416-
const validatedMapResult = await validateMap({ id, reader: archive.reader });
416+
const validatedMapResult = await validateMap({ id, archive });
417417
if (!validatedMapResult.success) {
418418
return validatedMapResult;
419419
}
@@ -532,6 +532,7 @@ export const submitErrorMap: Record<
532532
[ValidateMapError.MISSING_ALBUM_ART]: [400, 'Missing album art'],
533533
[ValidateMapError.NO_DATA]: [400, 'Invalid map archive; could not find map data'],
534534
[ValidateMapError.DESCRIPTION_TOO_LONG]: [400, 'Description is too long'],
535+
[ValidateMapError.FILE_TOO_LARGE]: [400, 'File is over the filesize limit for this song length'],
535536
[ValidateMapDifficultyError.NO_AUDIO]: [400, 'Invalid map archive; missing audio files'],
536537
[ValidateMapDifficultyError.INVALID_FORMAT]: [
537538
400,

src/services/maps/rlrr.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function parseRlrr(bytes: Uint8Array): unknown {
2+
// Paradiddle writes some rlrr files as UTF-16LE with a byte order mark. Both decoders strip the
3+
// mark themselves; leaving one in front would fail the parse.
4+
const isUtf16le = bytes[0] === 0xff && bytes[1] === 0xfe;
5+
return JSON.parse(new TextDecoder(isUtf16le ? 'utf-16le' : 'utf-8').decode(bytes));
6+
}
7+
8+
/** Song length in seconds, as declared by an rlrr's metadata. */
9+
export function rlrrSongLength(rlrr: unknown): number | undefined {
10+
const length = (rlrr as { recordingMetadata?: { length?: unknown } })?.recordingMetadata?.length;
11+
return typeof length === 'number' ? length : undefined;
12+
}

src/services/maps/tests/map_generator.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ import * as path from 'path';
99
* `files/` (`silence.ogg`, `album.jpg`), same as the Python script.
1010
*/
1111

12-
const FILES_DIR = path.resolve(__dirname, 'files');
12+
export const FILES_DIR = path.resolve(__dirname, 'files');
1313
const albumArt = fs.readFileSync(path.join(FILES_DIR, 'album.jpg'));
1414
const silence = fs.readFileSync(path.join(FILES_DIR, 'silence.ogg'));
1515

1616
export type MapZipSpec = {
1717
/** Top-level folder name. Must match the .rlrr filename prefix, which the validator enforces. */
1818
folder: string;
19-
difficulty?: string;
19+
/** `lengthSeconds: null` writes no length at all, as an rlrr predating the field would. */
20+
difficulties?: { name: string; lengthSeconds?: number | null }[];
2021
title: string;
2122
artist: string;
2223
creator?: string;
@@ -29,30 +30,30 @@ export type MapZipSpec = {
2930
};
3031

3132
export function buildMapZip(spec: MapZipSpec): Buffer {
32-
const difficulty = spec.difficulty ?? 'Easy';
33-
const rlrr = {
33+
const difficulties = spec.difficulties ?? [{ name: 'Easy' }];
34+
const rlrrFor = (difficulty: (typeof difficulties)[number]) => ({
3435
version: 0.6,
3536
recordingMetadata: {
3637
title: spec.title,
3738
description: spec.description ?? '',
3839
coverImagePath: 'album.jpg',
3940
artist: spec.artist,
4041
creator: spec.creator ?? '',
41-
length: 11.1814,
42+
...(difficulty.lengthSeconds === null ? {} : { length: difficulty.lengthSeconds ?? 11.1814 }),
4243
complexity: spec.complexity ?? 1,
4344
},
4445
audioFileData: { songTracks: ['song.ogg'], drumTracks: ['drums.ogg'], calibrationOffset: 0.0 },
4546
instruments: [],
4647
events: [],
4748
bpmEvents: [{ bpm: 120.0, time: 0.0 }],
48-
};
49+
});
4950

50-
// The .rlrr must come first: the validator derives the map name from the first file entry.
51+
// The .rlrrs must come first: the validator derives the map name from the first file entry.
5152
const entries: ZipEntry[] = [
52-
{
53-
name: `${spec.folder}/${spec.folder}_${difficulty}.rlrr`,
54-
data: encodeRlrr(JSON.stringify(rlrr, null, 2), spec.utf16le ?? false),
55-
},
53+
...difficulties.map((d) => ({
54+
name: `${spec.folder}/${spec.folder}_${d.name}.rlrr`,
55+
data: encodeRlrr(JSON.stringify(rlrrFor(d), null, 2), spec.utf16le ?? false),
56+
})),
5657
{ name: `${spec.folder}/album.jpg`, data: albumArt },
5758
{ name: `${spec.folder}/song.ogg`, data: silence },
5859
{ name: `${spec.folder}/drums.ogg`, data: silence },
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {
2+
MAX_MAP_FILE_SIZE,
3+
formatMaxFileSize,
4+
longestSongLength,
5+
maxMapFileSize,
6+
overBudgetMessage,
7+
} from 'services/maps/map_size';
8+
9+
const MIB = 1024 * 1024;
10+
11+
describe('maxMapFileSize', () => {
12+
it('scales with song length', () => {
13+
expect(maxMapFileSize(600)).toBeGreaterThan(maxMapFileSize(60));
14+
});
15+
16+
it('fits lossless audio for a standard-length song', () => {
17+
expect(maxMapFileSize(5 * 60)).toEqual(95 * MIB);
18+
});
19+
20+
it('only fits lossy audio for an hour-long song', () => {
21+
expect(maxMapFileSize(60 * 60)).toEqual(260 * MIB);
22+
});
23+
24+
it('never exceeds the hard cap', () => {
25+
expect(maxMapFileSize(60 * 60 * 24)).toEqual(MAX_MAP_FILE_SIZE);
26+
});
27+
28+
// An unreadable length mustn't be the most generous budget on offer, or dropping the field would
29+
// be the way to get one.
30+
it('falls back to a fixed allowance, not the cap, when the length is unusable', () => {
31+
for (const unusable of [undefined, 0, -1, NaN, Infinity]) {
32+
expect(maxMapFileSize(unusable)).toEqual(100 * MIB);
33+
}
34+
expect(maxMapFileSize(undefined)).toBeLessThan(MAX_MAP_FILE_SIZE);
35+
});
36+
});
37+
38+
describe('longestSongLength', () => {
39+
it('takes the longest declared length', () => {
40+
expect(longestSongLength([213.5, undefined, 214.25])).toEqual(214.25);
41+
});
42+
43+
it('is undefined when nothing declares one', () => {
44+
expect(longestSongLength([])).toBeUndefined();
45+
expect(longestSongLength([undefined, undefined])).toBeUndefined();
46+
});
47+
});
48+
49+
describe('formatMaxFileSize', () => {
50+
it('reports MiB, which is what the user sees in their file browser', () => {
51+
expect(formatMaxFileSize(100 * MIB)).toEqual('100MB');
52+
});
53+
54+
// Naming a limit larger than it is would tell the user a file that gets rejected should fit.
55+
it('never names a size larger than the limit it was given', () => {
56+
for (const mib of [0.05, 1.99, 49.99, 50.5, 95.9, 260.75]) {
57+
expect(parseFloat(formatMaxFileSize(mib * MIB))).toBeLessThanOrEqual(mib);
58+
}
59+
});
60+
61+
it('keeps a decimal below 50MB, where a whole megabyte is a big share of the limit', () => {
62+
expect(formatMaxFileSize(1.66 * MIB)).toEqual('1.6MB');
63+
expect(formatMaxFileSize(49.99 * MIB)).toEqual('49.9MB');
64+
expect(formatMaxFileSize(40 * MIB)).toEqual('40MB');
65+
});
66+
67+
it('drops to whole megabytes above that, where the decimal is noise', () => {
68+
expect(formatMaxFileSize(50.5 * MIB)).toEqual('50MB');
69+
expect(formatMaxFileSize(260.75 * MIB)).toEqual('260MB');
70+
});
71+
});
72+
73+
describe('overBudgetMessage', () => {
74+
it('names the limit', () => {
75+
expect(overBudgetMessage(maxMapFileSize(5 * 60))).toEqual(
76+
'File is over the 95MB limit for a song of this length'
77+
);
78+
});
79+
});

0 commit comments

Comments
 (0)