Skip to content

Commit 4fcf74f

Browse files
committed
feat: integrate Typesense for enhanced song indexing and search capabilities
- Added Typesense service for indexing and searching songs. - Implemented automatic indexing of unindexed songs using a scheduled job. - Updated song controller to handle search queries via Typesense. - Enhanced environment variables for Typesense configuration. - Modified song entity to include a search indexing flag. - Updated Docker Compose to include Typesense and its dashboard services.
1 parent db6bdec commit 4fcf74f

File tree

14 files changed

+646
-41
lines changed

14 files changed

+646
-41
lines changed
Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
NODE_ENV=
1+
NODE_ENV=development
22

33
GITHUB_CLIENT_ID=
44
GITHUB_CLIENT_SECRET=
@@ -9,27 +9,37 @@ GOOGLE_CLIENT_SECRET=
99
DISCORD_CLIENT_ID=
1010
DISCORD_CLIENT_SECRET=
1111

12-
JWT_SECRET=
13-
JWT_EXPIRES_IN=
12+
MAGIC_LINK_SECRET=development_magic_link_secret
1413

15-
JWT_REFRESH_SECRET=
16-
JWT_REFRESH_EXPIRES_IN=
14+
# in seconds
15+
COOKIE_EXPIRES_IN=604800 # 1 week
1716

18-
MONGO_URL=
17+
JWT_SECRET=developmentsecret
18+
JWT_EXPIRES_IN=1h
1919

20-
SERVER_URL=
21-
FRONTEND_URL=
22-
APP_DOMAIN=
20+
JWT_REFRESH_SECRET=developmentrefreshsecret
21+
JWT_REFRESH_EXPIRES_IN=7d
2322

24-
RECAPTCHA_KEY=
23+
MONGO_URL=mongodb://noteblockworlduser:noteblockworldpassword@localhost:27017/noteblockworld?authSource=admin
2524

26-
S3_ENDPOINT=
27-
S3_BUCKET_SONGS=
28-
S3_BUCKET_THUMBS=
29-
S3_KEY=
30-
S3_SECRET=
31-
S3_REGION=
25+
SERVER_URL=http://localhost:4000
26+
FRONTEND_URL=http://localhost:3000
3227

33-
WHITELISTED_USERS=
28+
RECAPTCHA_KEY=disabled
29+
30+
S3_ENDPOINT=http://localhost:9000
31+
S3_BUCKET_SONGS=noteblockworld-songs
32+
S3_BUCKET_THUMBS=noteblockworld-thumbs
33+
S3_KEY=minioadmin
34+
S3_SECRET=minioadmin
35+
S3_REGION=us-east-1
3436

3537
DISCORD_WEBHOOK_URL=
38+
39+
MAIL_TRANSPORT=smtp://user:pass@localhost:1025
40+
MAIL_FROM="Example <[email protected]>"
41+
42+
TYPESENSE_HOST=localhost
43+
TYPESENSE_PORT=8108
44+
TYPESENSE_PROTOCOL=http
45+
TYPESENSE_API_KEY=noteblockworld-typesense-api-key

