From cf40c64cf37e65f0cfedcac25886043c66ea3040 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:59:43 -0300 Subject: [PATCH 01/19] feat(song): add support for `.nbs` file format version 6 (NBS 3.12+) --- .../song/song-upload/song-upload.service.ts | 23 ++- packages/song/src/index.ts | 1 + packages/song/src/injectMetadata.ts | 1 + packages/song/src/nbsCompat.ts | 173 ++++++++++++++++++ packages/song/src/obfuscate.ts | 4 + packages/song/src/parse.ts | 5 +- packages/song/src/stats.ts | 4 +- 7 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 packages/song/src/nbsCompat.ts diff --git a/apps/backend/src/song/song-upload/song-upload.service.ts b/apps/backend/src/song/song-upload/song-upload.service.ts index b9ed8871..fc2091cf 100644 --- a/apps/backend/src/song/song-upload/song-upload.service.ts +++ b/apps/backend/src/song/song-upload/song-upload.service.ts @@ -1,4 +1,4 @@ -import { Song, fromArrayBuffer, toArrayBuffer } from '@encode42/nbs.js'; +import { Song, toArrayBuffer } from '@encode42/nbs.js'; import { HttpException, HttpStatus, @@ -19,7 +19,9 @@ import { import { NoteQuadTree, SongStatsGenerator, + UnsupportedNbsVersionError, injectSongFileMetadata, + loadNbsFromBuffer, obfuscateAndPackSong, } from '@nbw/song'; import { drawToImage } from '@nbw/thumbnail/node'; @@ -439,7 +441,24 @@ export class SongUploadService { } public getSongObject(loadedArrayBuffer: ArrayBuffer): Song { - const nbsSong = fromArrayBuffer(loadedArrayBuffer); + let nbsSong: Song; + + try { + nbsSong = loadNbsFromBuffer(loadedArrayBuffer); + } catch (error) { + if (error instanceof UnsupportedNbsVersionError) { + throw new HttpException( + { + error: { + file: error.message, + }, + }, + HttpStatus.BAD_REQUEST, + ); + } + + throw error; + } // If the above operation fails, it will return an empty song if (nbsSong.length === 0) { diff --git a/packages/song/src/index.ts b/packages/song/src/index.ts index 1faff3fa..a3acce9e 100644 --- a/packages/song/src/index.ts +++ b/packages/song/src/index.ts @@ -1,3 +1,4 @@ +export * from './nbsCompat'; export * from './injectMetadata'; export * from './notes'; export * from './obfuscate'; diff --git a/packages/song/src/injectMetadata.ts b/packages/song/src/injectMetadata.ts index 0b755a8d..6f018e83 100644 --- a/packages/song/src/injectMetadata.ts +++ b/packages/song/src/injectMetadata.ts @@ -1,6 +1,7 @@ import { Song } from '@encode42/nbs.js'; import unidecode from 'unidecode'; +/** Expects a song from {@link loadNbsFromBuffer} (normalized v5/v6 instrument layout). */ export function injectSongFileMetadata( nbsSong: Song, title: string, diff --git a/packages/song/src/nbsCompat.ts b/packages/song/src/nbsCompat.ts new file mode 100644 index 00000000..73667973 --- /dev/null +++ b/packages/song/src/nbsCompat.ts @@ -0,0 +1,173 @@ +import { + fromArrayBuffer, + Instrument, + Song, + type FromArrayBufferOptions, +} from '@encode42/nbs.js'; + +/** Default instruments 0–15 (NBS v5 and below). */ +export const NBS_V5_FIRST_CUSTOM = 16; + +/** First custom instrument index in NBS v6. */ +export const NBS_V6_FIRST_CUSTOM = 20; + +export const MAX_SUPPORTED_NBS_VERSION = 6; + +export class UnsupportedNbsVersionError extends Error { + constructor(public readonly version: number) { + super( + `Unsupported NBS version: ${version}. Maximum supported version is ${MAX_SUPPORTED_NBS_VERSION}.`, + ); + this.name = 'UnsupportedNbsVersionError'; + } +} + +// TODO: TEMP: Remove when @encode42/nbs.js ships v6 built-in instruments in Instrument.builtIn. +const NBS_V6_BUILTIN_INSTRUMENTS: Instrument[] = [ + new Instrument(16, { + name: 'Trumpet', + soundFile: 'trumpet.ogg', + builtIn: true, + key: 45, + }), + new Instrument(17, { + name: 'Exposed Trumpet', + soundFile: 'exposed_trumpet.ogg', + builtIn: true, + key: 45, + }), + new Instrument(18, { + name: 'Weathered Trumpet', + soundFile: 'weathered_trumpet.ogg', + builtIn: true, + key: 45, + }), + new Instrument(19, { + name: 'Oxidized Trumpet', + soundFile: 'oxidized_trumpet.ogg', + builtIn: true, + key: 45, + }), +]; + +export function getNbsFormatVersion(song: Song): 5 | 6 { + return song.nbsVersion >= 6 ? 6 : 5; +} + +export function isNbsV6(song: Song): boolean { + return getNbsFormatVersion(song) === 6; +} + +function findInstrumentById(song: Song, id: number): Instrument | undefined { + const { loaded } = song.instruments; + + if (loaded[id]?.id === id) { + return loaded[id]; + } + + return loaded.find((inst) => inst?.id === id); +} + +function cloneBuiltinInstrument( + source: Instrument | undefined, + fallback: Instrument, + id: number, +): Instrument { + const base = source ?? fallback; + + return new Instrument(id, { + name: base.meta.name, + soundFile: base.meta.soundFile, + key: base.key, + pressKey: base.pressKey, + builtIn: true, + }); +} + +function getDefaultBuiltinInstrument(id: number): Instrument { + if (id < Instrument.builtIn.length) { + return Instrument.builtIn[id]!; + } + + return NBS_V6_BUILTIN_INSTRUMENTS[id - NBS_V5_FIRST_CUSTOM]!; +} + +/** + * Rebuilds `instruments.loaded` so array indices match note instrument IDs. + * nbs.js 5.0.2 leaves gaps at 16–19 for v6 files and may place customs at wrong indices. + * + * // TODO: TEMP: Remove when @encode42/nbs.js natively models v6. + */ +export function normalizeNbsSong(song: Song): Song { + if (song.nbsVersion > MAX_SUPPORTED_NBS_VERSION) { + throw new UnsupportedNbsVersionError(song.nbsVersion); + } + + if (!isNbsV6(song)) { + return song; + } + + const firstCustom = song.instruments.firstCustomIndex; + const newLoaded: Instrument[] = []; + + for (let id = 0; id < firstCustom; id++) { + newLoaded[id] = cloneBuiltinInstrument( + findInstrumentById(song, id), + getDefaultBuiltinInstrument(id), + id, + ); + } + + const customs = song.instruments.loaded.filter( + (inst): inst is Instrument => Boolean(inst) && !inst.builtIn, + ); + + customs.sort((a, b) => a.id - b.id); + + for (const inst of customs) { + const targetId = + inst.id >= firstCustom ? inst.id : firstCustom + customs.indexOf(inst); + + newLoaded[targetId] = inst; + } + + song.instruments.loaded = newLoaded; + + return song; +} + +/** + * Seeds obfuscated output with the source song's format version and built-in instruments. + * + * TODO: TEMP: Remove when @encode42/nbs.js creates v6 songs from `new Song()`. + */ +export function seedOutputBuiltinInstruments(source: Song, output: Song): void { + output.nbsVersion = getNbsFormatVersion(source); + output.instruments.firstCustomIndex = source.instruments.firstCustomIndex; + + const firstCustom = source.instruments.firstCustomIndex; + const builtins: Instrument[] = []; + + for (let id = 0; id < firstCustom; id++) { + builtins[id] = cloneBuiltinInstrument( + findInstrumentById(source, id), + getDefaultBuiltinInstrument(id), + id, + ); + } + + output.instruments.loaded = builtins; +} + +export function loadNbsFromBuffer( + buffer: ArrayBuffer, + options?: FromArrayBufferOptions, +): Song { + const song = fromArrayBuffer(buffer, options); + + if (song.nbsVersion > MAX_SUPPORTED_NBS_VERSION) { + throw new UnsupportedNbsVersionError(song.nbsVersion); + } + + return normalizeNbsSong(song); +} diff --git a/packages/song/src/obfuscate.ts b/packages/song/src/obfuscate.ts index 490bcb80..25576bfc 100644 --- a/packages/song/src/obfuscate.ts +++ b/packages/song/src/obfuscate.ts @@ -1,5 +1,6 @@ import { Instrument, Layer, Note, Song } from '@encode42/nbs.js'; +import { seedOutputBuiltinInstruments } from './nbsCompat'; import { getInstrumentNoteCounts, getTempoChangerInstrumentIds } from './util'; export class SongObfuscator { @@ -21,6 +22,9 @@ export class SongObfuscator { const song = this.song; const output = new Song(); + // TODO: TEMP: preserve v5/v6 format until nbs.js writes v6 from `new Song()`. + seedOutputBuiltinInstruments(song, output); + // ✅ Clear work stats // ✅ Copy: title, author, description, loop info, time signature this.copyMetaAndStats(song, output); diff --git a/packages/song/src/parse.ts b/packages/song/src/parse.ts index 202daf8d..e60389e1 100644 --- a/packages/song/src/parse.ts +++ b/packages/song/src/parse.ts @@ -1,5 +1,6 @@ -import { Song, fromArrayBuffer } from '@encode42/nbs.js'; +import type { Song } from '@encode42/nbs.js'; +import { loadNbsFromBuffer } from './nbsCompat'; import { NoteQuadTree } from './notes'; import type { InstrumentArray, SongFileType } from './types'; import { getInstrumentNoteCounts } from './util'; @@ -20,7 +21,7 @@ async function getVanillaSoundList() { export async function parseSongFromBuffer( buffer: ArrayBuffer, ): Promise { - const song = fromArrayBuffer(buffer); + const song = loadNbsFromBuffer(buffer); if (song.length === 0) { throw new Error('Invalid song'); diff --git a/packages/song/src/stats.ts b/packages/song/src/stats.ts index 9b585ad9..9afcd23d 100644 --- a/packages/song/src/stats.ts +++ b/packages/song/src/stats.ts @@ -102,8 +102,10 @@ export class SongStatsGenerator { let customInstrumentNoteCount = 0; let incompatibleNoteCount = 0; + // At least one slot per default instrument (16 for v5, 20 for v6). + const minInstrumentSlots = this.song.instruments.firstCustomIndex; const instrumentNoteCounts = Array( - this.song.instruments.loaded.length, + Math.max(minInstrumentSlots, this.song.instruments.loaded.length), ).fill(0); for (const [layerId, layer] of this.song.layers.entries()) { From 87c2d289769f12a93b6aa0a45c54f22f0e00d319 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:00:27 -0300 Subject: [PATCH 02/19] test(song): add test suite for NBS compatibility module --- .../song/tests/song/files/testV6Trumpets.nbs | Bin 0 -> 372 bytes packages/song/tests/song/nbsCompat.spec.ts | 133 ++++++++++++++++++ packages/song/tests/song/util.ts | 10 +- 3 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 packages/song/tests/song/files/testV6Trumpets.nbs create mode 100644 packages/song/tests/song/nbsCompat.spec.ts diff --git a/packages/song/tests/song/files/testV6Trumpets.nbs b/packages/song/tests/song/files/testV6Trumpets.nbs new file mode 100644 index 0000000000000000000000000000000000000000..0a63372246bbe665841bf282d5006e199c8f57dd GIT binary patch literal 372 zcmZQzU=tBy5NALKFPIs)SSA8RgMgSFh&h0m8Hizkk%5sxKvy>f#AgD^@T;dl*^CT= zMo8kC2yr2EggCo4LR{DeA { + it('leaves v5 songs unchanged', () => { + const song = openSongFromPath('files/testSimple.nbs'); + + assert.strictEqual(song.nbsVersion, 5); + assert.strictEqual(song.instruments.firstCustomIndex, 16); + assert.strictEqual(song.instruments.loaded.length, 16); + }); + + it('pads v5 instrumentNoteCounts to at least 16 slots', () => { + const stats = SongStatsGenerator.getSongStats( + openSongFromPath('files/testSimple.nbs'), + ); + + assert(stats.instrumentNoteCounts.length >= 16); + assert.strictEqual(stats.firstCustomInstrumentIndex, 16); + }); + + it('normalizes v6 built-in instruments at indices 16–19', () => { + const song = createV6TestSong(); + + assert.strictEqual(song.instruments.firstCustomIndex, 20); + assert.strictEqual(song.instruments.loaded[16]?.builtIn, true); + assert.strictEqual(song.instruments.loaded[16]?.meta.name, 'Trumpet'); + assert.strictEqual(song.instruments.loaded[19]?.builtIn, true); + assert.strictEqual( + song.instruments.loaded[NBS_V6_FIRST_CUSTOM]?.meta.name, + 'Test Custom', + ); + }); + + it('pads v6 instrumentNoteCounts to at least 20 slots', () => { + const stats = SongStatsGenerator.getSongStats(createV6TestSong()); + + assert(stats.instrumentNoteCounts.length >= 20); + assert.strictEqual(stats.firstCustomInstrumentIndex, 20); + assert.strictEqual(stats.instrumentNoteCounts[16], 1); + assert.strictEqual(stats.instrumentNoteCounts[19], 1); + assert.strictEqual(stats.instrumentNoteCounts[20], 1); + }); + + it('preserves v6 format when obfuscating', () => { + const song = createV6TestSong(); + const obfuscated = SongObfuscator.obfuscateSong(song, ['customhash']); + + assert.strictEqual(getNbsFormatVersion(obfuscated), 6); + assert.strictEqual(obfuscated.nbsVersion, 6); + assert.strictEqual(obfuscated.instruments.firstCustomIndex, 20); + assert.strictEqual(obfuscated.instruments.loaded[16]?.builtIn, true); + assert.strictEqual( + obfuscated.instruments.loaded[20]?.meta.soundFile, + 'customhash', + ); + }); + + it('round-trips v6 through toArrayBuffer', () => { + const buffer = toArrayBuffer(createV6TestSong()); + const reloaded = loadNbsFromBuffer(buffer); + + assert.strictEqual(reloaded.nbsVersion, 6); + assert.strictEqual(reloaded.instruments.firstCustomIndex, 20); + assert.strictEqual(reloaded.instruments.loaded[16]?.builtIn, true); + assert.strictEqual( + reloaded.instruments.loaded[NBS_V6_FIRST_CUSTOM]?.meta.name, + 'Test Custom', + ); + }); + + it('loads v6 fixture file', () => { + const fixturePath = join(fixturesDir, 'testV6Trumpets.nbs'); + const buffer = asArrayBuffer(readFileSync(fixturePath)); + const song = loadNbsFromBuffer(buffer); + + assert.strictEqual(song.nbsVersion, 6); + assert.strictEqual(song.instruments.firstCustomIndex, 20); + assert.strictEqual(song.instruments.loaded[16]?.builtIn, true); + }); + + it('throws an error if the NBS version is too high', () => { + const song = createV6TestSong(); + song.nbsVersion = 7; + assert.throws( + () => loadNbsFromBuffer(toArrayBuffer(song)), + UnsupportedNbsVersionError, + ); + }); +}); diff --git a/packages/song/tests/song/util.ts b/packages/song/tests/song/util.ts index 40698920..c12c5f04 100644 --- a/packages/song/tests/song/util.ts +++ b/packages/song/tests/song/util.ts @@ -1,7 +1,9 @@ import { readFileSync } from 'fs'; import { join, resolve } from 'path'; -import { Song, fromArrayBuffer } from '@encode42/nbs.js'; +import type { Song } from '@encode42/nbs.js'; + +import { loadNbsFromBuffer } from '../../src/nbsCompat'; export function openSongFromPath(path: string): Song { // Specify the relative path to the file @@ -10,12 +12,10 @@ export function openSongFromPath(path: string): Song { // Read the file and get its array buffer const buffer = asArrayBuffer(readFileSync(filePath)); - const song = fromArrayBuffer(buffer); - - return song; + return loadNbsFromBuffer(buffer); } -function asArrayBuffer(buffer: Buffer): ArrayBuffer { +export function asArrayBuffer(buffer: Buffer): ArrayBuffer { const arrayBuffer = new ArrayBuffer(buffer.length); const view = new Uint8Array(arrayBuffer); From 91350d05d0d45b9d0227ef4278de44902b9cc9dc Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 01:34:05 -0300 Subject: [PATCH 03/19] feat(thumbnail): wrap instrument colors at the song's default inst count --- .../song/components/client/SongThumbnailInput.tsx | 11 +++++------ .../song/components/client/ThumbnailRenderer.tsx | 12 ++++++++---- packages/song/src/parse.ts | 1 + packages/song/src/types.ts | 1 + packages/thumbnail/src/shared/drawCore.ts | 4 +++- packages/thumbnail/src/shared/types.ts | 1 + 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx b/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx index 868b7b03..ec079e32 100644 --- a/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx +++ b/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx @@ -149,13 +149,12 @@ export const SongThumbnailInput: React.FC = ({ }: SongThumbnailInputProps) => { const { song, formMethods } = useSongProvider(type); - const [notes, maxTick, maxLayer] = useMemo(() => { - if (!song) return [null, 0, 0]; - const notes = song.notes; + const [maxTick, maxLayer] = useMemo(() => { + if (!song) return [0, 0]; const maxTick = song.length; const maxLayer = song.height; - return [notes, maxTick, maxLayer]; + return [maxTick, maxLayer]; }, [song]); return ( @@ -167,8 +166,8 @@ export const SongThumbnailInput: React.FC = ({ maxLayer={maxLayer} /> - {song && notes && ( - + {song && ( + )} {/* Background Color */} diff --git a/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx b/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx index c7d77afa..6d6404a5 100644 --- a/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx +++ b/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx @@ -4,18 +4,18 @@ import { useEffect, useRef, useState } from 'react'; import { UseFormReturn } from 'react-hook-form'; import { THUMBNAIL_CONSTANTS } from '@nbw/config'; -import { NoteQuadTree } from '@nbw/song'; +import { SongFileType } from '@nbw/song'; import { drawNotesOffscreen, swap } from '@nbw/thumbnail/browser'; import { UploadSongFormInput } from './SongForm.zod'; type ThumbnailRendererCanvasProps = { - notes: NoteQuadTree; + song: SongFileType; formMethods: UseFormReturn; }; export const ThumbnailRendererCanvas = ({ - notes, + song, formMethods, }: ThumbnailRendererCanvasProps) => { const canvasRef = useRef(null); @@ -31,6 +31,9 @@ export const ThumbnailRendererCanvas = ({ ], ); + const notes = song.notes; + const defaultInstrumentCount = song.defaultInstrumentCount; + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -61,6 +64,7 @@ export const ThumbnailRendererCanvas = ({ try { const output = (await drawNotesOffscreen({ notes, + defaultInstrumentCount, startTick: startTick ?? THUMBNAIL_CONSTANTS.startTick.default, startLayer: startLayer ?? THUMBNAIL_CONSTANTS.startLayer.default, zoomLevel: zoomLevel ?? THUMBNAIL_CONSTANTS.zoomLevel.default, @@ -78,7 +82,7 @@ export const ThumbnailRendererCanvas = ({ setLoading(false); } }); - }, [notes, startTick, startLayer, zoomLevel, backgroundColor]); + }, [startTick, startLayer, zoomLevel, backgroundColor]); return (
diff --git a/packages/song/src/parse.ts b/packages/song/src/parse.ts index e60389e1..7213d8b8 100644 --- a/packages/song/src/parse.ts +++ b/packages/song/src/parse.ts @@ -41,6 +41,7 @@ export async function parseSongFromBuffer( arrayBuffer: buffer, notes: quadTree, instruments: getInstruments(song, vanillaSoundList), + defaultInstrumentCount: song.instruments.firstCustomIndex, }; } diff --git a/packages/song/src/types.ts b/packages/song/src/types.ts index 19dc4bbe..2f0dfaf4 100644 --- a/packages/song/src/types.ts +++ b/packages/song/src/types.ts @@ -10,6 +10,7 @@ export type SongFileType = { arrayBuffer: ArrayBuffer; notes: NoteQuadTree; instruments: InstrumentArray; + defaultInstrumentCount: number; }; export type InstrumentArray = Instrument[]; diff --git a/packages/thumbnail/src/shared/drawCore.ts b/packages/thumbnail/src/shared/drawCore.ts index 30f2f230..aa4d4942 100644 --- a/packages/thumbnail/src/shared/drawCore.ts +++ b/packages/thumbnail/src/shared/drawCore.ts @@ -9,6 +9,7 @@ export async function drawNotes( ) { const { notes, + defaultInstrumentCount, startTick, startLayer, zoomLevel, @@ -78,7 +79,8 @@ export async function drawNotes( // Calculate position const x = (note.tick - startTick) * 8 * zoomFactor; const y = (note.layer - startLayer) * 8 * zoomFactor; - const overlayColor = instrumentColors[note.instrument % 16] ?? '#FF00FF'; + const overlayColor = + instrumentColors[note.instrument % defaultInstrumentCount] ?? '#FF00FF'; if (!loadedNoteBlockImage) { throw new Error('Note block image not loaded'); diff --git a/packages/thumbnail/src/shared/types.ts b/packages/thumbnail/src/shared/types.ts index 33506f22..397e7fe8 100644 --- a/packages/thumbnail/src/shared/types.ts +++ b/packages/thumbnail/src/shared/types.ts @@ -2,6 +2,7 @@ import type { NoteQuadTree } from '@nbw/song'; export interface DrawParams { notes: NoteQuadTree; + defaultInstrumentCount: number; startTick: number; startLayer: number; zoomLevel: number; From 20055cffc429a5a986452cf00acfb45788acc676 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 01:36:51 -0300 Subject: [PATCH 04/19] feat(thumbnail): add trumpet instrument colors --- packages/thumbnail/src/shared/utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/thumbnail/src/shared/utils.ts b/packages/thumbnail/src/shared/utils.ts index bd96978b..787a9292 100644 --- a/packages/thumbnail/src/shared/utils.ts +++ b/packages/thumbnail/src/shared/utils.ts @@ -22,6 +22,10 @@ export const instrumentColors = [ '#19be19', '#be1957', '#575757', + '#d26b50', + '#c38969', + '#78a07a', + '#5ca087', ]; const tintedImages: Record = {}; From 23407a7e05d9d238c69f869fc7888fb4bfaa85d9 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 01:41:09 -0300 Subject: [PATCH 05/19] fix(upload): add specific error message for unsupported nbs versions --- .../client/context/UploadSong.context.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx b/apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx index 12ccb606..f85f7355 100644 --- a/apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx +++ b/apps/frontend/src/modules/song-upload/components/client/context/UploadSong.context.tsx @@ -12,7 +12,11 @@ import { toast } from 'react-hot-toast'; import { create } from 'zustand'; import { BG_COLORS, THUMBNAIL_CONSTANTS, UPLOAD_CONSTANTS } from '@nbw/config'; -import { parseSongFromBuffer, type SongFileType } from '@nbw/song'; +import { + parseSongFromBuffer, + UnsupportedNbsVersionError, + type SongFileType, +} from '@nbw/song'; import axiosInstance from '@web/lib/axios'; import { InvalidTokenError, getTokenLocal } from '@web/lib/axios/token.utils'; import { @@ -250,7 +254,15 @@ export const UploadSongProvider = ({ parsedSong = await parseSongFromBuffer(await file.arrayBuffer()); } catch (e) { console.error('Error parsing song file', e); - toast.error('Invalid song file! Please try again with a different song.'); + if (e instanceof UnsupportedNbsVersionError) { + toast.error( + 'Unsupported NBS version! Please save this file in an older version.', + ); + } else { + toast.error( + 'Invalid song file! Please try again with a different song.', + ); + } setSong(null); return; } From c7f4d9a9da60f24d8402027e3a732dbb7b4cd5ad Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:43:28 -0300 Subject: [PATCH 06/19] fix(upload): add missing `defaultInstrumentCount` field to backend thumb gen --- apps/backend/src/song/song-upload/song-upload.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/src/song/song-upload/song-upload.service.ts b/apps/backend/src/song/song-upload/song-upload.service.ts index fc2091cf..108034a2 100644 --- a/apps/backend/src/song/song-upload/song-upload.service.ts +++ b/apps/backend/src/song/song-upload/song-upload.service.ts @@ -363,6 +363,7 @@ export class SongUploadService { const thumbBuffer = await drawToImage({ notes: quadTree, + defaultInstrumentCount: nbsSong.instruments.firstCustomIndex, startTick: startTick, startLayer: startLayer, zoomLevel: zoomLevel, From d5e90f4dd579f5263b7507557bba9e1b8044a439 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:51:06 -0300 Subject: [PATCH 07/19] test(thumbnail): adjust test to check for 20 color codes --- packages/thumbnail/src/shared/utils.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/thumbnail/src/shared/utils.spec.ts b/packages/thumbnail/src/shared/utils.spec.ts index 13928a00..a5435a5d 100644 --- a/packages/thumbnail/src/shared/utils.spec.ts +++ b/packages/thumbnail/src/shared/utils.spec.ts @@ -1,8 +1,8 @@ import { getKeyText, instrumentColors, isDarkColor } from './utils.js'; describe('instrumentColors', () => { - it('should contain 16 color codes', () => { - expect(instrumentColors).toHaveLength(16); + it('should contain 20 color codes', () => { + expect(instrumentColors).toHaveLength(20); instrumentColors.forEach((color) => { expect(color).toMatch(/^#[0-9a-f]{6}$/); From f2d8667c3224aedf360a2bab2f58bb7015087a7f Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 03:05:00 -0300 Subject: [PATCH 08/19] build(song): make nbs.js external to `song` package to avoid version mismatch Two changes fix this at the root and make the test more meaningful. ### 1. Externalize `@encode42/nbs.js` in the `@nbw/song` node build `packages/song/scripts/build.ts` was bundling `@encode42/nbs.js` into `dist/index.node.js`, so `loadNbsFromBuffer` returned a `Song` from a **different** constructor than the one imported directly in the backend test. Adding `external: ['@encode42/nbs.js']` to the node build makes the built package import from the shared `node_modules` copy instead. After rebuilding: ``` same constructor: true ``` ### 2. Stronger test assertions Removed the misleading TODO and added checks on the actual song data: ```ts expect(song).toBeInstanceOf(Song); expect(song.meta.name).toBe('Cool Test Song'); expect(song.length).toBe(16); expect(song.layers).toHaveLength(3); ``` Both `getSongObject` tests pass now. The browser build is unchanged (it still bundles `nbs.js`, which is fine for the frontend). --- .../backend/src/song/song-upload/song-upload.service.spec.ts | 5 ++++- packages/song/scripts/build.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/song/song-upload/song-upload.service.spec.ts b/apps/backend/src/song/song-upload/song-upload.service.spec.ts index c58c0d22..db3968a6 100644 --- a/apps/backend/src/song/song-upload/song-upload.service.spec.ts +++ b/apps/backend/src/song/song-upload/song-upload.service.spec.ts @@ -386,9 +386,12 @@ describe('SongUploadService', () => { const buffer = songTest.toArrayBuffer(); - const song = songUploadService.getSongObject(buffer); //TODO: For some reason the song is always empty + const song = songUploadService.getSongObject(buffer); expect(song).toBeInstanceOf(Song); + expect(song.meta.name).toBe('Cool Test Song'); + expect(song.length).toBe(16); + expect(song.layers).toHaveLength(3); }); it('should throw an error if the array buffer is invalid', () => { diff --git a/packages/song/scripts/build.ts b/packages/song/scripts/build.ts index c3eb47a9..4a0625aa 100644 --- a/packages/song/scripts/build.ts +++ b/packages/song/scripts/build.ts @@ -17,6 +17,8 @@ async function buildNode() { entrypoints: [join(packageRoot, 'src', 'index.ts')], outdir: join(packageRoot, 'dist'), target: 'node', + // Keep a single @encode42/nbs.js instance for consumers (e.g. backend instanceof checks). + external: ['@encode42/nbs.js'], }); if (!result.success) { From 863432acfcee626fd756880b92eefdeaffbd05b3 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 03:35:00 -0300 Subject: [PATCH 09/19] db: add migration runner infrastructure --- .../backend/src/migration/migration.module.ts | 15 ++ .../src/migration/migration.service.ts | 184 ++++++++++++++++++ packages/database/src/index.ts | 2 + packages/database/src/migration/index.ts | 2 + packages/database/src/migration/runner.ts | 63 ++++++ packages/database/src/migration/types.ts | 21 ++ .../database/tests/migration/runner.spec.ts | 72 +++++++ 7 files changed, 359 insertions(+) create mode 100644 apps/backend/src/migration/migration.module.ts create mode 100644 apps/backend/src/migration/migration.service.ts create mode 100644 packages/database/src/migration/index.ts create mode 100644 packages/database/src/migration/runner.ts create mode 100644 packages/database/src/migration/types.ts create mode 100644 packages/database/tests/migration/runner.spec.ts diff --git a/apps/backend/src/migration/migration.module.ts b/apps/backend/src/migration/migration.module.ts new file mode 100644 index 00000000..14148f1b --- /dev/null +++ b/apps/backend/src/migration/migration.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; + +import { Song, SongSchema } from '@nbw/database'; + +import { MigrationService } from './migration.service'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Song.name, schema: SongSchema }]), + ], + providers: [MigrationService], + exports: [MigrationService], +}) +export class MigrationModule {} diff --git a/apps/backend/src/migration/migration.service.ts b/apps/backend/src/migration/migration.service.ts new file mode 100644 index 00000000..9f82cc5d --- /dev/null +++ b/apps/backend/src/migration/migration.service.ts @@ -0,0 +1,184 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; + +import { + Song, + SONG_MIGRATION_REGISTRY, + SONG_PENDING_QUERY_CLAUSES, + buildPendingQuery, + isFullyMigrated, + migrateDocument, + type MigrationRegistry, +} from '@nbw/database'; + +export interface MigrationBatchResult { + collection: string; + matched: number; + migrated: number; + errors: string[]; +} + +export interface MigrationStatus { + collection: string; + currentVersion: number; + pendingCount: number; +} + +const DEFAULT_BATCH_SIZE = 500; + +@Injectable() +export class MigrationService { + private readonly logger = new Logger(MigrationService.name); + + private readonly registries = new Map>([ + [SONG_MIGRATION_REGISTRY.collection, SONG_MIGRATION_REGISTRY], + ]); + + constructor( + @InjectModel(Song.name) + private readonly songModel: Model, + ) {} + + async getMigrationStatus(collection: string): Promise { + const registry = this.getRegistry(collection); + const model = this.getModel(collection); + const pendingQuery = this.buildRegistryPendingQuery(collection); + + const pendingCount = await model.countDocuments(pendingQuery); + + return { + collection, + currentVersion: registry.currentVersion, + pendingCount, + }; + } + + async runAllPendingMigrations(options?: { + batchSize?: number; + }): Promise { + const results: MigrationBatchResult[] = []; + + for (const collection of this.registries.keys()) { + results.push(await this.runBulkMigration(collection, options)); + } + + return results; + } + + async runBulkMigration( + collection: string, + options?: { batchSize?: number }, + ): Promise { + const registry = this.getRegistry(collection); + const model = this.getModel(collection); + const batchSize = options?.batchSize ?? DEFAULT_BATCH_SIZE; + const pendingQuery = this.buildRegistryPendingQuery(collection); + + const matched = await model.countDocuments(pendingQuery); + + if (matched === 0) { + this.logger.log( + `No pending migrations for ${collection} (current version ${registry.currentVersion})`, + ); + + return { + collection, + matched: 0, + migrated: 0, + errors: [], + }; + } + + this.logger.log( + `Migrating ${matched} ${collection} document(s) to schema version ${registry.currentVersion}`, + ); + + let migrated = 0; + const errors: string[] = []; + let lastId: unknown; + + while (true) { + const query = lastId + ? { ...pendingQuery, _id: { $gt: lastId } } + : pendingQuery; + + const batch = await model + .find(query) + .sort({ _id: 1 }) + .limit(batchSize) + .exec(); + + if (batch.length === 0) { + break; + } + + for (const doc of batch) { + try { + if (isFullyMigrated(registry, doc)) { + lastId = doc._id; + continue; + } + + const updated = migrateDocument(registry, doc.toObject()); + + doc.set(updated); + await doc.save(); + migrated++; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + errors.push(`${doc._id}: ${message}`); + } + + lastId = doc._id; + } + } + + if (errors.length > 0) { + throw new Error( + `Migration failed for ${collection}: ${errors.join('; ')}`, + ); + } + + this.logger.log( + `Migrated ${migrated}/${matched} ${collection} document(s)`, + ); + + return { + collection, + matched, + migrated, + errors, + }; + } + + private getRegistry(collection: string): MigrationRegistry { + const registry = this.registries.get(collection); + + if (!registry) { + throw new Error(`No migration registry registered for ${collection}`); + } + + return registry; + } + + private getModel(collection: string): Model { + if (collection === SONG_MIGRATION_REGISTRY.collection) { + return this.songModel; + } + + throw new Error(`No model registered for ${collection}`); + } + + private buildRegistryPendingQuery(collection: string) { + if (collection === SONG_MIGRATION_REGISTRY.collection) { + return buildPendingQuery( + SONG_MIGRATION_REGISTRY, + SONG_PENDING_QUERY_CLAUSES, + ); + } + + return buildPendingQuery(this.getRegistry(collection)); + } +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index a9e1cc0a..072c43f0 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -15,6 +15,8 @@ export * from './song/dto/UploadSongResponseDto.dto'; export * from './song/dto/types'; export * from './song/entity/song.entity'; +export * from './migration'; + export * from './user/dto/CreateUser.dto'; export * from './user/dto/GetUser.dto'; export * from './user/dto/Login.dto copy'; diff --git a/packages/database/src/migration/index.ts b/packages/database/src/migration/index.ts new file mode 100644 index 00000000..27e9b344 --- /dev/null +++ b/packages/database/src/migration/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './runner'; diff --git a/packages/database/src/migration/runner.ts b/packages/database/src/migration/runner.ts new file mode 100644 index 00000000..c9303275 --- /dev/null +++ b/packages/database/src/migration/runner.ts @@ -0,0 +1,63 @@ +import type { + DocumentMigration, + MigrationRegistry, + PendingQueryClause, + SchemaVersionedDocument, +} from './types'; + +export function getDocumentSchemaVersion(doc: SchemaVersionedDocument): number { + return doc.schemaVersion ?? 0; +} + +export function getPendingMigrations( + registry: MigrationRegistry, + doc: TDoc, +): DocumentMigration[] { + const schemaVersion = getDocumentSchemaVersion(doc); + + return registry.migrations.filter( + (migration) => + schemaVersion < migration.version || migration.needsMigration(doc), + ); +} + +export function migrateDocument( + registry: MigrationRegistry, + doc: TDoc, +): TDoc { + let result = doc; + const pending = getPendingMigrations(registry, doc); + + for (const migration of pending) { + result = migration.apply(result); + result.schemaVersion = migration.version; + } + + return result; +} + +export function isFullyMigrated( + registry: MigrationRegistry, + doc: TDoc, +): boolean { + if (getDocumentSchemaVersion(doc) < registry.currentVersion) { + return false; + } + + return !registry.migrations.some((migration) => + migration.needsMigration(doc), + ); +} + +export function buildPendingQuery( + registry: MigrationRegistry, + extraClauses: PendingQueryClause[] = [], +): { $or: PendingQueryClause[] } { + const clauses: PendingQueryClause[] = [ + { schemaVersion: { $exists: false } }, + { schemaVersion: { $lt: registry.currentVersion } }, + ...extraClauses, + ]; + + return { $or: clauses }; +} diff --git a/packages/database/src/migration/types.ts b/packages/database/src/migration/types.ts new file mode 100644 index 00000000..badfb495 --- /dev/null +++ b/packages/database/src/migration/types.ts @@ -0,0 +1,21 @@ +export interface SchemaVersionedDocument { + schemaVersion?: number; +} + +export interface DocumentMigration { + version: number; + name: string; + collection: string; + needsMigration(doc: TDoc): boolean; + apply(doc: TDoc): TDoc; +} + +export interface MigrationRegistry { + collection: string; + currentVersion: number; + migrations: DocumentMigration[]; +} + +export interface PendingQueryClause { + [key: string]: unknown; +} diff --git a/packages/database/tests/migration/runner.spec.ts b/packages/database/tests/migration/runner.spec.ts new file mode 100644 index 00000000..d16266b5 --- /dev/null +++ b/packages/database/tests/migration/runner.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'bun:test'; + +import { + CURRENT_SONG_SCHEMA_VERSION, + SONG_MIGRATION_REGISTRY, + SONG_PENDING_QUERY_CLAUSES, +} from '../../src/migration/collections/song.migrations'; +import { + buildPendingQuery, + getPendingMigrations, + isFullyMigrated, + migrateDocument, +} from '../../src/migration/runner'; + +describe('migration runner', () => { + it('returns no pending migrations for a fully migrated song document', () => { + const doc = { + schemaVersion: CURRENT_SONG_SCHEMA_VERSION, + stats: { + nbsVersion: 5, + defaultInstrumentCount: 16, + }, + }; + + expect(getPendingMigrations(SONG_MIGRATION_REGISTRY, doc)).toEqual([]); + expect(isFullyMigrated(SONG_MIGRATION_REGISTRY, doc)).toBe(true); + }); + + it('applies song migration 1 to legacy documents', () => { + const doc = { + schemaVersion: 0, + stats: { + noteCount: 10, + }, + }; + + const migrated = migrateDocument(SONG_MIGRATION_REGISTRY, doc); + + expect(migrated.schemaVersion).toBe(1); + expect(migrated.stats.nbsVersion).toBe(5); + expect(migrated.stats.defaultInstrumentCount).toBe(16); + expect(migrated.stats.noteCount).toBe(10); + }); + + it('stamps schemaVersion when stats fields exist but version is missing', () => { + const doc = { + stats: { + nbsVersion: 5, + defaultInstrumentCount: 16, + }, + }; + + const migrated = migrateDocument(SONG_MIGRATION_REGISTRY, doc); + + expect(migrated.schemaVersion).toBe(1); + expect(migrated.stats.nbsVersion).toBe(5); + expect(migrated.stats.defaultInstrumentCount).toBe(16); + }); + + it('builds a pending query with schema and field guards', () => { + expect( + buildPendingQuery(SONG_MIGRATION_REGISTRY, SONG_PENDING_QUERY_CLAUSES), + ).toEqual({ + $or: [ + { schemaVersion: { $exists: false } }, + { schemaVersion: { $lt: CURRENT_SONG_SCHEMA_VERSION } }, + { 'stats.nbsVersion': { $exists: false } }, + { 'stats.defaultInstrumentCount': { $exists: false } }, + ], + }); + }); +}); From ffa27a4e1a91a2bcf7e212c1230c7bf6ecd58364 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 03:35:26 -0300 Subject: [PATCH 10/19] db: check and run pending migrations on backend startup --- apps/backend/src/app.module.ts | 2 ++ apps/backend/src/main.ts | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 215ce497..f7ce5c8b 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { EmailLoginModule } from './email-login/email-login.module'; import { FileModule } from './file/file.module'; import { ParseTokenPipe } from './lib/parseToken'; import { MailingModule } from './mailing/mailing.module'; +import { MigrationModule } from './migration/migration.module'; import { SeedModule } from './seed/seed.module'; import { SongModule } from './song/song.module'; import { UserModule } from './user/user.module'; @@ -78,6 +79,7 @@ import { UserModule } from './user/user.module'; SeedModule.forRoot(), EmailLoginModule, MailingModule, + MigrationModule, ], controllers: [], providers: [ diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 2fd6a1ed..e3d72e26 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -5,6 +5,7 @@ import * as express from 'express'; import { AppModule } from './app.module'; import { initializeSwagger } from './lib/initializeSwagger'; import { ParseTokenPipe } from './lib/parseToken'; +import { MigrationService } from './migration/migration.service'; const logger: Logger = new Logger('main.ts'); @@ -47,6 +48,10 @@ async function bootstrap() { app.use('/v1', express.static('public')); + const migrationService = app.get(MigrationService); + const migrationResults = await migrationService.runAllPendingMigrations(); + logger.log(`Migrations complete: ${JSON.stringify(migrationResults)}`); + const port = process.env.PORT || '4000'; logger.log('Note Block World API Backend 🎶🌎🌍🌏 '); From 7dddf6590e16ca57d999e20bd774e87edf8f10dd Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 03:37:05 -0300 Subject: [PATCH 11/19] db(song): add `nbsVersion` and `defaultInstrumentCount` fields to song entity --- apps/backend/src/song/song.service.spec.ts | 4 ++ .../migration/collections/song.migrations.ts | 47 +++++++++++++++++++ packages/database/src/migration/index.ts | 1 + packages/database/src/song/dto/SongStats.ts | 8 ++++ packages/song/src/stats.ts | 5 ++ packages/song/src/types.ts | 2 + packages/song/tests/song/index.spec.ts | 2 + packages/song/tests/song/nbsCompat.spec.ts | 4 ++ 8 files changed, 73 insertions(+) create mode 100644 packages/database/src/migration/collections/song.migrations.ts diff --git a/apps/backend/src/song/song.service.spec.ts b/apps/backend/src/song/song.service.spec.ts index edf8dfb6..a6d86916 100644 --- a/apps/backend/src/song/song.service.spec.ts +++ b/apps/backend/src/song/song.service.spec.ts @@ -133,6 +133,8 @@ describe('SongService', () => { vanillaInstrumentCount: 10, customInstrumentCount: 0, firstCustomInstrumentIndex: 0, + nbsVersion: 5, + defaultInstrumentCount: 16, outOfRangeNoteCount: 0, detunedNoteCount: 0, customInstrumentNoteCount: 0, @@ -351,6 +353,8 @@ describe('SongService', () => { vanillaInstrumentCount: 10, customInstrumentCount: 0, firstCustomInstrumentIndex: 0, + nbsVersion: 5, + defaultInstrumentCount: 16, outOfRangeNoteCount: 0, detunedNoteCount: 0, customInstrumentNoteCount: 0, diff --git a/packages/database/src/migration/collections/song.migrations.ts b/packages/database/src/migration/collections/song.migrations.ts new file mode 100644 index 00000000..dccebc2c --- /dev/null +++ b/packages/database/src/migration/collections/song.migrations.ts @@ -0,0 +1,47 @@ +import type { Song } from '../../song/entity/song.entity'; +import type { MigrationRegistry } from '../types'; + +export const CURRENT_SONG_SCHEMA_VERSION = 1; + +type SongMigrationDoc = Pick; + +function songNeedsMigration1(doc: SongMigrationDoc): boolean { + const schemaVersion = doc.schemaVersion ?? 0; + + return ( + schemaVersion < 1 || + doc.stats?.nbsVersion == null || + doc.stats?.defaultInstrumentCount == null + ); +} + +function applySongMigration1(doc: SongMigrationDoc): SongMigrationDoc { + return { + ...doc, + schemaVersion: 1, + stats: { + ...doc.stats, + nbsVersion: doc.stats?.nbsVersion ?? 5, + defaultInstrumentCount: doc.stats?.defaultInstrumentCount ?? 16, + }, + }; +} + +export const SONG_MIGRATION_REGISTRY: MigrationRegistry = { + collection: 'songs', + currentVersion: CURRENT_SONG_SCHEMA_VERSION, + migrations: [ + { + version: 1, + name: 'add-nbs-version-stats', + collection: 'songs', + needsMigration: songNeedsMigration1, + apply: applySongMigration1, + }, + ], +}; + +export const SONG_PENDING_QUERY_CLAUSES = [ + { 'stats.nbsVersion': { $exists: false } }, + { 'stats.defaultInstrumentCount': { $exists: false } }, +]; diff --git a/packages/database/src/migration/index.ts b/packages/database/src/migration/index.ts index 27e9b344..feaedcce 100644 --- a/packages/database/src/migration/index.ts +++ b/packages/database/src/migration/index.ts @@ -1,2 +1,3 @@ export * from './types'; export * from './runner'; +export * from './collections/song.migrations'; diff --git a/packages/database/src/song/dto/SongStats.ts b/packages/database/src/song/dto/SongStats.ts index 49cb712b..aa61638b 100644 --- a/packages/database/src/song/dto/SongStats.ts +++ b/packages/database/src/song/dto/SongStats.ts @@ -1,5 +1,6 @@ import { IsBoolean, + IsIn, IsInt, IsNumber, IsString, @@ -50,6 +51,13 @@ export class SongStats { @IsInt() firstCustomInstrumentIndex: number; + @IsInt() + @IsIn([5, 6]) + nbsVersion: number; + + @IsInt() + defaultInstrumentCount: number; + @IsInt() outOfRangeNoteCount: number; diff --git a/packages/song/src/stats.ts b/packages/song/src/stats.ts index 9afcd23d..a279f8c9 100644 --- a/packages/song/src/stats.ts +++ b/packages/song/src/stats.ts @@ -1,5 +1,6 @@ import { Song } from '@encode42/nbs.js'; +import { getNbsFormatVersion } from './nbsCompat'; import type { SongStatsType } from './types'; import { getTempoChangerInstrumentIds } from './util'; @@ -49,6 +50,8 @@ export class SongStatsGenerator { ); const firstCustomInstrumentIndex = this.getFirstCustomInstrumentIndex(); + const nbsVersion = getNbsFormatVersion(this.song); + const defaultInstrumentCount = this.song.instruments.firstCustomIndex; const compatible = incompatibleNoteCount === 0; @@ -67,6 +70,8 @@ export class SongStatsGenerator { vanillaInstrumentCount, customInstrumentCount, firstCustomInstrumentIndex, + nbsVersion, + defaultInstrumentCount, instrumentNoteCounts, customInstrumentNoteCount, outOfRangeNoteCount, diff --git a/packages/song/src/types.ts b/packages/song/src/types.ts index 2f0dfaf4..4fd85647 100644 --- a/packages/song/src/types.ts +++ b/packages/song/src/types.ts @@ -46,6 +46,8 @@ export type SongStatsType = { vanillaInstrumentCount: number; customInstrumentCount: number; firstCustomInstrumentIndex: number; + nbsVersion: number; + defaultInstrumentCount: number; outOfRangeNoteCount: number; detunedNoteCount: number; customInstrumentNoteCount: number; diff --git a/packages/song/tests/song/index.spec.ts b/packages/song/tests/song/index.spec.ts index 08b8d48c..61abc419 100644 --- a/packages/song/tests/song/index.spec.ts +++ b/packages/song/tests/song/index.spec.ts @@ -52,6 +52,8 @@ describe('SongStatsGenerator', () => { assert(stats.vanillaInstrumentCount === 5); assert(stats.customInstrumentCount === 0); assert(stats.firstCustomInstrumentIndex === 16); + assert(stats.nbsVersion === 5); + assert(stats.defaultInstrumentCount === 16); assert(stats.customInstrumentNoteCount === 0); assert(stats.outOfRangeNoteCount === 0); assert(stats.detunedNoteCount === 0); diff --git a/packages/song/tests/song/nbsCompat.spec.ts b/packages/song/tests/song/nbsCompat.spec.ts index 7f3a1823..e1ec0191 100644 --- a/packages/song/tests/song/nbsCompat.spec.ts +++ b/packages/song/tests/song/nbsCompat.spec.ts @@ -60,6 +60,8 @@ describe('nbsCompat', () => { assert(stats.instrumentNoteCounts.length >= 16); assert.strictEqual(stats.firstCustomInstrumentIndex, 16); + assert.strictEqual(stats.nbsVersion, 5); + assert.strictEqual(stats.defaultInstrumentCount, 16); }); it('normalizes v6 built-in instruments at indices 16–19', () => { @@ -80,6 +82,8 @@ describe('nbsCompat', () => { assert(stats.instrumentNoteCounts.length >= 20); assert.strictEqual(stats.firstCustomInstrumentIndex, 20); + assert.strictEqual(stats.nbsVersion, 6); + assert.strictEqual(stats.defaultInstrumentCount, 20); assert.strictEqual(stats.instrumentNoteCounts[16], 1); assert.strictEqual(stats.instrumentNoteCounts[19], 1); assert.strictEqual(stats.instrumentNoteCounts[20], 1); From f4d89776ac9a16f5fff5a7b3478197977d99d4a0 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 03:37:36 -0300 Subject: [PATCH 12/19] db(song): add `schemaVersion` field to song entity --- apps/backend/src/song/song-upload/song-upload.service.ts | 2 ++ packages/database/src/song/entity/song.entity.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/apps/backend/src/song/song-upload/song-upload.service.ts b/apps/backend/src/song/song-upload/song-upload.service.ts index 108034a2..8e7b3ce4 100644 --- a/apps/backend/src/song/song-upload/song-upload.service.ts +++ b/apps/backend/src/song/song-upload/song-upload.service.ts @@ -9,6 +9,7 @@ import { import { Types } from 'mongoose'; import { + CURRENT_SONG_SCHEMA_VERSION, SongDocument, Song as SongEntity, SongStats, @@ -111,6 +112,7 @@ export class SongUploadService { file: Express.Multer.File, ): Promise { const song = new SongEntity(); + song.schemaVersion = CURRENT_SONG_SCHEMA_VERSION; song.uploader = await this.validateUploader(user); song.publicId = publicId; song.title = removeExtraSpaces(body.title); diff --git a/packages/database/src/song/entity/song.entity.ts b/packages/database/src/song/entity/song.entity.ts index 29d7bd41..1bbc4935 100644 --- a/packages/database/src/song/entity/song.entity.ts +++ b/packages/database/src/song/entity/song.entity.ts @@ -17,6 +17,9 @@ import type { CategoryType, LicenseType, VisibilityType } from '../dto/types'; }, }) export class Song { + @Prop({ type: Number, required: true, default: 0 }) + schemaVersion: number; + @Prop({ type: String, required: true, unique: true }) publicId: string; From ea015535a7bdb7c54695d336cc47ab570baf142c Mon Sep 17 00:00:00 2001 From: Bernardo Costa <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:38:38 -0300 Subject: [PATCH 13/19] db: apply migrations in order and never decrease `schemaVersion` Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../migration/collections/song.migrations.ts | 1 - packages/database/src/migration/runner.ts | 19 +++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/database/src/migration/collections/song.migrations.ts b/packages/database/src/migration/collections/song.migrations.ts index dccebc2c..70f9e8cc 100644 --- a/packages/database/src/migration/collections/song.migrations.ts +++ b/packages/database/src/migration/collections/song.migrations.ts @@ -18,7 +18,6 @@ function songNeedsMigration1(doc: SongMigrationDoc): boolean { function applySongMigration1(doc: SongMigrationDoc): SongMigrationDoc { return { ...doc, - schemaVersion: 1, stats: { ...doc.stats, nbsVersion: doc.stats?.nbsVersion ?? 5, diff --git a/packages/database/src/migration/runner.ts b/packages/database/src/migration/runner.ts index c9303275..6ad2b1f8 100644 --- a/packages/database/src/migration/runner.ts +++ b/packages/database/src/migration/runner.ts @@ -26,11 +26,22 @@ export function migrateDocument( doc: TDoc, ): TDoc { let result = doc; - const pending = getPendingMigrations(registry, doc); + const originalSchemaVersion = getDocumentSchemaVersion(doc); - for (const migration of pending) { - result = migration.apply(result); - result.schemaVersion = migration.version; + for (const migration of registry.migrations) { + if ( + originalSchemaVersion < migration.version || + migration.needsMigration(result) + ) { + result = migration.apply(result); + + // Never downgrade schemaVersion if an older migration is applied via needsMigration(). + result.schemaVersion = Math.max( + getDocumentSchemaVersion(result), + originalSchemaVersion, + migration.version, + ); + } } return result; From ba4e80416ccfecddac31a88782407d69c47e03d0 Mon Sep 17 00:00:00 2001 From: Bernardo Costa <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:42:37 -0300 Subject: [PATCH 14/19] fix(song): constrain `defaultInstrumentCount` to 16 or 20 We currently implicitly expect all songs on the website to have been generated with Note Block Studio, which will save songs with either 16 or 20 default instruments since the first NBS format upgrade. If a custom-generated song is uploaded that contains fewer instruments, the song normalization should coerce it to have one of these instrument counts. The database allowed any integer to be in this field, though, which is fine. However, in the case this coercion process fails, we would like an extra safeguard so it fails 'early' before the suspicious document is persisted to the database. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/database/src/song/dto/SongStats.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/database/src/song/dto/SongStats.ts b/packages/database/src/song/dto/SongStats.ts index aa61638b..c410d8ef 100644 --- a/packages/database/src/song/dto/SongStats.ts +++ b/packages/database/src/song/dto/SongStats.ts @@ -56,8 +56,8 @@ export class SongStats { nbsVersion: number; @IsInt() + @IsIn([16, 20]) defaultInstrumentCount: number; - @IsInt() outOfRangeNoteCount: number; From 5bcc8756fdbeb55ac02691d13d418f48520225bb Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:26:49 -0300 Subject: [PATCH 15/19] style: blank line --- packages/database/src/song/dto/SongStats.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/database/src/song/dto/SongStats.ts b/packages/database/src/song/dto/SongStats.ts index c410d8ef..6826d675 100644 --- a/packages/database/src/song/dto/SongStats.ts +++ b/packages/database/src/song/dto/SongStats.ts @@ -58,6 +58,7 @@ export class SongStats { @IsInt() @IsIn([16, 20]) defaultInstrumentCount: number; + @IsInt() outOfRangeNoteCount: number; From 16a0af8fd48197a2941930f2eb44da6fd16bf8f2 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:14:44 -0300 Subject: [PATCH 16/19] chore: add dynamic `robots.txt` generation Prevents web crawling on non-production deployments. --- apps/frontend/src/app/robots.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/frontend/src/app/robots.ts diff --git a/apps/frontend/src/app/robots.ts b/apps/frontend/src/app/robots.ts new file mode 100644 index 00000000..ceb96f91 --- /dev/null +++ b/apps/frontend/src/app/robots.ts @@ -0,0 +1,25 @@ +import { MetadataRoute } from 'next'; + +export default function robots(): MetadataRoute.Robots { + // Check if the current deployment is Production + const isProd = process.env.NODE_ENV === 'production'; + + if (isProd) { + return { + rules: { + userAgent: '*', + allow: '/', + disallow: ['/api/', '/admin/'], // Block search engines from sensitive paths + }, + sitemap: 'https://noteblock.world', + }; + } + + // Block ALL crawling on Preview, Staging, and Local environments + return { + rules: { + userAgent: '*', + disallow: '/', + }, + }; +} From 51beed7cabcc34f8e59ccd20d0e4af75d8ed11c4 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:08:45 -0300 Subject: [PATCH 17/19] fix(event): postpone Summit '26 deadline to July 23, 2026 (midnight AOE) --- apps/frontend/posts/blog/2026-05-19_smithed-summit-2026.md | 6 +++++- apps/frontend/src/modules/browse/EventBanner.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/frontend/posts/blog/2026-05-19_smithed-summit-2026.md b/apps/frontend/posts/blog/2026-05-19_smithed-summit-2026.md index 1d5e3758..a98071df 100644 --- a/apps/frontend/posts/blog/2026-05-19_smithed-summit-2026.md +++ b/apps/frontend/posts/blog/2026-05-19_smithed-summit-2026.md @@ -7,6 +7,10 @@ image: '/img/blog/summit26/banner.png' tags: ['events'] --- +**Update (2026-06-17):** We've just added support for songs that use the four new [**trumpet**](https://minecraft.wiki/w/Java_Edition_26.1#Blocks_2) instruments added to Minecraft 26.1! To be fair to everyone, the submission deadline has been extended to **June 23rd**. + +--- + It's time for our third major community event! The in-game Minecraft data pack convention, [**Smithed Summit 2026**](https://smithed.net/summit), is coming this summer! We'll be holding a **booth** and a **live performance** at the event, and, just like last time, we're opening submissions for note block songs to be played on the server, around the convention grounds, to all attendees. Our goal is to include as many submissions as possible. No matter your skill level or experience creating note block music, everybody is welcome! Use this jam as an opportunity to **try new things**, and as motivation to experiment with interesting techniques. We encourage everybody to embrace their unique creativity to create original note block compositions or stylized covers. After all, we've got three great "prompts" to pique your imagination this time around. @@ -41,7 +45,7 @@ For a more detailed look into each region, check the [Summit announcement video] ## 🕛 Deadline -Submissions will be cut off on **June 16th, 2026**. You must submit your songs before then, but we can manually accept submissions up to **July 1st, 2026** on a case-by-case basis. +Submissions will be cut off on ~~June 16th~~ **June 23th, 2026**. You must submit your songs before then, but we can manually accept submissions up to **July 1st, 2026** on a case-by-case basis. ## ✅ What's allowed? diff --git a/apps/frontend/src/modules/browse/EventBanner.tsx b/apps/frontend/src/modules/browse/EventBanner.tsx index 0daceed3..f57b339b 100644 --- a/apps/frontend/src/modules/browse/EventBanner.tsx +++ b/apps/frontend/src/modules/browse/EventBanner.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; export const EventBanner = () => { - const targetDate = Date.UTC(2026, 5, 16, 17, 0, 0); // June is 5 (0-indexed) + const targetDate = Date.UTC(2026, 5, 24, 12, 0, 0); // June is 5 (0-indexed) const [timeLeft, setTimeLeft] = useState(() => Math.max(0, targetDate - Date.now()), From 6855f0a61b454c4b334045e7dbcdb4fa12bdb4a7 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:09:37 -0300 Subject: [PATCH 18/19] fix(blog): add remark-gfm to render strikethrough in blog posts --- apps/frontend/package.json | 1 + .../shared/components/CustomMarkdown.tsx | 8 +++++ bun.lock | 35 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 82a61e0d..3b6a39e3 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -57,6 +57,7 @@ "react-loading-skeleton": "^3.5.0", "react-markdown": "^10.1.0", "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", "remove-markdown": "^0.6.3", "schema-dts": "^1.1.5", "sharp": "^0.34.5", diff --git a/apps/frontend/src/modules/shared/components/CustomMarkdown.tsx b/apps/frontend/src/modules/shared/components/CustomMarkdown.tsx index 9b156819..684d35fe 100644 --- a/apps/frontend/src/modules/shared/components/CustomMarkdown.tsx +++ b/apps/frontend/src/modules/shared/components/CustomMarkdown.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import type { JSX } from 'react'; import Markdown, { ExtraProps } from 'react-markdown'; import rehypeRaw from 'rehype-raw'; +import remarkGfm from 'remark-gfm'; const YOUTUBE_EMBED_PREFIX = 'https://www.youtube.com/embed/'; @@ -12,6 +13,7 @@ export const CustomMarkdown = ({ }) => { return ( {MarkdownContent} @@ -167,6 +170,10 @@ const iframe = ({ ); }; +const del = ({ node, ...props }: JSX.IntrinsicElements['del'] & ExtraProps) => { + return ; +}; + export { p, h1, @@ -185,4 +192,5 @@ export { a, img, iframe, + del, }; diff --git a/bun.lock b/bun.lock index 48458e6d..c914e8a9 100644 --- a/bun.lock +++ b/bun.lock @@ -154,6 +154,7 @@ "react-loading-skeleton": "^3.5.0", "react-markdown": "^10.1.0", "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", "remove-markdown": "^0.6.3", "schema-dts": "^1.1.5", "sharp": "^0.34.5", @@ -2278,10 +2279,26 @@ "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], @@ -2318,6 +2335,20 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], @@ -2778,6 +2809,8 @@ "relateurl": ["relateurl@0.2.7", "", {}, "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog=="], + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -3596,6 +3629,8 @@ "make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "mjml-cli/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], From d14bec3770801ddc3bdccf28f4460fc96df3f927 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:34:46 -0300 Subject: [PATCH 19/19] refactor(song): remove `defaultCustomInstrument` field from song stats The field `firstCustomInstrumentIndex` already exists which serves the same purpose. --- apps/backend/src/song/song.service.spec.ts | 19 ++++++++----------- .../migration/collections/song.migrations.ts | 8 +------- packages/database/src/song/dto/SongStats.ts | 5 +---- .../database/tests/migration/runner.spec.ts | 7 ++----- packages/song/src/stats.ts | 2 -- packages/song/src/types.ts | 1 - packages/song/tests/song/index.spec.ts | 1 - packages/song/tests/song/nbsCompat.spec.ts | 2 -- 8 files changed, 12 insertions(+), 33 deletions(-) diff --git a/apps/backend/src/song/song.service.spec.ts b/apps/backend/src/song/song.service.spec.ts index a6d86916..8bcc2851 100644 --- a/apps/backend/src/song/song.service.spec.ts +++ b/apps/backend/src/song/song.service.spec.ts @@ -1,20 +1,19 @@ -import type { UserDocument } from '@nbw/database'; +import { HttpException } from '@nestjs/common'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import mongoose, { Model } from 'mongoose'; + import { SongDocument, Song as SongEntity, SongPreviewDto, - SongSchema, SongStats, SongViewDto, SongWithUser, UploadSongDto, UploadSongResponseDto, } from '@nbw/database'; -import { HttpException } from '@nestjs/common'; -import { getModelToken } from '@nestjs/mongoose'; -import { Test, TestingModule } from '@nestjs/testing'; -import mongoose, { Model } from 'mongoose'; - +import type { UserDocument } from '@nbw/database'; import { FileService } from '@server/file/file.service'; import { SongUploadService } from './song-upload/song-upload.service'; @@ -132,9 +131,8 @@ describe('SongService', () => { minutesSpent: 10, vanillaInstrumentCount: 10, customInstrumentCount: 0, - firstCustomInstrumentIndex: 0, + firstCustomInstrumentIndex: 16, nbsVersion: 5, - defaultInstrumentCount: 16, outOfRangeNoteCount: 0, detunedNoteCount: 0, customInstrumentNoteCount: 0, @@ -352,9 +350,8 @@ describe('SongService', () => { minutesSpent: 10, vanillaInstrumentCount: 10, customInstrumentCount: 0, - firstCustomInstrumentIndex: 0, + firstCustomInstrumentIndex: 16, nbsVersion: 5, - defaultInstrumentCount: 16, outOfRangeNoteCount: 0, detunedNoteCount: 0, customInstrumentNoteCount: 0, diff --git a/packages/database/src/migration/collections/song.migrations.ts b/packages/database/src/migration/collections/song.migrations.ts index 70f9e8cc..3c2499de 100644 --- a/packages/database/src/migration/collections/song.migrations.ts +++ b/packages/database/src/migration/collections/song.migrations.ts @@ -8,11 +8,7 @@ type SongMigrationDoc = Pick; function songNeedsMigration1(doc: SongMigrationDoc): boolean { const schemaVersion = doc.schemaVersion ?? 0; - return ( - schemaVersion < 1 || - doc.stats?.nbsVersion == null || - doc.stats?.defaultInstrumentCount == null - ); + return schemaVersion < 1 || doc.stats?.nbsVersion == null; } function applySongMigration1(doc: SongMigrationDoc): SongMigrationDoc { @@ -21,7 +17,6 @@ function applySongMigration1(doc: SongMigrationDoc): SongMigrationDoc { stats: { ...doc.stats, nbsVersion: doc.stats?.nbsVersion ?? 5, - defaultInstrumentCount: doc.stats?.defaultInstrumentCount ?? 16, }, }; } @@ -42,5 +37,4 @@ export const SONG_MIGRATION_REGISTRY: MigrationRegistry = { export const SONG_PENDING_QUERY_CLAUSES = [ { 'stats.nbsVersion': { $exists: false } }, - { 'stats.defaultInstrumentCount': { $exists: false } }, ]; diff --git a/packages/database/src/song/dto/SongStats.ts b/packages/database/src/song/dto/SongStats.ts index 6826d675..6c1cce69 100644 --- a/packages/database/src/song/dto/SongStats.ts +++ b/packages/database/src/song/dto/SongStats.ts @@ -49,16 +49,13 @@ export class SongStats { customInstrumentCount: number; @IsInt() + @IsIn([16, 20]) firstCustomInstrumentIndex: number; @IsInt() @IsIn([5, 6]) nbsVersion: number; - @IsInt() - @IsIn([16, 20]) - defaultInstrumentCount: number; - @IsInt() outOfRangeNoteCount: number; diff --git a/packages/database/tests/migration/runner.spec.ts b/packages/database/tests/migration/runner.spec.ts index d16266b5..83fdfc58 100644 --- a/packages/database/tests/migration/runner.spec.ts +++ b/packages/database/tests/migration/runner.spec.ts @@ -18,7 +18,7 @@ describe('migration runner', () => { schemaVersion: CURRENT_SONG_SCHEMA_VERSION, stats: { nbsVersion: 5, - defaultInstrumentCount: 16, + firstCustomInstrumentIndex: 16, }, }; @@ -38,7 +38,6 @@ describe('migration runner', () => { expect(migrated.schemaVersion).toBe(1); expect(migrated.stats.nbsVersion).toBe(5); - expect(migrated.stats.defaultInstrumentCount).toBe(16); expect(migrated.stats.noteCount).toBe(10); }); @@ -46,7 +45,7 @@ describe('migration runner', () => { const doc = { stats: { nbsVersion: 5, - defaultInstrumentCount: 16, + firstCustomInstrumentIndex: 16, }, }; @@ -54,7 +53,6 @@ describe('migration runner', () => { expect(migrated.schemaVersion).toBe(1); expect(migrated.stats.nbsVersion).toBe(5); - expect(migrated.stats.defaultInstrumentCount).toBe(16); }); it('builds a pending query with schema and field guards', () => { @@ -65,7 +63,6 @@ describe('migration runner', () => { { schemaVersion: { $exists: false } }, { schemaVersion: { $lt: CURRENT_SONG_SCHEMA_VERSION } }, { 'stats.nbsVersion': { $exists: false } }, - { 'stats.defaultInstrumentCount': { $exists: false } }, ], }); }); diff --git a/packages/song/src/stats.ts b/packages/song/src/stats.ts index a279f8c9..9c20b1f6 100644 --- a/packages/song/src/stats.ts +++ b/packages/song/src/stats.ts @@ -51,7 +51,6 @@ export class SongStatsGenerator { const firstCustomInstrumentIndex = this.getFirstCustomInstrumentIndex(); const nbsVersion = getNbsFormatVersion(this.song); - const defaultInstrumentCount = this.song.instruments.firstCustomIndex; const compatible = incompatibleNoteCount === 0; @@ -71,7 +70,6 @@ export class SongStatsGenerator { customInstrumentCount, firstCustomInstrumentIndex, nbsVersion, - defaultInstrumentCount, instrumentNoteCounts, customInstrumentNoteCount, outOfRangeNoteCount, diff --git a/packages/song/src/types.ts b/packages/song/src/types.ts index 4fd85647..47a2becc 100644 --- a/packages/song/src/types.ts +++ b/packages/song/src/types.ts @@ -47,7 +47,6 @@ export type SongStatsType = { customInstrumentCount: number; firstCustomInstrumentIndex: number; nbsVersion: number; - defaultInstrumentCount: number; outOfRangeNoteCount: number; detunedNoteCount: number; customInstrumentNoteCount: number; diff --git a/packages/song/tests/song/index.spec.ts b/packages/song/tests/song/index.spec.ts index 61abc419..c7bf233f 100644 --- a/packages/song/tests/song/index.spec.ts +++ b/packages/song/tests/song/index.spec.ts @@ -53,7 +53,6 @@ describe('SongStatsGenerator', () => { assert(stats.customInstrumentCount === 0); assert(stats.firstCustomInstrumentIndex === 16); assert(stats.nbsVersion === 5); - assert(stats.defaultInstrumentCount === 16); assert(stats.customInstrumentNoteCount === 0); assert(stats.outOfRangeNoteCount === 0); assert(stats.detunedNoteCount === 0); diff --git a/packages/song/tests/song/nbsCompat.spec.ts b/packages/song/tests/song/nbsCompat.spec.ts index e1ec0191..9ecc0c54 100644 --- a/packages/song/tests/song/nbsCompat.spec.ts +++ b/packages/song/tests/song/nbsCompat.spec.ts @@ -61,7 +61,6 @@ describe('nbsCompat', () => { assert(stats.instrumentNoteCounts.length >= 16); assert.strictEqual(stats.firstCustomInstrumentIndex, 16); assert.strictEqual(stats.nbsVersion, 5); - assert.strictEqual(stats.defaultInstrumentCount, 16); }); it('normalizes v6 built-in instruments at indices 16–19', () => { @@ -83,7 +82,6 @@ describe('nbsCompat', () => { assert(stats.instrumentNoteCounts.length >= 20); assert.strictEqual(stats.firstCustomInstrumentIndex, 20); assert.strictEqual(stats.nbsVersion, 6); - assert.strictEqual(stats.defaultInstrumentCount, 20); assert.strictEqual(stats.instrumentNoteCounts[16], 1); assert.strictEqual(stats.instrumentNoteCounts[19], 1); assert.strictEqual(stats.instrumentNoteCounts[20], 1);