Skip to content

Commit 7faa649

Browse files
committed
feat: Implement song deduplication, replace labels with composers, and enhance recommendation scoring and diversification in playNextService, and conditionally set Cache-Control for empty music responses.
1 parent cbcc256 commit 7faa649

2 files changed

Lines changed: 113 additions & 33 deletions

File tree

server/routes/musicRoutes.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,9 @@ musicRoutes.get("/play-next/:songId", async (req, res) => {
207207
})
208208
res.set(
209209
"Cache-Control",
210-
"public, max-age=86400, s-maxage=604800, stale-while-revalidate=604800",
210+
data.length > 0
211+
? "public, max-age=86400, s-maxage=604800, stale-while-revalidate=604800"
212+
: "no-store",
211213
)
212214
return res.json({ success: true, data })
213215
} catch (error) {

server/services/playNextService.js

Lines changed: 110 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,35 @@ const HistorySong = require("../models/music/HistorySong")
33
const { cache } = require("../utils/redis")
44
const { Op } = require("sequelize")
55

6-
const SONG_API_URL = process.env.SONG_API_URL || "https://songapi.thakur.dev"
6+
const SONG_API_URL = process.env.SONG_API_URL || "https://song.thakur.dev"
77
const CACHE_PREFIX = "song:playnext:"
88
const CACHE_TTL = 7 * 24 * 60 * 60
99

1010
let songMap = new Map()
1111
let artistMap = new Map()
12-
let labelMap = new Map()
12+
let composerMap = new Map()
1313
let languageMap = new Map()
1414
let albumMap = new Map()
1515
let singerMap = new Map()
16+
let nameIndex = new Map()
1617
let initialized = false
1718
let initializing = false
1819

1920
const normalize = (str) => (str || "").toLowerCase().trim()
2021

22+
const cleanTitle = (name) => {
23+
return normalize(name)
24+
.replace(/\s*[\(\[].*?[\)\]]/g, "")
25+
.replace(
26+
/\s*-\s*(from|feat|ft|remix|unplugged|reprise|acoustic|lofi|slowed|reverb|version|male|female|duet|sad|happy|jhankar|remastered|deluxe).*$/i,
27+
"",
28+
)
29+
.replace(/\s+/g, " ")
30+
.trim()
31+
}
32+
33+
const dedupKey = (name, artist) => `${cleanTitle(name)}::${normalize(artist)}`
34+
2135
const extractPrimaryArtist = (songData) => {
2236
const primary = songData?.artist_map?.primary_artists?.[0]?.name
2337
if (primary) return normalize(primary)
@@ -38,6 +52,14 @@ const extractAllPrimaryArtists = (songData) => {
3852
return primaries.map((a) => normalize(a.name)).filter(Boolean)
3953
}
4054

55+
const extractComposers = (songData) => {
56+
const musicField = songData?.music || ""
57+
return musicField
58+
.split(",")
59+
.map((c) => normalize(c))
60+
.filter((c) => c && c !== "unknown")
61+
}
62+
4163
const addToMap = (map, key, songId) => {
4264
if (!key) return
4365
const k = normalize(key)
@@ -70,22 +92,34 @@ const initialize = async () => {
7092

7193
songMap = new Map()
7294
artistMap = new Map()
73-
labelMap = new Map()
95+
composerMap = new Map()
7496
languageMap = new Map()
7597
albumMap = new Map()
7698
singerMap = new Map()
99+
nameIndex = new Map()
77100

78101
for (const song of songs) {
79102
const sd = song.songData || {}
103+
const primaryArtist = extractPrimaryArtist(sd)
104+
const dk = dedupKey(song.name || sd.name, primaryArtist)
105+
106+
if (nameIndex.has(dk)) {
107+
const existing = songMap.get(nameIndex.get(dk))
108+
if (existing && (sd.play_count || 0) <= (existing.playCount || 0)) continue
109+
songMap.delete(nameIndex.get(dk))
110+
}
111+
nameIndex.set(dk, song.songId)
112+
113+
const composers = extractComposers(sd)
80114
const entry = {
81115
songId: song.songId,
82116
name: song.name || sd.name || "Unknown",
83-
primaryArtist: extractPrimaryArtist(sd),
117+
primaryArtist,
84118
allPrimaryArtists: extractAllPrimaryArtists(sd),
85119
singers: extractSingers(sd),
120+
composers,
86121
albumName: normalize(song.albumName || sd.album || ""),
87122
language: normalize(song.language || sd.language || ""),
88-
label: normalize(sd.label || ""),
89123
duration: song.duration || sd.duration || 0,
90124
playCount: sd.play_count || 0,
91125
year: sd.year || 0,
@@ -100,7 +134,9 @@ const initialize = async () => {
100134
for (const singer of entry.singers) {
101135
addToMap(singerMap, singer, song.songId)
102136
}
103-
addToMap(labelMap, entry.label, song.songId)
137+
for (const composer of composers) {
138+
addToMap(composerMap, composer, song.songId)
139+
}
104140
addToMap(languageMap, entry.language, song.songId)
105141
addToMap(albumMap, entry.albumName, song.songId)
106142
}
@@ -109,7 +145,7 @@ const initialize = async () => {
109145
initializing = false
110146
await cache.set("playnext:last_init", Date.now(), 0)
111147
console.log(
112-
`[PlayNext] Initialized: ${songMap.size} songs, ${artistMap.size} artists, ${labelMap.size} labels, ${languageMap.size} languages, ${albumMap.size} albums`,
148+
`[PlayNext] Initialized: ${songMap.size} songs, ${artistMap.size} artists, ${composerMap.size} composers, ${languageMap.size} languages, ${albumMap.size} albums`,
113149
)
114150
} catch (err) {
115151
initializing = false
@@ -135,48 +171,81 @@ const collectCandidates = (baseSong, excludeSet) => {
135171
for (const singer of baseSong.singers) {
136172
addFromMap(singerMap, singer)
137173
}
174+
for (const composer of baseSong.composers) {
175+
addFromMap(composerMap, composer)
176+
}
177+
if (baseSong.albumName) {
178+
addFromMap(albumMap, baseSong.albumName)
179+
}
138180
addFromMap(languageMap, baseSong.language)
139-
addFromMap(labelMap, baseSong.label)
140181

141182
candidates.delete(baseSong.songId)
142183
return candidates
143184
}
144185

145-
const scoreSong = (candidate, baseSong, recentSongIds, userHistoryMap) => {
186+
const scoreSong = (candidate, baseSong) => {
146187
let score = 0
147188

189+
if (candidate.albumName && candidate.albumName === baseSong.albumName) score += 50
190+
148191
const sharedPrimaryArtists = candidate.allPrimaryArtists.filter((a) =>
149192
baseSong.allPrimaryArtists.includes(a),
150193
)
151-
if (sharedPrimaryArtists.length > 0) score += 60
194+
if (sharedPrimaryArtists.length > 0) score += 45
152195

153196
const sharedSingers = candidate.singers.filter((s) => baseSong.singers.includes(s))
154-
if (sharedSingers.length > 0) score += 40
155-
156-
if (candidate.albumName && candidate.albumName === baseSong.albumName) score += 30
197+
if (sharedSingers.length > 0) score += 35
157198

158-
if (candidate.label && candidate.label === baseSong.label) score += 25
199+
const sharedComposers = candidate.composers.filter((c) => baseSong.composers.includes(c))
200+
if (sharedComposers.length > 0) score += 30
159201

160202
if (candidate.language && candidate.language === baseSong.language) score += 15
161203

204+
if (candidate.year > 0 && baseSong.year > 0) {
205+
const yearDiff = Math.abs(candidate.year - baseSong.year)
206+
if (yearDiff === 0) score += 10
207+
else if (yearDiff <= 2) score += 7
208+
else if (yearDiff <= 5) score += 3
209+
}
210+
162211
if (candidate.duration > 0 && baseSong.duration > 0) {
163-
if (Math.abs(candidate.duration - baseSong.duration) < 30) score += 5
212+
const durationDiff = Math.abs(candidate.duration - baseSong.duration)
213+
if (durationDiff < 30) score += 5
214+
else if (durationDiff < 60) score += 2
164215
}
165216

166-
score += normalizePlayCount(candidate.playCount) * 10
217+
score += normalizePlayCount(candidate.playCount) * 8
218+
219+
score += Math.random() * 3
220+
221+
return score
222+
}
223+
224+
const diversify = (scored, songMap, limit) => {
225+
const result = []
226+
const artistCount = new Map()
227+
const seenNames = new Set()
228+
const MAX_PER_ARTIST = 4
229+
230+
for (const item of scored) {
231+
if (result.length >= limit) break
167232

168-
score += Math.random() * 5
233+
const entry = songMap.get(item.songId)
234+
if (!entry) continue
169235

170-
const history = userHistoryMap?.get(candidate.songId)
171-
if (history) {
172-
if (recentSongIds.has(candidate.songId)) score -= 50
173-
if (history.skipCount >= 5) score -= 40
174-
else if (history.skipCount >= 3) score -= 20
175-
if (history.playedCount > 10) score -= 30
176-
else if (history.playedCount > 5) score -= 15
236+
const nameKey = cleanTitle(entry.name)
237+
if (seenNames.has(nameKey)) continue
238+
seenNames.add(nameKey)
239+
240+
const artist = entry.primaryArtist
241+
const count = artistCount.get(artist) || 0
242+
if (count >= MAX_PER_ARTIST) continue
243+
244+
artistCount.set(artist, count + 1)
245+
result.push(item.songId)
177246
}
178247

179-
return score
248+
return result
180249
}
181250

182251
const getUserHistory = async (userId) => {
@@ -220,13 +289,24 @@ const getUserHistory = async (userId) => {
220289
return { recentSongIds, historyMap }
221290
}
222291

292+
const deduplicateExternal = (songs) => {
293+
const seen = new Set()
294+
return songs.filter((s) => {
295+
const key = dedupKey(s.name, s.artist_map?.primary_artists?.[0]?.name || "")
296+
if (seen.has(key)) return false
297+
seen.add(key)
298+
return true
299+
})
300+
}
301+
223302
const fetchExternalRecommendations = async (songId) => {
224303
try {
225304
const response = await fetch(`${SONG_API_URL}/song/recommend?id=${songId}`)
226305
if (!response.ok) return []
227306
const json = await response.json()
228307
const data = json?.data || []
229-
return data.filter((s) => s?.id && s?.download_url?.length > 0)
308+
const valid = data.filter((s) => s?.id && s?.download_url?.length > 0)
309+
return deduplicateExternal(valid)
230310
} catch {
231311
return []
232312
}
@@ -261,12 +341,12 @@ const getPlayNextSongs = async ({ baseSongId, userId = null, limit = 20, exclude
261341
for (const id of candidateIds) {
262342
const candidate = songMap.get(id)
263343
if (!candidate) continue
264-
const score = scoreSong(candidate, baseSong, new Set(), null)
344+
const score = scoreSong(candidate, baseSong)
265345
scored.push({ songId: id, score })
266346
}
267347

268348
scored.sort((a, b) => b.score - a.score)
269-
cachedIds = scored.slice(0, 100).map((s) => s.songId)
349+
cachedIds = diversify(scored, songMap, 100)
270350

271351
await cache.set(`${CACHE_PREFIX}${baseSongId}`, cachedIds, CACHE_TTL)
272352
}
@@ -277,8 +357,6 @@ const getPlayNextSongs = async ({ baseSongId, userId = null, limit = 20, exclude
277357
const { recentSongIds, historyMap } = await getUserHistory(userId)
278358

279359
const personalized = resultIds.map((id) => {
280-
const candidate = songMap.get(id)
281-
if (!candidate) return { songId: id, score: 0 }
282360
let penalty = 0
283361
const history = historyMap.get(id)
284362
if (history) {
@@ -331,12 +409,12 @@ const rebuildAllPlayNext = async () => {
331409
for (const id of candidateIds) {
332410
const candidate = songMap.get(id)
333411
if (!candidate) continue
334-
const score = scoreSong(candidate, baseSong, new Set(), null)
412+
const score = scoreSong(candidate, baseSong)
335413
scored.push({ songId: id, score })
336414
}
337415

338416
scored.sort((a, b) => b.score - a.score)
339-
const topIds = scored.slice(0, 100).map((s) => s.songId)
417+
const topIds = diversify(scored, songMap, 100)
340418

341419
await cache.set(`${CACHE_PREFIX}${songId}`, topIds, 0)
342420
processed++

0 commit comments

Comments
 (0)