@@ -4,10 +4,32 @@ const { cache, getRedis } = require("../utils/redis")
44const { Op } = require ( "sequelize" )
55
66const SONG_API_URL = process . env . SONG_API_URL || "https://song.thakur.dev"
7- const CACHE_PREFIX = "song:playnext :"
7+ const CACHE_PREFIX = "pn:v3 :"
88const CACHE_TTL = 30 * 24 * 60 * 60
99const 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+
1133const normalize = ( str ) => ( str || "" ) . toLowerCase ( ) . trim ( )
1234
1335const 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+
3356const 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
5492const 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
76115const 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
113161const 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