Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cf40c64
feat(song): add support for `.nbs` file format version 6 (NBS 3.12+)
Bentroen Jun 14, 2026
87c2d28
test(song): add test suite for NBS compatibility module
Bentroen Jun 14, 2026
91350d0
feat(thumbnail): wrap instrument colors at the song's default inst count
Bentroen Jun 15, 2026
20055cf
feat(thumbnail): add trumpet instrument colors
Bentroen Jun 15, 2026
23407a7
fix(upload): add specific error message for unsupported nbs versions
Bentroen Jun 15, 2026
c7f4d9a
fix(upload): add missing `defaultInstrumentCount` field to backend th…
Bentroen Jun 15, 2026
d5e90f4
test(thumbnail): adjust test to check for 20 color codes
Bentroen Jun 15, 2026
f2d8667
build(song): make nbs.js external to `song` package to avoid version …
Bentroen Jun 15, 2026
5924852
Merge branch 'develop' into feat/nbs-v6
Bentroen Jun 15, 2026
0ac564d
Add NBS v6 upload support (#93)
Bentroen Jun 15, 2026
863432a
db: add migration runner infrastructure
Bentroen Jun 15, 2026
ffa27a4
db: check and run pending migrations on backend startup
Bentroen Jun 15, 2026
7dddf65
db(song): add `nbsVersion` and `defaultInstrumentCount` fields to son…
Bentroen Jun 15, 2026
f4d8977
db(song): add `schemaVersion` field to song entity
Bentroen Jun 15, 2026
ea01553
db: apply migrations in order and never decrease `schemaVersion`
Bentroen Jun 15, 2026
ba4e804
fix(song): constrain `defaultInstrumentCount` to 16 or 20
Bentroen Jun 15, 2026
5bcc875
style: blank line
Bentroen Jun 16, 2026
efd3b04
Add database migration infrastructure + `nbsVersion` field on song st…
Bentroen Jun 16, 2026
16a0af8
chore: add dynamic `robots.txt` generation
Bentroen Jun 16, 2026
ec5128f
Add dynamic `robots.txt` generation (#95)
Bentroen Jun 17, 2026
51beed7
fix(event): postpone Summit '26 deadline to July 23, 2026 (midnight AOE)
Bentroen Jun 17, 2026
6855f0a
fix(blog): add remark-gfm to render strikethrough in blog posts
Bentroen Jun 17, 2026
d14bec3
refactor(song): remove `defaultCustomInstrument` field from song stats
Bentroen Jun 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -78,6 +79,7 @@ import { UserModule } from './user/user.module';
SeedModule.forRoot(),
EmailLoginModule,
MailingModule,
MigrationModule,
],
controllers: [],
providers: [
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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 🎶🌎🌍🌏 ');
Expand Down
15 changes: 15 additions & 0 deletions apps/backend/src/migration/migration.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
184 changes: 184 additions & 0 deletions apps/backend/src/migration/migration.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, MigrationRegistry<object>>([
[SONG_MIGRATION_REGISTRY.collection, SONG_MIGRATION_REGISTRY],
]);

constructor(
@InjectModel(Song.name)
private readonly songModel: Model<Song>,
) {}

async getMigrationStatus(collection: string): Promise<MigrationStatus> {
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<MigrationBatchResult[]> {
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<MigrationBatchResult> {
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<object> {
const registry = this.registries.get(collection);

if (!registry) {
throw new Error(`No migration registry registered for ${collection}`);
}

return registry;
}

private getModel(collection: string): Model<Song> {
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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
26 changes: 24 additions & 2 deletions apps/backend/src/song/song-upload/song-upload.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Song, fromArrayBuffer, toArrayBuffer } from '@encode42/nbs.js';
import { Song, toArrayBuffer } from '@encode42/nbs.js';
import {
HttpException,
HttpStatus,
Expand All @@ -9,6 +9,7 @@ import {
import { Types } from 'mongoose';

import {
CURRENT_SONG_SCHEMA_VERSION,
SongDocument,
Song as SongEntity,
SongStats,
Expand All @@ -19,7 +20,9 @@ import {
import {
NoteQuadTree,
SongStatsGenerator,
UnsupportedNbsVersionError,
injectSongFileMetadata,
loadNbsFromBuffer,
obfuscateAndPackSong,
} from '@nbw/song';
import { drawToImage } from '@nbw/thumbnail/node';
Expand Down Expand Up @@ -109,6 +112,7 @@ export class SongUploadService {
file: Express.Multer.File,
): Promise<SongEntity> {
const song = new SongEntity();
song.schemaVersion = CURRENT_SONG_SCHEMA_VERSION;
song.uploader = await this.validateUploader(user);
song.publicId = publicId;
song.title = removeExtraSpaces(body.title);
Expand Down Expand Up @@ -361,6 +365,7 @@ export class SongUploadService {

const thumbBuffer = await drawToImage({
notes: quadTree,
defaultInstrumentCount: nbsSong.instruments.firstCustomIndex,
startTick: startTick,
startLayer: startLayer,
zoomLevel: zoomLevel,
Expand Down Expand Up @@ -439,7 +444,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) {
Expand Down
19 changes: 10 additions & 9 deletions apps/backend/src/song/song.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -132,7 +131,8 @@ describe('SongService', () => {
minutesSpent: 10,
vanillaInstrumentCount: 10,
customInstrumentCount: 0,
firstCustomInstrumentIndex: 0,
firstCustomInstrumentIndex: 16,
nbsVersion: 5,
outOfRangeNoteCount: 0,
detunedNoteCount: 0,
customInstrumentNoteCount: 0,
Expand Down Expand Up @@ -350,7 +350,8 @@ describe('SongService', () => {
minutesSpent: 10,
vanillaInstrumentCount: 10,
customInstrumentCount: 0,
firstCustomInstrumentIndex: 0,
firstCustomInstrumentIndex: 16,
nbsVersion: 5,
outOfRangeNoteCount: 0,
detunedNoteCount: 0,
customInstrumentNoteCount: 0,
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion apps/frontend/posts/blog/2026-05-19_smithed-summit-2026.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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?

Expand Down
Loading
Loading