Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ae21b6e
feat: implement getFeaturedSongs method in SongService and add corres…
tomast1337 Sep 28, 2025
3b884ca
feat: enhance SongController with advanced song retrieval features
tomast1337 Sep 28, 2025
bc9e322
Merge branch 'develop' of github.com:OpenNBS/NoteBlockWorld into feat…
tomast1337 Oct 5, 2025
9de7c27
refactor: remove SongBrowserModule from app.module.ts
tomast1337 Oct 5, 2025
a9742d8
Merge branch 'develop' into feature/song-search
Bentroen Oct 6, 2025
ca60eee
feat: enhance song retrieval with new search and sorting capabilities
tomast1337 Oct 6, 2025
18a42ac
feat: add SearchButton component to enhance song search functionality
tomast1337 Oct 6, 2025
db6bdec
Merge branch 'feature/song-search' of github.com:OpenNBS/NoteBlockWor…
tomast1337 Oct 6, 2025
a41de9a
Merge branch 'develop' into feature/song-search
Bentroen Oct 7, 2025
b075679
refactor: rename `SearchButton` to `SearchBar`
Bentroen Oct 7, 2025
1180fa3
fix: move search bar outside of popup and detach from button group
Bentroen Oct 7, 2025
29dda31
feat: rework appearance of search bar
Bentroen Oct 7, 2025
6fedc00
feat: remove text logo, reorder NBW icon in navbar
Bentroen Oct 7, 2025
1797c10
fix: disable search bar auto-focus
Bentroen Oct 7, 2025
ce8d89d
fix: don't clear search query when performing search
Bentroen Oct 7, 2025
37dabb0
fix: rename `search-song` route to `search`
Bentroen Oct 7, 2025
4fcf74f
feat: integrate Typesense for enhanced song indexing and search capab…
tomast1337 Oct 12, 2025
9cf07b7
Merge branch 'feature/song-search' of github.com:OpenNBS/NoteBlockWor…
tomast1337 Oct 12, 2025
ca028ec
Revert "feat: integrate Typesense for enhanced song indexing and sear…
tomast1337 Oct 16, 2025
e96f8d5
chore: update dev script to use concurrently for running backend and …
tomast1337 Oct 16, 2025
a5d8d40
chore: add @nbw/config as a workspace dependency in bun.lock and pack…
tomast1337 Oct 16, 2025
713b113
feat: enhance FeaturedSongsProvider to determine initial timespan bas…
tomast1337 Oct 16, 2025
d436c81
feat: enhance song retrieval by adding category filtering for recent …
tomast1337 Oct 16, 2025
d8b0951
chore: add zustand as a dependency in bun.lock and package.json
tomast1337 Oct 18, 2025
dee73e2
feat: implement song search with filters and state management using Z…
tomast1337 Oct 18, 2025
47409c5
feat: refactor search page components for improved structure and read…
tomast1337 Oct 18, 2025
f9aa95e
Merge branch 'develop' of github.com:OpenNBS/NoteBlockWorld into feat…
tomast1337 Dec 8, 2025
8609519
chore(deps): update dependencies and configuration files
tomast1337 Dec 8, 2025
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
276 changes: 148 additions & 128 deletions apps/backend/src/song/song.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Get,
Headers,
HttpStatus,
Logger,
Param,
Patch,
Post,
Expand All @@ -26,8 +27,6 @@
ApiBody,
ApiConsumes,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
Expand All @@ -41,8 +40,11 @@
UploadSongDto,
UploadSongResponseDto,
PageDto,
SongListQueryDTO,
SongSortType,
FeaturedSongsDto,
} from '@nbw/database';
import type { FeaturedSongsDto, UserDocument } from '@nbw/database';
import type { UserDocument } from '@nbw/database';
import { FileService } from '@server/file/file.service';
import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser';

