Skip to content

Commit ca60eee

Browse files
committed
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.
1 parent 9de7c27 commit ca60eee

File tree

10 files changed

+513
-184
lines changed

10 files changed

+513
-184
lines changed

apps/backend/src/song/song.controller.ts

Lines changed: 148 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Get,
88
Headers,
99
HttpStatus,
10+
Logger,
1011
Param,
1112
Patch,
1213
Post,
@@ -26,8 +27,6 @@ import {
2627
ApiBody,
2728
ApiConsumes,
2829
ApiOperation,
29-
ApiParam,
30-
ApiQuery,
3130
ApiResponse,
3231
ApiTags,
3332
} from '@nestjs/swagger';
@@ -41,8 +40,11 @@ import {
4140
UploadSongDto,
4241
UploadSongResponseDto,
4342
PageDto,
43+
SongListQueryDTO,
44+
SongSortType,
45+
FeaturedSongsDto,
4446
} from '@nbw/database';
45-
import type { FeaturedSongsDto, UserDocument } from '@nbw/database';
47+
import type { UserDocument } from '@nbw/database';
4648
import { FileService } from '@server/file/file.service';
4749
import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser';
4850

@@ -51,6 +53,7 @@ import { SongService } from './song.service';
5153
@Controller('song')
5254
@ApiTags('song')
5355
export class SongController {
56+
private logger = new Logger(SongController.name);
5457
static multerConfig: MulterOptions = {
5558
limits: { fileSize: UPLOAD_CONSTANTS.file.maxSize },
5659
fileFilter: (req, file, cb) => {
@@ -67,147 +70,128 @@ export class SongController {
6770

6871
@Get('/')
6972
@ApiOperation({
70-
summary: 'Get songs with various filtering and browsing options',
73+
summary: 'Get songs with filtering and sorting options',
7174
description: `
72-
Retrieves songs based on the provided query parameters. Supports multiple modes:
73-
74-
**Default mode** (no 'q' parameter): Returns paginated songs with sorting/filtering
75-
76-
**Special query modes** (using 'q' parameter):
77-
- \`featured\`: Get recent popular songs with pagination
78-
- \`recent\`: Get recently uploaded songs with pagination
79-
- \`categories\`:
80-
- Without 'id': Returns a record of available categories and their song counts
81-
- With 'id': Returns songs from the specified category with pagination
82-
- \`random\`: Returns random songs (requires 'count' parameter, 1-10 songs, optionally filtered by 'category')
75+
Retrieves songs based on the provided query parameters.
8376
8477
**Query Parameters:**
85-
- Standard pagination/sorting via PageQueryDTO (page, limit, sort, order, timespan)
86-
- \`q\`: Special query mode ('featured', 'recent', 'categories', 'random')
87-
- \`id\`: Category ID (used with q=categories to get songs from specific category)
88-
- \`count\`: Number of random songs to return (1-10, used with q=random)
89-
- \`category\`: Category filter for random songs (used with q=random)
78+
- \`q\`: Search string to filter songs by title or description (optional)
79+
- \`sort\`: Sort songs by criteria (recent, random, play-count, title, duration, note-count)
80+
- \`order\`: Sort order (asc, desc) - only applies if sort is not random
81+
- \`category\`: Filter by category - if left empty, returns songs in any category
82+
- \`uploader\`: Filter by uploader username - if provided, will only return songs uploaded by that user
83+
- \`page\`: Page number (default: 1)
84+
- \`limit\`: Number of items to return per page (default: 10)
9085
91-
**Return Types:**
92-
- SongPreviewDto[]: Array of song previews (most cases)
93-
- Record<string, number>: Category name to count mapping (when q=categories without id)
86+
**Return Type:**
87+
- PageDto<SongPreviewDto>: Paginated list of song previews
9488
`,
9589
})
96-
@ApiQuery({
97-
name: 'q',
98-
required: false,
99-
enum: ['featured', 'recent', 'categories', 'random'],
100-
description:
101-
'Special query mode. If not provided, returns standard paginated song list.',
102-
example: 'recent',
103-
})
104-
@ApiParam({
105-
name: 'id',
106-
required: false,
107-
type: 'string',
108-
description:
109-
'Category ID. Only used when q=categories to get songs from a specific category.',
110-
example: 'pop',
111-
})
112-
@ApiQuery({
113-
name: 'count',
114-
required: false,
115-
type: 'string',
116-
description:
117-
'Number of random songs to return (1-10). Only used when q=random.',
118-
example: '5',
119-
})
120-
@ApiQuery({
121-
name: 'category',
122-
required: false,
123-
type: 'string',
124-
description: 'Category filter for random songs. Only used when q=random.',
125-
example: 'electronic',
126-
})
12790
@ApiResponse({
12891
status: 200,
129-
description:
130-
'Success. Returns either an array of song previews or category counts.',
131-
schema: {
132-
oneOf: [
133-
{
134-
type: 'array',
135-
items: { $ref: '#/components/schemas/SongPreviewDto' },
136-
description:
137-
'Array of song previews (default behavior and most query modes)',
138-
},
139-
{
140-
type: 'object',
141-
additionalProperties: { type: 'number' },
142-
description:
143-
'Category name to song count mapping (only when q=categories without id)',
144-
example: { pop: 42, rock: 38, electronic: 15 },
145-
},
146-
],
147-
},
92+
description: 'Success. Returns paginated list of song previews.',
93+
type: PageDto<SongPreviewDto>,
14894
})
14995
@ApiResponse({
15096
status: 400,
151-
description:
152-
'Bad Request. Invalid query parameters (e.g., invalid count for random query).',
97+
description: 'Bad Request. Invalid query parameters.',
15398
})
15499
public async getSongList(
155-
@Query() query: PageQueryDTO,
156-
@Query('q') q?: 'featured' | 'recent' | 'categories' | 'random',
157-
@Param('id') id?: string,
158-
@Query('category') category?: string,
159-
): Promise<
160-
PageDto<SongPreviewDto> | Record<string, number> | FeaturedSongsDto
161-
> {
162-
if (q) {
163-
switch (q) {
164-
case 'featured':
165-
return await this.songService.getFeaturedSongs();
166-
case 'recent':
167-
return new PageDto<SongPreviewDto>({
168-
content: await this.songService.getRecentSongs(
169-
query.page,
170-
query.limit,
171-
),
172-
page: query.page,
173-
limit: query.limit,
174-
total: 0,
175-
});
176-
case 'categories':
177-
if (id) {
178-
return new PageDto<SongPreviewDto>({
179-
content: await this.songService.getSongsByCategory(
180-
category,
181-
query.page,
182-
query.limit,
183-
),
184-
page: query.page,
185-
limit: query.limit,
186-
total: 0,
187-
});
188-
}
189-
return await this.songService.getCategories();
190-
case 'random': {
191-
if (query.limit && (query.limit < 1 || query.limit > 10)) {
192-
throw new BadRequestException('Invalid query parameters');
193-
}
194-
const data = await this.songService.getRandomSongs(
195-
query.limit ?? 1,
196-
category,
197-
);
198-
return new PageDto<SongPreviewDto>({
199-
content: data,
200-
page: query.page,
201-
limit: query.limit,
202-
total: data.length,
203-
});
204-
}
205-
default:
206-
throw new BadRequestException('Invalid query parameters');
100+
@Query() query: SongListQueryDTO,
101+
): Promise<PageDto<SongPreviewDto>> {
102+
// Handle search query
103+
if (query.q) {
104+
const sortFieldMap = new Map([
105+
[SongSortType.RECENT, 'createdAt'],
106+
[SongSortType.PLAY_COUNT, 'playCount'],
107+
[SongSortType.TITLE, 'title'],
108+
[SongSortType.DURATION, 'duration'],
109+
[SongSortType.NOTE_COUNT, 'noteCount'],
110+
]);
111+
112+
const sortField = sortFieldMap.get(query.sort) ?? 'createdAt';
113+
114+
const pageQuery = new PageQueryDTO({
115+
page: query.page,
116+
limit: query.limit,
117+
sort: sortField,
118+
order: query.order === 'desc' ? false : true,
119+
});
120+
const data = await this.songService.searchSongs(pageQuery, query.q);
121+
return new PageDto<SongPreviewDto>({
122+
content: data,
123+
page: query.page,
124+
limit: query.limit,
125+
total: data.length,
126+
});
127+
}
128+
129+
// Handle random sort
130+
if (query.sort === SongSortType.RANDOM) {
131+
if (query.limit && (query.limit < 1 || query.limit > 10)) {
132+
throw new BadRequestException(
133+
'Limit must be between 1 and 10 for random sort',
134+
);
207135
}
136+
const data = await this.songService.getRandomSongs(
137+
query.limit ?? 1,
138+
query.category,
139+
);
140+
141+
return new PageDto<SongPreviewDto>({
142+
content: data,
143+
page: query.page,
144+
limit: query.limit,
145+
total: data.length,
146+
});
147+
}
148+
149+
// Handle recent sort
150+
if (query.sort === SongSortType.RECENT) {
151+
const data = await this.songService.getRecentSongs(
152+
query.page,
153+
query.limit,
154+
);
155+
return new PageDto<SongPreviewDto>({
156+
content: data,
157+
page: query.page,
158+
limit: query.limit,
159+
total: data.length,
160+
});
161+
}
162+
163+
// Handle category filter
164+
if (query.category) {
165+
const data = await this.songService.getSongsByCategory(
166+
query.category,
167+
query.page,
168+
query.limit,
169+
);
170+
return new PageDto<SongPreviewDto>({
171+
content: data,
172+
page: query.page,
173+
limit: query.limit,
174+
total: data.length,
175+
});
208176
}
209177

210-
const data = await this.songService.getSongByPage(query);
178+
// Default: get songs with standard pagination
179+
const sortFieldMap = new Map([
180+
[SongSortType.PLAY_COUNT, 'playCount'],
181+
[SongSortType.TITLE, 'title'],
182+
[SongSortType.DURATION, 'duration'],
183+
[SongSortType.NOTE_COUNT, 'noteCount'],
184+
]);
185+
186+
const sortField = sortFieldMap.get(query.sort) ?? 'createdAt';
187+
188+
const pageQuery = new PageQueryDTO({
189+
page: query.page,
190+
limit: query.limit,
191+
sort: sortField,
192+
order: query.order === 'desc' ? false : true,
193+
});
194+
const data = await this.songService.getSongByPage(pageQuery);
211195
return new PageDto<SongPreviewDto>({
212196
content: data,
213197
page: query.page,
@@ -216,6 +200,42 @@ export class SongController {
216200
});
217201
}
218202

203+
@Get('/featured')
204+
@ApiOperation({
205+
summary: 'Get featured songs',
206+
description: `
207+
Returns featured songs with specific logic for showcasing popular/recent content.
208+
This endpoint has very specific business logic and is separate from the general song listing.
209+
`,
210+
})
211+
@ApiResponse({
212+
status: 200,
213+
description: 'Success. Returns featured songs data.',
214+
type: FeaturedSongsDto,
215+
})
216+
public async getFeaturedSongs(): Promise<FeaturedSongsDto> {
217+
return await this.songService.getFeaturedSongs();
218+
}
219+
220+
@Get('/categories')
221+
@ApiOperation({
222+
summary: 'Get available categories with song counts',
223+
description:
224+
'Returns a record of available categories and their song counts.',
225+
})
226+
@ApiResponse({
227+
status: 200,
228+
description: 'Success. Returns category name to count mapping.',
229+
schema: {
230+
type: 'object',
231+
additionalProperties: { type: 'number' },
232+
example: { pop: 42, rock: 38, electronic: 15 },
233+
},
234+
})
235+
public async getCategories(): Promise<Record<string, number>> {
236+
return await this.songService.getCategories();
237+
}
238+
219239
@Get('/search')
220240
@ApiOperation({
221241
summary: 'Search songs by keywords with pagination and sorting',

apps/backend/src/song/song.service.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -494,15 +494,21 @@ export class SongService {
494494

495495
public async getRandomSongs(
496496
count: number,
497-
category: string,
497+
category?: string,
498498
): Promise<SongPreviewDto[]> {
499+
const matchStage: Record<string, string> = {
500+
visibility: 'public',
501+
};
502+
503+
// Only add category filter if category is provided and not empty
504+
if (category && category.trim() !== '') {
505+
matchStage.category = category;
506+
}
507+
499508
const songs = (await this.songModel
500509
.aggregate([
501510
{
502-
$match: {
503-
visibility: 'public',
504-
category: category,
505-
},
511+
$match: matchStage,
506512
},
507513
{
508514
$sample: {

apps/frontend/src/app/(content)/page.tsx

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,14 @@ import { HomePageComponent } from '@web/modules/browse/components/HomePageCompon
77

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

2219
return response.data;
2320
} catch (error) {
@@ -28,9 +25,8 @@ async function fetchRecentSongs() {
2825
async function fetchFeaturedSongs(): Promise<FeaturedSongsDto> {
2926
try {
3027
const response = await axiosInstance.get<FeaturedSongsDto>(
31-
'/song-browser/featured',
28+
'/song/featured',
3229
);
33-
3430
return response.data;
3531
} catch (error) {
3632
return {

0 commit comments

Comments
 (0)