@@ -3,21 +3,35 @@ const HistorySong = require("../models/music/HistorySong")
33const { cache } = require ( "../utils/redis" )
44const { 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"
77const CACHE_PREFIX = "song:playnext:"
88const CACHE_TTL = 7 * 24 * 60 * 60
99
1010let songMap = new Map ( )
1111let artistMap = new Map ( )
12- let labelMap = new Map ( )
12+ let composerMap = new Map ( )
1313let languageMap = new Map ( )
1414let albumMap = new Map ( )
1515let singerMap = new Map ( )
16+ let nameIndex = new Map ( )
1617let initialized = false
1718let initializing = false
1819
1920const normalize = ( str ) => ( str || "" ) . toLowerCase ( ) . trim ( )
2021
22+ const cleanTitle = ( name ) => {
23+ return normalize ( name )
24+ . replace ( / \s * [ \( \[ ] .* ?[ \) \] ] / g, "" )
25+ . replace (
26+ / \s * - \s * ( f r o m | f e a t | f t | r e m i x | u n p l u g g e d | r e p r i s e | a c o u s t i c | l o f i | s l o w e d | r e v e r b | v e r s i o n | m a l e | f e m a l e | d u e t | s a d | h a p p y | j h a n k a r | r e m a s t e r e d | d e l u x e ) .* $ / i,
27+ "" ,
28+ )
29+ . replace ( / \s + / g, " " )
30+ . trim ( )
31+ }
32+
33+ const dedupKey = ( name , artist ) => `${ cleanTitle ( name ) } ::${ normalize ( artist ) } `
34+
2135const 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+
4163const 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
182251const 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+
223302const 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