From ae21b6edf29c388cda127e5fc56290e67df74d53 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sun, 28 Sep 2025 18:48:27 -0300 Subject: [PATCH 01/12] feat: implement getFeaturedSongs method in SongService and add corresponding tests - Added getFeaturedSongs method to SongService to retrieve featured songs across various timespans. - Implemented tests for getFeaturedSongs to ensure correct functionality and handling of empty results. - Removed SongBrowser module and related files as part of the refactor to streamline the service structure. - Introduced new PageDto class for pagination handling in the database module. --- .../song-browser.controller.spec.ts | 100 ------------- .../song-browser/song-browser.controller.ts | 64 --------- .../src/song-browser/song-browser.module.ts | 13 -- .../song-browser/song-browser.service.spec.ts | 136 ------------------ .../src/song-browser/song-browser.service.ts | 126 ---------------- apps/backend/src/song/song.service.spec.ts | 60 ++++++++ apps/backend/src/song/song.service.ts | 117 ++++++++++++++- packages/configs/index.ts | 1 - packages/database/src/common/dto/Page.dto.ts | 65 +++++++++ packages/database/src/index.ts | 1 + 10 files changed, 237 insertions(+), 446 deletions(-) delete mode 100644 apps/backend/src/song-browser/song-browser.controller.spec.ts delete mode 100644 apps/backend/src/song-browser/song-browser.controller.ts delete mode 100644 apps/backend/src/song-browser/song-browser.module.ts delete mode 100644 apps/backend/src/song-browser/song-browser.service.spec.ts delete mode 100644 apps/backend/src/song-browser/song-browser.service.ts delete mode 100644 packages/configs/index.ts create mode 100644 packages/database/src/common/dto/Page.dto.ts diff --git a/apps/backend/src/song-browser/song-browser.controller.spec.ts b/apps/backend/src/song-browser/song-browser.controller.spec.ts deleted file mode 100644 index 0e95d2ff..00000000 --- a/apps/backend/src/song-browser/song-browser.controller.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { FeaturedSongsDto, PageQueryDTO, SongPreviewDto } from '@nbw/database'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { SongBrowserController } from './song-browser.controller'; -import { SongBrowserService } from './song-browser.service'; - -const mockSongBrowserService = { - getFeaturedSongs: jest.fn(), - getRecentSongs: jest.fn(), - getCategories: jest.fn(), - getSongsByCategory: jest.fn(), -}; - -describe('SongBrowserController', () => { - let controller: SongBrowserController; - let songBrowserService: SongBrowserService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [SongBrowserController], - providers: [ - { - provide: SongBrowserService, - useValue: mockSongBrowserService, - }, - ], - }).compile(); - - controller = module.get(SongBrowserController); - songBrowserService = module.get(SongBrowserService); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); - - describe('getFeaturedSongs', () => { - it('should return a list of featured songs', async () => { - const featuredSongs: FeaturedSongsDto = {} as FeaturedSongsDto; - - mockSongBrowserService.getFeaturedSongs.mockResolvedValueOnce( - featuredSongs, - ); - - const result = await controller.getFeaturedSongs(); - - expect(result).toEqual(featuredSongs); - expect(songBrowserService.getFeaturedSongs).toHaveBeenCalled(); - }); - }); - - describe('getSongList', () => { - it('should return a list of recent songs', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; - const songList: SongPreviewDto[] = []; - - mockSongBrowserService.getRecentSongs.mockResolvedValueOnce(songList); - - const result = await controller.getSongList(query); - - expect(result).toEqual(songList); - expect(songBrowserService.getRecentSongs).toHaveBeenCalledWith(query); - }); - }); - - describe('getCategories', () => { - it('should return a list of song categories and song counts', async () => { - const categories: Record = { - category1: 10, - category2: 5, - }; - - mockSongBrowserService.getCategories.mockResolvedValueOnce(categories); - - const result = await controller.getCategories(); - - expect(result).toEqual(categories); - expect(songBrowserService.getCategories).toHaveBeenCalled(); - }); - }); - - describe('getSongsByCategory', () => { - it('should return a list of songs by category', async () => { - const id = 'test-category'; - const query: PageQueryDTO = { page: 1, limit: 10 }; - const songList: SongPreviewDto[] = []; - - mockSongBrowserService.getSongsByCategory.mockResolvedValueOnce(songList); - - const result = await controller.getSongsByCategory(id, query); - - expect(result).toEqual(songList); - - expect(songBrowserService.getSongsByCategory).toHaveBeenCalledWith( - id, - query, - ); - }); - }); -}); diff --git a/apps/backend/src/song-browser/song-browser.controller.ts b/apps/backend/src/song-browser/song-browser.controller.ts deleted file mode 100644 index f9746d1e..00000000 --- a/apps/backend/src/song-browser/song-browser.controller.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { FeaturedSongsDto, PageQueryDTO, SongPreviewDto } from '@nbw/database'; -import { - BadRequestException, - Controller, - Get, - Param, - Query, -} from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; - -import { SongBrowserService } from './song-browser.service'; - -@Controller('song-browser') -@ApiTags('song-browser') -@ApiTags('song') -export class SongBrowserController { - constructor(public readonly songBrowserService: SongBrowserService) {} - - @Get('/featured') - @ApiOperation({ summary: 'Get a list of featured songs' }) - public async getFeaturedSongs(): Promise { - return await this.songBrowserService.getFeaturedSongs(); - } - - @Get('/recent') - @ApiOperation({ - summary: 'Get a filtered/sorted list of recent songs with pagination', - }) - public async getSongList( - @Query() query: PageQueryDTO, - ): Promise { - return await this.songBrowserService.getRecentSongs(query); - } - - @Get('/categories') - @ApiOperation({ summary: 'Get a list of song categories and song counts' }) - public async getCategories(): Promise> { - return await this.songBrowserService.getCategories(); - } - - @Get('/categories/:id') - @ApiOperation({ summary: 'Get a list of song categories and song counts' }) - public async getSongsByCategory( - @Param('id') id: string, - @Query() query: PageQueryDTO, - ): Promise { - return await this.songBrowserService.getSongsByCategory(id, query); - } - - @Get('/random') - @ApiOperation({ summary: 'Get a list of songs at random' }) - public async getRandomSongs( - @Query('count') count: string, - @Query('category') category: string, - ): Promise { - const countInt = parseInt(count); - - if (isNaN(countInt) || countInt < 1 || countInt > 10) { - throw new BadRequestException('Invalid query parameters'); - } - - return await this.songBrowserService.getRandomSongs(countInt, category); - } -} diff --git a/apps/backend/src/song-browser/song-browser.module.ts b/apps/backend/src/song-browser/song-browser.module.ts deleted file mode 100644 index f1eb25e0..00000000 --- a/apps/backend/src/song-browser/song-browser.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { SongModule } from '@server/song/song.module'; - -import { SongBrowserController } from './song-browser.controller'; -import { SongBrowserService } from './song-browser.service'; - -@Module({ - providers: [SongBrowserService], - controllers: [SongBrowserController], - imports: [SongModule], -}) -export class SongBrowserModule {} diff --git a/apps/backend/src/song-browser/song-browser.service.spec.ts b/apps/backend/src/song-browser/song-browser.service.spec.ts deleted file mode 100644 index f46f98d6..00000000 --- a/apps/backend/src/song-browser/song-browser.service.spec.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { PageQueryDTO, SongPreviewDto, SongWithUser } from '@nbw/database'; -import { HttpException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; - -import { SongService } from '@server/song/song.service'; - -import { SongBrowserService } from './song-browser.service'; - -const mockSongService = { - getSongsForTimespan: jest.fn(), - getSongsBeforeTimespan: jest.fn(), - getRecentSongs: jest.fn(), - getCategories: jest.fn(), - getSongsByCategory: jest.fn(), -}; - -describe('SongBrowserService', () => { - let service: SongBrowserService; - let songService: SongService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - SongBrowserService, - { - provide: SongService, - useValue: mockSongService, - }, - ], - }).compile(); - - service = module.get(SongBrowserService); - songService = module.get(SongService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('getFeaturedSongs', () => { - it('should return featured songs', async () => { - const songWithUser: SongWithUser = { - title: 'Test Song', - uploader: { username: 'testuser', profileImage: 'testimage' }, - stats: { - duration: 100, - noteCount: 100, - }, - } as any; - - jest - .spyOn(songService, 'getSongsForTimespan') - .mockResolvedValue([songWithUser]); - - jest - .spyOn(songService, 'getSongsBeforeTimespan') - .mockResolvedValue([songWithUser]); - - await service.getFeaturedSongs(); - - expect(songService.getSongsForTimespan).toHaveBeenCalled(); - expect(songService.getSongsBeforeTimespan).toHaveBeenCalled(); - }); - }); - - describe('getRecentSongs', () => { - it('should return recent songs', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; - - const songPreviewDto: SongPreviewDto = { - title: 'Test Song', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as any; - - jest - .spyOn(songService, 'getRecentSongs') - .mockResolvedValue([songPreviewDto]); - - const result = await service.getRecentSongs(query); - - expect(result).toEqual([songPreviewDto]); - - expect(songService.getRecentSongs).toHaveBeenCalledWith( - query.page, - query.limit, - ); - }); - - it('should throw an error if query parameters are invalid', async () => { - const query: PageQueryDTO = { page: undefined, limit: undefined }; - - await expect(service.getRecentSongs(query)).rejects.toThrow( - HttpException, - ); - }); - }); - - describe('getCategories', () => { - it('should return categories', async () => { - const categories = { pop: 10, rock: 5 }; - - jest.spyOn(songService, 'getCategories').mockResolvedValue(categories); - - const result = await service.getCategories(); - - expect(result).toEqual(categories); - expect(songService.getCategories).toHaveBeenCalled(); - }); - }); - - describe('getSongsByCategory', () => { - it('should return songs by category', async () => { - const category = 'pop'; - const query: PageQueryDTO = { page: 1, limit: 10 }; - - const songPreviewDto: SongPreviewDto = { - title: 'Test Song', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as any; - - jest - .spyOn(songService, 'getSongsByCategory') - .mockResolvedValue([songPreviewDto]); - - const result = await service.getSongsByCategory(category, query); - - expect(result).toEqual([songPreviewDto]); - - expect(songService.getSongsByCategory).toHaveBeenCalledWith( - category, - query.page, - query.limit, - ); - }); - }); -}); diff --git a/apps/backend/src/song-browser/song-browser.service.ts b/apps/backend/src/song-browser/song-browser.service.ts deleted file mode 100644 index 0739d5c0..00000000 --- a/apps/backend/src/song-browser/song-browser.service.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { BROWSER_SONGS } from '@nbw/config'; -import { - FeaturedSongsDto, - PageQueryDTO, - SongPreviewDto, - SongWithUser, - TimespanType, -} from '@nbw/database'; -import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; - -import { SongService } from '@server/song/song.service'; - -@Injectable() -export class SongBrowserService { - constructor( - @Inject(SongService) - private songService: SongService, - ) {} - - public async getFeaturedSongs(): Promise { - const now = new Date(Date.now()); - - const times: Record = { - hour: new Date(Date.now()).setHours(now.getHours() - 1), - day: new Date(Date.now()).setDate(now.getDate() - 1), - week: new Date(Date.now()).setDate(now.getDate() - 7), - month: new Date(Date.now()).setMonth(now.getMonth() - 1), - year: new Date(Date.now()).setFullYear(now.getFullYear() - 1), - all: new Date(0).getTime(), - }; - - const songs: Record = { - hour: [], - day: [], - week: [], - month: [], - year: [], - all: [], - }; - - for (const [timespan, time] of Object.entries(times)) { - const songPage = await this.songService.getSongsForTimespan(time); - - // If the length is 0, send an empty array (no songs available in that timespan) - // If the length is less than the page size, pad it with songs "borrowed" - // from the nearest timestamp, regardless of view count - if ( - songPage.length > 0 && - songPage.length < BROWSER_SONGS.paddedFeaturedPageSize - ) { - const missing = BROWSER_SONGS.paddedFeaturedPageSize - songPage.length; - - const additionalSongs = await this.songService.getSongsBeforeTimespan( - time, - ); - - songPage.push(...additionalSongs.slice(0, missing)); - } - - songs[timespan as TimespanType] = songPage; - } - - const featuredSongs = FeaturedSongsDto.create(); - - featuredSongs.hour = songs.hour.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); - - featuredSongs.day = songs.day.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); - - featuredSongs.week = songs.week.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); - - featuredSongs.month = songs.month.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); - - featuredSongs.year = songs.year.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); - - featuredSongs.all = songs.all.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); - - return featuredSongs; - } - - public async getRecentSongs(query: PageQueryDTO): Promise { - const { page, limit } = query; - - if (!page || !limit) { - throw new HttpException( - 'Invalid query parameters', - HttpStatus.BAD_REQUEST, - ); - } - - return await this.songService.getRecentSongs(page, limit); - } - - public async getCategories(): Promise> { - return await this.songService.getCategories(); - } - - public async getSongsByCategory( - category: string, - query: PageQueryDTO, - ): Promise { - return await this.songService.getSongsByCategory( - category, - query.page ?? 1, - query.limit ?? 10, - ); - } - - public async getRandomSongs( - count: number, - category: string, - ): Promise { - return await this.songService.getRandomSongs(count, category); - } -} diff --git a/apps/backend/src/song/song.service.spec.ts b/apps/backend/src/song/song.service.spec.ts index b5445461..45a5eb58 100644 --- a/apps/backend/src/song/song.service.spec.ts +++ b/apps/backend/src/song/song.service.spec.ts @@ -1047,4 +1047,64 @@ describe('SongService', () => { expect(mockFind.exec).toHaveBeenCalled(); }); }); + + describe('getFeaturedSongs', () => { + it('should return featured songs', async () => { + const songWithUser: SongWithUser = { + title: 'Test Song', + publicId: 'test-id', + uploader: { username: 'testuser', profileImage: 'testimage' }, + description: 'Test Description', + originalAuthor: 'Test Author', + stats: { + duration: 100, + noteCount: 100, + }, + thumbnailUrl: 'test-thumbnail-url', + createdAt: new Date(), + updatedAt: new Date(), + playCount: 0, + visibility: 'public', + } as any; + + jest + .spyOn(service, 'getSongsForTimespan') + .mockResolvedValue([songWithUser]); + + jest.spyOn(service, 'getSongsBeforeTimespan').mockResolvedValue([]); + + const result = await service.getFeaturedSongs(); + + expect(service.getSongsForTimespan).toHaveBeenCalledTimes(6); // Called for each timespan + expect(result).toBeInstanceOf(Object); + expect(result).toHaveProperty('hour'); + expect(result).toHaveProperty('day'); + expect(result).toHaveProperty('week'); + expect(result).toHaveProperty('month'); + expect(result).toHaveProperty('year'); + expect(result).toHaveProperty('all'); + expect(Array.isArray(result.hour)).toBe(true); + expect(Array.isArray(result.day)).toBe(true); + expect(Array.isArray(result.week)).toBe(true); + expect(Array.isArray(result.month)).toBe(true); + expect(Array.isArray(result.year)).toBe(true); + expect(Array.isArray(result.all)).toBe(true); + }); + + it('should handle empty results gracefully', async () => { + jest.spyOn(service, 'getSongsForTimespan').mockResolvedValue([]); + + jest.spyOn(service, 'getSongsBeforeTimespan').mockResolvedValue([]); + + const result = await service.getFeaturedSongs(); + + expect(result).toBeInstanceOf(Object); + expect(result.hour).toEqual([]); + expect(result.day).toEqual([]); + expect(result.week).toEqual([]); + expect(result.month).toEqual([]); + expect(result.year).toEqual([]); + expect(result.all).toEqual([]); + }); + }); }); diff --git a/apps/backend/src/song/song.service.ts b/apps/backend/src/song/song.service.ts index 1da06c08..4279bc75 100644 --- a/apps/backend/src/song/song.service.ts +++ b/apps/backend/src/song/song.service.ts @@ -1,14 +1,15 @@ -import { BROWSER_SONGS } from '@nbw/config'; -import type { UserDocument } from '@nbw/database'; +import { BROWSER_SONGS, TIMESPANS } from '@nbw/config'; import { + FeaturedSongsDto, + type User as UserDocument, PageQueryDTO, - Song as SongEntity, + type Song as SongEntity, SongPageDto, SongPreviewDto, SongViewDto, - SongWithUser, UploadSongDto, UploadSongResponseDto, + type SongWithUser, } from '@nbw/database'; import { HttpException, @@ -202,6 +203,48 @@ export class SongService { }) .skip(page * limit - limit) .limit(limit) + .populate('uploader', 'username publicName profileImage -_id') + .exec()) as unknown as SongWithUser[]; + + return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); + } + + public async searchSongs( + query: PageQueryDTO, + q: string, + ): Promise { + const page = parseInt(query.page?.toString() ?? '1'); + const limit = parseInt(query.limit?.toString() ?? '10'); + const order = query.order ? query.order : false; + const allowedSorts = new Set(['likeCount', 'createdAt', 'playCount']); + const sortField = allowedSorts.has(query.sort ?? '') + ? (query.sort as string) + : 'createdAt'; + + const terms = (q || '') + .split(/\s+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); + + // Build Google-like search: all words must appear across any of the fields + const andClauses = terms.map((word) => ({ + $or: [ + { title: { $regex: word, $options: 'i' } }, + { originalAuthor: { $regex: word, $options: 'i' } }, + { description: { $regex: word, $options: 'i' } }, + ], + })); + + const mongoQuery: any = { + visibility: 'public', + ...(andClauses.length > 0 ? { $and: andClauses } : {}), + }; + + const songs = (await this.songModel + .find(mongoQuery) + .sort({ [sortField]: order ? 1 : -1 }) + .skip(limit * (page - 1)) + .limit(limit) .populate('uploader', 'username profileImage -_id') .exec()) as unknown as SongWithUser[]; @@ -457,6 +500,7 @@ export class SongService { { $match: { visibility: 'public', + category: category, }, }, { @@ -475,7 +519,68 @@ export class SongService { return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); } - public async getAllSongs() { - return this.songModel.find({}); + public async getFeaturedSongs(): Promise { + const now = new Date(Date.now()); + + const times: Record<(typeof TIMESPANS)[number], number> = { + hour: new Date(Date.now()).setHours(now.getHours() - 1), + day: new Date(Date.now()).setDate(now.getDate() - 1), + week: new Date(Date.now()).setDate(now.getDate() - 7), + month: new Date(Date.now()).setMonth(now.getMonth() - 1), + year: new Date(Date.now()).setFullYear(now.getFullYear() - 1), + all: new Date(0).getTime(), + }; + + const songs: Record<(typeof TIMESPANS)[number], SongWithUser[]> = { + hour: [], + day: [], + week: [], + month: [], + year: [], + all: [], + }; + + for (const [timespan, time] of Object.entries(times)) { + const songPage = await this.getSongsForTimespan(time); + + // If the length is 0, send an empty array (no songs available in that timespan) + // If the length is less than the page size, pad it with songs "borrowed" + // from the nearest timestamp, regardless of view count + if ( + songPage.length > 0 && + songPage.length < BROWSER_SONGS.paddedFeaturedPageSize + ) { + const missing = BROWSER_SONGS.paddedFeaturedPageSize - songPage.length; + + const additionalSongs = await this.getSongsBeforeTimespan(time); + + songPage.push(...additionalSongs.slice(0, missing)); + } + + songs[timespan as TimespanType] = songPage; + } + + const featuredSongs = FeaturedSongsDto.create(); + + featuredSongs.hour = songs.hour.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song), + ); + featuredSongs.day = songs.day.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song), + ); + featuredSongs.week = songs.week.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song), + ); + featuredSongs.month = songs.month.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song), + ); + featuredSongs.year = songs.year.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song), + ); + featuredSongs.all = songs.all.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song), + ); + + return featuredSongs; } } diff --git a/packages/configs/index.ts b/packages/configs/index.ts deleted file mode 100644 index 3f2f3f8c..00000000 --- a/packages/configs/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log('Hello via Bun!'); diff --git a/packages/database/src/common/dto/Page.dto.ts b/packages/database/src/common/dto/Page.dto.ts new file mode 100644 index 00000000..32a6a469 --- /dev/null +++ b/packages/database/src/common/dto/Page.dto.ts @@ -0,0 +1,65 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsArray, + IsBoolean, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; + +export class PageDto { + @IsNotEmpty() + @IsNumber({ + allowNaN: false, + allowInfinity: false, + maxDecimalPlaces: 0, + }) + @ApiProperty({ example: 150, description: 'Total number of items available' }) + total: number; + + @IsNotEmpty() + @IsNumber({ + allowNaN: false, + allowInfinity: false, + maxDecimalPlaces: 0, + }) + @ApiProperty({ example: 1, description: 'Current page number' }) + page: number; + + @IsNotEmpty() + @IsNumber({ + allowNaN: false, + allowInfinity: false, + maxDecimalPlaces: 0, + }) + @ApiProperty({ example: 20, description: 'Number of items per page' }) + limit: number; + + @IsOptional() + @IsString() + @ApiProperty({ example: 'createdAt', description: 'Field used for sorting' }) + sort?: string; + + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ + example: false, + description: 'Sort order: true for ascending, false for descending', + }) + order: boolean; + + @IsNotEmpty() + @IsArray() + @ValidateNested({ each: true }) + @ApiProperty({ + description: 'Array of items for the current page', + isArray: true, + }) + content: T[]; + + constructor(partial: Partial>) { + Object.assign(this, partial); + } +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index e30d29f1..e09b0765 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,3 +1,4 @@ +export * from './common/dto/Page.dto'; export * from './common/dto/PageQuery.dto'; export * from './common/dto/types'; From 3b884ca621579f5a2afeeca61a58f3a0a52a7f1b Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sun, 28 Sep 2025 18:49:48 -0300 Subject: [PATCH 02/12] feat: enhance SongController with advanced song retrieval features - Implemented various query modes in getSongList method to support fetching featured, recent, category-based, and random songs. - Added error handling for invalid query parameters and improved response types using PageDto. - Introduced searchSongs method for keyword-based song searches with pagination. - Updated Swagger documentation to reflect new query parameters and response structures. --- apps/backend/src/song/song.controller.spec.ts | 87 +++++++- apps/backend/src/song/song.controller.ts | 192 ++++++++++++++++-- 2 files changed, 258 insertions(+), 21 deletions(-) diff --git a/apps/backend/src/song/song.controller.spec.ts b/apps/backend/src/song/song.controller.spec.ts index f4fc3ac2..dd4752b4 100644 --- a/apps/backend/src/song/song.controller.spec.ts +++ b/apps/backend/src/song/song.controller.spec.ts @@ -11,13 +11,14 @@ import { AuthGuard } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; import { Response } from 'express'; -import { FileService } from '@server/file/file.service'; +import { FileService } from '../file/file.service'; import { SongController } from './song.controller'; import { SongService } from './song.service'; const mockSongService = { getSongByPage: jest.fn(), + searchSongs: jest.fn(), getSong: jest.fn(), getSongEdit: jest.fn(), patchSong: jest.fn(), @@ -52,6 +53,9 @@ describe('SongController', () => { songController = module.get(SongController); songService = module.get(SongService); + + // Clear all mocks + jest.clearAllMocks(); }); it('should be defined', () => { @@ -71,6 +75,87 @@ describe('SongController', () => { expect(songService.getSongByPage).toHaveBeenCalledWith(query); }); + it('should handle featured songs', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const songList: SongPreviewDto[] = []; + + const result = await songController.getSongList(query, 'featured'); + + expect(result).toEqual(songList); + }); + + it('should handle recent songs', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const songList: SongPreviewDto[] = []; + + const result = await songController.getSongList(query, 'recent'); + + expect(result).toEqual(songList); + }); + + it('should return categories when q=categories without id', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const categories = { pop: 42, rock: 38 }; + + const result = await songController.getSongList(query, 'categories'); + + expect(result).toEqual(categories); + }); + + it('should return songs by category when q=categories with id', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const songList: SongPreviewDto[] = []; + const categoryId = 'pop'; + + const result = await songController.getSongList( + query, + 'categories', + categoryId, + ); + + expect(result).toEqual(songList); + }); + + it('should return random songs', async () => { + const query: PageQueryDTO = { page: 1, limit: 5 }; + const songList: SongPreviewDto[] = []; + const category = 'electronic'; + + const result = await songController.getSongList( + query, + 'random', + undefined, + category, + ); + + expect(result).toEqual(songList); + }); + + it('should throw error for invalid random count', async () => { + const query: PageQueryDTO = { page: 1, limit: 15 }; // Invalid limit > 10 + + await expect(songController.getSongList(query, 'random')).rejects.toThrow( + 'Invalid query parameters', + ); + }); + + it('should handle zero limit for random (uses default)', async () => { + const query: PageQueryDTO = { page: 1, limit: 0 }; // limit 0 is falsy, so uses default + const songList: SongPreviewDto[] = []; + + const result = await songController.getSongList(query, 'random'); + + expect(result).toEqual(songList); + }); + + it('should throw error for invalid query mode', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + + await expect( + songController.getSongList(query, 'invalid' as any), + ).rejects.toThrow('Invalid query parameters'); + }); + it('should handle errors', async () => { const query: PageQueryDTO = { page: 1, limit: 10 }; diff --git a/apps/backend/src/song/song.controller.ts b/apps/backend/src/song/song.controller.ts index d6711cee..98cc48d0 100644 --- a/apps/backend/src/song/song.controller.ts +++ b/apps/backend/src/song/song.controller.ts @@ -1,14 +1,17 @@ import { UPLOAD_CONSTANTS } from '@nbw/config'; -import type { UserDocument } from '@nbw/database'; import { + PageDto, + UserDocument, PageQueryDTO, SongPreviewDto, SongViewDto, UploadSongDto, UploadSongResponseDto, + FeaturedSongsDto, } from '@nbw/database'; import type { RawBodyRequest } from '@nestjs/common'; import { + BadRequestException, Body, Controller, Delete, @@ -34,6 +37,9 @@ import { ApiBody, ApiConsumes, ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, ApiTags, } from '@nestjs/swagger'; import type { Response } from 'express'; @@ -43,20 +49,14 @@ import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser'; import { SongService } from './song.service'; -// Handles public-facing song routes. - @Controller('song') @ApiTags('song') export class SongController { static multerConfig: MulterOptions = { - limits: { - fileSize: UPLOAD_CONSTANTS.file.maxSize, - }, + limits: { fileSize: UPLOAD_CONSTANTS.file.maxSize }, fileFilter: (req, file, cb) => { - if (!file.originalname.match(/\.(nbs)$/)) { + if (!file.originalname.match(/\.(nbs)$/)) return cb(new Error('Only .nbs files are allowed!'), false); - } - cb(null, true); }, }; @@ -68,12 +68,170 @@ export class SongController { @Get('/') @ApiOperation({ - summary: 'Get a filtered/sorted list of songs with pagination', + summary: 'Get songs with various filtering and browsing 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') + + **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) + + **Return Types:** + - SongPreviewDto[]: Array of song previews (most cases) + - Record: Category name to count mapping (when q=categories without id) + `, + }) + @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 }, + }, + ], + }, + }) + @ApiResponse({ + status: 400, + description: + 'Bad Request. Invalid query parameters (e.g., invalid count for random query).', }) public async getSongList( @Query() query: PageQueryDTO, - ): Promise { - return await this.songService.getSongByPage(query); + @Query('q') q?: 'featured' | 'recent' | 'categories' | 'random', + @Param('id') id?: string, + @Query('category') category?: string, + ): Promise< + PageDto | Record | FeaturedSongsDto + > { + if (q) { + switch (q) { + case 'featured': + return await this.songService.getFeaturedSongs(); + case 'recent': + return new PageDto({ + content: await this.songService.getRecentSongs( + query.page, + query.limit, + ), + page: query.page, + limit: query.limit, + total: 0, + }); + case 'categories': + if (id) { + return new PageDto({ + 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({ + content: data, + page: query.page, + limit: query.limit, + total: data.length, + }); + } + default: + throw new BadRequestException('Invalid query parameters'); + } + } + + const data = await this.songService.getSongByPage(query); + return new PageDto({ + content: data, + page: query.page, + limit: query.limit, + total: data.length, + }); + } + + @Get('/search') + @ApiOperation({ + summary: 'Search songs by keywords with pagination and sorting', + }) + public async searchSongs( + @Query() query: PageQueryDTO, + @Query('q') q: string, + ): Promise> { + const data = await this.songService.searchSongs(query, q ?? ''); + return new PageDto({ + content: data, + page: query.page, + limit: query.limit, + total: data.length, + }); } @Get('/:id') @@ -101,10 +259,7 @@ export class SongController { @UseGuards(AuthGuard('jwt-refresh')) @ApiBearerAuth() @ApiOperation({ summary: 'Edit song info by ID' }) - @ApiBody({ - description: 'Upload Song', - type: UploadSongResponseDto, - }) + @ApiBody({ description: 'Upload Song', type: UploadSongResponseDto }) public async patchSong( @Param('id') id: string, @Req() req: RawBodyRequest, @@ -174,10 +329,7 @@ export class SongController { @UseGuards(AuthGuard('jwt-refresh')) @ApiBearerAuth() @ApiConsumes('multipart/form-data') - @ApiBody({ - description: 'Upload Song', - type: UploadSongResponseDto, - }) + @ApiBody({ description: 'Upload Song', type: UploadSongResponseDto }) @UseInterceptors(FileInterceptor('file', SongController.multerConfig)) @ApiOperation({ summary: 'Upload a .nbs file and send the song data, creating a new song', From 9de7c274830b6f6df864a0ba4cbfd2e0e8dc786e Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Sun, 5 Oct 2025 14:56:19 -0300 Subject: [PATCH 03/12] refactor: remove SongBrowserModule from app.module.ts --- apps/backend/src/app.module.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 455b6144..215ce497 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -14,7 +14,6 @@ import { ParseTokenPipe } from './lib/parseToken'; import { MailingModule } from './mailing/mailing.module'; import { SeedModule } from './seed/seed.module'; import { SongModule } from './song/song.module'; -import { SongBrowserModule } from './song-browser/song-browser.module'; import { UserModule } from './user/user.module'; @Module({ @@ -76,7 +75,6 @@ import { UserModule } from './user/user.module'; UserModule, AuthModule.forRootAsync(), FileModule.forRootAsync(), - SongBrowserModule, SeedModule.forRoot(), EmailLoginModule, MailingModule, From ca60eeeac841ece82b1553c849e4cf15d7aab76c Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 6 Oct 2025 19:39:43 -0300 Subject: [PATCH 04/12] feat: enhance song retrieval with new search and sorting capabilities - Introduced for improved query handling, allowing filtering by title, category, uploader, and sorting by various criteria. - Updated method in to support new query parameters and sorting logic. - Added new endpoints for fetching featured songs and available categories. - Refactored frontend components to align with updated API endpoints and enhance song search functionality. - Improved error handling and loading states in the frontend for better user experience. --- apps/backend/src/song/song.controller.ts | 276 ++++++++++-------- apps/backend/src/song/song.service.ts | 16 +- apps/frontend/src/app/(content)/page.tsx | 20 +- .../src/app/(content)/search-song/page.tsx | 200 +++++++++++++ .../client/context/FeaturedSongs.context.tsx | 1 - .../client/context/RecentSongs.context.tsx | 28 +- .../components/layout/RandomSongButton.tsx | 17 +- .../src/modules/song/components/SongPage.tsx | 33 ++- packages/database/src/index.ts | 1 + .../src/song/dto/SongListQuery.dto.ts | 105 +++++++ 10 files changed, 513 insertions(+), 184 deletions(-) create mode 100644 apps/frontend/src/app/(content)/search-song/page.tsx create mode 100644 packages/database/src/song/dto/SongListQuery.dto.ts diff --git a/apps/backend/src/song/song.controller.ts b/apps/backend/src/song/song.controller.ts index 86b927cd..ed4f0c39 100644 --- a/apps/backend/src/song/song.controller.ts +++ b/apps/backend/src/song/song.controller.ts @@ -7,6 +7,7 @@ import { Get, Headers, HttpStatus, + Logger, Param, Patch, Post, @@ -26,8 +27,6 @@ import { ApiBody, ApiConsumes, ApiOperation, - ApiParam, - ApiQuery, ApiResponse, ApiTags, } from '@nestjs/swagger'; @@ -41,8 +40,11 @@ import { 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'; @@ -51,6 +53,7 @@ import { SongService } from './song.service'; @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) => { @@ -67,147 +70,128 @@ export class SongController { @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: Category name to count mapping (when q=categories without id) + **Return Type:** + - PageDto: 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, }) @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 | Record | FeaturedSongsDto - > { - if (q) { - switch (q) { - case 'featured': - return await this.songService.getFeaturedSongs(); - case 'recent': - return new PageDto({ - content: await this.songService.getRecentSongs( - query.page, - query.limit, - ), - page: query.page, - limit: query.limit, - total: 0, - }); - case 'categories': - if (id) { - return new PageDto({ - 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({ - content: data, - page: query.page, - limit: query.limit, - total: data.length, - }); - } - default: - throw new BadRequestException('Invalid query parameters'); + @Query() query: SongListQueryDTO, + ): Promise> { + // 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({ + 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({ + 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({ + 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({ + 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({ content: data, page: query.page, @@ -216,6 +200,42 @@ export class SongController { }); } + @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 { + 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> { + return await this.songService.getCategories(); + } + @Get('/search') @ApiOperation({ summary: 'Search songs by keywords with pagination and sorting', diff --git a/apps/backend/src/song/song.service.ts b/apps/backend/src/song/song.service.ts index 8b46257e..827917e8 100644 --- a/apps/backend/src/song/song.service.ts +++ b/apps/backend/src/song/song.service.ts @@ -494,15 +494,21 @@ export class SongService { public async getRandomSongs( count: number, - category: string, + category?: string, ): Promise { + const matchStage: Record = { + 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: { diff --git a/apps/frontend/src/app/(content)/page.tsx b/apps/frontend/src/app/(content)/page.tsx index e04fdf25..98cc759f 100644 --- a/apps/frontend/src/app/(content)/page.tsx +++ b/apps/frontend/src/app/(content)/page.tsx @@ -7,17 +7,14 @@ import { HomePageComponent } from '@web/modules/browse/components/HomePageCompon async function fetchRecentSongs() { try { - const response = await axiosInstance.get( - '/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('/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) { @@ -28,9 +25,8 @@ async function fetchRecentSongs() { async function fetchFeaturedSongs(): Promise { try { const response = await axiosInstance.get( - '/song-browser/featured', + '/song/featured', ); - return response.data; } catch (error) { return { diff --git a/apps/frontend/src/app/(content)/search-song/page.tsx b/apps/frontend/src/app/(content)/search-song/page.tsx new file mode 100644 index 00000000..dd0c066b --- /dev/null +++ b/apps/frontend/src/app/(content)/search-song/page.tsx @@ -0,0 +1,200 @@ +'use client'; + +import { + faEllipsis, + faMagnifyingGlass, +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { SongPreviewDtoType } from '@nbw/database'; +import { useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import axiosInstance from '@web/lib/axios'; +import LoadMoreButton from '@web/modules/browse/components/client/LoadMoreButton'; +import SongCard from '@web/modules/browse/components/SongCard'; +import SongCardGroup from '@web/modules/browse/components/SongCardGroup'; + +interface PageDto { + content: T[]; + page: number; + limit: number; + total: number; +} + +const SearchSongPage = () => { + const searchParams = useSearchParams(); + const query = searchParams.get('q') || ''; + const sort = searchParams.get('sort') || 'recent'; + const order = searchParams.get('order') || 'desc'; + const category = searchParams.get('category') || ''; + const uploader = searchParams.get('uploader') || ''; + const initialPage = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '20'); + + const [songs, setSongs] = useState([]); + const [loading, setLoading] = useState(true); + const [hasMore, setHasMore] = useState(true); + const [currentPage, setCurrentPage] = useState(initialPage); + const [totalResults, setTotalResults] = useState(0); + + // Fetch songs from the API + const searchSongs = async (searchQuery: string, pageNum: number) => { + setLoading(true); + + try { + const params: Record = { + page: pageNum, + limit, + sort, + order, + }; + + if (searchQuery) { + params.q = searchQuery; + } + + if (category) { + params.category = category; + } + + if (uploader) { + params.uploader = uploader; + } + + const response = await axiosInstance.get>( + '/song', + { params }, + ); + + if (pageNum === 1) { + // First page - replace songs + setSongs(response.data.content); + } else { + // Load more - append songs + setSongs((prev) => [...prev, ...response.data.content]); + } + + setTotalResults(response.data.total); + // Check if there are more pages to load + setHasMore(response.data.content.length >= limit); + } catch (error) { + console.error('Error searching songs:', error); + setSongs([]); + setHasMore(false); + } finally { + setLoading(false); + } + }; + + const loadMore = () => { + const nextPage = currentPage + 1; + setCurrentPage(nextPage); + searchSongs(query, nextPage); + }; + + useEffect(() => { + setCurrentPage(initialPage); + searchSongs(query, initialPage); + }, [query, sort, order, category, uploader, initialPage]); + + if (loading && songs.length === 0) { + return ( +
+
+ +

Searching...

+
+ + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + +
+ ); + } + + return ( +
+ {/* Search header */} +
+ +
+

+ {query ? 'Search Results' : 'Browse Songs'} +

+ {query && ( +

+ {songs.length > 0 + ? `Found ${totalResults} song${ + totalResults !== 1 ? 's' : '' + } for "${query}"` + : `No songs found for "${query}"`} +

+ )} + {!query && songs.length > 0 && ( +

+ Showing {songs.length} of {totalResults} songs +

+ )} + {/* Active filters */} + {(category || uploader) && ( +
+ {category && ( + + Category: {category} + + )} + {uploader && ( + + By: {uploader} + + )} +
+ )} +
+
+ + {/* Results */} + {songs.length > 0 ? ( + <> + + {songs.map((song, i) => ( + + ))} + + + {/* Load more / End indicator */} +
+ {loading ? ( +
Loading more songs...
+ ) : hasMore ? ( + + ) : ( + + )} +
+ + ) : !loading ? ( +
+ +

No songs found

+

+ Try adjusting your search terms or browse our featured songs + instead. +

+
+ ) : null} +
+ ); +}; + +export default SearchSongPage; diff --git a/apps/frontend/src/modules/browse/components/client/context/FeaturedSongs.context.tsx b/apps/frontend/src/modules/browse/components/client/context/FeaturedSongs.context.tsx index e18b9204..2b91e6a4 100644 --- a/apps/frontend/src/modules/browse/components/client/context/FeaturedSongs.context.tsx +++ b/apps/frontend/src/modules/browse/components/client/context/FeaturedSongs.context.tsx @@ -27,7 +27,6 @@ export function FeaturedSongsProvider({ }) { // Featured songs const [featuredSongs] = useState(initialFeaturedSongs); - const [featuredSongsPage, setFeaturedSongsPage] = useState( initialFeaturedSongs.week, ); diff --git a/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx b/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx index bebf6725..bf09f674 100644 --- a/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx +++ b/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx @@ -55,15 +55,20 @@ export function RecentSongsProvider({ try { const fetchCount = pageSize - adCount; + const params: Record = { + page, + limit: fetchCount, // TODO: fix constants + sort: 'recent', + order: 'desc', + }; + + if (selectedCategory) { + params.category = selectedCategory; + } + const response = await axiosInstance.get( endpoint, - { - params: { - page, - limit: fetchCount, // TODO: fix constants - order: false, - }, - }, + { params }, ); const newSongs: Array = response.data; @@ -91,13 +96,13 @@ export function RecentSongsProvider({ setLoading(false); } }, - [page, endpoint], + [page, endpoint, selectedCategory], ); const fetchCategories = useCallback(async function () { try { const response = await axiosInstance.get>( - '/song-browser/categories', + '/song/categories', ); return response.data; @@ -117,10 +122,7 @@ export function RecentSongsProvider({ setRecentSongs(Array(12).fill(null)); setHasMore(true); - const newEndpoint = - selectedCategory === '' - ? '/song-browser/recent' - : `/song-browser/categories/${selectedCategory}`; + const newEndpoint = selectedCategory === '' ? '/song' : '/song'; setEndpoint(newEndpoint); }, [selectedCategory]); diff --git a/apps/frontend/src/modules/shared/components/layout/RandomSongButton.tsx b/apps/frontend/src/modules/shared/components/layout/RandomSongButton.tsx index e86a9dc0..5c7504a7 100644 --- a/apps/frontend/src/modules/shared/components/layout/RandomSongButton.tsx +++ b/apps/frontend/src/modules/shared/components/layout/RandomSongButton.tsx @@ -4,7 +4,7 @@ import { faDice } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useRouter } from 'next/navigation'; -import { SongPreviewDto } from '@nbw/database'; +import { PageDto, SongPreviewDto } from '@nbw/database'; import axios from '@web/lib/axios'; import { MusicalNote } from './MusicalNote'; @@ -16,16 +16,13 @@ export const RandomSongButton = () => { let songId; try { - const response = await axios.get( - '/song-browser/random', - { - params: { - count: 1, - }, + const response = await axios.get>('/song', { + params: { + sort: 'random', + limit: 1, }, - ); - - songId = response.data[0].publicId; + }); + songId = response.data.content[0].publicId; } catch { console.error('Failed to retrieve a random song'); return; diff --git a/apps/frontend/src/modules/song/components/SongPage.tsx b/apps/frontend/src/modules/song/components/SongPage.tsx index 76a81258..27f5367d 100644 --- a/apps/frontend/src/modules/song/components/SongPage.tsx +++ b/apps/frontend/src/modules/song/components/SongPage.tsx @@ -1,15 +1,20 @@ import { cookies } from 'next/headers'; import Image from 'next/image'; -import type { SongPreviewDtoType, SongViewDtoType } from '@nbw/database'; +import type { + PageDto, + SongPreviewDtoType, + SongViewDtoType, +} from '@nbw/database'; import axios from '@web/lib/axios'; -import SongCard from '../../browse/components/SongCard'; -import SongCardGroup from '../../browse/components/SongCardGroup'; -import { MultiplexAdSlot } from '../../shared/components/client/ads/AdSlots'; -import { ErrorBox } from '../../shared/components/client/ErrorBox'; -import { formatTimeAgo } from '../../shared/util/format'; +import SongCard from '@web/modules/browse/components/SongCard'; +import SongCardGroup from '@web/modules/browse/components/SongCardGroup'; +import { MultiplexAdSlot } from '@web/modules/shared/components/client/ads/AdSlots'; +import { formatTimeAgo } from '@web/modules/shared/util/format'; + +import { ErrorBox } from '@web/modules/shared/components/client/ErrorBox'; import { LicenseInfo } from './client/LicenseInfo'; import { SongDetails } from './SongDetails'; import { @@ -46,17 +51,15 @@ export async function SongPage({ id }: { id: string }) { let suggestions: SongPreviewDtoType[] = []; try { - const response = await axios.get( - `/song-browser/random`, - { - params: { - count: 4, - category: song.category, - }, + const response = await axios.get>(`/song`, { + params: { + sort: 'random', + limit: 4, + category: song.category, }, - ); + }); - suggestions = await response.data; + suggestions = await response.data.content; } catch { console.error('Failed to retrieve suggested songs'); } diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index e09b0765..a9e1cc0a 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -4,6 +4,7 @@ export * from './common/dto/types'; export * from './song/dto/CustomInstrumentData.dto'; export * from './song/dto/FeaturedSongsDto.dto'; +export * from './song/dto/SongListQuery.dto'; export * from './song/dto/SongPage.dto'; export * from './song/dto/SongPreview.dto'; export * from './song/dto/SongStats'; diff --git a/packages/database/src/song/dto/SongListQuery.dto.ts b/packages/database/src/song/dto/SongListQuery.dto.ts new file mode 100644 index 00000000..4897f50e --- /dev/null +++ b/packages/database/src/song/dto/SongListQuery.dto.ts @@ -0,0 +1,105 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsEnum, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export enum SongSortType { + RECENT = 'recent', + RANDOM = 'random', + PLAY_COUNT = 'play-count', + TITLE = 'title', + DURATION = 'duration', + NOTE_COUNT = 'note-count', +} + +export enum SongOrderType { + ASC = 'asc', + DESC = 'desc', +} + +export class SongListQueryDTO { + @IsString() + @IsOptional() + @ApiProperty({ + example: 'my search query', + description: 'Search string to filter songs by title or description', + required: false, + }) + q?: string; + + @IsEnum(SongSortType) + @IsOptional() + @ApiProperty({ + enum: SongSortType, + example: SongSortType.RECENT, + description: 'Sort songs by the specified criteria', + required: false, + }) + sort?: SongSortType = SongSortType.RECENT; + + @IsEnum(SongOrderType) + @IsOptional() + @ApiProperty({ + enum: SongOrderType, + example: SongOrderType.DESC, + description: 'Sort order (only applies if sort is not random)', + required: false, + }) + order?: SongOrderType = SongOrderType.DESC; + + @IsString() + @IsOptional() + @ApiProperty({ + example: 'pop', + description: + 'Filter by category. If left empty, returns songs in any category', + required: false, + }) + category?: string; + + @IsString() + @IsOptional() + @ApiProperty({ + example: 'username123', + description: + 'Filter by uploader username. If provided, will only return songs uploaded by that user', + required: false, + }) + uploader?: string; + + @IsNumber({ + allowNaN: false, + allowInfinity: false, + maxDecimalPlaces: 0, + }) + @Min(1) + @ApiProperty({ + example: 1, + description: 'Page number', + required: false, + }) + page?: number = 1; + + @IsNumber({ + allowNaN: false, + allowInfinity: false, + maxDecimalPlaces: 0, + }) + @Min(1) + @Max(100) + @ApiProperty({ + example: 10, + description: 'Number of items to return per page', + required: false, + }) + limit?: number = 10; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} From 18a42ac471c7744b2ba90ddc7025605ed2807575 Mon Sep 17 00:00:00 2001 From: tomast1337 Date: Mon, 6 Oct 2025 19:46:32 -0300 Subject: [PATCH 05/12] feat: add SearchButton component to enhance song search functionality - Introduced a new SearchButton component for searching songs. - Integrated the SearchButton into the Header component for improved accessibility. - Implemented search query handling and routing to the search results page. --- .../shared/components/layout/Header.tsx | 2 + .../shared/components/layout/SearchButton.tsx | 76 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 apps/frontend/src/modules/shared/components/layout/SearchButton.tsx diff --git a/apps/frontend/src/modules/shared/components/layout/Header.tsx b/apps/frontend/src/modules/shared/components/layout/Header.tsx index c2862739..8e38af70 100644 --- a/apps/frontend/src/modules/shared/components/layout/Header.tsx +++ b/apps/frontend/src/modules/shared/components/layout/Header.tsx @@ -13,6 +13,7 @@ import { checkLogin, getUserData } from '@web/modules/auth/features/auth.utils'; import { BlockTab } from './BlockTab'; import { NavLinks } from './NavLinks'; import { RandomSongButton } from './RandomSongButton'; +import { SearchButton } from './SearchButton'; export async function Header() { let isLogged; @@ -93,6 +94,7 @@ export async function Header() { label='About' className='bg-cyan-700 after:bg-cyan-900 before:bg-cyan-950' /> + diff --git a/apps/frontend/src/modules/shared/components/layout/SearchButton.tsx b/apps/frontend/src/modules/shared/components/layout/SearchButton.tsx new file mode 100644 index 00000000..80e1c522 --- /dev/null +++ b/apps/frontend/src/modules/shared/components/layout/SearchButton.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@web/modules/shared/components/layout/popover'; +import { cn } from '@web/lib/tailwind.utils'; +import { MusicalNote } from './MusicalNote'; + +export function SearchButton() { + const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + + const handleSearch = () => { + if (searchQuery.trim()) { + setIsOpen(false); + router.push(`/search-song?q=${encodeURIComponent(searchQuery.trim())}`); + setSearchQuery(''); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch(); + } + }; + + return ( + + + + + +
+

Search Songs

+
+ setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder='Type your search...' + className='flex-1 px-3 py-2 bg-zinc-900 border border-zinc-700 rounded text-zinc-100 placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent' + autoFocus + /> + +
+
+
+
+ ); +} From b0756790edf263e881a939e637bc4031984bf9ec Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 6 Oct 2025 23:23:27 -0300 Subject: [PATCH 06/12] refactor: rename `SearchButton` to `SearchBar` --- .../src/modules/shared/components/layout/Header.tsx | 8 ++++++-- .../components/layout/{SearchButton.tsx => SearchBar.tsx} | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) rename apps/frontend/src/modules/shared/components/layout/{SearchButton.tsx => SearchBar.tsx} (98%) diff --git a/apps/frontend/src/modules/shared/components/layout/Header.tsx b/apps/frontend/src/modules/shared/components/layout/Header.tsx index 8e38af70..1de824fb 100644 --- a/apps/frontend/src/modules/shared/components/layout/Header.tsx +++ b/apps/frontend/src/modules/shared/components/layout/Header.tsx @@ -13,7 +13,7 @@ import { checkLogin, getUserData } from '@web/modules/auth/features/auth.utils'; import { BlockTab } from './BlockTab'; import { NavLinks } from './NavLinks'; import { RandomSongButton } from './RandomSongButton'; -import { SearchButton } from './SearchButton'; +import { SearchBar } from './SearchBar'; export async function Header() { let isLogged; @@ -50,6 +50,11 @@ export async function Header() { + {/* Search bar */} +
+ +
+ {/* Icon */}
@@ -94,7 +99,6 @@ export async function Header() { label='About' className='bg-cyan-700 after:bg-cyan-900 before:bg-cyan-950' /> -
diff --git a/apps/frontend/src/modules/shared/components/layout/SearchButton.tsx b/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx similarity index 98% rename from apps/frontend/src/modules/shared/components/layout/SearchButton.tsx rename to apps/frontend/src/modules/shared/components/layout/SearchBar.tsx index 80e1c522..8d3e8a95 100644 --- a/apps/frontend/src/modules/shared/components/layout/SearchButton.tsx +++ b/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx @@ -13,7 +13,7 @@ import { import { cn } from '@web/lib/tailwind.utils'; import { MusicalNote } from './MusicalNote'; -export function SearchButton() { +export function SearchBar() { const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); const [isOpen, setIsOpen] = useState(false); From 1180fa3b0fad9c75d53a597564fea20a04f0afa9 Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 6 Oct 2025 23:28:05 -0300 Subject: [PATCH 07/12] fix: move search bar outside of popup and detach from button group --- .../shared/components/layout/SearchBar.tsx | 68 ++++++------------- 1 file changed, 19 insertions(+), 49 deletions(-) diff --git a/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx b/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx index 8d3e8a95..e93e03bd 100644 --- a/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx +++ b/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx @@ -5,22 +5,12 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@web/modules/shared/components/layout/popover'; -import { cn } from '@web/lib/tailwind.utils'; -import { MusicalNote } from './MusicalNote'; - export function SearchBar() { const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); - const [isOpen, setIsOpen] = useState(false); const handleSearch = () => { if (searchQuery.trim()) { - setIsOpen(false); router.push(`/search-song?q=${encodeURIComponent(searchQuery.trim())}`); setSearchQuery(''); } @@ -33,44 +23,24 @@ export function SearchBar() { }; return ( - - - - - -
-

Search Songs

-
- setSearchQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder='Type your search...' - className='flex-1 px-3 py-2 bg-zinc-900 border border-zinc-700 rounded text-zinc-100 placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent' - autoFocus - /> - -
-
-
-
+
+ setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder='Type your search...' + className='flex-1 px-3 py-2 bg-zinc-900 border border-zinc-700 rounded text-zinc-100 placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent' + autoFocus + /> + +
); } From 29dda317c83fdec761a91c9d36288acd93dac8ca Mon Sep 17 00:00:00 2001 From: Bentroen <29354120+Bentroen@users.noreply.github.com> Date: Mon, 6 Oct 2025 23:34:35 -0300 Subject: [PATCH 08/12] feat: rework appearance of search bar --- .../src/modules/shared/components/layout/SearchBar.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx b/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx index e93e03bd..a85f9551 100644 --- a/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx +++ b/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx @@ -23,20 +23,20 @@ export function SearchBar() { }; return ( -
+
setSearchQuery(e.target.value)} onKeyDown={handleKeyDown} - placeholder='Type your search...' - className='flex-1 px-3 py-2 bg-zinc-900 border border-zinc-700 rounded text-zinc-100 placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent' + placeholder='Search songs...' + className='flex-1 px-3 py-2 bg-transparent border border-zinc-700 rounded-l-full text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500 focus:border-transparent' autoFocus />