From fe2edce6b9e77a6cdc07fe1e0185eecc5c2c8560 Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 01:18:44 -0300
Subject: [PATCH 29/46] fix: adjust top margin of load more button
---
apps/frontend/src/app/(content)/search/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/frontend/src/app/(content)/search/page.tsx b/apps/frontend/src/app/(content)/search/page.tsx
index 3625f715..2069dc4e 100644
--- a/apps/frontend/src/app/(content)/search/page.tsx
+++ b/apps/frontend/src/app/(content)/search/page.tsx
@@ -402,7 +402,7 @@ const SearchResults = ({
{/* Load more / End indicator */}
-
+
{hasMore ? (
) : (
From 3a72f1ae1aea65d8a964a10b9857a9833a17f368 Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 01:19:54 -0300
Subject: [PATCH 30/46] refactor: remove unused loading state reference in
search results
---
apps/frontend/src/app/(content)/search/page.tsx | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/apps/frontend/src/app/(content)/search/page.tsx b/apps/frontend/src/app/(content)/search/page.tsx
index 2069dc4e..3d6b8307 100644
--- a/apps/frontend/src/app/(content)/search/page.tsx
+++ b/apps/frontend/src/app/(content)/search/page.tsx
@@ -385,12 +385,7 @@ interface SearchResultsProps {
onLoadMore: () => void;
}
-const SearchResults = ({
- songs,
- loading,
- hasMore,
- onLoadMore,
-}: SearchResultsProps) => (
+const SearchResults = ({ songs, hasMore, onLoadMore }: SearchResultsProps) => (
<>
{songs.map((song, i) => (
From e0b92319aba6562921da8a5f66cd76449215b5a4 Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 01:33:41 -0300
Subject: [PATCH 31/46] chore: add `nuqs` as dependency for query state
management
---
apps/frontend/package.json | 1 +
bun.lock | 5 +++++
2 files changed, 6 insertions(+)
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/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=="],
From a5f7f95fa157d048b6a77f096998983a305ecf3d Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 01:35:47 -0300
Subject: [PATCH 32/46] refactor: replace Next.js `useSearchParams` with nuqs
`useQueryStates`
---
.../src/app/(content)/search/page.tsx | 93 ++++++++-----------
apps/frontend/src/app/layout.tsx | 3 +-
2 files changed, 40 insertions(+), 56 deletions(-)
diff --git a/apps/frontend/src/app/(content)/search/page.tsx b/apps/frontend/src/app/(content)/search/page.tsx
index 3d6b8307..8db7592c 100644
--- a/apps/frontend/src/app/(content)/search/page.tsx
+++ b/apps/frontend/src/app/(content)/search/page.tsx
@@ -11,7 +11,7 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Image from 'next/image';
import Link from 'next/link';
-import { useRouter, useSearchParams, usePathname } from 'next/navigation';
+import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { useEffect, useMemo, useState } from 'react';
import { create } from 'zustand';
@@ -410,28 +410,41 @@ const SearchResults = ({ songs, hasMore, onLoadMore }: SearchResultsProps) => (
);
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') || '12', 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('recent'),
+ order: parseAsString.withDefault('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, searchSongs, loadMore } =
+ const {
+ 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);
@@ -469,47 +482,17 @@ 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 });
};
const handleOrderChange = () => {
const newOrder = order === 'asc' ? 'desc' : 'asc';
- updateURL({ order: newOrder });
+ setQueryState({ order: newOrder, page: 1 });
};
/* Use 19/91 button if sorting by a numeric value, otherwise use AZ/ZA */
diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx
index 0f45a714..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';
@@ -111,7 +112,7 @@ export default function RootLayout({
highlightColor='rgb(63 63 70)'
>
- {children}
+ {children}
From 14c3e014be418f00d5fe7034349e9bcd8cecba1b Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 01:52:32 -0300
Subject: [PATCH 33/46] refactor: replace hardcoded strings with enums in sort
& order options
---
.../src/app/(content)/search/page.tsx | 29 ++++++++++---------
1 file changed, 16 insertions(+), 13 deletions(-)
diff --git a/apps/frontend/src/app/(content)/search/page.tsx b/apps/frontend/src/app/(content)/search/page.tsx
index 8db7592c..7bcafff5 100644
--- a/apps/frontend/src/app/(content)/search/page.tsx
+++ b/apps/frontend/src/app/(content)/search/page.tsx
@@ -16,7 +16,7 @@ import { useEffect, useMemo, useState } from 'react';
import { create } from 'zustand';
import { UPLOAD_CONSTANTS, SEARCH_FEATURES, INSTRUMENTS } from '@nbw/config';
-import { SongPreviewDtoType } from '@nbw/database';
+import { SongOrderType, SongPreviewDtoType, SongSortType } 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';
@@ -412,8 +412,8 @@ const SearchResults = ({ songs, hasMore, onLoadMore }: SearchResultsProps) => (
const SearchSongPage = () => {
const [queryState, setQueryState] = useQueryStates({
q: parseAsString.withDefault(''),
- sort: parseAsString.withDefault('recent'),
- order: parseAsString.withDefault('desc'),
+ sort: parseAsString.withDefault(SongSortType.RECENT),
+ order: parseAsString.withDefault(SongOrderType.DESC),
category: parseAsString.withDefault(''),
uploader: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1),
@@ -491,16 +491,17 @@ const SearchSongPage = () => {
};
const handleOrderChange = () => {
- const newOrder = order === 'asc' ? 'desc' : 'asc';
+ const newOrder =
+ order === SongOrderType.ASC ? SongOrderType.DESC : SongOrderType.ASC;
setQueryState({ order: newOrder, page: 1 });
};
/* Use 19/91 button if sorting by a numeric value, otherwise use AZ/ZA */
const orderIcon = useMemo(() => {
- if (sort === 'title') {
- return order === 'asc' ? faArrowUpZA : faArrowUpAZ;
+ if (sort === SongSortType.TITLE) {
+ return order === SongOrderType.ASC ? faArrowUpZA : faArrowUpAZ;
} else {
- return order === 'asc' ? faArrowUp19 : faArrowUp91;
+ return order === SongOrderType.ASC ? faArrowUp19 : faArrowUp91;
}
}, [sort, order]);
@@ -555,11 +556,11 @@ const SearchSongPage = () => {
onChange={(e) => handleSortChange(e.target.value)}
className='h-10 w-48 rounded-md bg-zinc-900 border-2 border-zinc-600 hover:border-zinc-500 focus:border-blue-500 focus:outline-none px-1.5 text-sm transition-colors'
>
-
-
-
-
-
+
+
+
+
+
@@ -568,7 +569,9 @@ const SearchSongPage = () => {
className='bg-zinc-700 hover:bg-zinc-600 h-10 w-10 rounded-md flex items-center justify-center transition-colors enabled:cursor-pointer'
onClick={handleOrderChange}
aria-label={
- order === 'asc' ? 'Sort ascending' : 'Sort descending'
+ order === SongOrderType.ASC
+ ? 'Sort ascending'
+ : 'Sort descending'
}
>
From e1f32dd85405474a39d26c61ab2a6c53c37ae827 Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 01:53:58 -0300
Subject: [PATCH 34/46] fix: use camelCase instead of kebab-case in sort
options
---
apps/backend/src/song/song.controller.ts | 2 +-
packages/database/src/song/dto/SongListQuery.dto.ts | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/backend/src/song/song.controller.ts b/apps/backend/src/song/song.controller.ts
index dd25d291..3ea7b60f 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
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 {
From 3029a6ffcfbf28a231537618e2d36b8eca8cbf13 Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 02:13:30 -0300
Subject: [PATCH 35/46] fix: redefine sort and order enums inline
We've had Next.js complain about importing tons of stuff from the `@nbw/database` module into the client bundle, even though we already imported `SongPreviewDtoType` from that package before. I've decided to inline the enums and add a TODO so we can look deeper into that issue later. That's still better than going back to the magic strings :)
---
.../frontend/src/app/(content)/search/page.tsx | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/apps/frontend/src/app/(content)/search/page.tsx b/apps/frontend/src/app/(content)/search/page.tsx
index 7bcafff5..c8ab5e54 100644
--- a/apps/frontend/src/app/(content)/search/page.tsx
+++ b/apps/frontend/src/app/(content)/search/page.tsx
@@ -16,7 +16,7 @@ import { useEffect, useMemo, useState } from 'react';
import { create } from 'zustand';
import { UPLOAD_CONSTANTS, SEARCH_FEATURES, INSTRUMENTS } from '@nbw/config';
-import { SongOrderType, SongPreviewDtoType, SongSortType } from '@nbw/database';
+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';
@@ -46,6 +46,22 @@ 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;
From 7602b427d89dbc14aff82b1a7d6e9d467326845c Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 03:28:07 -0300
Subject: [PATCH 36/46] feat: implement generic song query method supporting
filtering + sorting
Replaces and combines the functionality of methods `getSongsByCategory`, `getRecentSongs` and `searchSongs` into a generic `querySongs` method that supports all parameters.
---
apps/backend/src/song/song.controller.ts | 95 ++++-----------------
apps/backend/src/song/song.service.ts | 100 +++++++++--------------
2 files changed, 57 insertions(+), 138 deletions(-)
diff --git a/apps/backend/src/song/song.controller.ts b/apps/backend/src/song/song.controller.ts
index 3ea7b60f..55961e52 100644
--- a/apps/backend/src/song/song.controller.ts
+++ b/apps/backend/src/song/song.controller.ts
@@ -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,67 +120,33 @@ 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 data = await this.songService.querySongs(
+ pageQuery,
+ query.q,
+ query.category,
+ );
+
return new PageDto({
content: data,
page: query.page,
@@ -323,7 +262,7 @@ export class SongController {
@Query() query: PageQueryDTO,
@Query('q') q: string,
): Promise> {
- const data = await this.songService.searchSongs(query, q ?? '');
+ const data = await this.songService.querySongs(query, q ?? '');
return new PageDto({
content: data,
page: query.page,
diff --git a/apps/backend/src/song/song.service.ts b/apps/backend/src/song/song.service.ts
index d07bfa65..3027a9d7 100644
--- a/apps/backend/src/song/song.service.ts
+++ b/apps/backend/src/song/song.service.ts
@@ -210,40 +210,60 @@ export class SongService {
return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song));
}
- public async searchSongs(
+ public async querySongs(
query: PageQueryDTO,
- q: string,
+ 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 } : {}),
};
+ // Add category filter if provided
+ if (category) {
+ mongoQuery.category = category;
+ }
+
+ // 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;
+ }
+ }
+
+ const sortOrder = descending ? -1 : 1;
+
const songs = (await this.songModel
.find(mongoQuery)
- .sort({ [sortField]: order ? 1 : -1 })
+ .sort({ [sortField]: sortOrder })
.skip(limit * (page - 1))
.limit(limit)
.populate('uploader', 'username profileImage -_id')
@@ -252,27 +272,6 @@ export class SongService {
return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song));
}
- public async getRecentSongs(
- page: number,
- limit: number,
- ): Promise {
- const queryObject: any = {
- visibility: 'public',
- };
-
- 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[];
-
- return data.map((song) => SongPreviewDto.fromSongDocumentWithUser(song));
- }
-
public async getSongsForTimespan(timespan: number): Promise {
return this.songModel
.find({
@@ -524,25 +523,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,
From c915db6ea77dfbe14c50b37c65b1fed18766bdd3 Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 03:28:41 -0300
Subject: [PATCH 37/46] test: update tests for song controller and service
---
apps/backend/src/song/song.controller.spec.ts | 42 +++++++---
apps/backend/src/song/song.service.spec.ts | 82 ++++++++++++++-----
2 files changed, 92 insertions(+), 32 deletions(-)
diff --git a/apps/backend/src/song/song.controller.spec.ts b/apps/backend/src/song/song.controller.spec.ts
index 099763d6..88c12c3d 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,13 @@ describe('SongController', () => {
const query: SongListQueryDTO = { page: 1, limit: 10, q: 'test search' };
const songList: SongPreviewDto[] = [];
- mockSongService.searchSongs.mockResolvedValueOnce(songList);
+ mockSongService.querySongs.mockResolvedValueOnce(songList);
const result = await songController.getSongList(query);
expect(result).toBeInstanceOf(PageDto);
expect(result.content).toEqual(songList);
- expect(songService.searchSongs).toHaveBeenCalled();
+ expect(songService.querySongs).toHaveBeenCalled();
});
it('should handle random sort', async () => {
@@ -161,13 +159,22 @@ describe('SongController', () => {
};
const songList: SongPreviewDto[] = [];
- mockSongService.getRecentSongs.mockResolvedValueOnce(songList);
+ mockSongService.querySongs.mockResolvedValueOnce(songList);
const result = await songController.getSongList(query);
expect(result).toBeInstanceOf(PageDto);
expect(result.content).toEqual(songList);
- expect(songService.getRecentSongs).toHaveBeenCalledWith(1, 10);
+ 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 +186,22 @@ describe('SongController', () => {
};
const songList: SongPreviewDto[] = [];
- mockSongService.getSongsByCategory.mockResolvedValueOnce(songList);
+ mockSongService.querySongs.mockResolvedValueOnce(songList);
const result = await songController.getSongList(query);
expect(result).toBeInstanceOf(PageDto);
expect(result.content).toEqual(songList);
- expect(songService.getSongsByCategory).toHaveBeenCalledWith('pop', 1, 10);
+ expect(songService.querySongs).toHaveBeenCalledWith(
+ expect.objectContaining({
+ page: 1,
+ limit: 10,
+ sort: 'createdAt',
+ order: true,
+ }),
+ undefined,
+ 'pop',
+ );
});
it('should handle category filter', async () => {
@@ -196,16 +212,18 @@ describe('SongController', () => {
};
const songList: SongPreviewDto[] = [];
- mockSongService.getSongsByCategory.mockResolvedValueOnce(songList);
+ mockSongService.querySongs.mockResolvedValueOnce(songList);
const result = await songController.getSongList(query);
expect(result).toBeInstanceOf(PageDto);
expect(result.content).toEqual(songList);
- expect(songService.getSongsByCategory).toHaveBeenCalledWith(
- 'rock',
+ expect(songService.getSongsBySortAndCategory).toHaveBeenCalledWith(
+ 'createdAt',
+ true,
1,
10,
+ 'rock',
);
});
diff --git a/apps/backend/src/song/song.service.spec.ts b/apps/backend/src/song/song.service.spec.ts
index 024d719c..2f6d6366 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 = {
@@ -1045,20 +1049,22 @@ describe('SongService', () => {
jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any);
- const result = await service.getSongsByCategory(category, page, limit);
+ const result = await service.querySongs(query, undefined, category);
expect(result).toEqual(
songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)),
);
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',
@@ -1067,12 +1073,14 @@ describe('SongService', () => {
expect(mockFind.exec).toHaveBeenCalled();
});
- });
- 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 = {
@@ -1085,16 +1093,50 @@ describe('SongService', () => {
jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any);
- const result = await service.getRecentSongs(page, limit);
+ const result = await service.querySongs(query);
+ expect(songModel.find).toHaveBeenCalledWith({ visibility: 'public' });
+ expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: -1 });
expect(result).toEqual(
songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)),
);
+ });
- 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);
+
+ const result = await service.querySongs(query, searchTerm, category);
+
+ expect(result).toEqual(
+ songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)),
+ );
+
+ expect(songModel.find).toHaveBeenCalledWith(
+ expect.objectContaining({
+ visibility: 'public',
+ category,
+ }),
+ );
+
+ expect(mockFind.sort).toHaveBeenCalledWith({ playCount: 1 });
});
});
From 7141ccd2659a2c7edb69554fb7500271d6afa9e3 Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 03:34:56 -0300
Subject: [PATCH 38/46] perf(db): create indices for frequently queried fields
---
packages/database/src/song/entity/song.entity.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/packages/database/src/song/entity/song.entity.ts b/packages/database/src/song/entity/song.entity.ts
index 74e335c8..ba909dff 100644
--- a/packages/database/src/song/entity/song.entity.ts
+++ b/packages/database/src/song/entity/song.entity.ts
@@ -1,4 +1,4 @@
-import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
+import { Index, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, Types } from 'mongoose';
import { SongStats } from '../dto/SongStats';
@@ -16,11 +16,15 @@ import type { CategoryType, LicenseType, VisibilityType } from '../dto/types';
},
},
})
+@Index({ 'stats.duration': 1 })
+@Index({ 'stats.noteCount': 1 })
+@Index({ visibility: 1, createdAt: -1 })
+@Index({ category: 1, createdAt: -1 })
export class Song {
@Prop({ type: String, required: true, unique: true })
publicId: string;
- @Prop({ type: Date, required: true, default: Date.now })
+ @Prop({ type: Date, required: true, default: Date.now, index: true })
createdAt: Date;
@Prop({ type: Date, required: true, default: Date.now })
From 46f654014e7067e5fa48b2d582225a2a7019df2c Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 12:28:02 -0300
Subject: [PATCH 39/46] fix: index definition syntax
---
packages/database/src/song/entity/song.entity.ts | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/packages/database/src/song/entity/song.entity.ts b/packages/database/src/song/entity/song.entity.ts
index ba909dff..29d7bd41 100644
--- a/packages/database/src/song/entity/song.entity.ts
+++ b/packages/database/src/song/entity/song.entity.ts
@@ -1,4 +1,4 @@
-import { Index, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
+import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, Types } from 'mongoose';
import { SongStats } from '../dto/SongStats';
@@ -16,15 +16,11 @@ import type { CategoryType, LicenseType, VisibilityType } from '../dto/types';
},
},
})
-@Index({ 'stats.duration': 1 })
-@Index({ 'stats.noteCount': 1 })
-@Index({ visibility: 1, createdAt: -1 })
-@Index({ category: 1, createdAt: -1 })
export class Song {
@Prop({ type: String, required: true, unique: true })
publicId: string;
- @Prop({ type: Date, required: true, default: Date.now, index: true })
+ @Prop({ type: Date, required: true, default: Date.now })
createdAt: Date;
@Prop({ type: Date, required: true, default: Date.now })
@@ -98,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 & {
From cf3108d4e12634120ac5d30cefc5aac113a97ef4 Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 13:21:48 -0300
Subject: [PATCH 40/46] fix: return total result count instead of page length
in query endpoint
---
apps/backend/src/song/song.controller.ts | 12 +++++-----
apps/backend/src/song/song.service.ts | 28 ++++++++++++++++--------
2 files changed, 25 insertions(+), 15 deletions(-)
diff --git a/apps/backend/src/song/song.controller.ts b/apps/backend/src/song/song.controller.ts
index 55961e52..d50f2a8f 100644
--- a/apps/backend/src/song/song.controller.ts
+++ b/apps/backend/src/song/song.controller.ts
@@ -141,17 +141,17 @@ export class SongController {
});
// Query songs with optional search and category filters
- const data = await this.songService.querySongs(
+ 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,
});
}
@@ -262,12 +262,12 @@ export class SongController {
@Query() query: PageQueryDTO,
@Query('q') q: string,
): Promise> {
- const data = await this.songService.querySongs(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.ts b/apps/backend/src/song/song.service.ts
index 3027a9d7..6e6d59cf 100644
--- a/apps/backend/src/song/song.service.ts
+++ b/apps/backend/src/song/song.service.ts
@@ -214,7 +214,7 @@ export class SongService {
query: PageQueryDTO,
q?: string,
category?: string,
- ): Promise {
+ ): Promise {
const page = parseInt(query.page?.toString() ?? '1');
const limit = parseInt(query.limit?.toString() ?? '10');
const descending = query.order ?? true;
@@ -261,15 +261,25 @@ export class SongService {
const sortOrder = descending ? -1 : 1;
- const songs = (await this.songModel
- .find(mongoQuery)
- .sort({ [sortField]: sortOrder })
- .skip(limit * (page - 1))
- .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 songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song));
+ return {
+ content: songs.map((song) =>
+ SongPreviewDto.fromSongDocumentWithUser(song),
+ ),
+ page,
+ limit,
+ total,
+ };
}
public async getSongsForTimespan(timespan: number): Promise {
From 283529d84d900771dce7ff3965ea3ee1b3221f48 Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 14:51:43 -0300
Subject: [PATCH 41/46] fix: add thousands separator on total search result
count
---
apps/frontend/src/app/(content)/search/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/frontend/src/app/(content)/search/page.tsx b/apps/frontend/src/app/(content)/search/page.tsx
index c8ab5e54..afce0b55 100644
--- a/apps/frontend/src/app/(content)/search/page.tsx
+++ b/apps/frontend/src/app/(content)/search/page.tsx
@@ -170,7 +170,7 @@ const SearchHeader = ({
if (isSearch) {
// TODO: implement this with proper variable substitution for translations
if (totalResults > 1) {
- return `${totalResults} results for "${query}"`;
+ return `${totalResults.toLocaleString('en-UK')} results for "${query}"`;
}
return `1 result for "${query}"`;
}
From b5b63c98488a579c1b1624e580bc6e2d9877e569 Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 15:24:20 -0300
Subject: [PATCH 42/46] fix: implement loading skeleton for search page header
---
apps/frontend/src/app/(content)/search/page.tsx | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/apps/frontend/src/app/(content)/search/page.tsx b/apps/frontend/src/app/(content)/search/page.tsx
index afce0b55..2a8e12f1 100644
--- a/apps/frontend/src/app/(content)/search/page.tsx
+++ b/apps/frontend/src/app/(content)/search/page.tsx
@@ -13,6 +13,7 @@ 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 { UPLOAD_CONSTANTS, SEARCH_FEATURES, INSTRUMENTS } from '@nbw/config';
@@ -151,6 +152,7 @@ export const useSongSearchStore = create(
interface SearchHeaderProps {
query: string;
+ loading: boolean;
songsCount: number;
totalResults: number;
}
@@ -161,12 +163,14 @@ interface SearchHeaderProps {
*/
const SearchHeader = ({
query,
+ loading,
songsCount,
totalResults,
}: SearchHeaderProps) => {
const isSearch = useMemo(() => query !== '', [query]);
const title = useMemo(() => {
+ if (loading) return '';
if (isSearch) {
// TODO: implement this with proper variable substitution for translations
if (totalResults > 1) {
@@ -175,11 +179,13 @@ const SearchHeader = ({
return `1 result for "${query}"`;
}
return 'Browse songs';
- }, [isSearch, query, songsCount, totalResults]);
+ }, [loading, isSearch, query, songsCount, totalResults]);
return (
-
{title}
+
+ {title || }
+
);
};
@@ -552,6 +558,7 @@ const SearchSongPage = () => {
From 12957c153cb99fb10de8e63cb24869b6f0ea6e9f Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 15:25:06 -0300
Subject: [PATCH 43/46] fix: disable sort and order controls while loading
---
apps/frontend/src/app/(content)/search/page.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/apps/frontend/src/app/(content)/search/page.tsx b/apps/frontend/src/app/(content)/search/page.tsx
index 2a8e12f1..e7f6bccb 100644
--- a/apps/frontend/src/app/(content)/search/page.tsx
+++ b/apps/frontend/src/app/(content)/search/page.tsx
@@ -577,7 +577,8 @@ const SearchSongPage = () => {
From 769841f2fb7c6ec06a594ec532d28170af78b21b Mon Sep 17 00:00:00 2001
From: Bentroen <29354120+Bentroen@users.noreply.github.com>
Date: Tue, 30 Dec 2025 15:42:32 -0300
Subject: [PATCH 46/46] test: improve test cases to check for total song count
in result
---
apps/backend/src/song/song.controller.spec.ts | 422 +++++++++++++++++-
apps/backend/src/song/song.service.spec.ts | 18 +-
.../src/app/(content)/search/page.tsx | 2 +-
3 files changed, 421 insertions(+), 21 deletions(-)
diff --git a/apps/backend/src/song/song.controller.spec.ts b/apps/backend/src/song/song.controller.spec.ts
index 88c12c3d..51865a09 100644
--- a/apps/backend/src/song/song.controller.spec.ts
+++ b/apps/backend/src/song/song.controller.spec.ts
@@ -95,12 +95,18 @@ describe('SongController', () => {
const query: SongListQueryDTO = { page: 1, limit: 10, q: 'test search' };
const songList: SongPreviewDto[] = [];
- mockSongService.querySongs.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(result.total).toBe(0);
expect(songService.querySongs).toHaveBeenCalled();
});
@@ -159,12 +165,18 @@ describe('SongController', () => {
};
const songList: SongPreviewDto[] = [];
- mockSongService.querySongs.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(result.total).toBe(0);
expect(songService.querySongs).toHaveBeenCalledWith(
expect.objectContaining({
page: 1,
@@ -186,12 +198,18 @@ describe('SongController', () => {
};
const songList: SongPreviewDto[] = [];
- mockSongService.querySongs.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(result.total).toBe(0);
expect(songService.querySongs).toHaveBeenCalledWith(
expect.objectContaining({
page: 1,
@@ -212,19 +230,128 @@ describe('SongController', () => {
};
const songList: SongPreviewDto[] = [];
- mockSongService.querySongs.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.getSongsBySortAndCategory).toHaveBeenCalledWith(
- 'createdAt',
- true,
- 1,
- 10,
- 'rock',
- );
+ 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 () => {
@@ -289,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.service.spec.ts b/apps/backend/src/song/song.service.spec.ts
index 2f6d6366..4a944f7f 100644
--- a/apps/backend/src/song/song.service.spec.ts
+++ b/apps/backend/src/song/song.service.spec.ts
@@ -1048,12 +1048,16 @@ describe('SongService', () => {
};
jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any);
+ jest.spyOn(songModel, 'countDocuments').mockResolvedValue(0);
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({
visibility: 'public',
@@ -1072,6 +1076,10 @@ describe('SongService', () => {
);
expect(mockFind.exec).toHaveBeenCalled();
+ expect(songModel.countDocuments).toHaveBeenCalledWith({
+ visibility: 'public',
+ category,
+ });
});
it('should work without category filter', async () => {
@@ -1092,14 +1100,16 @@ describe('SongService', () => {
};
jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any);
+ jest.spyOn(songModel, 'countDocuments').mockResolvedValue(0);
const result = await service.querySongs(query);
expect(songModel.find).toHaveBeenCalledWith({ visibility: 'public' });
expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: -1 });
- expect(result).toEqual(
+ expect(result.content).toEqual(
songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)),
);
+ expect(result.total).toBe(0);
});
it('should search with text query and filters', async () => {
@@ -1122,12 +1132,14 @@ describe('SongService', () => {
};
jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any);
+ jest.spyOn(songModel, 'countDocuments').mockResolvedValue(0);
const result = await service.querySongs(query, searchTerm, category);
- expect(result).toEqual(
+ expect(result.content).toEqual(
songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)),
);
+ expect(result.total).toBe(0);
expect(songModel.find).toHaveBeenCalledWith(
expect.objectContaining({
diff --git a/apps/frontend/src/app/(content)/search/page.tsx b/apps/frontend/src/app/(content)/search/page.tsx
index d5e55d8f..10d3144b 100644
--- a/apps/frontend/src/app/(content)/search/page.tsx
+++ b/apps/frontend/src/app/(content)/search/page.tsx
@@ -183,7 +183,7 @@ const SearchHeader = ({
return (
-
+
{title || }