Skip to content

Commit 55bf374

Browse files
committed
feat: Implement Redis caching and a dedicated recommendation service, and refactor history song retrieval with optimized SQL queries.
1 parent 6c8ad08 commit 55bf374

5 files changed

Lines changed: 342 additions & 158 deletions

File tree

server/controllers/music/historyController.js

Lines changed: 64 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ const HistorySong = require("../../models/music/HistorySong")
22
const Song = require("../../models/music/Song")
33
const { Op } = require("sequelize")
44
const sequelize = require("../../utils/sequelize")
5+
const {
6+
getRecommendationsForUser,
7+
getRecentlyPlayed,
8+
queueUserForRecalc,
9+
} = require("../../services/recommendationService")
510

611
require("../../models/music/index")
712

8-
const recommendationCache = new Map()
9-
1013
const addToHistory = async (req, res) => {
1114
try {
1215
const { songData: rawSongData, playedTime = 0 } = req.body
@@ -33,6 +36,7 @@ const addToHistory = async (req, res) => {
3336
lastPlayedAt: new Date(),
3437
})
3538

39+
queueUserForRecalc(userId)
3640
res.json({ message: "History updated successfully" })
3741
} catch (error) {
3842
console.error("Error in addToHistory:", error)
@@ -83,6 +87,7 @@ const batchAddToHistory = async (req, res) => {
8387
}
8488
}
8589