apps/backend/package.json

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
"@aws-sdk/client-s3": "3.717.0",
2626
"@aws-sdk/s3-request-presigner": "3.717.0",
2727
"@encode42/nbs.js": "^5.0.2",
28+
"@nbw/config": "workspace:*",
29+
"@nbw/database": "workspace:*",
30+
"@nbw/song": "workspace:*",
31+
"@nbw/sounds": "workspace:*",
32+
"@nbw/thumbnail": "workspace:*",
2833
"@nestjs-modules/mailer": "^2.0.2",
2934
"@nestjs/common": "^10.4.15",
3035
"@nestjs/config": "^3.3.0",
@@ -33,6 +38,7 @@
3338
"@nestjs/mongoose": "^10.1.0",
3439
"@nestjs/passport": "^10.0.3",
3540
"@nestjs/platform-express": "^10.4.15",
41+
"@nestjs/schedule": "^6.0.1",
3642
"@nestjs/swagger": "^11.1.5",
3743
"@nestjs/throttler": "^6.3.0",
3844
"@types/uuid": "^9.0.8",
@@ -54,14 +60,10 @@
5460
"passport-oauth2": "^1.8.0",
5561
"reflect-metadata": "^0.1.14",
5662
"rxjs": "^7.8.1",
63+
"typesense": "^2.1.0",
5764
"uuid": "^9.0.1",
5865
"zod": "^3.24.1",
59-
"zod-validation-error": "^3.4.0",
60-
"@nbw/database": "workspace:*",
61-
"@nbw/song": "workspace:*",
62-
"@nbw/thumbnail": "workspace:*",
63-
"@nbw/sounds": "workspace:*",
64-
"@nbw/config": "workspace:*"
66+
"zod-validation-error": "^3.4.0"
6567
},
6668
"devDependencies": {
6769
"@faker-js/faker": "^9.3.0",

apps/backend/src/config/EnvironmentVariables.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ export class EnvironmentVariables {
9393

9494
@IsString()
9595
COOKIE_EXPIRES_IN: string;
96+
97+
// typesense
98+
@IsString()
99+
TYPESENSE_HOST: string;
100+
101+
@IsString()
102+
TYPESENSE_PORT: string;
103+
104+
@IsString()
105+
TYPESENSE_PROTOCOL: string;
106+
107+
@IsString()
108+
TYPESENSE_API_KEY: string;
96109
}
97110

98111
export function validate(config: Record<string, unknown>) {
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { InjectModel } from '@nestjs/mongoose';
3+
import { Cron, CronExpression } from '@nestjs/schedule';
4+
import { TypesenseService } from '@server/typesense/typesense.service';
5+
import { Model } from 'mongoose';
6+
7+
import { Song as SongEntity, SongPreviewDto } from '@nbw/database';
8+
import type { SongWithUser } from '@nbw/database';
9+
10+
@Injectable()
11+
export class SongIndexingService {
12+
private readonly logger = new Logger(SongIndexingService.name);
13+
private readonly batchSize = 50;
14+
15+
constructor(
16+
@InjectModel(SongEntity.name)
17+
private songModel: Model<SongEntity>,
18+
private typesenseService: TypesenseService,
19+
) {}
20+
21+
@Cron(CronExpression.EVERY_5_MINUTES)
22+
async indexUnindexedSongs() {
23+
try {
24+
this.logger.log('Starting batch indexing of unindexed songs...');
25+
26+
// Find songs that are not indexed and are public
27+
const unindexedSongs = await this.songModel
28+
.find({
29+
$or: [
30+
{ searchIndexed: false },
31+
{ searchIndexed: { $exists: false } },
32+
{ searchIndexed: null },
33+
],
34+
visibility: 'public', // Exclude private songs
35+
})
36+
.limit(this.batchSize)
37+
.populate('uploader', 'username displayName profileImage -_id')
38+
.lean() // Use lean() to get plain JavaScript objects
39+
.exec();
40+
41+
if (unindexedSongs.length === 0) {
42+
this.logger.log('No unindexed songs found');
43+
return;
44+
}
45+
46+
this.logger.log(`Found ${unindexedSongs.length} unindexed songs`);
47+
48+
// Debug: Log first song to see what fields are present
49+
if (unindexedSongs.length > 0) {
50+
const firstSong = unindexedSongs[0];
51+
this.logger.debug(
52+
`First song sample - Has stats: ${!!firstSong.stats}, Has uploader: ${!!firstSong.uploader}`,
53+
);
54+
if (firstSong.stats) {
55+
this.logger.debug(
56+
`Stats sample - duration: ${firstSong.stats.duration}, noteCount: ${firstSong.stats.noteCount}`,
57+
);
58+
}
59+
}
60+
61+
// Convert to SongPreviewDto format, filtering out songs with missing data
62+
const songPreviews = unindexedSongs
63+
.filter((song) => {
64+
if (!song.stats) {
65+
this.logger.warn(
66+
`Song ${song.publicId} has no stats field, skipping indexing`,
67+
);
68+
return false;
69+
}
70+
if (!song.stats.duration || !song.stats.noteCount) {
71+
this.logger.warn(
72+
`Song ${song.publicId} has incomplete stats, skipping indexing`,
73+
);
74+
return false;
75+
}
76+
return true;
77+
})
78+
.map((song) => {
79+
const songWithUser = song as unknown as SongWithUser;
80+
return SongPreviewDto.fromSongDocumentWithUser(songWithUser);
81+
});
82+
83+
if (songPreviews.length === 0) {
84+
this.logger.warn('No valid songs to index after filtering');
85+
return;
86+
}
87+
88+
// Index songs in Typesense
89+
await this.typesenseService.indexSongs(songPreviews);
90+
91+
// Mark only the successfully indexed songs
92+
const indexedSongIds = unindexedSongs
93+
.filter(
94+
(song) => song.stats && song.stats.duration && song.stats.noteCount,
95+
)
96+
.map((song) => song._id);
97+
98+
await this.songModel.updateMany(
99+
{ _id: { $in: indexedSongIds } },
100+
{ $set: { searchIndexed: true } },
101+
);
102+
103+
this.logger.log(`Successfully indexed ${songPreviews.length} songs`);
104+
} catch (error) {
105+
this.logger.error('Error during batch indexing:', error);
106+
}
107+
}
108+
109+
/**
110+
* Manually trigger indexing of all songs
111+
* This is useful for initial setup or reindexing
112+
*/
113+
async reindexAllSongs() {
114+
this.logger.log('Starting full reindex of all songs...');
115+
116+
try {
117+
// Reset searchIndexed flag for all public songs
118+
await this.songModel.updateMany(
119+
{ visibility: 'public' },
120+
{ $set: { searchIndexed: false } },
121+
);
122+
123+
this.logger.log('Reset searchIndexed flag for all public songs');
124+
125+
// Recreate the collection in Typesense
126+
await this.typesenseService.recreateCollection();
127+
128+
this.logger.log('Recreated Typesense collection');
129+
130+
// Index songs in batches
131+
let processedCount = 0;
132+
let hasMore = true;
133+
134+
while (hasMore) {
135+
const songs = await this.songModel
136+
.find({
137+
visibility: 'public',
138+
searchIndexed: false,
139+
})
140+
.limit(this.batchSize)
141+
.populate('uploader', 'username displayName profileImage -_id')
142+
.lean()
143+
.exec();
144+
145+
if (songs.length === 0) {
146+
hasMore = false;
147+
break;
148+
}
149+
150+
const songPreviews = songs
151+
.filter((song) => {
152+
if (!song.stats) {
153+
this.logger.warn(
154+
`Song ${song.publicId} has no stats field, skipping indexing`,
155+
);
156+
return false;
157+
}
158+
if (!song.stats.duration || !song.stats.noteCount) {
159+
this.logger.warn(
160+
`Song ${song.publicId} has incomplete stats, skipping indexing`,
161+
);
162+
return false;
163+
}
164+
return true;
165+
})
166+
.map((song) => {
167+
const songWithUser = song as unknown as SongWithUser;
168+
return SongPreviewDto.fromSongDocumentWithUser(songWithUser);
169+
});
170+
171+
if (songPreviews.length === 0) {
172+
this.logger.warn('No valid songs to index in this batch');
173+
continue;
174+
}
175+
176+
await this.typesenseService.indexSongs(songPreviews);
177+
178+
// Mark only the successfully indexed songs
179+
const indexedSongIds = songs
180+
.filter(
181+
(song) => song.stats && song.stats.duration && song.stats.noteCount,
182+
)
183+
.map((song) => song._id);
184+
185+
await this.songModel.updateMany(
186+
{ _id: { $in: indexedSongIds } },
187+
{ $set: { searchIndexed: true } },
188+
);
189+
190+
processedCount += songPreviews.length;
191+
this.logger.log(`Indexed ${processedCount} songs so far...`);
192+
}
193+
194+
this.logger.log(
195+
`Full reindex complete. Total songs indexed: ${processedCount}`,
196+
);
197+
} catch (error) {
198+
this.logger.error('Error during full reindex:', error);
199+
throw error;
200+
}
201+
}
202+
}

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

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ import {
3030
ApiResponse,
3131
ApiTags,
3232
} from '@nestjs/swagger';
33+
import { FileService } from '@server/file/file.service';
34+
import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser';
35+
import { TypesenseService } from '@server/typesense/typesense.service';
3336
import type { Response } from 'express';
3437

3538
import { UPLOAD_CONSTANTS } from '@nbw/config';
@@ -45,8 +48,6 @@ import {
4548
FeaturedSongsDto,
4649
} from '@nbw/database';
4750
import type { UserDocument } from '@nbw/database';
48-
import { FileService } from '@server/file/file.service';
49-
import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser';
5051

5152
import { SongService } from './song.service';
5253

@@ -66,6 +67,7 @@ export class SongController {
6667
constructor(
6768
public readonly songService: SongService,
6869
public readonly fileService: FileService,
70+
public readonly typesenseService: TypesenseService,
6971
) {}
7072

7173
@Get('/')
@@ -99,25 +101,32 @@ export class SongController {
99101
public async getSongList(
100102
@Query() query: SongListQueryDTO,
101103
): Promise<PageDto<SongPreviewDto>> {
102-
// Handle search query
104+
// Handle search query with Typesense
103105
if (query.q) {
104106
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'],
107+
[SongSortType.RECENT, 'createdAt:desc'],
108+
[SongSortType.PLAY_COUNT, 'playCount:desc'],
109+
[SongSortType.TITLE, 'title:asc'],
110+
[SongSortType.DURATION, 'duration:desc'],
111+
[SongSortType.NOTE_COUNT, 'noteCount:desc'],
110112
]);
111113

112-
const sortField = sortFieldMap.get(query.sort) ?? 'createdAt';
114+
let sortBy = sortFieldMap.get(query.sort) ?? 'createdAt:desc';
113115

114-
const pageQuery = new PageQueryDTO({
116+
// Override sort order if specified
117+
if (query.order && query.sort !== SongSortType.RANDOM) {
118+
const field =
119+
sortFieldMap.get(query.sort)?.split(':')[0] ?? 'createdAt';
120+
sortBy = `${field}:${query.order}`;
121+
}
122+
123+
const data = await this.typesenseService.searchSongs(query.q, {
115124
page: query.page,
116125
limit: query.limit,
117-
sort: sortField,
118-
order: query.order === 'desc' ? false : true,
126+
sortBy,
127+
category: query.category,
119128
});
120-
const data = await this.songService.searchSongs(pageQuery, query.q);
129+
121130
return new PageDto<SongPreviewDto>({
122131
content: data,
123132
page: query.page,

0 commit comments

Comments
 (0)