diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e8cbbe84..c40fc7c3 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,6 +3,9 @@ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "unifiedjs.vscode-mdx", - "orta.vscode-jest" + "orta.vscode-jest", + "bradlc.vscode-tailwindcss", + "fill-labs.dependi", + "gruntfuggly.todo-tree" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index e46823ff..ad6a31bc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,9 @@ "eslint.format.enable": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "always" + }, + "files.associations": { + ".css": "tailwindcss", + "*.scss": "tailwindcss" } } diff --git a/apps/backend/src/song/song.controller.spec.ts b/apps/backend/src/song/song.controller.spec.ts index 099763d6..51865a09 100644 --- a/apps/backend/src/song/song.controller.spec.ts +++ b/apps/backend/src/song/song.controller.spec.ts @@ -26,7 +26,7 @@ import { SongService } from './song.service'; const mockSongService = { getSongByPage: jest.fn(), - searchSongs: jest.fn(), + querySongs: jest.fn(), getSong: jest.fn(), getSongEdit: jest.fn(), patchSong: jest.fn(), @@ -34,8 +34,6 @@ const mockSongService = { deleteSong: jest.fn(), uploadSong: jest.fn(), getRandomSongs: jest.fn(), - getRecentSongs: jest.fn(), - getSongsByCategory: jest.fn(), getSongsForTimespan: jest.fn(), getSongsBeforeTimespan: jest.fn(), getCategories: jest.fn(), @@ -97,13 +95,19 @@ describe('SongController', () => { const query: SongListQueryDTO = { page: 1, limit: 10, q: 'test search' }; const songList: SongPreviewDto[] = []; - mockSongService.searchSongs.mockResolvedValueOnce(songList); + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 0, + }); const result = await songController.getSongList(query); expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); - expect(songService.searchSongs).toHaveBeenCalled(); + expect(result.total).toBe(0); + expect(songService.querySongs).toHaveBeenCalled(); }); it('should handle random sort', async () => { @@ -161,13 +165,28 @@ describe('SongController', () => { }; const songList: SongPreviewDto[] = []; - mockSongService.getRecentSongs.mockResolvedValueOnce(songList); + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 0, + }); const result = await songController.getSongList(query); expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); - expect(songService.getRecentSongs).toHaveBeenCalledWith(1, 10); + expect(result.total).toBe(0); + expect(songService.querySongs).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + limit: 10, + sort: 'createdAt', + order: true, + }), + undefined, + undefined, + ); }); it('should handle recent sort with category', async () => { @@ -179,13 +198,28 @@ describe('SongController', () => { }; const songList: SongPreviewDto[] = []; - mockSongService.getSongsByCategory.mockResolvedValueOnce(songList); + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 0, + }); const result = await songController.getSongList(query); expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); - expect(songService.getSongsByCategory).toHaveBeenCalledWith('pop', 1, 10); + expect(result.total).toBe(0); + expect(songService.querySongs).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + limit: 10, + sort: 'createdAt', + order: true, + }), + undefined, + 'pop', + ); }); it('should handle category filter', async () => { @@ -196,17 +230,128 @@ describe('SongController', () => { }; const songList: SongPreviewDto[] = []; - mockSongService.getSongsByCategory.mockResolvedValueOnce(songList); + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 0, + }); const result = await songController.getSongList(query); expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); - expect(songService.getSongsByCategory).toHaveBeenCalledWith( - 'rock', - 1, - 10, - ); + expect(result.total).toBe(0); + expect(songService.querySongs).toHaveBeenCalled(); + }); + + it('should return correct total when total exceeds limit', async () => { + const query: SongListQueryDTO = { page: 1, limit: 10 }; + const songList: SongPreviewDto[] = Array(10) + .fill(null) + .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 150, + }); + + const result = await songController.getSongList(query); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toHaveLength(10); + expect(result.total).toBe(150); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + }); + + it('should return correct total when total is less than limit', async () => { + const query: SongListQueryDTO = { page: 1, limit: 10 }; + const songList: SongPreviewDto[] = Array(5) + .fill(null) + .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 5, + }); + + const result = await songController.getSongList(query); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toHaveLength(5); + expect(result.total).toBe(5); + }); + + it('should return correct total on later pages', async () => { + const query: SongListQueryDTO = { page: 3, limit: 10 }; + const songList: SongPreviewDto[] = Array(10) + .fill(null) + .map((_, i) => ({ id: `song-${20 + i}` } as SongPreviewDto)); + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 3, + limit: 10, + total: 25, + }); + + const result = await songController.getSongList(query); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toHaveLength(10); + expect(result.total).toBe(25); + expect(result.page).toBe(3); + }); + + it('should handle search query with total count', async () => { + const query: SongListQueryDTO = { page: 1, limit: 10, q: 'test search' }; + const songList: SongPreviewDto[] = Array(8) + .fill(null) + .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 8, + }); + + const result = await songController.getSongList(query); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toHaveLength(8); + expect(result.total).toBe(8); + expect(songService.querySongs).toHaveBeenCalled(); + }); + + it('should handle category filter with total count', async () => { + const query: SongListQueryDTO = { + page: 1, + limit: 10, + category: 'rock', + }; + const songList: SongPreviewDto[] = Array(3) + .fill(null) + .map((_, i) => ({ id: `rock-song-${i}` } as SongPreviewDto)); + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 3, + }); + + const result = await songController.getSongList(query); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toHaveLength(3); + expect(result.total).toBe(3); + expect(songService.querySongs).toHaveBeenCalled(); }); it('should handle errors', async () => { @@ -271,30 +416,291 @@ describe('SongController', () => { }); describe('searchSongs', () => { - it('should return paginated search results', async () => { + it('should return paginated search results with query', async () => { const query: PageQueryDTO = { page: 1, limit: 10 }; const q = 'test query'; + const songList: SongPreviewDto[] = Array(5) + .fill(null) + .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 5, + }); + + const result = await songController.searchSongs(query, q); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toHaveLength(5); + expect(result.total).toBe(5); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + }); + + it('should handle search with empty query string', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const q = ''; const songList: SongPreviewDto[] = []; - mockSongService.searchSongs.mockResolvedValueOnce(songList); + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 0, + }); const result = await songController.searchSongs(query, q); expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); - expect(songService.searchSongs).toHaveBeenCalledWith(query, q); + expect(result.total).toBe(0); + expect(songService.querySongs).toHaveBeenCalledWith(query, '', undefined); }); - it('should handle errors', async () => { + it('should handle search with null query string', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const q = null as any; + const songList: SongPreviewDto[] = []; + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 0, + }); + + const result = await songController.searchSongs(query, q); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toEqual(songList); + expect(songService.querySongs).toHaveBeenCalledWith(query, '', undefined); + }); + + it('should handle search with multiple pages', async () => { + const query: PageQueryDTO = { page: 2, limit: 10 }; + const q = 'test search'; + const songList: SongPreviewDto[] = Array(10) + .fill(null) + .map((_, i) => ({ id: `song-${10 + i}` } as SongPreviewDto)); + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 2, + limit: 10, + total: 25, + }); + + const result = await songController.searchSongs(query, q); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toHaveLength(10); + expect(result.total).toBe(25); + expect(result.page).toBe(2); + expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + }); + + it('should handle search with large result set', async () => { + const query: PageQueryDTO = { page: 1, limit: 50 }; + const q = 'popular song'; + const songList: SongPreviewDto[] = Array(50) + .fill(null) + .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 50, + total: 500, + }); + + const result = await songController.searchSongs(query, q); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toHaveLength(50); + expect(result.total).toBe(500); + expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + }); + + it('should handle search on last page with partial results', async () => { + const query: PageQueryDTO = { page: 5, limit: 10 }; + const q = 'search term'; + const songList: SongPreviewDto[] = Array(3) + .fill(null) + .map((_, i) => ({ id: `song-${40 + i}` } as SongPreviewDto)); + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 5, + limit: 10, + total: 43, + }); + + const result = await songController.searchSongs(query, q); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toHaveLength(3); + expect(result.total).toBe(43); + expect(result.page).toBe(5); + }); + + it('should handle search with special characters', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const q = 'test@#$%^&*()'; + const songList: SongPreviewDto[] = []; + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 0, + }); + + const result = await songController.searchSongs(query, q); + + expect(result).toBeInstanceOf(PageDto); + expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + }); + + it('should handle search with very long query string', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const q = 'a'.repeat(500); + const songList: SongPreviewDto[] = []; + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 0, + }); + + const result = await songController.searchSongs(query, q); + + expect(result).toBeInstanceOf(PageDto); + expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + }); + + it('should handle search with custom limit', async () => { + const query: PageQueryDTO = { page: 1, limit: 25 }; + const q = 'test'; + const songList: SongPreviewDto[] = Array(25) + .fill(null) + .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 25, + total: 100, + }); + + const result = await songController.searchSongs(query, q); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toHaveLength(25); + expect(result.limit).toBe(25); + expect(result.total).toBe(100); + }); + + it('should handle search with sorting parameters', async () => { + const query: PageQueryDTO = { + page: 1, + limit: 10, + sort: 'playCount', + order: false, + }; + const q = 'trending'; + const songList: SongPreviewDto[] = Array(10) + .fill(null) + .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 100, + }); + + const result = await songController.searchSongs(query, q); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toHaveLength(10); + expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + }); + + it('should return correct pagination info with search results', async () => { + const query: PageQueryDTO = { page: 3, limit: 20 }; + const q = 'search'; + const songList: SongPreviewDto[] = Array(20) + .fill(null) + .map((_, i) => ({ id: `song-${40 + i}` } as SongPreviewDto)); + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 3, + limit: 20, + total: 250, + }); + + const result = await songController.searchSongs(query, q); + + expect(result.page).toBe(3); + expect(result.limit).toBe(20); + expect(result.total).toBe(250); + expect(result.content).toHaveLength(20); + }); + + it('should handle search with no results', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const q = 'nonexistent song title xyz'; + const songList: SongPreviewDto[] = []; + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 0, + }); + + const result = await songController.searchSongs(query, q); + + expect(result).toBeInstanceOf(PageDto); + expect(result.content).toHaveLength(0); + expect(result.total).toBe(0); + }); + + it('should handle search errors', async () => { const query: PageQueryDTO = { page: 1, limit: 10 }; const q = 'test query'; - mockSongService.searchSongs.mockRejectedValueOnce(new Error('Error')); + mockSongService.querySongs.mockRejectedValueOnce( + new Error('Database error'), + ); await expect(songController.searchSongs(query, q)).rejects.toThrow( - 'Error', + 'Database error', ); }); + + it('should handle search with whitespace-only query', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const q = ' '; + const songList: SongPreviewDto[] = []; + + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 0, + }); + + const result = await songController.searchSongs(query, q); + + expect(result).toBeInstanceOf(PageDto); + expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + }); }); describe('getSong', () => { diff --git a/apps/backend/src/song/song.controller.ts b/apps/backend/src/song/song.controller.ts index dd25d291..d50f2a8f 100644 --- a/apps/backend/src/song/song.controller.ts +++ b/apps/backend/src/song/song.controller.ts @@ -77,7 +77,7 @@ export class SongController { **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) + - \`sort\`: Sort songs by criteria (recent, random, playCount, title, duration, noteCount) - \`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 @@ -100,33 +100,6 @@ export class SongController { 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)) { @@ -147,72 +120,38 @@ export class SongController { }); } - // Handle recent sort - if (query.sort === SongSortType.RECENT) { - // If category is provided, use getSongsByCategory (which also sorts by recent) - 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.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([ + // Map sort types to MongoDB field paths + const sortFieldMap = new Map([ + [SongSortType.RECENT, 'createdAt'], [SongSortType.PLAY_COUNT, 'playCount'], [SongSortType.TITLE, 'title'], - [SongSortType.DURATION, 'duration'], - [SongSortType.NOTE_COUNT, 'noteCount'], + [SongSortType.DURATION, 'stats.duration'], + [SongSortType.NOTE_COUNT, 'stats.noteCount'], ]); const sortField = sortFieldMap.get(query.sort) ?? 'createdAt'; + const isDescending = query.order ? query.order === 'desc' : true; + // Build PageQueryDTO with the sort field const pageQuery = new PageQueryDTO({ page: query.page, limit: query.limit, sort: sortField, - order: query.order === 'desc' ? false : true, + order: isDescending, }); - const data = await this.songService.getSongByPage(pageQuery); + + // Query songs with optional search and category filters + const result = await this.songService.querySongs( + pageQuery, + query.q, + query.category, + ); + return new PageDto({ - content: data, + content: result.content, page: query.page, limit: query.limit, - total: data.length, + total: result.total, }); } @@ -323,12 +262,12 @@ export class SongController { @Query() query: PageQueryDTO, @Query('q') q: string, ): Promise> { - const data = await this.songService.searchSongs(query, q ?? ''); + const result = await this.songService.querySongs(query, q ?? ''); return new PageDto({ - content: data, + content: result.content, page: query.page, limit: query.limit, - total: data.length, + total: result.total, }); } diff --git a/apps/backend/src/song/song.service.spec.ts b/apps/backend/src/song/song.service.spec.ts index 024d719c..4a944f7f 100644 --- a/apps/backend/src/song/song.service.spec.ts +++ b/apps/backend/src/song/song.service.spec.ts @@ -1028,11 +1028,15 @@ describe('SongService', () => { }); }); - describe('getSongsByCategory', () => { - it('should return a list of songs by category', async () => { - const category = 'test-category'; - const page = 1; - const limit = 10; + describe('querySongs', () => { + it('should return songs sorted by field with optional category filter', async () => { + const query = { + page: 1, + limit: 10, + sort: 'stats.duration', + order: false, + }; + const category = 'pop'; const songList: SongWithUser[] = []; const mockFind = { @@ -1044,21 +1048,27 @@ describe('SongService', () => { }; jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); + jest.spyOn(songModel, 'countDocuments').mockResolvedValue(0); - const result = await service.getSongsByCategory(category, page, limit); + const result = await service.querySongs(query, undefined, category); - expect(result).toEqual( + expect(result.content).toEqual( songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), ); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + expect(result.total).toBe(0); expect(songModel.find).toHaveBeenCalledWith({ - category, visibility: 'public', + category, }); - expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: -1 }); - expect(mockFind.skip).toHaveBeenCalledWith(page * limit - limit); - expect(mockFind.limit).toHaveBeenCalledWith(limit); + expect(mockFind.sort).toHaveBeenCalledWith({ 'stats.duration': 1 }); + expect(mockFind.skip).toHaveBeenCalledWith( + (query.limit as number) * ((query.page as number) - 1), + ); + expect(mockFind.limit).toHaveBeenCalledWith(query.limit); expect(mockFind.populate).toHaveBeenCalledWith( 'uploader', @@ -1066,13 +1076,19 @@ describe('SongService', () => { ); expect(mockFind.exec).toHaveBeenCalled(); + expect(songModel.countDocuments).toHaveBeenCalledWith({ + visibility: 'public', + category, + }); }); - }); - describe('getRecentSongs', () => { - it('should return recent songs', async () => { - const page = 1; - const limit = 10; + it('should work without category filter', async () => { + const query = { + page: 1, + limit: 10, + sort: 'createdAt', + order: true, + }; const songList: SongWithUser[] = []; const mockFind = { @@ -1084,17 +1100,55 @@ describe('SongService', () => { }; jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); + jest.spyOn(songModel, 'countDocuments').mockResolvedValue(0); - const result = await service.getRecentSongs(page, limit); + const result = await service.querySongs(query); - expect(result).toEqual( + expect(songModel.find).toHaveBeenCalledWith({ visibility: 'public' }); + expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: -1 }); + expect(result.content).toEqual( songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), ); + expect(result.total).toBe(0); + }); - expect(songModel.find).toHaveBeenCalledWith({ visibility: 'public' }); - expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: -1 }); - expect(mockFind.skip).toHaveBeenCalledWith(page * limit - limit); - expect(mockFind.limit).toHaveBeenCalledWith(limit); + it('should search with text query and filters', async () => { + const query = { + page: 1, + limit: 10, + sort: 'playCount', + order: false, + }; + const searchTerm = 'test song'; + const category = 'rock'; + const songList: SongWithUser[] = []; + + const mockFind = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + populate: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(songList), + }; + + jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); + jest.spyOn(songModel, 'countDocuments').mockResolvedValue(0); + + const result = await service.querySongs(query, searchTerm, category); + + expect(result.content).toEqual( + songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), + ); + expect(result.total).toBe(0); + + expect(songModel.find).toHaveBeenCalledWith( + expect.objectContaining({ + visibility: 'public', + category, + }), + ); + + expect(mockFind.sort).toHaveBeenCalledWith({ playCount: 1 }); }); }); diff --git a/apps/backend/src/song/song.service.ts b/apps/backend/src/song/song.service.ts index d07bfa65..6e6d59cf 100644 --- a/apps/backend/src/song/song.service.ts +++ b/apps/backend/src/song/song.service.ts @@ -210,67 +210,76 @@ export class SongService { return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); } - public async searchSongs( + public async querySongs( query: PageQueryDTO, - q: string, - ): Promise { + q?: string, + category?: 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 descending = query.order ?? true; + + const allowedSorts = new Set([ + 'createdAt', + 'playCount', + 'title', + 'stats.duration', + 'stats.noteCount', + ]); 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[]; + // Add category filter if provided + if (category) { + mongoQuery.category = category; + } - return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); - } + // Add search filter if search query is provided + if (q) { + 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 + if (terms.length > 0) { + const andClauses = terms.map((word) => ({ + $or: [ + { title: { $regex: word, $options: 'i' } }, + { originalAuthor: { $regex: word, $options: 'i' } }, + { description: { $regex: word, $options: 'i' } }, + ], + })); + mongoQuery.$and = andClauses; + } + } - public async getRecentSongs( - page: number, - limit: number, - ): Promise { - const queryObject: any = { - visibility: 'public', - }; + const sortOrder = descending ? -1 : 1; - const data = (await this.songModel - .find(queryObject) - .sort({ - createdAt: -1, - }) - .skip(page * limit - limit) - .limit(limit) - .populate('uploader', 'username profileImage -_id') - .exec()) as unknown as SongWithUser[]; + const [songs, total] = await Promise.all([ + this.songModel + .find(mongoQuery) + .sort({ [sortField]: sortOrder }) + .skip(limit * (page - 1)) + .limit(limit) + .populate('uploader', 'username profileImage -_id') + .exec() as unknown as Promise, + this.songModel.countDocuments(mongoQuery), + ]); - return data.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); + return { + content: songs.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song), + ), + page, + limit, + total, + }; } public async getSongsForTimespan(timespan: number): Promise { @@ -524,25 +533,6 @@ export class SongService { }, {} as Record); } - public async getSongsByCategory( - category: string, - page: number, - limit: number, - ): Promise { - const songs = (await this.songModel - .find({ - category: category, - visibility: 'public', - }) - .sort({ createdAt: -1 }) - .skip(page * limit - limit) - .limit(limit) - .populate('uploader', 'username profileImage -_id') - .exec()) as unknown as SongWithUser[]; - - return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); - } - public async getRandomSongs( count: number, category?: string, diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 081c9bbe..296f3c78 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -46,6 +46,7 @@ "next-recaptcha-v3": "^1.5.3", "nextjs-toploader": "^3.9.17", "npm": "^11.7.0", + "nuqs": "^2.8.6", "postcss": "8.5.6", "react": "19.2.1", "react-dom": "19.2.1", diff --git a/apps/frontend/src/app/(content)/search/page.tsx b/apps/frontend/src/app/(content)/search/page.tsx index d975afab..61276b91 100644 --- a/apps/frontend/src/app/(content)/search/page.tsx +++ b/apps/frontend/src/app/(content)/search/page.tsx @@ -1,23 +1,28 @@ 'use client'; import { + faArrowDown19, + faArrowDown91, + faArrowDownAZ, + faArrowDownZA, faEllipsis, faFilter, - faMagnifyingGlass, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { UPLOAD_CONSTANTS, SEARCH_FEATURES, INSTRUMENTS } from '@nbw/config'; -import { useRouter, useSearchParams, usePathname } from 'next/navigation'; +import Image from 'next/image'; +import Link from 'next/link'; +import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; import { useEffect, useMemo, useState } from 'react'; +import Skeleton from 'react-loading-skeleton'; import { create } from 'zustand'; -import { SongPreviewDtoType } from '@nbw/database'; +import { UPLOAD_CONSTANTS, SEARCH_FEATURES, INSTRUMENTS } from '@nbw/config'; +import { SongPreviewDtoType } from '@nbw/database'; 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'; import { DualRangeSlider } from '@web/modules/shared/components/ui/dualRangeSlider'; -import { Select } from '@web/modules/shared/components/client/FormElements'; import MultipleSelector from '@web/modules/shared/components/ui/multipleSelectorProps'; interface SearchParams { @@ -42,10 +47,31 @@ interface PageDto { total: number; } +// TODO: importing these enums from '@nbw/database' is causing issues. +// They shouldn't be redefined here. +enum SongSortType { + RECENT = 'recent', + RANDOM = 'random', + PLAY_COUNT = 'playCount', + TITLE = 'title', + DURATION = 'duration', + NOTE_COUNT = 'noteCount', +} + +enum SongOrderType { + ASC = 'asc', + DESC = 'desc', +} + +// TODO: refactor with PAGE_SIZE constant +const PLACEHOLDER_COUNT = 12; + +const makePlaceholders = () => + Array.from({ length: PLACEHOLDER_COUNT }, () => null); + interface SongSearchState { - songs: SongPreviewDtoType[]; + songs: Array; loading: boolean; - isFilterChange: boolean; hasMore: boolean; currentPage: number; totalResults: number; @@ -59,7 +85,6 @@ interface SongSearchActions { const initialState: SongSearchState = { songs: [], loading: true, - isFilterChange: false, hasMore: true, currentPage: 1, totalResults: 0, @@ -71,11 +96,20 @@ export const useSongSearchStore = create( // The core data fetching action searchSongs: async (params, pageNum) => { - // Set loading states. If it's the first page, it's a filter change. - set({ - loading: true, - isFilterChange: pageNum === 1 && get().songs.length > 0, - }); + // New search/sort (page 1): reset to placeholders. Load more: append placeholders. + if (pageNum === 1) { + set({ + loading: true, + songs: makePlaceholders(), + currentPage: 1, + hasMore: true, + }); + } else { + set((state) => ({ + loading: true, + songs: [...state.songs, ...makePlaceholders()], + })); + } try { const response = await axiosInstance.get>( @@ -84,11 +118,14 @@ export const useSongSearchStore = create( ); const { content, total } = response.data; - const limit = params.limit || 20; + const limit = params.limit || 12; set((state) => ({ - // If it's the first page, replace songs. Otherwise, append them. - songs: pageNum === 1 ? content : [...state.songs, ...content], + // Remove placeholders and add the new results + songs: + pageNum === 1 + ? content + : [...state.songs.filter((s) => s !== null), ...content], totalResults: total, currentPage: pageNum, // Check if there are more pages to load @@ -98,7 +135,7 @@ export const useSongSearchStore = create( console.error('Error searching songs:', error); set({ songs: [], hasMore: false, totalResults: 0 }); // Reset on error } finally { - set({ loading: false, isFilterChange: false }); + set({ loading: false }); } }, @@ -113,45 +150,9 @@ export const useSongSearchStore = create( }), ); -const SearchPageSkeleton = () => ( -
-
- -

Searching...

-
- - {/* Filter skeletons */} -
-
-
-
-
- - - {Array.from({ length: 12 }).map((_, i) => ( - - ))} - -
-); - -/** - * A full-screen overlay with a spinner, shown during filter changes. - */ -const LoadingOverlay = () => ( -
-
-
-

Updating results...

-
-
-); - interface SearchHeaderProps { query: string; + loading: boolean; songsCount: number; totalResults: number; } @@ -162,46 +163,29 @@ interface SearchHeaderProps { */ const SearchHeader = ({ query, + loading, songsCount, totalResults, }: SearchHeaderProps) => { const isSearch = useMemo(() => query !== '', [query]); - const title = useMemo( - () => (isSearch ? 'Search Results' : 'Browse Songs'), - [isSearch], - ); - const description = useMemo(() => { + const title = useMemo(() => { + if (loading) return ''; if (isSearch) { - if (totalResults !== 1) { - const template = '{totalResults} result{plural} for "{query}"'; - return template - .replace('{totalResults}', totalResults.toString()) - .replace('{plural}', totalResults !== 1 ? 's' : '') - .replace('{query}', query); + // TODO: implement this with proper variable substitution for translations + if (totalResults != 1) { + return `${totalResults.toLocaleString('en-UK')} results for "${query}"`; } - const template = '1 result for "{query}"'; - return template.replace('{query}', query); - } - if (songsCount !== 1) { - const template = 'Showing {songsCount} of {totalResults} songs'; - return template - .replace('{songsCount}', songsCount.toString()) - .replace('{totalResults}', totalResults.toString()); + return `1 result for "${query}"`; } - const template = 'Showing 1 song of {totalResults} songs'; - return template.replace('{totalResults}', totalResults.toString()); - }, [isSearch, query, songsCount, totalResults]); + return 'Browse songs'; + }, [loading, isSearch, query, songsCount, totalResults]); + return (
- -
-

{title}

- {query &&

{description}

} -
+

+ {title || } +

); }; @@ -400,49 +384,47 @@ const SearchFilters = ({ filters, onFilterChange }: SearchFiltersProps) => { }; const NoResults = () => ( -
- -

No songs found

-

- Try adjusting your search terms or filters, or browse our featured songs +

+ +

No songs found

+

+ Try adjusting your search terms, or browse our{' '} + + featured songs + {' '} instead.

); interface SearchResultsProps { - songs: SongPreviewDtoType[]; + songs: Array; loading: boolean; hasMore: boolean; onLoadMore: () => void; } -const SearchResults = ({ - songs, - loading, - hasMore, - onLoadMore, -}: SearchResultsProps) => ( +const SearchResults = ({ songs, hasMore, onLoadMore }: SearchResultsProps) => ( <> {songs.map((song, i) => ( - + ))} {/* Load more / End indicator */} -
- {loading ? ( -
-
- Loading more songs... -
- ) : hasMore ? ( +
+ {hasMore ? ( ) : (
-

You've reached the end

)}
@@ -450,37 +432,44 @@ const SearchResults = ({ ); const SearchSongPage = () => { - const router = useRouter(); - const pathname = usePathname(); - 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', 10); - const limit = parseInt(searchParams.get('limit') || '20', 10); - const noteCountMin = parseInt(searchParams.get('noteCountMin') || '0', 10); - const noteCountMax = parseInt( - searchParams.get('noteCountMax') || '10000', - 10, - ); - const durationMin = parseInt(searchParams.get('durationMin') || '0', 10); - const durationMax = parseInt(searchParams.get('durationMax') || '10000', 10); - const features = searchParams.get('features') || ''; - const instruments = searchParams.get('instruments') || ''; + const [queryState, setQueryState] = useQueryStates({ + q: parseAsString.withDefault(''), + sort: parseAsString.withDefault(SongSortType.RECENT), + order: parseAsString.withDefault(SongOrderType.DESC), + category: parseAsString.withDefault(''), + uploader: parseAsString.withDefault(''), + page: parseAsInteger.withDefault(1), + limit: parseAsInteger.withDefault(12), + noteCountMin: parseAsInteger, + noteCountMax: parseAsInteger, + durationMin: parseAsInteger, + durationMax: parseAsInteger, + features: parseAsString, + instruments: parseAsString, + }); const { - songs, - loading, - hasMore, - totalResults, - isFilterChange, - searchSongs, - loadMore, - } = useSongSearchStore(); - const [showFilters, setShowFilters] = useState(true); + q: query, + sort, + order, + category, + uploader, + page: currentPageParam, + limit, + noteCountMin, + noteCountMax, + durationMin, + durationMax, + features, + instruments, + } = queryState; + + const initialPage = currentPageParam ?? 1; + + const { songs, loading, hasMore, totalResults, searchSongs } = + useSongSearchStore(); + + const [showFilters, setShowFilters] = useState(false); useEffect(() => { const params: SearchParams = { @@ -515,57 +504,35 @@ const SearchSongPage = () => { searchSongs, ]); - const updateURL = (params: Record) => { - const newParams = new URLSearchParams(searchParams.toString()); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') { - newParams.set(key, String(value)); - } else { - newParams.delete(key); - } - }); - // Reset to page 1 on any filter change - if (!params.page) { - newParams.set('page', '1'); - } - router.push(`${pathname}?${newParams.toString()}`); - }; - const handleLoadMore = () => { - const params: SearchParams = { - q: query, - sort, - order, - category, - uploader, - limit, - noteCountMin: noteCountMin > 0 ? noteCountMin : undefined, - noteCountMax: noteCountMax < 10000 ? noteCountMax : undefined, - durationMin: durationMin > 0 ? durationMin : undefined, - durationMax: durationMax < 10000 ? durationMax : undefined, - features: features || undefined, - instruments: instruments || undefined, - }; - loadMore(params); + setQueryState({ page: (currentPageParam ?? 1) + 1 }); }; const handleSortChange = (value: string) => { - updateURL({ sort: value }); + setQueryState({ sort: value, page: 1 }); }; - if (loading && songs.length === 0) { - return ; - } + const handleOrderChange = () => { + const newOrder = + order === SongOrderType.ASC ? SongOrderType.DESC : SongOrderType.ASC; + setQueryState({ order: newOrder, page: 1 }); + }; - return ( -
- {/* Loading overlay for filter changes */} - {isFilterChange && } + /* Use 19/91 button if sorting by a numeric value, otherwise use AZ/ZA */ + const orderIcon = useMemo(() => { + if (sort === SongSortType.TITLE) { + return order === SongOrderType.ASC ? faArrowDownAZ : faArrowDownZA; + } else { + return order === SongOrderType.ASC ? faArrowDown19 : faArrowDown91; + } + }, [sort, order]); + return ( +
{/* Filters Sidebar */} - {showFilters && ( -
+ {/* {showFilters && ( +
{ onFilterChange={(params) => updateURL(params)} />
- )} + )} */} {/* Main Content */}
@@ -591,33 +558,53 @@ const SearchSongPage = () => {
- + */}
Sort by:
+ + {/* Order button */} +
diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css index f449a0d9..ac1db4c4 100644 --- a/apps/frontend/src/app/globals.css +++ b/apps/frontend/src/app/globals.css @@ -16,32 +16,27 @@ body { } ::-webkit-scrollbar { - width: 1rem; /* w-4 */ + @apply w-4; } /* Track */ ::-webkit-scrollbar-track { - background-color: rgb(39 39 42 / 0.5); /* bg-zinc-800/50 */ - border-radius: 9999px; /* rounded-full */ + @apply bg-zinc-800/50 rounded-full; } /* Handle */ ::-webkit-scrollbar-thumb { - background-color: rgb(113 113 122); /* bg-zinc-500 */ - width: 0.5rem; /* w-2 */ - border-radius: 9999px; /* rounded-full */ - border: 1rem solid transparent; /* border-4 */ - background-clip: content-box; /* bg-clip-content */ + @apply bg-zinc-500 w-2 rounded-full border-4 border-solid border-transparent bg-clip-content; } /* Handle on hover */ ::-webkit-scrollbar-thumb:hover { - background-color: rgb(82 82 91); /* bg-zinc-600 */ + @apply bg-zinc-600; } /* Handle on active */ ::-webkit-scrollbar-thumb:active { - background-color: rgb(63 63 70); /* bg-zinc-700 */ + @apply bg-zinc-700; } /************** Animations **************/ @@ -153,8 +148,8 @@ body { .bevel { position: relative; - --bevel-after-bg: rgb(63 63 70); /* zinc-800 default */ - --bevel-before-bg: rgb(24 24 27); /* zinc-900 default */ + --bevel-after-bg: var(--color-zinc-700); + --bevel-before-bg: var(--color-zinc-800); } .bevel:after, .bevel:before { diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index 20449b37..6e9f65ae 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -4,6 +4,7 @@ import { Lato } from 'next/font/google'; import './globals.css'; import { ReCaptchaProvider } from 'next-recaptcha-v3'; import NextTopLoader from 'nextjs-toploader'; +import { NuqsAdapter } from 'nuqs/adapters/next/app'; import { Toaster } from 'react-hot-toast'; import 'react-loading-skeleton/dist/skeleton.css'; import { SkeletonTheme } from 'react-loading-skeleton'; @@ -88,7 +89,9 @@ export default function RootLayout({ /> - + - {children} + {children} diff --git a/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx b/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx index 8db19494..73ebec00 100644 --- a/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx +++ b/apps/frontend/src/modules/shared/components/layout/SearchBar.tsx @@ -9,36 +9,66 @@ export function SearchBar() { const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); - const handleSearch = () => { - if (searchQuery.trim()) { - router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch(); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const q = searchQuery.trim(); + if (q) { + router.push(`/search?q=${encodeURIComponent(q)}`); } }; return ( -
+
+ setSearchQuery(e.target.value)} - onKeyDown={handleKeyDown} - 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' + placeholder='Search songs' + enterKeyHint='search' + autoComplete='off' + autoCorrect='off' + autoCapitalize='none' + spellCheck={false} + className='flex-1 px-3 py-2 pr-1 bg-transparent border border-zinc-700 rounded-l-full text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500' /> -
+ {/* eslint-disable-next-line react/no-unknown-property */} + + ); } diff --git a/apps/frontend/src/modules/shared/components/layout/SignOutButton.tsx b/apps/frontend/src/modules/shared/components/layout/SignOutButton.tsx index be4638ed..593ffa5f 100644 --- a/apps/frontend/src/modules/shared/components/layout/SignOutButton.tsx +++ b/apps/frontend/src/modules/shared/components/layout/SignOutButton.tsx @@ -15,7 +15,7 @@ export function SignInButton() {
- +
Sign in @@ -30,10 +30,10 @@ export function UploadButton() { -
+
@@ -49,10 +49,10 @@ export function SettingsButton() { return ( -
+
diff --git a/bun.lock b/bun.lock index 3e7b103b..232e3c66 100644 --- a/bun.lock +++ b/bun.lock @@ -143,6 +143,7 @@ "next-recaptcha-v3": "^1.5.3", "nextjs-toploader": "^3.9.17", "npm": "^11.7.0", + "nuqs": "^2.8.6", "postcss": "8.5.6", "react": "19.2.1", "react-dom": "19.2.1", @@ -951,6 +952,8 @@ "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="], + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="], @@ -2513,6 +2516,8 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "nuqs": ["nuqs@2.8.6", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-aRxeX68b4ULmhio8AADL2be1FWDy0EPqaByPvIYWrA7Pm07UjlrICp/VPlSnXJNAG0+3MQwv3OporO2sOXMVGA=="], + "oauth": ["oauth@0.10.2", "", {}, "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], diff --git a/packages/database/src/song/dto/SongListQuery.dto.ts b/packages/database/src/song/dto/SongListQuery.dto.ts index 4897f50e..ae7f72bf 100644 --- a/packages/database/src/song/dto/SongListQuery.dto.ts +++ b/packages/database/src/song/dto/SongListQuery.dto.ts @@ -11,10 +11,10 @@ import { export enum SongSortType { RECENT = 'recent', RANDOM = 'random', - PLAY_COUNT = 'play-count', + PLAY_COUNT = 'playCount', TITLE = 'title', DURATION = 'duration', - NOTE_COUNT = 'note-count', + NOTE_COUNT = 'noteCount', } export enum SongOrderType { diff --git a/packages/database/src/song/entity/song.entity.ts b/packages/database/src/song/entity/song.entity.ts index 74e335c8..29d7bd41 100644 --- a/packages/database/src/song/entity/song.entity.ts +++ b/packages/database/src/song/entity/song.entity.ts @@ -94,6 +94,12 @@ export class Song { export const SongSchema = SchemaFactory.createForClass(Song); +// Add indexes for commonly queried fields +SongSchema.index({ 'stats.duration': 1 }); +SongSchema.index({ 'stats.noteCount': 1 }); +SongSchema.index({ visibility: 1, createdAt: -1 }); +SongSchema.index({ category: 1, createdAt: -1 }); + export type SongDocument = Song & Document; export type SongWithUser = Omit & {