Skip to content

Commit 8f4667f

Browse files
committed
feat: overhaul play-next recommendation logic with refined scoring, artist extraction, and diversified candidate selection.
1 parent dc7dac1 commit 8f4667f

2 files changed

Lines changed: 148 additions & 68 deletions

File tree

server/routes/musicRoutes.js

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -205,18 +205,11 @@ musicRoutes.get("/play-next/:songId", async (req, res) => {
205205
limit,
206206
excludeSongIds,
207207
})
208-
res.set(
209-
"Cache-Control",
210-
data.length > 0
211-
? "public, max-age=86400, s-maxage=604800, stale-while-revalidate=604800"
212-
: "no-store",
213-
)
214208
return res.json({ success: true, data })
215209
} catch (error) {
216210
console.error("[PlayNext] Error:", error.message)
217211
if (res.headersSent) return
218212
const status = error.message.includes("not found") ? 404 : 500
219-
res.set("Cache-Control", "no-store")
220213
return res.status(status).json({ success: false, error: error.message })
221214
}
222215
})

server/services/playNextService.js

Lines changed: 148 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,32 @@ const { cache, getRedis } = require("../utils/redis")
44
const { Op } = require("sequelize")
55

66
const SONG_API_URL = process.env.SONG_API_URL || "https://song.thakur.dev"
7-
const CACHE_PREFIX = "song:playnext:"
7+
const CACHE_PREFIX = "pn:v3:"
88
const CACHE_TTL = 30 * 24 * 60 * 60
99
const SONG_ATTRS = ["songId", "name", "artistNames", "albumName", "language", "duration", "songData"]
1010

11+
const MIN_SCORE_THRESHOLD = 30
12+
13+
const buildArtistILike = (name) => {
14+
if (name.length <= 3) {
15+
return {
16+
[Op.or]: [
17+
{ artistNames: { [Op.iLike]: `${name}` } },
18+
{ artistNames: { [Op.iLike]: `${name}, %` } },
19+
{ artistNames: { [Op.iLike]: `%, ${name}` } },
20+
{ artistNames: { [Op.iLike]: `%, ${name}, %` } },
21+
],
22+
}
23+
}
24+
return { artistNames: { [Op.iLike]: `%${name}%` } }
25+
}
26+
27+
const VARIANT_TAGS = [
28+
"remix", "dubstep", "edm", "lofi", "lo-fi", "slowed", "reverb", "8d",
29+
"mashup", "nightcore", "bass boosted", "phonk", "trap", "instrumental",
30+
"karaoke", "cover", "unplugged", "acoustic", "reprise", "jhankar", "jhankar beats",
31+
]
32+
1133
const normalize = (str) => (str || "").toLowerCase().trim()
1234

