View Javadoc

1   package org.musicontroller.songselection;
2   
3   import java.util.Date;
4   import java.util.HashMap;
5   import java.util.HashSet;
6   import java.util.Iterator;
7   import java.util.List;
8   import java.util.Map;
9   import java.util.Set;
10  
11  import org.apache.log4j.Logger;
12  import org.musicontroller.core.Band;
13  import org.musicontroller.core.Keyword;
14  import org.musicontroller.core.Keywordbag;
15  import org.musicontroller.core.Song;
16  import org.musicontroller.dao.Dao;
17  import org.musicontroller.security.IUser;
18  import org.varienaja.util.DateTools;
19  import org.varienaja.util.LRUMap;
20  
21  public class AdvancedRandomSongSelector implements SongSelector {
22  	private static final Logger log = Logger.getLogger(AdvancedRandomSongSelector.class);
23  
24  	/**
25  	 * A Map of Strings (kwbId1,kwbId2) --> Similarity score
26  	 */
27  	private static Map<String,Integer> SCORES = new LRUMap<String,Integer>("KeywordSimilarityScores",1000);
28  	
29  	private Dao _dao;
30  
31  	/**
32  	 * A map, containing knowledge of the popularity of all Bands.
33  	 */
34  	private Map<Long,PlaySkipContainer> _bandPopularity;
35  
36  	/**
37  	 * A map, containing knowledge of the popularity of all Keywords.
38  	 */
39  	private Map<Long,PlaySkipContainer> _keywordPopularity;
40  
41  	/**
42  	 * The specific implementation of selecting the best song from a list of
43  	 * candidates, using the advanced random algorithm.
44  	 * @param candidates The list of candidates
45  	 * @param user The user for who we're selecting
46  	 * @param lpc The LastPlayedContainer
47  	 * @return The Song from the candidatelist that fitted best.
48  	 */
49  	private Song selectSongInternal(List<Song> candidates, IUser user, LastPlayedContainer lpc) {
50  		Set<Song> best = new HashSet<Song>();
51  		Song bestsong = null;
52  		int maxscore=Integer.MIN_VALUE;
53  
54  		// Stage 1: select highest scoring candidates
55  		for (Song song : candidates) {
56  			int score = calculateScore(song,lpc,user);
57  			if (score>=maxscore) {
58  				if (score>maxscore) { // New highscore: clear best-set
59  					best.clear();
60  					maxscore=score;
61  				}
62  				best.add(song);
63  			}
64  		}
65  
66  		// Stage 2: if there are more than one equally scoring songs, pick the
67  		// best, based on the EventScore.
68  		if (best.size()==1) {
69  			bestsong = best.iterator().next();
70  		} else {
71  			maxscore=Integer.MIN_VALUE;
72  			for (Song song : best) {
73  				int songscore = calculateEventScore(user,song);
74  				if (songscore>maxscore) {
75  					maxscore = songscore; 
76  					bestsong = song;
77  				}
78  			}
79  		}
80  		if (bestsong==null) {
81  			return null;
82  		}
83  		if (log.isInfoEnabled()) {
84  			Date lastYear = DateTools.lastYear();
85  			log.info("Song selected: "+
86  					bestsong.getBand().getName()+
87  					" - "+bestsong.getName()+
88  					" P:"+bestsong.getPlaycount(user,lastYear,null)+
89  					" R:"+bestsong.getRequestcount(user,lastYear,null)+
90  					" S:"+bestsong.getSkipcount(user,lastYear,null)
91  			);
92  		}
93  
94  		return bestsong;
95  	}
96  
97  	/*
98  	 * (non-Javadoc)
99  	 * @see org.musicontroller.songselection.SongSelector#selectSong(java.util.List, org.musicontroller.songselection.LastPlayedContainer, org.musicontroller.security.IUser)
100 	 */
101 	public Song selectSong(List<Song> candidates, LastPlayedContainer lpc, IUser user) {
102 		if(candidates==null) {
103 			return null;
104 		}
105 		if(lpc==null) {
106 			lpc = new LastPlayedContainer();
107 		}
108 		Song result = selectSongInternal(candidates,user,lpc);
109 		return result;
110 	}
111 
112 	/**
113 	 * Calculates the score for a Song. The score is the sum of the playcount score, the
114 	 * skip score, the request score, the keyword similarity score, the band popularity score,
115 	 * and a possible penalty for songs by recently played bands.
116 	 * <ul>
117 	 * <li>The playcount score is the square root of the number of plays the song got in the last year.
118 	 * <li>The skip score is the number of skip events of the song in the last year, times -1.
119 	 * <li>The request score if the number of request events of the song in the last year.
120 	 * <li>The keyword similarity score is TODO: explain this.
121 	 * <li>The band popularity score is TODO: explain this.
122 	 * <li>Songs played by bands that received a played event recently get a penalty equal to the
123 	 * keyword similarity score.
124 	 * </ul>
125 	 */
126 	private int calculateScore(Song song, LastPlayedContainer lpc,IUser user) {
127 		StringBuilder sb = new StringBuilder();
128 		int score=0;
129 		sb.append(song.getBand().getName());
130 		sb.append(" - ");
131 		sb.append(song.getName());
132 		sb.append(" ");
133 
134 		int playscore = calculateEventScore(user,song); 
135 		score = playscore;
136 		if(playscore!=0) {
137 			sb.append("(play:");
138 			sb.append(playscore);
139 			sb.append(")");
140 		}
141 		
142 		//The skips are already a part of the playscore, but because skips are
143 		//such a clear indication of dislike, we penalize extra for them.
144 		int skipscore = -1 * song.getSkipcount(user,DateTools.lastYear(),null);
145 		score += skipscore;
146 		if(skipscore!=0) {
147 			sb.append("(skip:");
148 			sb.append(skipscore);
149 			sb.append(")");
150 		}
151 		
152 		int kwscore = calculateKeywordScore(song, lpc, sb);
153 		score += kwscore;
154 		
155 		int bandPenalty = getPenalty(song.getBand()); // score negative for often-skipped bands.
156 		score += bandPenalty;
157 		if(bandPenalty!=0) {
158 			sb.append("(bandPenalty:");
159 			sb.append(bandPenalty);
160 			sb.append(")");
161 		}
162 
163 		sb.append("[");
164 		sb.append(score);
165 		sb.append("]");
166 		log.debug(sb.toString());
167 		return score;
168 	}
169 
170 	/**
171 	 * calculates the song score for keyword similarity with recently played songs.
172 	 * If the song is by the same band as one of the recently played songs, the song
173 	 * cannot get a positive score (but negative scores still apply).
174 	 * 
175 	 * @param song The candidate song 
176 	 * @param lpc The container of the set of recently played songs.
177 	 * @param sb A string builder for receiving logging messages.
178 	 * @return The keyword similarity score for this song compared to the recently played songs.
179 	 */
180 	private int calculateKeywordScore(Song song, LastPlayedContainer lpc, StringBuilder sb) {
181 		int totalkwscore = 0;
182 		Keywordbag songKeywords = song.getKeywordbag();
183 		if(songKeywords!=null) {
184 			// The last 3 songs weigh positive, one weighs 0, earlier played songs
185 			// negative.
186 			// The more recently a song is played, the more keyword-similarity
187 			// counts in the score.
188 			// This is reflected by the modifier.
189 			int modifier=4-lpc.getSize(); 
190 			Iterator<LastPlayedEntry> it = lpc.getIterator();
191 			while (it.hasNext()) {
192 				sb.append(" (");
193 				sb.append(modifier);
194 				sb.append(" * ");
195 				LastPlayedEntry lastsongentry = it.next();
196 	
197 				Song lastsong = getDao().getSongById(lastsongentry.getSongid());
198 				if (lastsong!=null) {
199 					boolean scoreNegative = false;
200 					
201 					int kwscore = kwbSimilarityScore(songKeywords, lastsong.getKeywordbag(), sb);
202 					//If the lastsong was skipped, or if the Band matches we let this one count negative:
203 					if (kwscore>0) {
204 						scoreNegative = (!lastsongentry.isPlayedEntirely() || song.getBand().equals(lastsong.getBand()));
205 					}
206 					sb.append(scoreNegative ? "!" : "");
207 					sb.append(kwscore);
208 					sb.append(scoreNegative ? "!" : "");
209 					
210 					// Use the modifier on the keyword score.
211 					kwscore *= modifier;
212 					if (scoreNegative && kwscore>0) {
213 						totalkwscore -= kwscore;
214 					} else {
215 						totalkwscore += kwscore;
216 					}
217 				} else {
218 					log.debug("Detected SongID in lastplayedcontainer for " +
219 							"which there is no song in the database. Was is " +
220 							"just deleted or merged? The id is: " + lastsongentry.getSongid());
221 				}
222 				sb.append(") ");
223 				sb.append(it.hasNext() ? "+" : "= ");
224 				modifier++;
225 			}
226 			sb.append(totalkwscore);
227 			sb.append(" sqrt(sqrt(" + totalkwscore + ")) = ");
228 			// Use the double square root as the final keyword score.
229 			if(totalkwscore<0) {
230 				totalkwscore = -1 * isqrt(isqrt(-1 * totalkwscore));
231 			} else {
232 				totalkwscore = isqrt(isqrt(totalkwscore));
233 			}
234 		}
235 		sb.append(totalkwscore);
236 		return totalkwscore;
237 	}
238 
239 	/**
240 	 * Return a score for the popularity of the song. The score is the square
241 	 * root of the popularity. A popularity of 0 still earns the song 1 point
242 	 * (there should be no difference between a 0 or 1 popularity score).
243 	 * @param user The logged in user (for calculating the popularity).
244 	 * @param song The song.
245 	 * @return A score for the popularity of the song in the last year.
246 	 */
247 	private int calculateEventScore(IUser user, Song song) {
248 		int nplays = song.getPopularity(user,DateTools.lastYear(),null);
249 		int evScore = nplays == 0 ? 1 : nplays < 0 ? -1*isqrt(-1 * nplays) : isqrt(nplays);
250 		return evScore;
251 	}
252 	
253 	/**
254 	 * Helper function for the <tt>isqrt</tt> method. 
255 	 * @param n
256 	 * @param i
257 	 * @return
258 	 */
259 	private static int next(int n, int i) {
260 		return (n + i/n) >> 1;
261 	}
262 
263 	/**
264 	 * Calculates the integer square root of an integer using Newtons' method.
265 	 * This only works for numbers >= 0.
266 	 * 
267 	 * From: http://www.codecodex.com/wiki/index.php?title=Calculate_an_integer_square_root.
268 	 * 
269 	 * @param number The number to take the square root from.
270 	 * @return The square root of the number.
271 	 */
272 	private static int isqrt(int number) {
273 		if(number<0) {
274 			throw new IllegalArgumentException("Cannot take the sqare root of a negative integer.");
275 		}
276 		int n  = 1;
277 		int n1 = next(n, number);
278 
279 		while(Math.abs(n1 - n) > 1) {
280 			n  = n1;
281 			n1 = next(n, number);
282 		}
283 		while((n1*n1) > number) {
284 			n1 -= 1;
285 		}
286 		return n1;
287 	}
288 
289 
290 	/**
291 	 * Setter for the DAO.
292 	 * 
293 	 * @param dao
294 	 *            The DAO.
295 	 */
296 	public void setDao(Dao dao) {
297 		_dao = dao;
298 	}
299 
300 	public Dao getDao() {
301 		return _dao;
302 	}
303 
304 	public AdvancedRandomSongSelector() {
305 		_bandPopularity = new HashMap<Long,PlaySkipContainer>();
306 		_keywordPopularity = new HashMap<Long,PlaySkipContainer>();
307 	}
308 
309 	static class PlaySkipContainer {
310 		private int _playcount;
311 		private int _skipcount;
312 
313 		public PlaySkipContainer(int playcount,int skipcount) {
314 			_playcount = playcount;
315 			_skipcount = skipcount;
316 		}
317 
318 		/**
319 		 * Calculates the popularity-score for a skip/play-ratio. If something
320 		 * has been skipped at least 3 times, it's popularity decreases. A score
321 		 * can only be negative or 0.
322 		 * 
323 		 * @return A value <=0
324 		 */
325 		public int getPopularity() {
326 			if (_skipcount<3) return 0;
327 			if (_playcount>=_skipcount) return 0;
328 			return _playcount-_skipcount;
329 		}
330 
331 		public void addPlay() {
332 			_playcount++;
333 		}
334 
335 		public void addSkip() {
336 			_skipcount++;
337 		}
338 
339 		public String toString() {
340 			return Integer.toString(getPopularity());
341 		}
342 	}
343 
344 	/**
345 	 * Returns an integer, representing the penalty of a Band.
346 	 * @param band The Band to check the popularity for.
347 	 * @return An integer equal to or smaller than 0.
348 	 */
349 	public int getPenalty(Band band) {
350 		PlaySkipContainer psc = _bandPopularity.get(band.getId());
351 		if (psc==null) return 0;
352 
353 		return psc.getPopularity();
354 	}
355 
356 	/**
357 	 * Returns an integer, representing the penalty of a Keyword.
358 	 * @param keyword The Keyword to check the popularity for.
359 	 * @return An integer equal to or smaller than 0.
360 	 */
361 	public int getPenalty(Keyword keyword) {
362 		PlaySkipContainer psc = _keywordPopularity.get(keyword.getId());
363 		if (psc==null) return 0;
364 
365 		return psc.getPopularity();
366 	}
367 
368 	/**
369 	 * Adds knowledge about a Band. Knowledge is 'sparse', Bands without plays
370 	 * and skips are not stored.
371 	 * 
372 	 * @param bandid
373 	 *            The bandid
374 	 * @param playcount
375 	 *            The amount of plays for this band
376 	 * @param skipcount
377 	 *            The amount of skips for this band
378 	 */
379 	public void addBandKnowledge(Long bandid, int playcount, int skipcount) {
380 		if (playcount>0 || skipcount>0) {
381 			PlaySkipContainer psc = new PlaySkipContainer(playcount,skipcount);
382 			_bandPopularity.put(bandid,psc);
383 		}
384 	}
385 
386 	/**
387 	 * Adds knowledge about a Keyword. Knowledge is 'sparse', Keywords without
388 	 * plays and skips are not stored.
389 	 * 
390 	 * @param keywordid
391 	 *            The keywordid
392 	 * @param playcount
393 	 *            The amount of plays for this keyword
394 	 * @param skipcount
395 	 *            The amount of skips for this keyword
396 	 */
397 	public void addKeywordKnowledge(Long keywordid, int playcount, int skipcount) {
398 		if (playcount>0 && skipcount>0) {
399 			PlaySkipContainer psc = new PlaySkipContainer(playcount,skipcount);
400 			_keywordPopularity.put(keywordid,psc);
401 		}
402 	}
403 
404 	/*
405 	 * (non-Javadoc)
406 	 * 
407 	 * @see org.musicontroller.songselection.SongSelector#addBandPlay(long)
408 	 */
409 	public void addBandPlay(long bandid) {
410 		PlaySkipContainer psc = _bandPopularity.get(bandid);
411 		if (psc==null) {
412 			psc = new PlaySkipContainer(1,0);
413 			_bandPopularity.put(bandid,psc);
414 		} else {
415 			psc.addPlay();
416 		}
417 	}
418 
419 	/*
420 	 * (non-Javadoc)
421 	 * 
422 	 * @see org.musicontroller.songselection.SongSelector#addBandSkip(long)
423 	 */
424 	public void addBandSkip(long bandid) {
425 		PlaySkipContainer psc = _bandPopularity.get(bandid);
426 		if (psc==null) {
427 			psc = new PlaySkipContainer(0,1);
428 			_bandPopularity.put(bandid,psc);
429 		} else {
430 			psc.addSkip();
431 		}
432 	}
433 
434 	/*
435 	 * (non-Javadoc)
436 	 * 
437 	 * @see org.musicontroller.songselection.SongSelector#addKeywordPlay(long)
438 	 */
439 	public void addKeywordPlay(long keywordid) {
440 		PlaySkipContainer psc = _keywordPopularity.get(keywordid);
441 		if (psc==null) {
442 			psc = new PlaySkipContainer(1,0);
443 			_keywordPopularity.put(keywordid,psc);
444 		} else {
445 			psc.addPlay();
446 		}
447 	}
448 
449 	/*
450 	 * (non-Javadoc)
451 	 * 
452 	 * @see org.musicontroller.songselection.SongSelector#addKeywordSkip(long)
453 	 */
454 	public void addKeywordSkip(long keywordid) {
455 		PlaySkipContainer psc = _keywordPopularity.get(keywordid);
456 		if (psc==null) {
457 			psc = new PlaySkipContainer(0,1);
458 			_keywordPopularity.put(keywordid,psc);
459 		} else {
460 			psc.addSkip();
461 		}
462 	}
463 	
464 	/**
465 	 * Calculates the amount of similarity between two Keywordbags. This method
466 	 * used a caching mechanism. Calculating a similarity between Keywordbags is
467 	 * relatively expensive. Once such a score has been calculated, this method
468 	 * is of O(1).
469 	 * 
470 	 * @param kwb1 The first keywordbag.
471 	 * @param kwb2 The second keywordbag.
472 	 * @param sb A StringBuffer to write debug-info to.
473 	 * @return An integer, expressing the amount of similarity.
474 	 */
475 	private int kwbSimilarityScore(Keywordbag kwb1, Keywordbag kwb2, StringBuilder sb) {
476 		//Create a key out of the two keywordbags and the user, making sure that x,y == y,x
477 		StringBuilder hashkeyBuilder = new StringBuilder();
478 		if(kwb1==null) {
479 			if(kwb2==null) {
480 				hashkeyBuilder.append("null,null");
481 			} else {
482 				hashkeyBuilder.append("null,");
483 				hashkeyBuilder.append(kwb2.getId());
484 			}
485 		} else if (kwb2==null) {
486 			hashkeyBuilder.append(kwb1.getId());
487 			hashkeyBuilder.append(",null");
488 		} else if (kwb1.getId()<kwb2.getId()) {
489 			hashkeyBuilder.append(kwb1.getId());
490 			hashkeyBuilder.append(",");
491 			hashkeyBuilder.append(kwb2.getId());
492 		} else {
493 			hashkeyBuilder.append(kwb2.getId());
494 			hashkeyBuilder.append(",");
495 			hashkeyBuilder.append(kwb1.getId());			
496 		}
497 		String key = hashkeyBuilder.toString();
498 		Integer score = SCORES.get(key);
499 		
500 		if (score!=null) {
501 			return score;
502 		}
503 		
504 		//If we manage to get here, we need to calculate a new score.
505 		int kwscore = 0;
506 		Set<Keyword> alreadyCounted = new HashSet<Keyword>();
507 		int kwcounter = 0; //The amount of keywords of the song.
508 		for (Keyword kw : kwb1.getKeywords()) {
509 			kwcounter++;
510 			if(alreadyCounted.contains(kw)) {
511 				continue;
512 			}
513 			kwscore += getPenalty(kw); // score negative for often-skipped keywords.
514 			if (kwb2!=null) {
515 				for(Keyword kwlast : kwb2.getKeywords()) {
516 					if(alreadyCounted.contains(kwlast)) {
517 						continue;
518 					}
519 					int similarity = kw.similarityScore(kwlast,sb);
520 					if(similarity>0) {
521 						sb.append("(sim:");
522 						sb.append(similarity);
523 						sb.append(")");
524 						while(kwlast.getParent()!=null && !kwlast.getParent().equals(kwlast)) {
525 							Keyword parent = kwlast.getParent();
526 							sb.append("(ac:");
527 							sb.append(parent);
528 							sb.append(")");
529 							alreadyCounted.add(parent);
530 							kwlast = kwlast.getParent();	
531 						}
532 					}
533 					kwscore+=similarity;
534 				}
535 			}
536 		}
537 		
538 		if (kwcounter>0) {
539 			kwscore = kwscore / kwcounter;
540 		}
541 		
542 		SCORES.put(key, kwscore);
543 		return kwscore;
544 	}
545 	
546 /*	
547 */
548 
549 	
550 }