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, 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 6a045e43..00000000 --- a/apps/backend/src/song-browser/song-browser.controller.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - BadRequestException, - Controller, - Get, - Param, - Query, -} from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; - -import { FeaturedSongsDto, PageQueryDTO, SongPreviewDto } from '@nbw/database'; - -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 a99e2d2a..00000000 --- a/apps/backend/src/song-browser/song-browser.service.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; - -import { BROWSER_SONGS } from '@nbw/config'; -import { - FeaturedSongsDto, - PageQueryDTO, - SongPreviewDto, - SongWithUser, - TimespanType, -} from '@nbw/database'; -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.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 55329563..ed4f0c39 100644 --- a/apps/backend/src/song/song.controller.ts +++ b/apps/backend/src/song/song.controller.ts @@ -1,11 +1,13 @@ import type { RawBodyRequest } from '@nestjs/common'; import { + BadRequestException, Body, Controller, Delete, Get, Headers, HttpStatus, + Logger, Param, Patch, Post, @@ -25,6 +27,7 @@ import { ApiBody, ApiConsumes, ApiOperation, + ApiResponse, ApiTags, } from '@nestjs/swagger'; import type { Response } from 'express'; @@ -36,6 +39,10 @@ import { SongViewDto, UploadSongDto, UploadSongResponseDto, + PageDto, + SongListQueryDTO, + SongSortType, + FeaturedSongsDto, } from '@nbw/database'; import type { UserDocument } from '@nbw/database'; import { FileService } from '@server/file/file.service'; @@ -43,20 +50,15 @@ import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser'; import { SongService } from './song.service'; -// Handles public-facing song routes. - @Controller('song') @ApiTags('song') export class SongController { + private logger = new Logger(SongController.name); 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 +70,187 @@ export class SongController { @Get('/') @ApiOperation({ - summary: 'Get a filtered/sorted list of songs with pagination', + summary: 'Get songs with filtering and sorting options', + description: ` + Retrieves songs based on the provided query parameters. + + **Query Parameters:** + - \`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 Type:** + - PageDto: Paginated list of song previews + `, + }) + @ApiResponse({ + status: 200, + description: 'Success. Returns paginated list of song previews.', + type: PageDto, + }) + @ApiResponse({ + status: 400, + description: 'Bad Request. Invalid query parameters.', }) public async getSongList( + @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, + }); + } + + // 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, + limit: query.limit, + total: data.length, + }); + } + + @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', + }) + public async searchSongs( @Query() query: PageQueryDTO, - ): Promise { - return await this.songService.getSongByPage(query); + @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 +278,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 +348,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', 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 5db33619..827917e8 100644 --- a/apps/backend/src/song/song.service.ts +++ b/apps/backend/src/song/song.service.ts @@ -8,17 +8,19 @@ import { import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; -import { BROWSER_SONGS } from '@nbw/config'; -import type { UserDocument } from '@nbw/database'; +import { BROWSER_SONGS, TIMESPANS } from '@nbw/config'; import { + FeaturedSongsDto, + UserDocument, PageQueryDTO, Song as SongEntity, SongPageDto, SongPreviewDto, SongViewDto, - SongWithUser, UploadSongDto, UploadSongResponseDto, + type SongWithUser, + TimespanType, } from '@nbw/database'; import { FileService } from '@server/file/file.service'; @@ -202,6 +204,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[]; @@ -450,14 +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', - }, + $match: matchStage, }, { $sample: { @@ -475,7 +526,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/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index ef734667..59ed0bed 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -1,36 +1,26 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - // NestJS specific settings - "module": "commonjs", - "target": "ES2021", - "declaration": true, - "removeComments": true, - "allowSyntheticDefaultImports": true, - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - // Relaxed strict settings for backend - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, - // Path mapping - "paths": { - "@server/*": [ - "src/*" - ] - } - }, - "include": [ - "src/**/*.ts", - "src/**/*.d.ts" - ], - "exclude": [ - "node_modules", - "dist", - "e2e/**/*", - "test/**/*" - ] -} \ No newline at end of file + "extends": "../../tsconfig.base.json", + "compilerOptions": { + // NestJS specific settings + "module": "commonjs", + "target": "ES2021", + "declaration": true, + "removeComments": true, + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + // Relaxed strict settings for backend + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + // Path mapping + "paths": { + "@server/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["node_modules", "dist", "e2e/**/*", "test/**/*"] +} 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/page.tsx b/apps/frontend/src/app/(content)/search/page.tsx new file mode 100644 index 00000000..dd0c066b --- /dev/null +++ b/apps/frontend/src/app/(content)/search/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/Header.tsx b/apps/frontend/src/modules/shared/components/layout/Header.tsx index c2862739..64216a19 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 { SearchBar } from './SearchBar'; export async function Header() { let isLogged; @@ -33,22 +34,6 @@ export async function Header() {
{/* Navbar */}