1335
const cleanTitle = (name) => {
@@ -21,34 +43,50 @@ const cleanTitle = (name) => {
2143
.trim()
2244
}
2345

24-
const dedupKey = (name, artist) => `${cleanTitle(name)}::${normalize(artist)}`
25-
26-
const extractPrimaryArtist = (songData) => {
27-
const primary = songData?.artist_map?.primary_artists?.[0]?.name
28-
if (primary) return normalize(primary)
29-
const firstArtist = songData?.artist_map?.artists?.[0]?.name
30-
return normalize(firstArtist || "unknown")
46+
const hasVariantTag = (name) => {
47+
const lower = normalize(name)
48+
return VARIANT_TAGS.some((tag) => {
49+
const pattern = new RegExp(`\\b${tag}\\b|[\\(\\[]\\s*${tag}`, "i")
50+
return pattern.test(lower)
51+
})
3152
}
3253

54+
const dedupKey = (name, artist) => `${cleanTitle(name)}::${normalize(artist)}`
55+
3356
const extractSingers = (songData) => {
3457
const artists = songData?.artist_map?.artists || []
3558
return artists
36-
.filter((a) => a?.role?.toLowerCase().includes("singer"))
59+
.filter((a) => {
60+
const role = (a?.role || "").toLowerCase()
61+
return role.includes("singer") && !role.includes("music")
62+
})
3763
.map((a) => normalize(a.name))
3864
.filter(Boolean)
3965
}
4066

41-
const extractAllPrimaryArtists = (songData) => {
42-
const primaries = songData?.artist_map?.primary_artists || []
43-
return primaries.map((a) => normalize(a.name)).filter(Boolean)
44-
}
67+
const extractMusicDirectors = (songData) => {
68+
const artists = songData?.artist_map?.artists || []
69+
const fromRole = artists
70+
.filter((a) => {
71+
const role = (a?.role || "").toLowerCase()
72+
return role.includes("music") || role === "composer"
73+
})
74+
.map((a) => normalize(a.name))
4575

46-
const extractComposers = (songData) => {
47-
const musicField = songData?.music || ""
48-
return musicField
76+
const fromField = (songData?.music || "")
4977
.split(",")
5078
.map((c) => normalize(c))
5179
.filter((c) => c && c !== "unknown")
80+
81+
return [...new Set([...fromRole, ...fromField])].filter(Boolean)
82+
}
83+
84+
const extractLyricists = (songData) => {
85+
const artists = songData?.artist_map?.artists || []
86+
return artists
87+
.filter((a) => (a?.role || "").toLowerCase().includes("lyricist"))
88+
.map((a) => normalize(a.name))
89+
.filter(Boolean)
5290
}
5391

5492
const normalizePlayCount = (playCount) => {
@@ -61,60 +99,70 @@ const buildEntry = (song) => {
6199
return {
62100
songId: song.songId,
63101
name: song.name || sd.name || "Unknown",
64-
primaryArtist: extractPrimaryArtist(sd),
65-
allPrimaryArtists: extractAllPrimaryArtists(sd),
66102
singers: extractSingers(sd),
67-
composers: extractComposers(sd),
103+
musicDirectors: extractMusicDirectors(sd),
104+
lyricists: extractLyricists(sd),
68105
albumName: normalize(song.albumName || sd.album || ""),
106+
albumId: sd.album_id || "",
69107
language: normalize(song.language || sd.language || ""),
70108
duration: song.duration || sd.duration || 0,
71109
playCount: sd.play_count || 0,
72110
year: sd.year || 0,
111+
isVariant: hasVariantTag(song.name || sd.name || ""),
73112
}
74113
}
75114

76115
const scoreSong = (candidate, baseSong) => {
77116
let score = 0
78117

79-
if (candidate.albumName && candidate.albumName === baseSong.albumName) score += 50
118+
if (baseSong.isVariant !== candidate.isVariant) {
119+
score -= 40
120+
}
80121

81-
const sharedPrimaryArtists = candidate.allPrimaryArtists.filter((a) =>
82-
baseSong.allPrimaryArtists.includes(a),
83-
)
84-
if (sharedPrimaryArtists.length > 0) score += 45
122+
if (candidate.albumId && candidate.albumId === baseSong.albumId) {
123+
score += 60
124+
} else if (candidate.albumName && candidate.albumName === baseSong.albumName) {
125+
score += 55
126+
}
85127

86128
const sharedSingers = candidate.singers.filter((s) => baseSong.singers.includes(s))
87-
if (sharedSingers.length > 0) score += 35
129+
score += Math.min(sharedSingers.length, 3) * 35
130+
131+
const sharedMDs = candidate.musicDirectors.filter((m) => baseSong.musicDirectors.includes(m))
132+
score += Math.min(sharedMDs.length, 2) * 8
88133

89-
const sharedComposers = candidate.composers.filter((c) => baseSong.composers.includes(c))
90-
if (sharedComposers.length > 0) score += 30
134+
const sharedLyricists = candidate.lyricists.filter((l) => baseSong.lyricists.includes(l))
135+
if (sharedLyricists.length > 0) score += 5
91136

92-
if (candidate.language && candidate.language === baseSong.language) score += 15
137+
if (candidate.language && candidate.language === baseSong.language) {
138+
score += 10
139+
} else if (candidate.language && baseSong.language && candidate.language !== baseSong.language) {
140+
score -= 30
141+
}
93142

94143
if (candidate.year > 0 && baseSong.year > 0) {
95144
const yearDiff = Math.abs(candidate.year - baseSong.year)
96-
if (yearDiff === 0) score += 10
97-
else if (yearDiff <= 2) score += 7
98-
else if (yearDiff <= 5) score += 3
145+
if (yearDiff === 0) score += 8
146+
else if (yearDiff <= 2) score += 5
147+
else if (yearDiff <= 5) score += 2
148+
else if (yearDiff > 10) score -= 10
99149
}
100150

101151
if (candidate.duration > 0 && baseSong.duration > 0) {
102152
const durationDiff = Math.abs(candidate.duration - baseSong.duration)
103-
if (durationDiff < 30) score += 5
104-
else if (durationDiff < 60) score += 2
153+
if (durationDiff < 30) score += 3
105154
}
106155

107-
score += normalizePlayCount(candidate.playCount) * 8
108-
score += Math.random() * 3
156+
score += normalizePlayCount(candidate.playCount) * 5
109157

110158
return score
111159
}
112160

113161
const diversifyScored = (scored, limit) => {
114162
const result = []
115-
const artistCount = new Map()
163+
const singerGroupCount = new Map()
116164
const seenNames = new Set()
117-
const MAX_PER_ARTIST = 4
165+
const MAX_PER_SINGER = 4
118166

119167
for (const item of scored) {
120168
if (result.length >= limit) break
@@ -123,10 +171,11 @@ const diversifyScored = (scored, limit) => {
123171
if (seenNames.has(nameKey)) continue
124172
seenNames.add(nameKey)
125173

126-
const count = artistCount.get(item.primaryArtist) || 0
127-
if (count >= MAX_PER_ARTIST) continue
174+
const topSinger = item.topSinger || "unknown"
175+
const count = singerGroupCount.get(topSinger) || 0
176+
if (count >= MAX_PER_SINGER) continue
177+
singerGroupCount.set(topSinger, count + 1)
128178

129-
artistCount.set(item.primaryArtist, count + 1)
130179
result.push(item.songId)
131180
}
132181

@@ -142,47 +191,85 @@ const computeForSong = async (baseSongId) => {
142191
if (!baseSongRow) return []
143192

144193
const baseSong = buildEntry(baseSongRow)
145-
const orConditions = []
146194

147-
if (baseSong.language) {
148-
orConditions.push({ language: baseSong.language })
149-
}
150-
if (baseSongRow.albumName) {
151-
orConditions.push({ albumName: baseSongRow.albumName })
152-
}
195+
const singerConditions = baseSong.singers
196+
.filter((s) => s && s !== "unknown")
197+
.slice(0, 5)
198+
.map((singer) => buildArtistILike(singer))
153199

154-
const artists = [...new Set([...baseSong.allPrimaryArtists, ...baseSong.singers])]
155-
for (const artist of artists.slice(0, 3)) {
156-
if (artist && artist !== "unknown") {
157-
orConditions.push({ artistNames: { [Op.iLike]: `%${artist}%` } })
158-
}
159-
}
200+
const albumCondition = baseSongRow.albumName
201+
? [{ albumName: baseSongRow.albumName }]
202+
: []
160203

161-
if (orConditions.length === 0) return []
204+
const primaryConditions = [...singerConditions, ...albumCondition]
162205

163-
const candidates = await Song.findAll({
206+
if (primaryConditions.length === 0) return []
207+
208+
const primaryCandidates = await Song.findAll({
164209
where: {
165-
[Op.or]: orConditions,
210+
[Op.or]: primaryConditions,
166211
songId: { [Op.ne]: baseSongId },
167212
},
168213
attributes: SONG_ATTRS,
169214
raw: true,
170-
limit: 500,
215+
limit: 300,
171216
})
172217

218+
let allCandidates = [...primaryCandidates]
219+
220+
if (allCandidates.length < 80) {
221+
const existingIds = new Set(allCandidates.map((c) => c.songId))
222+
existingIds.add(baseSongId)
223+
224+
const mdConditions = baseSong.musicDirectors
225+
.filter((m) => m && m !== "unknown")
226+
.slice(0, 3)
227+
.map((md) => buildArtistILike(md))
228+
229+
if (mdConditions.length > 0 && baseSong.language) {
230+
const { sequelize } = Song
231+
const yearFilter = baseSong.year > 0
232+
? sequelize.where(
233+
sequelize.cast(sequelize.json("songData.year"), "integer"),
234+
{ [Op.between]: [baseSong.year - 10, baseSong.year + 10] },
235+
)
236+
: undefined
237+
238+
const supplementary = await Song.findAll({
239+
where: {
240+
[Op.and]: [
241+
{ [Op.or]: mdConditions },
242+
{ language: baseSong.language },
243+
{ songId: { [Op.notIn]: [...existingIds] } },
244+
yearFilter,
245+
],
246+
},
247+
attributes: SONG_ATTRS,
248+
raw: true,
249+
limit: 150,
250+
})
251+
252+
allCandidates = [...allCandidates, ...supplementary]
253+
}
254+
}
255+
173256
const seen = new Set()
174257
const scored = []
175258

176-
for (const c of candidates) {
259+
for (const c of allCandidates) {
177260
const entry = buildEntry(c)
178-
const dk = dedupKey(entry.name, entry.primaryArtist)
261+
const dk = dedupKey(entry.name, entry.singers[0] || "unknown")
179262
if (seen.has(dk)) continue
180263
seen.add(dk)
264+
265+
const score = scoreSong(entry, baseSong)
266+
if (score < MIN_SCORE_THRESHOLD) continue
267+
181268
scored.push({
182269
songId: entry.songId,
183-
score: scoreSong(entry, baseSong),
270+
score,
184271
name: entry.name,
185-
primaryArtist: entry.primaryArtist,
272+
topSinger: entry.singers[0] || "unknown",
186273
})
187274
}
188275

0 commit comments

Comments
 (0)