90+
queueUserForRecalc(userId)
8691
res.json({
8792
message: "Batch history updated successfully",
8893
results,
@@ -100,34 +105,16 @@ const getPersonalizedRecommendations = async (req, res) => {
100105
const userId = req.user.userid
101106
const limit = parseInt(req.query.limit || 12, 10)
102107

103-
const cacheKey = `recs:${userId}:${limit}`
104-
const cached = recommendationCache.get(cacheKey)
105-
106-
let recommendationSongs
107-
if (cached && cached.expiresAt > Date.now()) {
108-
recommendationSongs = cached.data
109-
} else {
110-
const rows = await calculateWeightedRecommendations(userId, limit)
111-
recommendationSongs = rows.map((r) => r.song?.songData)
112-
113-
recommendationCache.set(cacheKey, {
114-
expiresAt: Date.now() + 5 * 60 * 1000,
115-
data: recommendationSongs,
116-
})
117-
}
118-
119-
const recentlyPlayed = await HistorySong.findAll({
120-
where: { userId, songRefId: { [Op.ne]: null } },
121-
include: [{ model: Song, as: "song", attributes: ["songData"], required: true }],
122-
order: [["lastPlayedAt", "DESC"]],
123-
limit: 15,
124-
})
108+
const [recommendationSongs, recentlyPlayedSongs] = await Promise.all([
109+
getRecommendationsForUser(userId, limit),
110+
getRecentlyPlayed(userId, 15),
111+
])
125112

126113
res.status(200).json({
127114
success: true,
128115
data: {
129116
songs: recommendationSongs,
130-
recentlyPlayed: recentlyPlayed.map((r) => r.song?.songData),
117+
recentlyPlayed: recentlyPlayedSongs,
131118
},
132119
})
133120
} catch (error) {
@@ -136,107 +123,68 @@ const getPersonalizedRecommendations = async (req, res) => {
136123
}
137124
}
138125

139-
const calculateWeightedRecommendations = async (userId, limit) => {
140-
const { artists, languages } = await getRecentContext(userId)
141-
142-
const artistLikes = artists.map((a) => `%${a}%`)
143-
const languageList = languages.map((l) => sequelize.escape(l)).join(",")
144-
145-
return HistorySong.findAll({
146-
include: [
147-
{
148-
model: Song,
149-
as: "song",
150-
attributes: ["songData", "artistNames", "language"],
151-
required: true,
152-
},
153-
],
154-
where: {
155-
userId,
156-
songRefId: { [Op.ne]: null },
157-
lastPlayedAt: {
158-
[Op.gte]: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
159-
},
160-
},
161-
order: [
162-
[
163-
sequelize.literal(`
164-
(
165-
-- 🔥 STRONG RECENCY
166-
EXP(-EXTRACT(HOUR FROM NOW() - "HistorySong"."lastPlayedAt") / 24) * 0.45 +
167-
168-
-- 🎧 ARTIST CONTEXT
169-
CASE
170-
WHEN "song"."artistNames" ILIKE ANY (
171-
ARRAY[${artistLikes.map((a) => sequelize.escape(a)).join(",")}]
172-
)
173-
THEN 0.25
174-
ELSE 0
175-
END +
176-
177-
-- 🌍 LANGUAGE CONTEXT
178-
CASE
179-
WHEN "song"."language" IN (${languageList || "NULL"})
180-
THEN 0.15
181-
ELSE 0
182-
END +
183-
184-
-- ❤️ USER SIGNALS
185-
("HistorySong"."completionRate" / 100) * 0.1 +
186-
CASE WHEN "HistorySong"."likeStatus" = true THEN 0.1 ELSE 0 END
187-
)
188-
`),
189-
"DESC",
190-
],
191-
],
192-
limit,
193-
})
194-
}
195-
196126
const getHistorySongs = async (req, res) => {
197127
try {
198128
const userId = req.user.userid
199-
const { page = 1, limit = 10, searchQuery = "" } = req.query
200-
201-
const whereClause = { userId, songRefId: { [Op.ne]: null } }
202-
203-
const includeOptions = {
204-
model: Song,
205-
as: "song",
206-
attributes: ["songData", "name", "artistNames"],
207-
required: true,
208-
}
209-
210-
if (searchQuery?.trim()) {
211-
const q = searchQuery.toLowerCase().trim()
212-
includeOptions.where = {
213-
[Op.or]: [
214-
sequelize.literal(`similarity("song"."name", ${sequelize.escape(q)}) > 0.2`),
215-
sequelize.literal(`similarity("song"."artistNames", ${sequelize.escape(q)}) > 0.2`),
216-
sequelize.literal(`similarity("song"."albumName", ${sequelize.escape(q)}) > 0.2`),
217-
],
218-
}
129+
const pageNum = parseInt(req.query.page, 10) || 1
130+
const limitNum = parseInt(req.query.limit, 10) || 10
131+
const searchQuery = req.query.searchQuery?.trim() || ""
132+
const offset = (pageNum - 1) * limitNum
133+
134+
let query
135+
let replacements = { userId, limit: limitNum, offset }
136+
137+
if (searchQuery) {
138+
query = `
139+
WITH matched_songs AS (
140+
SELECT id, "songData",
141+
GREATEST(
142+
similarity(name, :search),
143+
similarity("artistNames", :search),
144+
similarity("albumName", :search)
145+
) AS score
146+
FROM songs
147+
WHERE name % :search
148+
OR "artistNames" % :search
149+
OR "albumName" % :search
150+
)
151+
SELECT
152+
s."songData",
153+
COUNT(*) OVER() AS total_count
154+
FROM history_songs hs
155+
INNER JOIN matched_songs s ON s.id = hs."songRefId"
156+
WHERE hs."userId" = :userId
157+
ORDER BY s.score DESC, hs."lastPlayedAt" DESC
158+
LIMIT :limit OFFSET :offset
159+
`
160+
replacements.search = searchQuery.toLowerCase()
161+
} else {
162+
query = `
163+
SELECT
164+
s."songData",
165+
COUNT(*) OVER() AS total_count
166+
FROM history_songs hs
167+
INNER JOIN songs s ON s.id = hs."songRefId"
168+
WHERE hs."userId" = :userId AND hs."songRefId" IS NOT NULL
169+
ORDER BY hs."lastPlayedAt" DESC
170+
LIMIT :limit OFFSET :offset
171+
`
219172
}
220173

221-
const offset = (parseInt(page, 10) - 1) * parseInt(limit, 10)
222-
223-
const historySongs = await HistorySong.findAndCountAll({
224-
where: whereClause,
225-
include: [includeOptions],
226-
limit: parseInt(limit, 10),
227-
offset,
228-
order: [["lastPlayedAt", "DESC"]],
229-
distinct: true,
230-
subQuery: false,
174+
const results = await sequelize.query(query, {
175+
replacements,
176+
type: sequelize.QueryTypes.SELECT,
231177
})
232178

179+
const totalCount = results[0]?.total_count || 0
180+
233181
res.status(200).json({
234182
status: "success",
235183
data: {
236-
songs: historySongs.rows.map((r) => r.song?.songData),
237-
count: historySongs.count,
238-
currentPage: parseInt(page, 10),
239-
totalPages: Math.ceil(historySongs.count / parseInt(limit, 10)),
184+
songs: results.map((r) => r.songData),
185+
count: parseInt(totalCount, 10),
186+
currentPage: pageNum,
187+
totalPages: Math.ceil(totalCount / limitNum),
240188
},
241189
})
242190
} catch (error) {
@@ -245,48 +193,6 @@ const getHistorySongs = async (req, res) => {
245193
}
246194
}
247195

248-
const getRecentContext = async (userId) => {
249-
const recent = await HistorySong.findAll({
250-
where: {
251-
userId,
252-
lastPlayedAt: {
253-
[Op.gte]: new Date(Date.now() - 24 * 60 * 60 * 1000), // last 24h
254-
},
255-
},
256-
include: [
257-
{
258-
model: Song,
259-
as: "song",
260-
attributes: ["artistNames", "language"],
261-
required: true,
262-
},
263-
],
264-
limit: 20,
265-
})
266-
267-
const artists = new Set()
268-
const languages = new Set()
269-
270-
for (const r of recent) {
271-
const artistNames = r.song?.artistNames
272-
if (artistNames) {
273-
artistNames.split(",").forEach((a) => {
274-
const name = a.trim()
275-
if (name) artists.add(name)
276-
})
277-
}
278-
279-
if (r.song?.language) {
280-
languages.add(r.song.language)
281-
}
282-
}
283-
284-
return {
285-
artists: [...artists],
286-
languages: [...languages],
287-
}
288-
}
289-
290196
const updateLikeStatus = async (req, res) => {
291197
try {
292198
const userId = req.user.userid

server/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,13 @@ const io = new Server(server, {
2222

2323
socketManager(io)
2424

25+
const { startBackgroundRecalc } = require("./services/recommendationService")
26+
2527
sequelize.authenticate().then(() => {
2628
const port = process.env.PORT || 4000
2729
server.listen(port, () => {
2830
console.log(`Server running on port ${port}`)
31+
startBackgroundRecalc(60000)
2932
})
3033
})
3134

server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"express-slow-down": "^2.1.0",
3030
"helmet": "^8.1.0",
3131
"hpp": "^0.2.3",
32+
"ioredis": "^5.9.2",
3233
"jsonwebtoken": "^9.0.3",
3334
"morgan": "^1.10.1",
3435
"multer": "^2.0.2",

0 commit comments

Comments
 (0)