Expand All @@ -51,6 +53,7 @@
@Controller('song')
@ApiTags('song')
export class SongController {
private logger = new Logger(SongController.name);
static multerConfig: MulterOptions = {
limits: { fileSize: UPLOAD_CONSTANTS.file.maxSize },
fileFilter: (req, file, cb) => {
Expand All @@ -67,155 +70,172 @@

@Get('/')
@ApiOperation({
summary: 'Get songs with various filtering and browsing options',
summary: 'Get songs with filtering and sorting options',
description: `
Retrieves songs based on the provided query parameters. Supports multiple modes:

**Default mode** (no 'q' parameter): Returns paginated songs with sorting/filtering

**Special query modes** (using 'q' parameter):
- \`featured\`: Get recent popular songs with pagination
- \`recent\`: Get recently uploaded songs with pagination
- \`categories\`:
- Without 'id': Returns a record of available categories and their song counts
- With 'id': Returns songs from the specified category with pagination
- \`random\`: Returns random songs (requires 'count' parameter, 1-10 songs, optionally filtered by 'category')
Retrieves songs based on the provided query parameters.

**Query Parameters:**
- Standard pagination/sorting via PageQueryDTO (page, limit, sort, order, timespan)
- \`q\`: Special query mode ('featured', 'recent', 'categories', 'random')
- \`id\`: Category ID (used with q=categories to get songs from specific category)
- \`count\`: Number of random songs to return (1-10, used with q=random)
- \`category\`: Category filter for random songs (used with q=random)
- \`q\`: Search string to filter songs by title or description (optional)
- \`sort\`: Sort songs by criteria (recent, random, play-count, title, duration, note-count)
- \`order\`: Sort order (asc, desc) - only applies if sort is not random
- \`category\`: Filter by category - if left empty, returns songs in any category
- \`uploader\`: Filter by uploader username - if provided, will only return songs uploaded by that user
- \`page\`: Page number (default: 1)
- \`limit\`: Number of items to return per page (default: 10)

**Return Types:**
- SongPreviewDto[]: Array of song previews (most cases)
- Record<string, number>: Category name to count mapping (when q=categories without id)
**Return Type:**
- PageDto<SongPreviewDto>: Paginated list of song previews
`,
})
@ApiQuery({
name: 'q',
required: false,
enum: ['featured', 'recent', 'categories', 'random'],
description:
'Special query mode. If not provided, returns standard paginated song list.',
example: 'recent',
})
@ApiParam({
name: 'id',
required: false,
type: 'string',
description:
'Category ID. Only used when q=categories to get songs from a specific category.',
example: 'pop',
})
@ApiQuery({
name: 'count',
required: false,
type: 'string',
description:
'Number of random songs to return (1-10). Only used when q=random.',
example: '5',
})
@ApiQuery({
name: 'category',
required: false,
type: 'string',
description: 'Category filter for random songs. Only used when q=random.',
example: 'electronic',
})
@ApiResponse({
status: 200,
description:
'Success. Returns either an array of song previews or category counts.',
schema: {
oneOf: [
{
type: 'array',
items: { $ref: '#/components/schemas/SongPreviewDto' },
description:
'Array of song previews (default behavior and most query modes)',
},
{
type: 'object',
additionalProperties: { type: 'number' },
description:
'Category name to song count mapping (only when q=categories without id)',
example: { pop: 42, rock: 38, electronic: 15 },
},
],
},
description: 'Success. Returns paginated list of song previews.',
type: PageDto<SongPreviewDto>,
})
@ApiResponse({
status: 400,
description:
'Bad Request. Invalid query parameters (e.g., invalid count for random query).',
description: 'Bad Request. Invalid query parameters.',
})
public async getSongList(
@Query() query: PageQueryDTO,
@Query('q') q?: 'featured' | 'recent' | 'categories' | 'random',
@Param('id') id?: string,
@Query('category') category?: string,
): Promise<
PageDto<SongPreviewDto> | Record<string, number> | FeaturedSongsDto
> {
if (q) {
switch (q) {
case 'featured':
return await this.songService.getFeaturedSongs();
case 'recent':
return new PageDto<SongPreviewDto>({
content: await this.songService.getRecentSongs(
query.page,
query.limit,
),
page: query.page,
limit: query.limit,
total: 0,
});
case 'categories':
if (id) {
return new PageDto<SongPreviewDto>({
content: await this.songService.getSongsByCategory(
category,
query.page,
query.limit,
),
page: query.page,
limit: query.limit,
total: 0,
});
}
return await this.songService.getCategories();
case 'random': {
if (query.limit && (query.limit < 1 || query.limit > 10)) {
throw new BadRequestException('Invalid query parameters');
}
const data = await this.songService.getRandomSongs(
query.limit ?? 1,
category,
);
return new PageDto<SongPreviewDto>({
content: data,
page: query.page,
limit: query.limit,
total: data.length,
});
}
default:
throw new BadRequestException('Invalid query parameters');
@Query() query: SongListQueryDTO,
): Promise<PageDto<SongPreviewDto>> {
// Handle search query
if (query.q) {
const sortFieldMap = new Map([
[SongSortType.RECENT, 'createdAt'],
[SongSortType.PLAY_COUNT, 'playCount'],
[SongSortType.TITLE, 'title'],
[SongSortType.DURATION, 'duration'],
[SongSortType.NOTE_COUNT, 'noteCount'],
]);

const sortField = sortFieldMap.get(query.sort) ?? 'createdAt';

const pageQuery = new PageQueryDTO({
page: query.page,
limit: query.limit,
sort: sortField,
order: query.order === 'desc' ? false : true,
});
const data = await this.songService.searchSongs(pageQuery, query.q);
return new PageDto<SongPreviewDto>({
content: data,
page: query.page,
limit: query.limit,
total: data.length,
});
}

// Handle random sort
if (query.sort === SongSortType.RANDOM) {
if (query.limit && (query.limit < 1 || query.limit > 10)) {
throw new BadRequestException(
'Limit must be between 1 and 10 for random sort',
);
}
const data = await this.songService.getRandomSongs(
query.limit ?? 1,
query.category,
);

return new PageDto<SongPreviewDto>({
content: data,
page: query.page,
limit: query.limit,
total: data.length,
});
}

// Handle recent sort
if (query.sort === SongSortType.RECENT) {
const data = await this.songService.getRecentSongs(
query.page,
query.limit,
);
return new PageDto<SongPreviewDto>({
content: data,
page: query.page,
limit: query.limit,
total: data.length,
});
}

// Handle category filter
if (query.category) {
const data = await this.songService.getSongsByCategory(
query.category,
query.page,
query.limit,
);
return new PageDto<SongPreviewDto>({
Copy link
Member

@Bentroen Bentroen Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this part of the code be extracted outside of the if clause? It's repeated exactly for each query mode :)

EDIT: I mean the return new PageDto({...}) part. For some reason I highlighted completely wrong lines.

content: data,
page: query.page,
limit: query.limit,
total: data.length,
});
}

const data = await this.songService.getSongByPage(query);
// Default: get songs with standard pagination
const sortFieldMap = new Map([
[SongSortType.PLAY_COUNT, 'playCount'],
[SongSortType.TITLE, 'title'],
[SongSortType.DURATION, 'duration'],
[SongSortType.NOTE_COUNT, 'noteCount'],
]);

const sortField = sortFieldMap.get(query.sort) ?? 'createdAt';

const pageQuery = new PageQueryDTO({
page: query.page,
limit: query.limit,
sort: sortField,
order: query.order === 'desc' ? false : true,
});
const data = await this.songService.getSongByPage(pageQuery);
return new PageDto<SongPreviewDto>({
content: data,
page: query.page,
limit: query.limit,
total: data.length,

Check failure on line 199 in apps/backend/src/song/song.controller.ts

View workflow job for this annotation

GitHub Actions / test

TypeError: undefined is not an object (evaluating 'data.length')

at getSongList (/home/runner/work/NoteBlockWorld/NoteBlockWorld/apps/backend/src/song/song.controller.ts:199:14) at async <anonymous> (/home/runner/work/NoteBlockWorld/NoteBlockWorld/apps/backend/src/song/song.controller.spec.ts:146:43)

Check failure on line 199 in apps/backend/src/song/song.controller.ts

View workflow job for this annotation

GitHub Actions / test

TypeError: undefined is not an object (evaluating 'data.length')

at getSongList (/home/runner/work/NoteBlockWorld/NoteBlockWorld/apps/backend/src/song/song.controller.ts:199:14) at async <anonymous> (/home/runner/work/NoteBlockWorld/NoteBlockWorld/apps/backend/src/song/song.controller.spec.ts:124:43)

Check failure on line 199 in apps/backend/src/song/song.controller.ts

View workflow job for this annotation

GitHub Actions / test

TypeError: undefined is not an object (evaluating 'data.length')

at getSongList (/home/runner/work/NoteBlockWorld/NoteBlockWorld/apps/backend/src/song/song.controller.ts:199:14) at async <anonymous> (/home/runner/work/NoteBlockWorld/NoteBlockWorld/apps/backend/src/song/song.controller.spec.ts:110:43)

Check failure on line 199 in apps/backend/src/song/song.controller.ts

View workflow job for this annotation

GitHub Actions / test

TypeError: undefined is not an object (evaluating 'data.length')

at getSongList (/home/runner/work/NoteBlockWorld/NoteBlockWorld/apps/backend/src/song/song.controller.ts:199:14) at async <anonymous> (/home/runner/work/NoteBlockWorld/NoteBlockWorld/apps/backend/src/song/song.controller.spec.ts:100:43)

Check failure on line 199 in apps/backend/src/song/song.controller.ts

View workflow job for this annotation

GitHub Actions / test

TypeError: undefined is not an object (evaluating 'data.length')

at getSongList (/home/runner/work/NoteBlockWorld/NoteBlockWorld/apps/backend/src/song/song.controller.ts:199:14) at async <anonymous> (/home/runner/work/NoteBlockWorld/NoteBlockWorld/apps/backend/src/song/song.controller.spec.ts:91:43)

Check failure on line 199 in apps/backend/src/song/song.controller.ts

View workflow job for this annotation

GitHub Actions / test

TypeError: undefined is not an object (evaluating 'data.length')

at getSongList (/home/runner/work/NoteBlockWorld/NoteBlockWorld/apps/backend/src/song/song.controller.ts:199:14) at async <anonymous> (/home/runner/work/NoteBlockWorld/NoteBlockWorld/apps/backend/src/song/song.controller.spec.ts:82:43)
});
}

@Get('/featured')
@ApiOperation({
summary: 'Get featured songs',
description: `
Returns featured songs with specific logic for showcasing popular/recent content.
This endpoint has very specific business logic and is separate from the general song listing.
`,
})
@ApiResponse({
status: 200,
description: 'Success. Returns featured songs data.',
type: FeaturedSongsDto,
})
public async getFeaturedSongs(): Promise<FeaturedSongsDto> {
return await this.songService.getFeaturedSongs();
}

@Get('/categories')
@ApiOperation({
summary: 'Get available categories with song counts',
description:
'Returns a record of available categories and their song counts.',
})
@ApiResponse({
status: 200,
description: 'Success. Returns category name to count mapping.',
schema: {
type: 'object',
additionalProperties: { type: 'number' },
example: { pop: 42, rock: 38, electronic: 15 },
},
})
public async getCategories(): Promise<Record<string, number>> {
return await this.songService.getCategories();
}

@Get('/search')
@ApiOperation({
summary: 'Search songs by keywords with pagination and sorting',
Expand Down
16 changes: 11 additions & 5 deletions apps/backend/src/song/song.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,15 +494,21 @@ export class SongService {

public async getRandomSongs(
count: number,
category: string,
category?: string,
): Promise<SongPreviewDto[]> {
const matchStage: Record<string, string> = {
visibility: 'public',
};

// Only add category filter if category is provided and not empty
if (category && category.trim() !== '') {
matchStage.category = category;
}

const songs = (await this.songModel
.aggregate([
{
$match: {
visibility: 'public',
category: category,
},
$match: matchStage,
},
{
$sample: {
Expand Down
20 changes: 8 additions & 12 deletions apps/frontend/src/app/(content)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,14 @@ import { HomePageComponent } from '@web/modules/browse/components/HomePageCompon

async function fetchRecentSongs() {
try {
const response = await axiosInstance.get<SongPreviewDto[]>(
'/song-browser/recent',
{
params: {
page: 1, // TODO: fiz constants
limit: 16, // TODO: change 'limit' parameter to 'skip' and load 12 songs initially, then load 8 more songs on each pagination
sort: 'recent',
order: false,
},
const response = await axiosInstance.get<SongPreviewDto[]>('/song', {
params: {
page: 1, // TODO: fix constants
limit: 16, // TODO: change 'limit' parameter to 'skip' and load 12 songs initially, then load 8 more songs on each pagination
sort: 'recent',
order: 'desc',
},
);
});

return response.data;
} catch (error) {
Expand All @@ -28,9 +25,8 @@ async function fetchRecentSongs() {
async function fetchFeaturedSongs(): Promise<FeaturedSongsDto> {
try {
const response = await axiosInstance.get<FeaturedSongsDto>(
'/song-browser/featured',
'/song/featured',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this route supposed to clash with the GET /song/:id endpoint? Is this currently the case, or is there some precedence order going on to prevent this from happening?

Our song IDs are 10 characters long, spanning A-Za-z0-9, but were they 8 characters long, featured could technically be a valid song ID.

);

return response.data;
} catch (error) {
return {
Expand Down
Loading
Loading