diff --git a/lib/player.js b/lib/player.js index 24e4fcc1..f9b7325a 100644 --- a/lib/player.js +++ b/lib/player.js @@ -12,6 +12,7 @@ var findit = require('findit2'); var shuffle = require('mess'); var mv = require('mv'); var MusicLibraryIndex = require('music-library-index'); +var Compilation = require('compilation'); var keese = require('keese'); var safePath = require('./safe_path'); var PassThrough = require('stream').PassThrough; @@ -307,7 +308,8 @@ function Player(db, config) { this.pausedTime = 0; this.autoDjOn = false; this.autoDjHistorySize = 10; - this.autoDjFutureSize = 10; + this.autoDjFutureSize = 15; + this.compilation = new Compilation(); this.ongoingScans = {}; this.scanQueue = new DedupedQueue({ @@ -2187,61 +2189,181 @@ Player.prototype.performScan = function(args, cb) { } }; +function normalizeName(name) { + return ('' + name).toLowerCase() + .replace(/[\[\(][\d]+[\]\)]/g, ' ').trim(); +} + Player.prototype.checkAutoDj = function() { var self = this; - if (!self.autoDjOn) return; + if (!self.autoDjOn || self.autoDjActive || !self.tracksInOrder.length) return; // if no track is playing, assume the first track is about to be var currentIndex = self.currentTrack ? self.currentTrack.index : 0; + var currentKey = self.tracksInOrder[currentIndex].key; + var currentTrack = self.libraryIndex.trackTable[currentKey]; var deleteCount = Math.max(currentIndex - self.autoDjHistorySize, 0); if (self.autoDjHistorySize < 0) deleteCount = 0; var addCount = Math.max(self.autoDjFutureSize + 1 - (self.tracksInOrder.length - currentIndex), 0); + if (!addCount) { + return; + } + var idsToDelete = []; for (var i = 0; i < deleteCount; i += 1) { idsToDelete.push(self.tracksInOrder[i].id); } - var keys = getRandomSongKeys(addCount); - self.removeQueueItems(idsToDelete); - self.appendTracks(keys, true); + var currentTrackInfo = { + artist: currentTrack.artistName, + album: currentTrack.albumName, + track: currentTrack.name, + }; + if (!currentTrackInfo.artist || !currentTrackInfo.track) { + return; + } + + self.autoDjActive = true; + console.log(currentTrackInfo); + return self.compilation.similarTracks(currentTrackInfo) + .then(function(similarTracks) { + // console.log(similarTracks.map(function(track) { + // return [track.artist.name, track.name, track.similarity]; + // })); + // Convert into object for quick lookup + var similarMap = {}; + similarTracks.forEach(function(track) { + var artistMap = similarMap[normalizeName(track.artist.name)]; + if (!artistMap) { + artistMap = similarMap[normalizeName(track.artist.name)] = { + tracks: {}, + // Initialize artist similarity to track similarity. This is + // normally overwritten later. + similarity: track.similarity / 4, + }; + } + artistMap.tracks[normalizeName(track.name)] = track.similarity; + }); + + // Add the current artist as similar to itself + return self.compilation.similarArtists(currentTrackInfo) + .then(function(similarArtists) { + // The current artist is fairly similar to itself, but we don't want + // to completely overpower other factors either. + var maxSimilarity = similarArtists.length ? + similarArtists[0].similarity : 0.7; + console.log(similarArtists[0]); + similarArtists.push({ + name: normalizeName(currentTrackInfo.artist), + // Slightly prefer another artist to encourage variety. Track + // similarity tends to be high for the current artist. + similarity: maxSimilarity * 0.75, + }); + similarArtists.forEach(function(artist) { + var artistName = normalizeName(artist.name); + var mapEntry = similarMap[artistName] = similarMap[artistName] || {tracks:{}}; + mapEntry.similarity = artist.similarity; + }); + return similarMap; + }); + }) + .then(function(similarMap) { + // console.log(JSON.stringify(similarMap, null, 2)); + var keys = getRandomSongKeys(addCount, similarMap); + self.removeQueueItems(idsToDelete); + self.appendTracks(keys, true); + }) + .finally(function() { + self.autoDjActive = false; + }); + - function getRandomSongKeys(count) { + function getRandomSongKeys(count, similarMap) { if (count === 0) return []; - var neverQueued = []; - var sometimesQueued = []; - for (var key in self.libraryIndex.trackTable) { + var keys = Object.keys(self.libraryIndex.trackTable); + var items = []; + var prevKey = null; + var similarCount = 0; + keys.forEach(function(key) { + prevKey = key; var dbFile = self.libraryIndex.trackTable[key]; - if (dbFile.lastQueueDate == null) { - neverQueued.push(dbFile); - } else { - sometimesQueued.push(dbFile); + var item = { + key: key, + info: { + key: dbFile.key, + artist: normalizeName(dbFile.artistName), + track: normalizeName(dbFile.name), + }, + // Seed with some randomness + weight: Math.random() / 50 + }; + items.push(item); + + // Consider similarity + var trackSimilarityFactor = 0.9; + // Artist similarity, considered less important than track similarity. + var artistSimilarityFactor = 0.75; + + var similarMapEntry = similarMap[item.info.artist]; + if (similarMapEntry) { + var trackSimilarity = similarMapEntry.tracks[item.info.track]; + if (trackSimilarity) { + item.weight += trackSimilarity * trackSimilarityFactor; + } + item.weight += similarMapEntry.similarity * artistSimilarityFactor; + similarCount++; } - } - // backwards by time - sometimesQueued.sort(function(a, b) { - return b.lastQueueDate - a.lastQueueDate; + + // Penalize items that were queued recently. + var daysSinceLastQueued = (Date.now() - (dbFile.lastQueueDate || 0)) / 86400000; + var lastQueueWeight = 2 - (1 / (1 + daysSinceLastQueued)); + // Weigh last-played + item.weight += 0.6 * lastQueueWeight; + + // Boost titles that have been played fully in the past. + var playBoost = 1 - (1 / (1 + (dbFile.playCount || 0))); + item.weight += 0.3 * playBoost; + }); + // Sort by weight, descending. + items.sort(function(a, b) { + return b.weight - a.weight; }); - // distribution is a triangle for ever queued, and a rectangle for never queued - // ___ - // /| | - // / | | - // /__|_| - var maxWeight = sometimesQueued.length; - var triangleArea = Math.floor(maxWeight * maxWeight / 2); - if (maxWeight === 0) maxWeight = 1; - var rectangleArea = maxWeight * neverQueued.length; - var totalSize = triangleArea + rectangleArea; - if (totalSize === 0) return []; - // decode indexes through the distribution shape - var keys = []; - for (var i = 0; i < count; i += 1) { - var index = Math.random() * totalSize; - if (index < triangleArea) { - // triangle - keys.push(sometimesQueued[Math.floor(Math.sqrt(index))].key); + + function printItems(a) { + for (var i = a.length - 1; i; i--) { + var item = a[i]; + console.log(item.info.artist, '-', item.info.track, item.weight); + } + } + //printItems(items.slice(0, 10)); + printItems(items.slice(0, 100)); + + keys = []; + + // How much we should focus on the top-ranked candidates. Values between 5 + // and 1000 seem to work well. + var focus = 50; + + // Only consider 'similar' tracks for now. + console.log('similarCount', similarCount); + var baseIndex = items.length - similarCount; + var constFac = Math.pow(similarCount, focus); + for (i = 0; i < count; i += 1) { + var rand = Math.random(); + var index = Math.floor(similarCount * (1 / (1 + rand * focus) - rand / (1 + focus))); + console.log(index); + var item = items[index]; + if (item.weight !== null && + (item.info.artist !== normalizeName(currentTrackInfo.artist) || + // Penalize large collections of the same title. + Math.random() < (4 / (1 + index)))) { + console.log('push', index, item.info.artist, '-', item.info.track); + keys.push(item.info.key); + item.weight = null; } else { - keys.push(neverQueued[Math.floor((index - triangleArea) / maxWeight)].key); + // retry + i--; } } return keys; diff --git a/package.json b/package.json index cf65fe9c..c31aef73 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "url": "git://github.com/andrewrk/groovebasin.git" }, "dependencies": { + "compilation": "^0.1.5", "connect-static": "~1.5.0", "content-disposition": "~0.5.0", "cookies": "~0.5.0",