From 6aefe0542e12563b5e67873d83e0d28804e6cc44 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 18 May 2015 14:24:04 +0300 Subject: [PATCH 001/103] coveralls test --- .travis.yml | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 67162b3..8af901f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,4 @@ language: node_js node_js: - "node" +after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" diff --git a/package.json b/package.json index 5d5fb32..d726f5f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "chai": "*", + "coveralls": "^2.11.2", "istanbul": "*", "jscs": "^1.11.3", "mocha": "*", From 747123780005424b5026493829ae6ca142b57328 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 18 May 2015 14:27:10 +0300 Subject: [PATCH 002/103] remember to generate coverage first --- .travis.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 8af901f..bb9c4f4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,6 @@ language: node_js node_js: - "node" -after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" +after_script: + - "npm run-script coverage" + - "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" From 6e625a13ec1cb3b77c357715f4048ff3356da0f0 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 18 May 2015 14:29:30 +0300 Subject: [PATCH 003/103] travis ci: cache node_modules for faster builds --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index bb9c4f4..c416619 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,9 @@ language: node_js node_js: - "node" +cache: + directories: + - node_modules after_script: - "npm run-script coverage" - "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" From 5dc0a158dd2a2accbd20051c9114f75328454ce3 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 12 Nov 2015 12:37:18 +0200 Subject: [PATCH 004/103] getPlaylists function calls all backends and fetches playlists --- lib/player.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/lib/player.js b/lib/player.js index 4b742a1..bf8925b 100644 --- a/lib/player.js +++ b/lib/player.js @@ -326,6 +326,35 @@ Player.prototype.searchQueue = function(backendName, songID) { return null; }; +Player.prototype.getPlaylists = function(callback) { + var resultCnt = 0; + var allResults = {}; + var player = this; + + _.each(player.backends, function(backend) { + if (!backend.getPlaylists) { + resultCnt++; + + // got results from all services? + if (resultCnt >= Object.keys(player.backends).length) { + callback(allResults); + } + return; + } + + backend.getPlaylists(function(err, results) { + resultCnt++; + + allResults[backend.name] = results; + + // got results from all services? + if (resultCnt >= Object.keys(player.backends).length) { + callback(allResults); + } + }); + }); +}; + // make a search query to backends Player.prototype.searchBackends = function(query, callback) { var resultCnt = 0; From 970570b5e8dd643f5d06a746206b63fee311e598 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 00:26:36 +0200 Subject: [PATCH 005/103] WIP: playlist refactor --- lib/player.js | 175 ++++++++++++++++++++++++++++++++++++++++++++++++-- package.json | 1 + 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/lib/player.js b/lib/player.js index bf8925b..e9a555b 100644 --- a/lib/player.js +++ b/lib/player.js @@ -2,6 +2,7 @@ var _ = require('underscore'); var async = require('async'); var labeledLogger = require('./logger'); +var uuid = require('node-uuid'); function Player(options) { options = options || {}; @@ -12,6 +13,8 @@ function Player(options) { this.logger = options.logger || labeledLogger('core'); this.playedQueue = options.playedQueue || []; this.queue = options.queue || []; + this.playlist = options.playlist || []; + this.playlistPos = options.playlistPos || 0; this.plugins = options.plugins || {}; this.backends = options.backends || {}; this.songsPreparing = options.songsPreparing || {}; @@ -70,7 +73,7 @@ Player.prototype.endOfSong = function() { this.playbackStart = null; this.queue[0] = null; this.songEndTimeout = null; - this.onQueueModify(); + this.refreshPlaylist(); }; // start or resume playback of now playing song. @@ -295,7 +298,8 @@ Player.prototype.prepareSongs = function() { // this function will: // - play back the first song in the queue if no song is playing // - call prepareSongs() -Player.prototype.onQueueModify = function() { +Player.prototype.refreshPlaylist = function() { + console.log('DEPRECATED'); this.callHooks('preQueueModify', [this.queue]); // set next song as now playing @@ -314,6 +318,122 @@ Player.prototype.onQueueModify = function() { this.callHooks('postQueueModify', [this.queue]); }; +Player.prototype.findSongPos = function(at) { + var player = this; + + // found in which list + var listType = 'playlist'; + // at which position + var listIndex = 0; + + if (at === -1) { + // value of -1 is beginning of queue + listType = 'queue'; + } else if (!at) { + // falsy value is beginning of playlist + listType = 'playlist'; + } else { + // other values assumed to be UUIDs: search playlist and queue + listIndex = _.findIndex(player.playlist, function(song) { + return song.uuid === at; + }); + + if (listIndex === -1) { + // song was not found in playlist, search queue + listType = 'queue'; + listIndex = _.findIndex(player.queue, function(song) { + return song.uuid === at; + }); + } + + if (listIndex === -1) { + // if still not found, bail out + return; + } + + return { + listIndex: listIndex, + listType: listType + }; + } +}; + +/** + * Insert songs into queue or playlist + * @param {String} at - Insert songs after song with this UUID + * (null = start of playlist, -1 = start of queue) + * @param {Object[]} songs - List of songs to insert + */ +Player.prototype.insertSongs = function(at, songs) { + var player = this; + + var pos = player.findSongPos(at); + + // insert the songs + var args = [pos.listIndex, 0].concat(songs); + Array.prototype.splice.apply(player[pos.listType], args); + + // sync player state with changes + if (pos.listType === 'playlist') { + if (pos.listIndex <= player.playlistPos) { + // songs inserted before playlist pos, add to it the no. of songs + player.playlistPos += songs.length; + } else { + // songs inserted after playlist pos, check if next song changed + if (pos.listIndex === player.playlistPos + 1 && !player.queue.length) { + // inserted songs immediately after playlist pos and queue is empty, + // next song must have changed so prepare it + prepareSong(player.playlist[player.playlistPos + 1]); + } + } + } else { + // queue modified, only way the next song changed is if we inserted at + // index zero + if (pos.listIndex === 0) { + prepareSong(player.queue[0]); + } + } +}; + +/** + * Removes songs from queue or playlist + * @param {String} at - Start removing at song with this UUID + * (null = start of playlist, -1 = start of queue) + * @param {Number} cnt - Number of songs to delete + */ +Player.prototype.removeSongs = function(at, cnt) { + var player = this; + + var pos = player.findSongPos(at); + + var deleted = 0; + + // delete items before playlist pos + if (pos < player.playlistPos) { + deleted += player.playlist.splice(pos, Math.min(cnt, player.playlistPos - pos)).length; + } + + // delete now playing item + if (pos + cnt >= player.playlistPos) { + deleted += player.playlist.splice(pos, 1).length; + } + + // delete items after old now playing item + if (pos + cnt > player.playlistPos) { + player.playlist.splice(pos, cnt - deleted); + } + + // sync player state with changes + if (pos.listType === 'queue') { + // starting delete from queue, now playing can't change + if (pos.listIndex === 0) { + // but may need to prepare if we remove the first item + prepareSong(player.queue[0] || player.playlist[player.playlistPos + 1]); + } + } else { + } +}; + // find song from queue Player.prototype.searchQueue = function(backendName, songID) { for (var i = 0; i < this.queue.length; i++) { @@ -355,6 +475,47 @@ Player.prototype.getPlaylists = function(callback) { }); }; +Player.prototype.replacePlaylist = function(backendName, playlistId) { + var player = this; + + if (backendName === 'core') { + fs.readFile(path.join(config.getBaseDir(), 'playlists', playlistId + '.json'), + function(err, playlist) { + if (err) { + return player.logger.error('error while fetching playlist' + err); + } + + playlist = JSON.parse(playlist); + + // reset playlist position + player.playlistPos = 0; + player.playlist = playlist; + }); + + return; + } + + var backend = this.backends[backendName]; + + if (!backend) { + return player.logger.error('replacePlaylist(): unknown backend ' + backendName); + } + + if (!backend.getPlaylist) { + return player.logger.error('backend ' + backendName + ' does not support playlists'); + } + + backend.getPlaylist(playlistId, function(err, playlist) { + if (err) { + return this.logger.error('error while fetching playlist' + err); + } + + // reset playlist position + player.playlistPos = 0; + player.playlist = playlist; + }); +}; + // make a search query to backends Player.prototype.searchBackends = function(query, callback) { var resultCnt = 0; @@ -444,7 +605,7 @@ Player.prototype.removeFromQueue = function(pos, cnt, onlyRemove) { } if (!onlyRemove) { - this.onQueueModify(); + this.refreshPlaylist(); this.callHooks('postSongsRemoved', [pos, cnt]); } @@ -465,7 +626,7 @@ Player.prototype.moveInQueue = function(from, to, cnt) { Array.prototype.splice.apply(this.queue, [to, 0].concat(songs)); this.callHooks('sortQueue'); - this.onQueueModify(); + this.refreshPlaylist(); this.callHooks('postSongsMoved', [songs, from, to, cnt]); return songs; @@ -503,7 +664,7 @@ Player.prototype.addToQueue = function(songs, pos) { }, this); this.callHooks('sortQueue'); - this.onQueueModify(); + this.refreshPlaylist(); this.callHooks('postSongsQueued', [songs, pos]); }; @@ -514,7 +675,7 @@ Player.prototype.shuffleQueue = function() { this.queue.unshift(temp); this.callHooks('onQueueShuffled', [this.queue]); - this.onQueueModify(); + this.refreshPlaylist(); }; // cnt can be negative to go back or zero to restart current song @@ -547,7 +708,7 @@ Player.prototype.skipSongs = function(cnt) { this.playbackStart = null; clearTimeout(this.songEndTimeout); this.songEndTimeout = null; - this.onQueueModify(); + this.refreshPlaylist(); }; // TODO: userID does not belong into core...? diff --git a/package.json b/package.json index 5d5fb32..f19a7f0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "async": "^0.9.0", "mkdirp": "^0.5.0", + "node-uuid": "^1.4.4", "npm": "^2.7.1", "underscore": "^1.7.0", "winston": "^0.9.0", From 04d45ec9c8207241a19fe6862fe8ce00acc0f9e7 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 00:36:33 +0200 Subject: [PATCH 006/103] more WIP --- lib/player.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/player.js b/lib/player.js index e9a555b..a04568e 100644 --- a/lib/player.js +++ b/lib/player.js @@ -395,6 +395,11 @@ Player.prototype.insertSongs = function(at, songs) { } }; +Player.prototype.getNowPlaying = function() { + var song = this.playlist[this.playlistPos]; + return song ? song.uuid : undefined; +}; + /** * Removes songs from queue or playlist * @param {String} at - Start removing at song with this UUID @@ -405,21 +410,24 @@ Player.prototype.removeSongs = function(at, cnt) { var player = this; var pos = player.findSongPos(at); + var oldPlaylistPos = player.playlistPos; + var oldNowPlaying = player.getNowPlaying(); var deleted = 0; // delete items before playlist pos - if (pos < player.playlistPos) { - deleted += player.playlist.splice(pos, Math.min(cnt, player.playlistPos - pos)).length; + if (pos < oldPlaylistPos) { + deleted += player.playlist.splice(pos, Math.min(cnt, oldPlaylistPos - pos)).length; + player.playlistPos -= deleted; } // delete now playing item - if (pos + cnt >= player.playlistPos) { + if (pos + cnt >= oldPlaylistPos) { deleted += player.playlist.splice(pos, 1).length; } // delete items after old now playing item - if (pos + cnt > player.playlistPos) { + if (pos + cnt > oldPlaylistPos) { player.playlist.splice(pos, cnt - deleted); } @@ -428,9 +436,11 @@ Player.prototype.removeSongs = function(at, cnt) { // starting delete from queue, now playing can't change if (pos.listIndex === 0) { // but may need to prepare if we remove the first item - prepareSong(player.queue[0] || player.playlist[player.playlistPos + 1]); + prepareSong(player.queue[0] || player.playlist[oldPlaylistPos + 1]); } } else { + if('np song changed') { + } } }; From 84c4b4a97c1c2403044eeb85b8af730da6ae69ca Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 16:50:42 +0200 Subject: [PATCH 007/103] simplify playlist logic by only using one list --- lib/player.js | 152 +++++++++++++++++++------------------------------- 1 file changed, 56 insertions(+), 96 deletions(-) diff --git a/lib/player.js b/lib/player.js index a04568e..46b974c 100644 --- a/lib/player.js +++ b/lib/player.js @@ -11,10 +11,8 @@ function Player(options) { _.bindAll.apply(_, [this].concat(_.functions(this))); this.config = options.config || require('./config').getConfig(); this.logger = options.logger || labeledLogger('core'); - this.playedQueue = options.playedQueue || []; - this.queue = options.queue || []; this.playlist = options.playlist || []; - this.playlistPos = options.playlistPos || 0; + this.curPlaylistPos = options.curPlaylistPos || 0; this.plugins = options.plugins || {}; this.backends = options.backends || {}; this.songsPreparing = options.songsPreparing || {}; @@ -318,130 +316,92 @@ Player.prototype.refreshPlaylist = function() { this.callHooks('postQueueModify', [this.queue]); }; -Player.prototype.findSongPos = function(at) { - var player = this; - - // found in which list - var listType = 'playlist'; - // at which position - var listIndex = 0; - - if (at === -1) { - // value of -1 is beginning of queue - listType = 'queue'; - } else if (!at) { - // falsy value is beginning of playlist - listType = 'playlist'; - } else { - // other values assumed to be UUIDs: search playlist and queue - listIndex = _.findIndex(player.playlist, function(song) { - return song.uuid === at; - }); - - if (listIndex === -1) { - // song was not found in playlist, search queue - listType = 'queue'; - listIndex = _.findIndex(player.queue, function(song) { - return song.uuid === at; - }); - } - - if (listIndex === -1) { - // if still not found, bail out - return; - } +/** + * Find index of song in playlist + * @param {String} at - Look for song with this UUID + */ +Player.prototype.findSongIndex = function(at) { + return _.findIndex(player.playlist, function(song) { + return song.uuid === at; + }); +}; - return { - listIndex: listIndex, - listType: listType - }; - } +/** + * Returns currently playing song + */ +Player.prototype.getNowPlaying = function() { + return this.playlist[this.curPlaylistPos]; }; /** * Insert songs into queue or playlist * @param {String} at - Insert songs after song with this UUID - * (null = start of playlist, -1 = start of queue) + * (-1 = start of playlist) * @param {Object[]} songs - List of songs to insert */ -Player.prototype.insertSongs = function(at, songs) { +Player.prototype.insertSongs = function(at, songs, callback) { var player = this; - var pos = player.findSongPos(at); + var pos; + if (at === -1) { + pos = 0; + } else { + pos = player.findSongPos(at); + + if (pos < 0) { + return callback(new Error('Song with UUID ' + at + ' not found!')); + } + + pos++; // insert after song + } // insert the songs - var args = [pos.listIndex, 0].concat(songs); - Array.prototype.splice.apply(player[pos.listType], args); + var args = [pos, 0].concat(songs); + Array.prototype.splice.apply(player.playlist, args); // sync player state with changes - if (pos.listType === 'playlist') { - if (pos.listIndex <= player.playlistPos) { - // songs inserted before playlist pos, add to it the no. of songs - player.playlistPos += songs.length; - } else { - // songs inserted after playlist pos, check if next song changed - if (pos.listIndex === player.playlistPos + 1 && !player.queue.length) { - // inserted songs immediately after playlist pos and queue is empty, - // next song must have changed so prepare it - prepareSong(player.playlist[player.playlistPos + 1]); - } - } - } else { - // queue modified, only way the next song changed is if we inserted at - // index zero - if (pos.listIndex === 0) { - prepareSong(player.queue[0]); - } + if (pos <= player.curPlaylistPos) { + // songs inserted before curPlaylistPos, increment it + player.curPlaylistPos += songs.length; + } else if (pos === player.curPlaylistPos + 1) { + // new next song, prepare it + prepareSong(player.playlist[player.curPlaylistPos + 1]); } -}; -Player.prototype.getNowPlaying = function() { - var song = this.playlist[this.playlistPos]; - return song ? song.uuid : undefined; + callback(); }; /** * Removes songs from queue or playlist * @param {String} at - Start removing at song with this UUID - * (null = start of playlist, -1 = start of queue) * @param {Number} cnt - Number of songs to delete */ -Player.prototype.removeSongs = function(at, cnt) { +Player.prototype.removeSongs = function(at, cnt, callback) { var player = this; var pos = player.findSongPos(at); - var oldPlaylistPos = player.playlistPos; - var oldNowPlaying = player.getNowPlaying(); - - var deleted = 0; - - // delete items before playlist pos - if (pos < oldPlaylistPos) { - deleted += player.playlist.splice(pos, Math.min(cnt, oldPlaylistPos - pos)).length; - player.playlistPos -= deleted; - } - - // delete now playing item - if (pos + cnt >= oldPlaylistPos) { - deleted += player.playlist.splice(pos, 1).length; + if (pos < 0) { + return callback(new Error('Song with UUID ' + at + ' not found!')); } - // delete items after old now playing item - if (pos + cnt > oldPlaylistPos) { - player.playlist.splice(pos, cnt - deleted); - } + player.playlist.splice(pos, cnt); - // sync player state with changes - if (pos.listType === 'queue') { - // starting delete from queue, now playing can't change - if (pos.listIndex === 0) { - // but may need to prepare if we remove the first item - prepareSong(player.queue[0] || player.playlist[oldPlaylistPos + 1]); - } - } else { - if('np song changed') { + if (pos <= player.curPlaylistPos) { + if (pos + cnt >= player.curPlaylistPos) { + // removed now playing, change to first song after splice + player.curPlaylistPos = pos; + prepareSong(player.playlist[player.curPlaylistPos]); + // TODO: prepare song and switch + } else { + // removed songs before now playing, update playlist pos + player.curPlaylistPos -= cnt; } + } else if (pos === player.curPlaylistPos + 1) { + // new next song, prepare it + prepareSong(player.playlist[player.curPlaylistPos + 1]); } + + callback(); }; // find song from queue From 36da5ad37b3443100f38c9bc0b53cbef7386be3a Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 17:42:24 +0200 Subject: [PATCH 008/103] WIP: playlist refactor --- lib/player.js | 187 ++++++++++++++++++++++++-------------------------- 1 file changed, 90 insertions(+), 97 deletions(-) diff --git a/lib/player.js b/lib/player.js index 46b974c..daa668f 100644 --- a/lib/player.js +++ b/lib/player.js @@ -59,19 +59,19 @@ Player.prototype.numHooks = function(hook) { }; Player.prototype.endOfSong = function() { - var np = this.queue[0]; + //var np = this.queue[0]; + var player = this; - this.logger.info('end of song ' + np.songID); - this.callHooks('onSongEnd', [np]); + player.curPlaylistPos++; - this.playedQueue.push(this.queue[0]); - this.playedQueue = _.last(this.playedQueue, this.config.playedQueueSize); + player.logger.info('end of song ' + np.songID); + player.callHooks('onSongEnd', [np]); - this.playbackPosition = null; - this.playbackStart = null; - this.queue[0] = null; - this.songEndTimeout = null; - this.refreshPlaylist(); + player.playbackPosition = null; + player.playbackStart = null; + player.queue[0] = null; + player.songEndTimeout = null; + player.refreshPlaylist(); }; // start or resume playback of now playing song. @@ -174,11 +174,11 @@ Player.prototype.prepareProgCallback = function(song, newData, done, asyncCallba } // start playback if it hasn't been started yet - if (this.queue[0] && - this.queue[0].backendName === song.backendName && - this.queue[0].songID === song.songID && - !this.playbackStart && newData) { - this.startPlayback(); + // TODO: not if paused + if (player.getNowPlaying() && + player.getNowPlaying().uuid === song.uuid + !player.playbackStart && newData) { + player.startPlayback(); } // tell plugins that new data is available for this song, and @@ -228,94 +228,75 @@ Player.prototype.prepareErrCallback = function(song, err, asyncCallback) { delete(this.songsPreparing[song.backendName][song.songID]); }; -// TODO: get rid of the callback hell, use promises? -Player.prototype.prepareSong = function(song, asyncCallback) { +Player.prototype.prepareSong = function(song, callback) { + var player = this; + if (!song) { - this.logger.warn('prepareSong() without song'); - asyncCallback(true); - return; + return callback(new Error('prepareSong() without song')); } - if (!this.backends[song.backendName]) { - this.prepareError(song, 'prepareSong() with unknown backend ' + song.backendName); - asyncCallback(true); - return; + if (!player.backends[song.backendName]) { + return callback(new Error('prepareSong() without unknown backend: ' + song.backendName)); } - if (this.backends[song.backendName].isPrepared(song)) { + if (player.backends[song.backendName].isPrepared(song)) { // start playback if it hasn't been started yet - if (this.queue[0] && - this.queue[0].backendName === song.backendName && - this.queue[0].songID === song.songID && - !this.playbackStart) { - this.startPlayback(); + // TODO: not if paused + if (player.getNowPlaying() && + player.getNowPlaying().uuid === song.uuid + !player.playbackStart) { + player.startPlayback(); } // song is already prepared, ok to prepare more songs - asyncCallback(); - } else if (this.songsPreparing[song.backendName][song.songID]) { + callback(); + } else if (player.songsPreparing[song.backendName][song.songID]) { // this song is already preparing, so don't yet prepare next song - asyncCallback(true); + callback(true); } else { // song is not prepared and not currently preparing: let backend prepare it - this.logger.debug('DEBUG: prepareSong() ' + song.songID); - this.songsPreparing[song.backendName][song.songID] = song; + player.logger.debug('DEBUG: prepareSong() ' + song.songID); + player.songsPreparing[song.backendName][song.songID] = song; - song.cancelPrepare = this.backends[song.backendName].prepareSong( + song.cancelPrepare = player.backends[song.backendName].prepareSong( song, - _.partial(this.prepareProgCallback, _, _, _, asyncCallback), - _.partial(this.prepareErrCallback, _, _, asyncCallback) + _.partial(player.prepareProgCallback, _, _, _, callback), + _.partial(player.prepareErrCallback, _, _, callback) ); - this.setPrepareTimeout(song); + player.setPrepareTimeout(song); } }; -// prepare now playing and queued songs for playback +/** + * Prepare now playing and next song for playback + */ Player.prototype.prepareSongs = function() { + var player = this; + async.series([ - _.bind(function(callback) { - // prepare now-playing song if it exists and if not prepared - if (this.queue[0]) { - this.prepareSong(this.queue[0], callback); + function(callback) { + // prepare now-playing song + var song = player.playlist[player.curPlaylistPos]; + if (song) { + player.prepareSong(song, callback); } else { + // bail out callback(true); } - }, this), - _.bind(function(callback) { - // prepare next song in queue if it exists and if not prepared - if (this.queue[1]) { - this.prepareSong(this.queue[1], callback); + }), + function(callback) { + // prepare next song in playlist + var song = player.playlist[player.curPlaylistPos + 1]; + if (song) { + player.prepareSong(song, callback); } else { + // bail out callback(true); } - }, this) + }) ]); }; -// to be called whenever the queue has been modified -// this function will: -// - play back the first song in the queue if no song is playing -// - call prepareSongs() -Player.prototype.refreshPlaylist = function() { - console.log('DEPRECATED'); - this.callHooks('preQueueModify', [this.queue]); - - // set next song as now playing - if (!this.queue[0]) { - this.queue.shift(); - } - - if (!this.queue.length) { - // if the queue is now empty, do nothing - this.callHooks('onEndOfQueue'); - this.logger.info('end of queue, waiting for more songs'); - } else { - // else prepare songs - this.prepareSongs(); - } - this.callHooks('postQueueModify', [this.queue]); -}; - /** * Find index of song in playlist * @param {String} at - Look for song with this UUID @@ -326,6 +307,16 @@ Player.prototype.findSongIndex = function(at) { }); }; +/** + * Find song in playlist + * @param {String} at - Look for song with this UUID + */ +Player.prototype.findSong = function(at) { + return _.find(player.playlist, function(song) { + return song.uuid === at; + }); +}; + /** * Returns currently playing song */ @@ -344,8 +335,10 @@ Player.prototype.insertSongs = function(at, songs, callback) { var pos; if (at === -1) { + // insert at start of playlist pos = 0; } else { + // insert song after song with UUID pos = player.findSongPos(at); if (pos < 0) { @@ -355,7 +348,12 @@ Player.prototype.insertSongs = function(at, songs, callback) { pos++; // insert after song } - // insert the songs + // generate UUIDs for each song + _.each(songs, function(song) { + song.uuid = uuid.v4(); + }); + + // perform insertion var args = [pos, 0].concat(songs); Array.prototype.splice.apply(player.playlist, args); @@ -365,7 +363,7 @@ Player.prototype.insertSongs = function(at, songs, callback) { player.curPlaylistPos += songs.length; } else if (pos === player.curPlaylistPos + 1) { // new next song, prepare it - prepareSong(player.playlist[player.curPlaylistPos + 1]); + prepareSongs(); } callback(); @@ -384,38 +382,33 @@ Player.prototype.removeSongs = function(at, cnt, callback) { return callback(new Error('Song with UUID ' + at + ' not found!')); } + // cancel preparing all songs to be deleted + for (var i = pos; i < pos + cnt && i < player.playlist.length) { + var song = player.playlist[i]; + if (song.cancelPrepare) { + song.cancelPrepare('Song removed.'); + } + } + player.playlist.splice(pos, cnt); if (pos <= player.curPlaylistPos) { if (pos + cnt >= player.curPlaylistPos) { // removed now playing, change to first song after splice player.curPlaylistPos = pos; - prepareSong(player.playlist[player.curPlaylistPos]); - // TODO: prepare song and switch + prepareSongs(); } else { // removed songs before now playing, update playlist pos player.curPlaylistPos -= cnt; } } else if (pos === player.curPlaylistPos + 1) { - // new next song, prepare it - prepareSong(player.playlist[player.curPlaylistPos + 1]); + // new next song, make sure it's prepared + prepareSongs(); } callback(); }; -// find song from queue -Player.prototype.searchQueue = function(backendName, songID) { - for (var i = 0; i < this.queue.length; i++) { - if (this.queue[i].songID === songID && - this.queue[i].backendName === backendName) { - return this.queue[i]; - } - } - - return null; -}; - Player.prototype.getPlaylists = function(callback) { var resultCnt = 0; var allResults = {}; @@ -445,14 +438,14 @@ Player.prototype.getPlaylists = function(callback) { }); }; -Player.prototype.replacePlaylist = function(backendName, playlistId) { +Player.prototype.replacePlaylist = function(backendName, playlistId, callback) { var player = this; if (backendName === 'core') { fs.readFile(path.join(config.getBaseDir(), 'playlists', playlistId + '.json'), function(err, playlist) { if (err) { - return player.logger.error('error while fetching playlist' + err); + return callback(new Error('Error while fetching playlist' + err)); } playlist = JSON.parse(playlist); @@ -468,16 +461,16 @@ Player.prototype.replacePlaylist = function(backendName, playlistId) { var backend = this.backends[backendName]; if (!backend) { - return player.logger.error('replacePlaylist(): unknown backend ' + backendName); + return callback(new Error('Unknown backend ' + backendName)); } if (!backend.getPlaylist) { - return player.logger.error('backend ' + backendName + ' does not support playlists'); + return callback(new Error('Backend ' + backendName + ' does not support playlists')); } backend.getPlaylist(playlistId, function(err, playlist) { if (err) { - return this.logger.error('error while fetching playlist' + err); + return callback(new Error('Error while fetching playlist' + err)); } // reset playlist position From 9962ef7b3c07da4da5ace03557871d218a18c5b9 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 19:46:08 +0200 Subject: [PATCH 009/103] it runs! --- lib/player.js | 83 ++++++++++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/lib/player.js b/lib/player.js index daa668f..e423005 100644 --- a/lib/player.js +++ b/lib/player.js @@ -69,72 +69,77 @@ Player.prototype.endOfSong = function() { player.playbackPosition = null; player.playbackStart = null; - player.queue[0] = null; player.songEndTimeout = null; - player.refreshPlaylist(); + player.prepareSongs(); }; // start or resume playback of now playing song. // if pos is undefined, playback continues (or starts from 0 if !playbackPosition) Player.prototype.startPlayback = function(pos) { - var np = this.queue[0]; + var player = this; + var np = player.getNowPlaying(); + if (!np) { - this.logger.verbose('startPlayback called, but hit end of queue'); + player.logger.verbose('startPlayback called, but hit end of queue'); return; } if (!_.isUndefined(pos) && !_.isNull(pos)) { - this.logger.info('playing song: ' + np.songID + ', from pos: ' + pos); + player.logger.info('playing song: ' + np.songID + ', from pos: ' + pos); } else { - this.logger.info('playing song: ' + np.songID); + player.logger.info('playing song: ' + np.songID); } - var oldPlaybackStart = this.playbackStart; - this.playbackStart = new Date().getTime(); // song is playing while this is truthy + var oldPlaybackStart = player.playbackStart; + player.playbackStart = new Date().getTime(); // song is playing while this is truthy // where did the song start playing from at playbackStart? if (!_.isUndefined(pos) && !_.isNull(pos)) { - this.playbackPosition = pos; - } else if (!this.playbackPosition) { - this.playbackPosition = 0; + player.playbackPosition = pos; + } else if (!player.playbackPosition) { + player.playbackPosition = 0; } if (oldPlaybackStart) { - this.callHooks('onSongSeek', [np]); + player.callHooks('onSongSeek', [np]); } else { - this.callHooks('onSongChange', [np]); + player.callHooks('onSongChange', [np]); } - var durationLeft = parseInt(np.duration) - this.playbackPosition + this.config.songDelayMs; - if (this.songEndTimeout) { - this.logger.debug('songEndTimeout was cleared'); - clearTimeout(this.songEndTimeout); - this.songEndTimeout = null; + var durationLeft = parseInt(np.duration) - player.playbackPosition + player.config.songDelayMs; + if (player.songEndTimeout) { + player.logger.debug('songEndTimeout was cleared'); + clearTimeout(player.songEndTimeout); + player.songEndTimeout = null; } - this.songEndTimeout = setTimeout(this.endOfSong, durationLeft); + player.songEndTimeout = setTimeout(player.endOfSong, durationLeft); }; Player.prototype.pausePlayback = function() { + var player = this; + // update position - this.playbackPosition += new Date().getTime() - this.playbackStart; - this.playbackStart = null; + player.playbackPosition += new Date().getTime() - player.playbackStart; + player.playbackStart = null; - clearTimeout(this.songEndTimeout); - this.songEndTimeout = null; - this.callHooks('onSongPause', [this.nowPlaying]); + clearTimeout(player.songEndTimeout); + player.songEndTimeout = null; + player.callHooks('onSongPause', [player.nowPlaying]); }; // TODO: proper song object with constructor? Player.prototype.setPrepareTimeout = function(song) { + var player = this; + if (song.prepareTimeout) { clearTimeout(song.prepareTimeout); } - song.prepareTimeout = setTimeout(_.bind(function() { - this.logger.info('prepare timeout for song: ' + song.songID + ', removing'); + song.prepareTimeout = setTimeout(function() { + player.logger.info('prepare timeout for song: ' + song.songID + ', removing'); song.cancelPrepare('prepare timeout'); song.prepareTimeout = null; - }, this), this.config.songPrepareTimeout); + }, player.config.songPrepareTimeout); Object.defineProperty(song, 'prepareTimeout', { enumerable: false, @@ -144,6 +149,7 @@ Player.prototype.setPrepareTimeout = function(song) { Player.prototype.prepareError = function(song, err) { // remove all instances of this song + /* for (var i = this.queue.length - 1; i >= 0; i--) { if (this.queue[i].songID === song.songID && this.queue[i].backendName === song.backendName) { @@ -154,11 +160,12 @@ Player.prototype.prepareError = function(song, err) { } } } + */ this.callHooks('onSongPrepareError', [song, err]); }; -Player.prototype.prepareProgCallback = function(song, newData, done, asyncCallback) { +Player.prototype.prepareProgCallback = function(song, newData, done, callback) { /* progress callback * when this is called, new song data has been flushed to disk */ @@ -176,7 +183,7 @@ Player.prototype.prepareProgCallback = function(song, newData, done, asyncCallba // start playback if it hasn't been started yet // TODO: not if paused if (player.getNowPlaying() && - player.getNowPlaying().uuid === song.uuid + player.getNowPlaying().uuid === song.uuid && !player.playbackStart && newData) { player.startPlayback(); } @@ -200,14 +207,14 @@ Player.prototype.prepareProgCallback = function(song, newData, done, asyncCallba clearTimeout(song.prepareTimeout); song.prepareTimeout = null; - asyncCallback(); + callback(); } else { // reset prepare timeout this.setPrepareTimeout(song); } }; -Player.prototype.prepareErrCallback = function(song, err, asyncCallback) { +Player.prototype.prepareErrCallback = function(song, err, callback) { /* error callback */ // don't let anything run cancelPrepare anymore @@ -219,9 +226,9 @@ Player.prototype.prepareErrCallback = function(song, err, asyncCallback) { // abort preparing more songs; current song will be deleted -> // onQueueModified is called -> song preparation is triggered again - asyncCallback(true); + callback(true); - // TODO: investigate this, should probably be above asyncCallback + // TODO: investigate this, should probably be above callback this.prepareError(song, err); song.songData = undefined; @@ -242,7 +249,7 @@ Player.prototype.prepareSong = function(song, callback) { // start playback if it hasn't been started yet // TODO: not if paused if (player.getNowPlaying() && - player.getNowPlaying().uuid === song.uuid + player.getNowPlaying().uuid === song.uuid && !player.playbackStart) { player.startPlayback(); } @@ -250,7 +257,7 @@ Player.prototype.prepareSong = function(song, callback) { // song is already prepared, ok to prepare more songs callback(); } else if (player.songsPreparing[song.backendName][song.songID]) { - // this song is already preparing, so don't yet prepare next song + // this song is still preparing, so don't yet prepare next song callback(true); } else { // song is not prepared and not currently preparing: let backend prepare it @@ -283,7 +290,7 @@ Player.prototype.prepareSongs = function() { // bail out callback(true); } - }), + }, function(callback) { // prepare next song in playlist var song = player.playlist[player.curPlaylistPos + 1]; @@ -293,7 +300,7 @@ Player.prototype.prepareSongs = function() { // bail out callback(true); } - }) + } ]); }; @@ -383,7 +390,7 @@ Player.prototype.removeSongs = function(at, cnt, callback) { } // cancel preparing all songs to be deleted - for (var i = pos; i < pos + cnt && i < player.playlist.length) { + for (var i = pos; i < pos + cnt && i < player.playlist.length; i++) { var song = player.playlist[i]; if (song.cancelPrepare) { song.cancelPrepare('Song removed.'); From 2f4aebcdcca81a37b89c6dd86fec8952a4085db5 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 20:33:52 +0200 Subject: [PATCH 010/103] WIP --- lib/player.js | 51 ++++++++++++++++++--------------------------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/lib/player.js b/lib/player.js index e423005..be187af 100644 --- a/lib/player.js +++ b/lib/player.js @@ -550,10 +550,12 @@ Player.prototype.removeFromQueue = function(pos, cnt, onlyRemove) { // signal prepareError function not to run removeFromQueue again // TODO: try getting rid of this ugly hack (beingDeleted)... // TODO: more non enumerable properties, especially plugins? + /* Object.defineProperty(song, 'beingDeleted', { enumerable: false, writable: true }); + */ song.beingDeleted = true; if (song.cancelPrepare) { @@ -638,54 +640,37 @@ Player.prototype.addToQueue = function(songs, pos) { this.callHooks('postSongsQueued', [songs, pos]); }; +// TODO: this can't be undone :( Player.prototype.shuffleQueue = function() { // don't change now playing - var temp = this.queue.shift(); - this.queue = _.shuffle(this.queue); - this.queue.unshift(temp); + var temp = this.playlist.shift(); + this.playlist = _.shuffle(this.playlist); + this.playlist.unshift(temp); - this.callHooks('onQueueShuffled', [this.queue]); + this.callHooks('onQueueShuffled', [this.playlist]); this.refreshPlaylist(); }; // cnt can be negative to go back or zero to restart current song Player.prototype.skipSongs = function(cnt) { - this.npIsPlaying = false; - - // TODO: this could be replaced with a splice? - for (var i = 0; i < Math.abs(cnt); i++) { - if (cnt > 0) { - if (this.queue[0]) { - this.playedQueue.push(this.queue[0]); - } - - this.queue.shift(); - } else if (cnt < 0) { - if (this.playedQueue.length) { - this.queue.unshift(this.playedQueue.pop()); - } - } - - // ran out of songs while skipping, stop - if (!this.queue[0]) { - break; - } - } + var player = this; - this.playedQueue = _.last(this.playedQueue, this.config.playedQueueSize); + player.curPlaylistPos = Math.min(player.playlist.length, player.curPlaylistPos + cnt); - this.playbackPosition = null; - this.playbackStart = null; - clearTimeout(this.songEndTimeout); - this.songEndTimeout = null; - this.refreshPlaylist(); + player.playbackPosition = null; + player.playbackStart = null; + clearTimeout(player.songEndTimeout); + player.songEndTimeout = null; + player.prepareSongs(); }; // TODO: userID does not belong into core...? Player.prototype.setVolume = function(newVol, userID) { + var player = this; + newVol = Math.min(1, Math.max(0, newVol)); - this.volume = newVol; - this.callHooks('onVolumeChange', [newVol, userID]); + player.volume = newVol; + player.callHooks('onVolumeChange', [newVol, userID]); }; module.exports = Player; From cecb4ecdfebd753e90df33ac7dd0f4b8a211a6eb Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 22:54:39 +0200 Subject: [PATCH 011/103] first song plays fine --- lib/player.js | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/player.js b/lib/player.js index be187af..3e9dcee 100644 --- a/lib/player.js +++ b/lib/player.js @@ -12,7 +12,7 @@ function Player(options) { this.config = options.config || require('./config').getConfig(); this.logger = options.logger || labeledLogger('core'); this.playlist = options.playlist || []; - this.curPlaylistPos = options.curPlaylistPos || 0; + this.curPlaylistPos = options.curPlaylistPos || -1; this.plugins = options.plugins || {}; this.backends = options.backends || {}; this.songsPreparing = options.songsPreparing || {}; @@ -59,10 +59,15 @@ Player.prototype.numHooks = function(hook) { }; Player.prototype.endOfSong = function() { - //var np = this.queue[0]; var player = this; + var np = player.playlist[player.curPlaylistPos]; - player.curPlaylistPos++; + if (player.curPlaylistPos === playlist.length - 1) { + // end of playlist + player.curPlaylistPos = -1; + } else { + player.curPlaylistPos++; + } player.logger.info('end of song ' + np.songID); player.callHooks('onSongEnd', [np]); @@ -77,10 +82,11 @@ Player.prototype.endOfSong = function() { // if pos is undefined, playback continues (or starts from 0 if !playbackPosition) Player.prototype.startPlayback = function(pos) { var player = this; + var np = player.getNowPlaying(); if (!np) { - player.logger.verbose('startPlayback called, but hit end of queue'); + player.logger.verbose('startPlayback called, but no song at curPlaylistPos'); return; } @@ -365,12 +371,16 @@ Player.prototype.insertSongs = function(at, songs, callback) { Array.prototype.splice.apply(player.playlist, args); // sync player state with changes - if (pos <= player.curPlaylistPos) { + if (player.curPlaylistPos === -1) { + // playlist was empty, start playing first song + player.curPlaylistPos++; + player.prepareSongs(); + } else if (pos <= player.curPlaylistPos) { // songs inserted before curPlaylistPos, increment it player.curPlaylistPos += songs.length; } else if (pos === player.curPlaylistPos + 1) { // new next song, prepare it - prepareSongs(); + player.prepareSongs(); } callback(); @@ -403,14 +413,14 @@ Player.prototype.removeSongs = function(at, cnt, callback) { if (pos + cnt >= player.curPlaylistPos) { // removed now playing, change to first song after splice player.curPlaylistPos = pos; - prepareSongs(); + player.prepareSongs(); } else { // removed songs before now playing, update playlist pos player.curPlaylistPos -= cnt; } } else if (pos === player.curPlaylistPos + 1) { // new next song, make sure it's prepared - prepareSongs(); + player.prepareSongs(); } callback(); From d90db797cee18cd42370d206ca68f7703c2bbd6e Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 17:10:12 +0200 Subject: [PATCH 012/103] playlist concepts --- playlist-wip.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 playlist-wip.md diff --git a/playlist-wip.md b/playlist-wip.md new file mode 100644 index 0000000..d7eb236 --- /dev/null +++ b/playlist-wip.md @@ -0,0 +1,45 @@ +# Nodeplayer playlist concepts +## Definitions +### Playlist + +- Generic list of songs that can be added to the playback queue. +- Core contains playlist management functionality for: + - Creating playlists + - Adding/removing songs to/from playlists + - Editing playlists + - Listing playlists +- Backends can provide playlists, containing songs from that backend. + - These will start out as read-only to keep things simple. +- Plugins can provide autogenerated playlists, for example based on ratings. + - These are also read-only by the user, plugin can modify. + +### Playback queue + +- Contains list of songs that the player will play / has played. +- Keeps track of where playback currently is. +- Keeps track of which playlist a song was added from (if any). This is simply + a field in the song object containing the name and backend of the playlist. + - This makes it possible for clients to display songs differently depending + on which playlist they were added from. + - One time use, 'pseudo-playlists', are also possible. nodeplayer blindly + trusts which playlist a song was added from, the client can decide. + - Pseudo-playlists can be used by clients to implement functionality such + as queuing songs before the rest of the playback queue. This simulates + something like the WinAmp queue, which is always played before the rest + of the playback queue. Let's call this the 'pre-queue'. + - This is done by having the client reserve a certain playlist name + for the pre-queue, say '__prequeue'. + - The client checks the current playback queue at the current playback + position. + - If the current song was added from a playlist '__prequeue', find + the first song after it that is not part of __prequeue, insert + song before it. + - If the current song is not part of __prequeue, insert song into + the playback queue immediately after the current song and mark it + as __prequeue. + - Implementing pre-queue functionality like this makes playlist handling + code much much simpler. E.g. song preparing doesn't need to care about + a separate queue and playlist, it only needs to know about the one and + only playback queue. + - Plugins can restrict how the playback queue is managed, e.g. partyplay + only allows appending into __prequeue. From d71f892e76734e8c78f3ed80248add8f1fd23920 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 17:11:37 +0200 Subject: [PATCH 013/103] clarification --- playlist-wip.md | 1 + 1 file changed, 1 insertion(+) diff --git a/playlist-wip.md b/playlist-wip.md index d7eb236..a913b8e 100644 --- a/playlist-wip.md +++ b/playlist-wip.md @@ -3,6 +3,7 @@ ### Playlist - Generic list of songs that can be added to the playback queue. +- Can *not* be played directly, must *always* be added to playback queue first. - Core contains playlist management functionality for: - Creating playlists - Adding/removing songs to/from playlists From 3baf5e549c4784e7902dd910182f3691e31ce69c Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 17:14:42 +0200 Subject: [PATCH 014/103] clarification --- playlist-wip.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/playlist-wip.md b/playlist-wip.md index a913b8e..cb44915 100644 --- a/playlist-wip.md +++ b/playlist-wip.md @@ -40,7 +40,7 @@ as __prequeue. - Implementing pre-queue functionality like this makes playlist handling code much much simpler. E.g. song preparing doesn't need to care about - a separate queue and playlist, it only needs to know about the one and - only playback queue. + a separate queue and playlist, it only needs to care about one array + which is the playback queue. - Plugins can restrict how the playback queue is managed, e.g. partyplay only allows appending into __prequeue. From 8131a3fa5a9a7e77de580392b38560627d67d820 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 17:18:17 +0200 Subject: [PATCH 015/103] clarification --- playlist-wip.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/playlist-wip.md b/playlist-wip.md index cb44915..ec9fd56 100644 --- a/playlist-wip.md +++ b/playlist-wip.md @@ -30,8 +30,8 @@ of the playback queue. Let's call this the 'pre-queue'. - This is done by having the client reserve a certain playlist name for the pre-queue, say '__prequeue'. - - The client checks the current playback queue at the current playback - position. + - Pre-queuing songs works like this: The client checks the current + playback queue at the current playback position. - If the current song was added from a playlist '__prequeue', find the first song after it that is not part of __prequeue, insert song before it. @@ -40,7 +40,7 @@ as __prequeue. - Implementing pre-queue functionality like this makes playlist handling code much much simpler. E.g. song preparing doesn't need to care about - a separate queue and playlist, it only needs to care about one array + a separate queue and playlists, it only needs to care about one array which is the playback queue. - Plugins can restrict how the playback queue is managed, e.g. partyplay only allows appending into __prequeue. From 3b8aab74df530b10160bdb76a3cfe817b86e8010 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 19:07:27 +0200 Subject: [PATCH 016/103] WIP: refactor playback queue handling into queue.js --- lib/player.js | 116 -------------------------------------------- lib/queue.js | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 116 deletions(-) create mode 100644 lib/queue.js diff --git a/lib/player.js b/lib/player.js index 3e9dcee..da1702f 100644 --- a/lib/player.js +++ b/lib/player.js @@ -310,122 +310,6 @@ Player.prototype.prepareSongs = function() { ]); }; -/** - * Find index of song in playlist - * @param {String} at - Look for song with this UUID - */ -Player.prototype.findSongIndex = function(at) { - return _.findIndex(player.playlist, function(song) { - return song.uuid === at; - }); -}; - -/** - * Find song in playlist - * @param {String} at - Look for song with this UUID - */ -Player.prototype.findSong = function(at) { - return _.find(player.playlist, function(song) { - return song.uuid === at; - }); -}; - -/** - * Returns currently playing song - */ -Player.prototype.getNowPlaying = function() { - return this.playlist[this.curPlaylistPos]; -}; - -/** - * Insert songs into queue or playlist - * @param {String} at - Insert songs after song with this UUID - * (-1 = start of playlist) - * @param {Object[]} songs - List of songs to insert - */ -Player.prototype.insertSongs = function(at, songs, callback) { - var player = this; - - var pos; - if (at === -1) { - // insert at start of playlist - pos = 0; - } else { - // insert song after song with UUID - pos = player.findSongPos(at); - - if (pos < 0) { - return callback(new Error('Song with UUID ' + at + ' not found!')); - } - - pos++; // insert after song - } - - // generate UUIDs for each song - _.each(songs, function(song) { - song.uuid = uuid.v4(); - }); - - // perform insertion - var args = [pos, 0].concat(songs); - Array.prototype.splice.apply(player.playlist, args); - - // sync player state with changes - if (player.curPlaylistPos === -1) { - // playlist was empty, start playing first song - player.curPlaylistPos++; - player.prepareSongs(); - } else if (pos <= player.curPlaylistPos) { - // songs inserted before curPlaylistPos, increment it - player.curPlaylistPos += songs.length; - } else if (pos === player.curPlaylistPos + 1) { - // new next song, prepare it - player.prepareSongs(); - } - - callback(); -}; - -/** - * Removes songs from queue or playlist - * @param {String} at - Start removing at song with this UUID - * @param {Number} cnt - Number of songs to delete - */ -Player.prototype.removeSongs = function(at, cnt, callback) { - var player = this; - - var pos = player.findSongPos(at); - if (pos < 0) { - return callback(new Error('Song with UUID ' + at + ' not found!')); - } - - // cancel preparing all songs to be deleted - for (var i = pos; i < pos + cnt && i < player.playlist.length; i++) { - var song = player.playlist[i]; - if (song.cancelPrepare) { - song.cancelPrepare('Song removed.'); - } - } - - player.playlist.splice(pos, cnt); - - if (pos <= player.curPlaylistPos) { - if (pos + cnt >= player.curPlaylistPos) { - // removed now playing, change to first song after splice - player.curPlaylistPos = pos; - player.prepareSongs(); - } else { - // removed songs before now playing, update playlist pos - player.curPlaylistPos -= cnt; - } - } else if (pos === player.curPlaylistPos + 1) { - // new next song, make sure it's prepared - player.prepareSongs(); - } - - callback(); -}; - Player.prototype.getPlaylists = function(callback) { var resultCnt = 0; var allResults = {}; diff --git a/lib/queue.js b/lib/queue.js new file mode 100644 index 0000000..f49b990 --- /dev/null +++ b/lib/queue.js @@ -0,0 +1,130 @@ +var _ = require('underscore'); + +function Queue(modifyCallback) { + if (!modifyCallback) { + return new Error('Queue constructor called without a modifyCallback!'); + } + + this.songs = []; + this.curQueuePos = -1; + this.playbackStart = -1; + this.playbackPosition = -1; + this.modifyCallback = modifyCallback; +} + +/** + * Find index of song in queue + * @param {String} at - Look for song with this UUID + */ +Queue.prototype.findSongIndex = function(at) { + return _.findIndex(this.songs, function(song) { + return song.uuid === at; + }); +}; + +/** + * Find song in queue + * @param {String} at - Look for song with this UUID + */ +Queue.prototype.findSong = function(at) { + return _.find(this.songs, function(song) { + return song.uuid === at; + }); +}; + +/** + * Returns currently playing song + */ +Queue.prototype.getNowPlaying = function() { + return this.songs[this.curQueuePos]; +}; + +/** + * Insert songs into queue + * @param {String} at - Insert songs after song with this UUID + * (-1 = start of queue) + * @param {Object[]} songs - List of songs to insert + */ +Queue.prototype.insertSongs = function(at, songs, callback) { + var player = this; + + var pos; + if (at === '-1') { + // insert at start of queue + pos = 0; + } else { + // insert song after song with UUID + pos = player.findSongPos(at); + + if (pos < 0) { + return callback(new Error('Song with UUID ' + at + ' not found!')); + } + + pos++; // insert after song + } + + // generate UUIDs for each song + _.each(songs, function(song) { + song.uuid = uuid.v4(); + }); + + // perform insertion + var args = [pos, 0].concat(songs); + Array.prototype.splice.apply(this.songs, args); + + // sync player state with changes + if (player.curQueuePos === -1) { + // queue was empty, start playing first song + player.curQueuePos++; + player.prepareSongs(); + } else if (pos <= player.curQueuePos) { + // songs inserted before curQueuePos, increment it + player.curQueuePos += songs.length; + } else if (pos === player.curQueuePos + 1) { + // new next song, prepare it + player.prepareSongs(); + } + + callback(); +}; + +/** + * Removes songs from queue + * @param {String} at - Start removing at song with this UUID + * @param {Number} cnt - Number of songs to delete + */ +Queue.prototype.removeSongs = function(at, cnt, callback) { + var player = this; + + var pos = player.findSongPos(at); + if (pos < 0) { + return callback(new Error('Song with UUID ' + at + ' not found!')); + } + + // cancel preparing all songs to be deleted + for (var i = pos; i < pos + cnt && i < this.songs.length; i++) { + var song = this.songs[i]; + if (song.cancelPrepare) { + song.cancelPrepare('Song removed.'); + } + } + + this.songs.splice(pos, cnt); + + if (pos <= player.curQueuePos) { + if (pos + cnt >= player.curQueuePos) { + // removed now playing, change to first song after splice + player.curQueuePos = pos; + player.prepareSongs(); + } else { + // removed songs before now playing, update queue pos + player.curQueuePos -= cnt; + } + } else if (pos === player.curQueuePos + 1) { + // new next song, make sure it's prepared + player.prepareSongs(); + } + + callback(); +}; + From 43bb8ac7922a977dc9cff50f44932e707d4ea73a Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 21:10:05 +0200 Subject: [PATCH 017/103] WIP: refactor and document, comment out tests for now --- lib/player.js | 132 ++------------------------------------------------ lib/queue.js | 120 ++++++++++++++++++++++++++++++++------------- test/test.js | 2 + 3 files changed, 93 insertions(+), 161 deletions(-) diff --git a/lib/player.js b/lib/player.js index da1702f..25e0d51 100644 --- a/lib/player.js +++ b/lib/player.js @@ -3,6 +3,7 @@ var _ = require('underscore'); var async = require('async'); var labeledLogger = require('./logger'); var uuid = require('node-uuid'); +var Queue = require('./queue'); function Player(options) { options = options || {}; @@ -11,7 +12,7 @@ function Player(options) { _.bindAll.apply(_, [this].concat(_.functions(this))); this.config = options.config || require('./config').getConfig(); this.logger = options.logger || labeledLogger('core'); - this.playlist = options.playlist || []; + this.queue = options.queue || new Queue(this.callHooks); this.curPlaylistPos = options.curPlaylistPos || -1; this.plugins = options.plugins || {}; this.backends = options.backends || {}; @@ -60,9 +61,9 @@ Player.prototype.numHooks = function(hook) { Player.prototype.endOfSong = function() { var player = this; - var np = player.playlist[player.curPlaylistPos]; + var np = player.queue.getNowPlaying(); - if (player.curPlaylistPos === playlist.length - 1) { + if (player.curPlaylistPos === player.queue.getLength() - 1) { // end of playlist player.curPlaylistPos = -1; } else { @@ -420,131 +421,6 @@ Player.prototype.searchBackends = function(query, callback) { }, this); }; -// get rid of song in queue -// cnt can be left out for deleting only one song -Player.prototype.removeFromQueue = function(pos, cnt, onlyRemove) { - var retval = []; - if (!cnt) { - cnt = 1; - } - pos = Math.max(0, parseInt(pos)); - - if (!onlyRemove) { - this.callHooks('preSongsRemoved', [pos, cnt]); - } - - // remove songs from queue - if (pos + cnt > 0) { - if (this.queue.length) { - // stop preparing songs we are about to remove - // we want to limit this to this.queue.length if cnt is very large - for (var i = 0; i < Math.min(this.queue.length, pos + cnt); i++) { - var song = this.queue[i]; - - // signal prepareError function not to run removeFromQueue again - // TODO: try getting rid of this ugly hack (beingDeleted)... - // TODO: more non enumerable properties, especially plugins? - /* - Object.defineProperty(song, 'beingDeleted', { - enumerable: false, - writable: true - }); - */ - - song.beingDeleted = true; - if (song.cancelPrepare) { - song.cancelPrepare('song deleted'); - delete(song.cancelPrepare); - } - } - - retval = this.queue.splice(pos, cnt); - - if (pos === 0) { - // now playing was deleted - this.playbackPosition = null; - this.playbackStart = null; - clearTimeout(this.songEndTimeout); - this.songEndTimeout = null; - } - } - } - - if (!onlyRemove) { - this.refreshPlaylist(); - this.callHooks('postSongsRemoved', [pos, cnt]); - } - - return retval; -}; - -Player.prototype.moveInQueue = function(from, to, cnt) { - if (!cnt || cnt < 1) { - cnt = 1; - } - if (from < 0 || from + cnt > this.queue.length || to + cnt > this.queue.length) { - return null; - } - - this.callHooks('preSongsMoved', [from, to, cnt]); - - var songs = this.removeFromQueue(from, cnt, true); - Array.prototype.splice.apply(this.queue, [to, 0].concat(songs)); - - this.callHooks('sortQueue'); - this.refreshPlaylist(); - this.callHooks('postSongsMoved', [songs, from, to, cnt]); - - return songs; -}; - -// add songs to the queue, at optional position -Player.prototype.addToQueue = function(songs, pos) { - if (!pos) { - pos = this.queue.length; - } - if (pos < 0) { - pos = 1; - } - pos = Math.min(pos, this.queue.length); - - this.callHooks('preSongsQueued', [songs, pos]); - _.each(songs, function(song) { - // check that required fields are provided - if (!song.title || !song.songID || !song.backendName || !song.duration) { - this.logger.info('required song fields not provided: ' + song.songID); - return; - //return 'required song fields not provided'; // TODO: this ain't gonna work - } - - var err = this.callHooks('preSongQueued', [song]); - if (err) { - this.logger.error('not adding song to queue: ' + err); - } else { - song.timeAdded = new Date().getTime(); - - this.queue.splice(pos++, 0, song); - this.logger.info('added song to queue: ' + song.songID); - this.callHooks('postSongQueued', [song]); - } - }, this); - - this.callHooks('sortQueue'); - this.refreshPlaylist(); - this.callHooks('postSongsQueued', [songs, pos]); -}; - -// TODO: this can't be undone :( -Player.prototype.shuffleQueue = function() { - // don't change now playing - var temp = this.playlist.shift(); - this.playlist = _.shuffle(this.playlist); - this.playlist.unshift(temp); - - this.callHooks('onQueueShuffled', [this.playlist]); - this.refreshPlaylist(); -}; - // cnt can be negative to go back or zero to restart current song Player.prototype.skipSongs = function(cnt) { var player = this; diff --git a/lib/queue.js b/lib/queue.js index f49b990..5571354 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -1,20 +1,30 @@ var _ = require('underscore'); -function Queue(modifyCallback) { - if (!modifyCallback) { - return new Error('Queue constructor called without a modifyCallback!'); +/** + * Constructor + * @param {Function} modifyCallback - Called whenever the queue is modified + * @returns {Error} - in case of errors + */ +function Queue(callHooks) { + if (!callHooks || !_.isFunction(callHooks)) { + return new Error('Queue constructor called without a callHooks function!'); } + this.unshuffledSongs = undefined; this.songs = []; this.curQueuePos = -1; this.playbackStart = -1; this.playbackPosition = -1; - this.modifyCallback = modifyCallback; + this.callHooks = callHooks; } +// TODO: hooks +// TODO: moveSongs + /** * Find index of song in queue * @param {String} at - Look for song with this UUID + * @returns {Number} - Index of song, -1 if not found */ Queue.prototype.findSongIndex = function(at) { return _.findIndex(this.songs, function(song) { @@ -25,6 +35,7 @@ Queue.prototype.findSongIndex = function(at) { /** * Find song in queue * @param {String} at - Look for song with this UUID + * @returns {Song|undefined} - Song object, undefined if not found */ Queue.prototype.findSong = function(at) { return _.find(this.songs, function(song) { @@ -34,30 +45,38 @@ Queue.prototype.findSong = function(at) { /** * Returns currently playing song + * @returns {Song|undefined} - Song object, undefined if no now playing song */ Queue.prototype.getNowPlaying = function() { return this.songs[this.curQueuePos]; }; +/** + * Returns queue length + * @returns {Number} - Queue length + */ +Queue.prototype.getLength = function() { + return this.songs.length; +}; + /** * Insert songs into queue * @param {String} at - Insert songs after song with this UUID * (-1 = start of queue) * @param {Object[]} songs - List of songs to insert + * @return {Error} - in case of errors */ -Queue.prototype.insertSongs = function(at, songs, callback) { - var player = this; - +Queue.prototype.insertSongs = function(at, songs) { var pos; if (at === '-1') { // insert at start of queue pos = 0; } else { // insert song after song with UUID - pos = player.findSongPos(at); + pos = this.findSongPos(at); if (pos < 0) { - return callback(new Error('Song with UUID ' + at + ' not found!')); + return new Error('Song with UUID ' + at + ' not found!'); } pos++; // insert after song @@ -72,33 +91,30 @@ Queue.prototype.insertSongs = function(at, songs, callback) { var args = [pos, 0].concat(songs); Array.prototype.splice.apply(this.songs, args); - // sync player state with changes - if (player.curQueuePos === -1) { + // sync queue & player state with changes + if (this.curQueuePos === -1) { // queue was empty, start playing first song - player.curQueuePos++; - player.prepareSongs(); - } else if (pos <= player.curQueuePos) { + this.curQueuePos++; + this.callHooks('queueModified'); + } else if (pos <= this.curQueuePos) { // songs inserted before curQueuePos, increment it - player.curQueuePos += songs.length; - } else if (pos === player.curQueuePos + 1) { + this.curQueuePos += songs.length; + } else if (pos === this.curQueuePos + 1) { // new next song, prepare it - player.prepareSongs(); + this.callHooks('queueModified'); } - - callback(); }; /** * Removes songs from queue * @param {String} at - Start removing at song with this UUID * @param {Number} cnt - Number of songs to delete + * @return {Song[] | Error} - List of removed songs, Error in case of errors */ -Queue.prototype.removeSongs = function(at, cnt, callback) { - var player = this; - - var pos = player.findSongPos(at); +Queue.prototype.removeSongs = function(at, cnt) { + var pos = this.findSongPos(at); if (pos < 0) { - return callback(new Error('Song with UUID ' + at + ' not found!')); + return new Error('Song with UUID ' + at + ' not found!'); } // cancel preparing all songs to be deleted @@ -109,22 +125,60 @@ Queue.prototype.removeSongs = function(at, cnt, callback) { } } - this.songs.splice(pos, cnt); + var removed = this.songs.splice(pos, cnt); - if (pos <= player.curQueuePos) { - if (pos + cnt >= player.curQueuePos) { + if (pos <= this.curQueuePos) { + if (pos + cnt >= this.curQueuePos) { // removed now playing, change to first song after splice - player.curQueuePos = pos; - player.prepareSongs(); + this.curQueuePos = pos; + this.callHooks('queueModified'); } else { // removed songs before now playing, update queue pos - player.curQueuePos -= cnt; + this.curQueuePos -= cnt; } - } else if (pos === player.curQueuePos + 1) { + } else if (pos === this.curQueuePos + 1) { // new next song, make sure it's prepared - player.prepareSongs(); + this.callHooks('queueModified'); + } + + return removed; +}; + +/** + * Toggle queue shuffling + */ +Queue.prototype.shuffle = function() { + var nowPlaying; + + if (this.unshuffledSongs) { + // unshuffle + + // store now playing + nowPlaying = this.getNowPlaying(); + + // restore unshuffled list + this.songs = this.unshuffledSongs; + + // find new now playing index by UUID, update curQueuePos + this.curQueuePos = this.findSongIndex(nowPlaying.uuid); + + this.unshuffledSongs = undefined; + } else { + // shuffle + + // store copy of current songs array + this.unshuffledSongs = this.songs.slice(); + + // store now playing + nowPlaying = this.songs.splice(this.curQueuePos, 1); + + this.songs = _.shuffle(this.songs); + + // re-insert now playing + this.songs.splice(this.curQueuePos, 0, nowPlaying); } - callback(); + this.callHooks('queueModified'); }; +module.exports = Queue; diff --git a/test/test.js b/test/test.js index 51437f9..44855ca 100644 --- a/test/test.js +++ b/test/test.js @@ -53,6 +53,7 @@ describe('Player', function() { }); }); + /* describe('#skipSongs()', function() { var player; var playedQueueSize = 3; // TODO: better handling of config variables here @@ -558,4 +559,5 @@ describe('Player', function() { player.queue.should.deep.equal(exampleQueue); }); }); + */ }); From f1bc0fff639f77d92899cb3c61970b0ccc972e5f Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 21:25:18 +0200 Subject: [PATCH 018/103] s/curPlaylistPos/queue.curQueuePos --- lib/player.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/player.js b/lib/player.js index 25e0d51..63aacf3 100644 --- a/lib/player.js +++ b/lib/player.js @@ -63,11 +63,11 @@ Player.prototype.endOfSong = function() { var player = this; var np = player.queue.getNowPlaying(); - if (player.curPlaylistPos === player.queue.getLength() - 1) { + if (player.queue.curQueuePos === player.queue.getLength() - 1) { // end of playlist - player.curPlaylistPos = -1; + player.queue.curQueuePos = -1; } else { - player.curPlaylistPos++; + player.queue.curQueuePos++; } player.logger.info('end of song ' + np.songID); From b5b242ef2cb510b8aebfd7c569c06690e2d6230d Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 27 Nov 2015 15:16:11 +0200 Subject: [PATCH 019/103] renames --- lib/player.js | 124 ++++++++++++++++++++++++-------------------------- 1 file changed, 60 insertions(+), 64 deletions(-) diff --git a/lib/player.js b/lib/player.js index 63aacf3..47ddc91 100644 --- a/lib/player.js +++ b/lib/player.js @@ -13,15 +13,11 @@ function Player(options) { this.config = options.config || require('./config').getConfig(); this.logger = options.logger || labeledLogger('core'); this.queue = options.queue || new Queue(this.callHooks); - this.curPlaylistPos = options.curPlaylistPos || -1; this.plugins = options.plugins || {}; this.backends = options.backends || {}; this.songsPreparing = options.songsPreparing || {}; this.volume = options.volume || 1; this.songEndTimeout = options.songEndTimeout || null; - this.playbackState = { - // TODO: move playbackStart, playbackPosition etc here - }; } // call hook function in all modules @@ -61,22 +57,22 @@ Player.prototype.numHooks = function(hook) { Player.prototype.endOfSong = function() { var player = this; - var np = player.queue.getNowPlaying(); + var np = this.queue.getNowPlaying(); - if (player.queue.curQueuePos === player.queue.getLength() - 1) { + if (this.queue.curQueuePos === this.queue.getLength() - 1) { // end of playlist - player.queue.curQueuePos = -1; + this.queue.curQueuePos = -1; } else { - player.queue.curQueuePos++; + this.queue.curQueuePos++; } - player.logger.info('end of song ' + np.songID); - player.callHooks('onSongEnd', [np]); + this.logger.info('end of song ' + np.songID); + this.callHooks('onSongEnd', [np]); - player.playbackPosition = null; - player.playbackStart = null; - player.songEndTimeout = null; - player.prepareSongs(); + this.queue.playbackPosition = null; + this.queue.playbackStart = null; + this.queue.songEndTimeout = null; + this.prepareSongs(); }; // start or resume playback of now playing song. @@ -84,54 +80,54 @@ Player.prototype.endOfSong = function() { Player.prototype.startPlayback = function(pos) { var player = this; - var np = player.getNowPlaying(); + var np = this.queue.getNowPlaying(); if (!np) { - player.logger.verbose('startPlayback called, but no song at curPlaylistPos'); + this.logger.verbose('startPlayback called, but no song at curPlaylistPos'); return; } if (!_.isUndefined(pos) && !_.isNull(pos)) { - player.logger.info('playing song: ' + np.songID + ', from pos: ' + pos); + this.logger.info('playing song: ' + np.songID + ', from pos: ' + pos); } else { - player.logger.info('playing song: ' + np.songID); + this.logger.info('playing song: ' + np.songID); } - var oldPlaybackStart = player.playbackStart; - player.playbackStart = new Date().getTime(); // song is playing while this is truthy + var oldPlaybackStart = this.queue.playbackStart; + this.queue.playbackStart = new Date().getTime(); // song is playing while this is truthy // where did the song start playing from at playbackStart? if (!_.isUndefined(pos) && !_.isNull(pos)) { - player.playbackPosition = pos; - } else if (!player.playbackPosition) { - player.playbackPosition = 0; + this.queue.playbackPosition = pos; + } else if (!this.queue.playbackPosition) { + this.queue.playbackPosition = 0; } if (oldPlaybackStart) { - player.callHooks('onSongSeek', [np]); + this.callHooks('onSongSeek', [np]); } else { - player.callHooks('onSongChange', [np]); + this.callHooks('onSongChange', [np]); } - var durationLeft = parseInt(np.duration) - player.playbackPosition + player.config.songDelayMs; - if (player.songEndTimeout) { - player.logger.debug('songEndTimeout was cleared'); - clearTimeout(player.songEndTimeout); - player.songEndTimeout = null; + var durationLeft = parseInt(np.duration) - this.queue.playbackPosition + this.config.songDelayMs; + if (this.songEndTimeout) { + this.logger.debug('songEndTimeout was cleared'); + clearTimeout(this.songEndTimeout); + this.songEndTimeout = null; } - player.songEndTimeout = setTimeout(player.endOfSong, durationLeft); + this.songEndTimeout = setTimeout(this.queue.endOfSong, durationLeft); }; Player.prototype.pausePlayback = function() { var player = this; // update position - player.playbackPosition += new Date().getTime() - player.playbackStart; - player.playbackStart = null; + player.queue.playbackPosition += new Date().getTime() - player.queue.playbackStart; + player.queue.playbackStart = null; clearTimeout(player.songEndTimeout); player.songEndTimeout = null; - player.callHooks('onSongPause', [player.nowPlaying]); + player.callHooks('onSongPause', [player.queue.getNowPlaying()]); }; // TODO: proper song object with constructor? @@ -146,7 +142,7 @@ Player.prototype.setPrepareTimeout = function(song) { player.logger.info('prepare timeout for song: ' + song.songID + ', removing'); song.cancelPrepare('prepare timeout'); song.prepareTimeout = null; - }, player.config.songPrepareTimeout); + }, this.config.songPrepareTimeout); Object.defineProperty(song, 'prepareTimeout', { enumerable: false, @@ -189,9 +185,9 @@ Player.prototype.prepareProgCallback = function(song, newData, done, callback) { // start playback if it hasn't been started yet // TODO: not if paused - if (player.getNowPlaying() && - player.getNowPlaying().uuid === song.uuid && - !player.playbackStart && newData) { + if (this.queue.getNowPlaying() && + this.queue.getNowPlaying().uuid === song.uuid && + !this.queue.playbackStart && newData) { player.startPlayback(); } @@ -248,36 +244,36 @@ Player.prototype.prepareSong = function(song, callback) { if (!song) { return callback(new Error('prepareSong() without song')); } - if (!player.backends[song.backendName]) { + if (!this.backends[song.backendName]) { return callback(new Error('prepareSong() without unknown backend: ' + song.backendName)); } - if (player.backends[song.backendName].isPrepared(song)) { + if (this.backends[song.backendName].isPrepared(song)) { // start playback if it hasn't been started yet // TODO: not if paused - if (player.getNowPlaying() && - player.getNowPlaying().uuid === song.uuid && - !player.playbackStart) { - player.startPlayback(); + if (this.queue.getNowPlaying() && + this.queue.getNowPlaying().uuid === song.uuid && + !this.queue.playbackStart) { + this.startPlayback(); } // song is already prepared, ok to prepare more songs callback(); - } else if (player.songsPreparing[song.backendName][song.songID]) { + } else if (this.songsPreparing[song.backendName][song.songID]) { // this song is still preparing, so don't yet prepare next song callback(true); } else { // song is not prepared and not currently preparing: let backend prepare it - player.logger.debug('DEBUG: prepareSong() ' + song.songID); - player.songsPreparing[song.backendName][song.songID] = song; + this.logger.debug('DEBUG: prepareSong() ' + song.songID); + this.songsPreparing[song.backendName][song.songID] = song; - song.cancelPrepare = player.backends[song.backendName].prepareSong( + song.cancelPrepare = this.backends[song.backendName].prepareSong( song, - _.partial(player.prepareProgCallback, _, _, _, callback), - _.partial(player.prepareErrCallback, _, _, callback) + _.partial(this.prepareProgCallback, _, _, _, callback), + _.partial(this.prepareErrCallback, _, _, callback) ); - player.setPrepareTimeout(song); + this.setPrepareTimeout(song); } }; @@ -290,7 +286,7 @@ Player.prototype.prepareSongs = function() { async.series([ function(callback) { // prepare now-playing song - var song = player.playlist[player.curPlaylistPos]; + var song = player.queue.getNowPlaying(); if (song) { player.prepareSong(song, callback); } else { @@ -300,7 +296,7 @@ Player.prototype.prepareSongs = function() { }, function(callback) { // prepare next song in playlist - var song = player.playlist[player.curPlaylistPos + 1]; + var song = player.queue.songs[player.queue.curQueuePos + 1]; if (song) { player.prepareSong(song, callback); } else { @@ -316,7 +312,7 @@ Player.prototype.getPlaylists = function(callback) { var allResults = {}; var player = this; - _.each(player.backends, function(backend) { + _.each(this.backends, function(backend) { if (!backend.getPlaylists) { resultCnt++; @@ -340,6 +336,7 @@ Player.prototype.getPlaylists = function(callback) { }); }; +/* Player.prototype.replacePlaylist = function(backendName, playlistId, callback) { var player = this; @@ -380,6 +377,7 @@ Player.prototype.replacePlaylist = function(backendName, playlistId, callback) { player.playlist = playlist; }); }; +*/ // make a search query to backends Player.prototype.searchBackends = function(query, callback) { @@ -425,22 +423,20 @@ Player.prototype.searchBackends = function(query, callback) { Player.prototype.skipSongs = function(cnt) { var player = this; - player.curPlaylistPos = Math.min(player.playlist.length, player.curPlaylistPos + cnt); + this.queue.curQueuePos = Math.min(this.queue.length, this.queue.curQueuePos + cnt); - player.playbackPosition = null; - player.playbackStart = null; - clearTimeout(player.songEndTimeout); - player.songEndTimeout = null; - player.prepareSongs(); + this.queue.playbackPosition = null; + this.queue.playbackStart = null; + clearTimeout(this.songEndTimeout); + this.songEndTimeout = null; + this.prepareSongs(); }; // TODO: userID does not belong into core...? Player.prototype.setVolume = function(newVol, userID) { - var player = this; - newVol = Math.min(1, Math.max(0, newVol)); - player.volume = newVol; - player.callHooks('onVolumeChange', [newVol, userID]); + this.volume = newVol; + this.callHooks('onVolumeChange', [newVol, userID]); }; module.exports = Player; From 9c1090ce5914d41c6dba7557e677fa94e664fb37 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sun, 19 Apr 2015 12:57:08 +0300 Subject: [PATCH 020/103] add screenshot of weblistener --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index d168122..2c5820b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ appropriately so that others can't access your music. I take no responsibility for example if your streaming services find you are violating their ToS. You're running this software entirely at your own risk! +### Screenshot of the [weblistener](https://github.com/FruitieX/nodeplayer-plugin-weblistener) + +![Screenshot of the weblistener] (https://raw.githubusercontent.com/FruitieX/nodeplayer-plugin-weblistener/master/screenshot.png) + Quickstart ---------- From 4f3c976341887b8cc83a73c57d0a97c2fd07f3ee Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Tue, 12 May 2015 16:26:02 +0300 Subject: [PATCH 021/103] travis gitter webhook --- .travis.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.travis.yml b/.travis.yml index c416619..05c53b7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,10 @@ +notifications: + webhooks: + urls: + - https://webhooks.gitter.im/e/90c139c7c84b7aab80d9 + on_success: change # options: [always|never|change] default: always + on_failure: always # options: [always|never|change] default: always + on_start: false # default: false language: node_js node_js: - "node" From 6d1e44d6e3dfbd5904126bef1a24b99fb887c3df Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 18 May 2015 14:37:00 +0300 Subject: [PATCH 022/103] gitter and coverall badges --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2c5820b..a5feb60 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ Simple, modular music player written in node.js [![Build Status](https://travis-ci.org/FruitieX/nodeplayer.svg?branch=develop)](https://travis-ci.org/FruitieX/nodeplayer) +[![Coverage Status](https://coveralls.io/repos/FruitieX/nodeplayer/badge.svg?branch=master)](https://coveralls.io/r/FruitieX/nodeplayer?branch=master) +[![Gitter](https://img.shields.io/badge/gitter-join%20chat-green.svg)](https://gitter.im/FruitieX/nodeplayer?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Gratipay](https://img.shields.io/gratipay/FruitieX.svg)](https://gratipay.com/FruitieX/) Disclaimer: for personal use only - make sure you configure nodeplayer From bbaacbc1aafe79ee00196fde254d42e5727fc408 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 12 Nov 2015 12:37:18 +0200 Subject: [PATCH 023/103] getPlaylists function calls all backends and fetches playlists --- lib/player.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/lib/player.js b/lib/player.js index 4b742a1..bf8925b 100644 --- a/lib/player.js +++ b/lib/player.js @@ -326,6 +326,35 @@ Player.prototype.searchQueue = function(backendName, songID) { return null; }; +Player.prototype.getPlaylists = function(callback) { + var resultCnt = 0; + var allResults = {}; + var player = this; + + _.each(player.backends, function(backend) { + if (!backend.getPlaylists) { + resultCnt++; + + // got results from all services? + if (resultCnt >= Object.keys(player.backends).length) { + callback(allResults); + } + return; + } + + backend.getPlaylists(function(err, results) { + resultCnt++; + + allResults[backend.name] = results; + + // got results from all services? + if (resultCnt >= Object.keys(player.backends).length) { + callback(allResults); + } + }); + }); +}; + // make a search query to backends Player.prototype.searchBackends = function(query, callback) { var resultCnt = 0; From 4f45c4b02fd93e5f441c61b918a666542de8c33e Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 00:26:36 +0200 Subject: [PATCH 024/103] WIP: playlist refactor --- lib/player.js | 175 ++++++++++++++++++++++++++++++++++++++++++++++++-- package.json | 1 + 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/lib/player.js b/lib/player.js index bf8925b..e9a555b 100644 --- a/lib/player.js +++ b/lib/player.js @@ -2,6 +2,7 @@ var _ = require('underscore'); var async = require('async'); var labeledLogger = require('./logger'); +var uuid = require('node-uuid'); function Player(options) { options = options || {}; @@ -12,6 +13,8 @@ function Player(options) { this.logger = options.logger || labeledLogger('core'); this.playedQueue = options.playedQueue || []; this.queue = options.queue || []; + this.playlist = options.playlist || []; + this.playlistPos = options.playlistPos || 0; this.plugins = options.plugins || {}; this.backends = options.backends || {}; this.songsPreparing = options.songsPreparing || {}; @@ -70,7 +73,7 @@ Player.prototype.endOfSong = function() { this.playbackStart = null; this.queue[0] = null; this.songEndTimeout = null; - this.onQueueModify(); + this.refreshPlaylist(); }; // start or resume playback of now playing song. @@ -295,7 +298,8 @@ Player.prototype.prepareSongs = function() { // this function will: // - play back the first song in the queue if no song is playing // - call prepareSongs() -Player.prototype.onQueueModify = function() { +Player.prototype.refreshPlaylist = function() { + console.log('DEPRECATED'); this.callHooks('preQueueModify', [this.queue]); // set next song as now playing @@ -314,6 +318,122 @@ Player.prototype.onQueueModify = function() { this.callHooks('postQueueModify', [this.queue]); }; +Player.prototype.findSongPos = function(at) { + var player = this; + + // found in which list + var listType = 'playlist'; + // at which position + var listIndex = 0; + + if (at === -1) { + // value of -1 is beginning of queue + listType = 'queue'; + } else if (!at) { + // falsy value is beginning of playlist + listType = 'playlist'; + } else { + // other values assumed to be UUIDs: search playlist and queue + listIndex = _.findIndex(player.playlist, function(song) { + return song.uuid === at; + }); + + if (listIndex === -1) { + // song was not found in playlist, search queue + listType = 'queue'; + listIndex = _.findIndex(player.queue, function(song) { + return song.uuid === at; + }); + } + + if (listIndex === -1) { + // if still not found, bail out + return; + } + + return { + listIndex: listIndex, + listType: listType + }; + } +}; + +/** + * Insert songs into queue or playlist + * @param {String} at - Insert songs after song with this UUID + * (null = start of playlist, -1 = start of queue) + * @param {Object[]} songs - List of songs to insert + */ +Player.prototype.insertSongs = function(at, songs) { + var player = this; + + var pos = player.findSongPos(at); + + // insert the songs + var args = [pos.listIndex, 0].concat(songs); + Array.prototype.splice.apply(player[pos.listType], args); + + // sync player state with changes + if (pos.listType === 'playlist') { + if (pos.listIndex <= player.playlistPos) { + // songs inserted before playlist pos, add to it the no. of songs + player.playlistPos += songs.length; + } else { + // songs inserted after playlist pos, check if next song changed + if (pos.listIndex === player.playlistPos + 1 && !player.queue.length) { + // inserted songs immediately after playlist pos and queue is empty, + // next song must have changed so prepare it + prepareSong(player.playlist[player.playlistPos + 1]); + } + } + } else { + // queue modified, only way the next song changed is if we inserted at + // index zero + if (pos.listIndex === 0) { + prepareSong(player.queue[0]); + } + } +}; + +/** + * Removes songs from queue or playlist + * @param {String} at - Start removing at song with this UUID + * (null = start of playlist, -1 = start of queue) + * @param {Number} cnt - Number of songs to delete + */ +Player.prototype.removeSongs = function(at, cnt) { + var player = this; + + var pos = player.findSongPos(at); + + var deleted = 0; + + // delete items before playlist pos + if (pos < player.playlistPos) { + deleted += player.playlist.splice(pos, Math.min(cnt, player.playlistPos - pos)).length; + } + + // delete now playing item + if (pos + cnt >= player.playlistPos) { + deleted += player.playlist.splice(pos, 1).length; + } + + // delete items after old now playing item + if (pos + cnt > player.playlistPos) { + player.playlist.splice(pos, cnt - deleted); + } + + // sync player state with changes + if (pos.listType === 'queue') { + // starting delete from queue, now playing can't change + if (pos.listIndex === 0) { + // but may need to prepare if we remove the first item + prepareSong(player.queue[0] || player.playlist[player.playlistPos + 1]); + } + } else { + } +}; + // find song from queue Player.prototype.searchQueue = function(backendName, songID) { for (var i = 0; i < this.queue.length; i++) { @@ -355,6 +475,47 @@ Player.prototype.getPlaylists = function(callback) { }); }; +Player.prototype.replacePlaylist = function(backendName, playlistId) { + var player = this; + + if (backendName === 'core') { + fs.readFile(path.join(config.getBaseDir(), 'playlists', playlistId + '.json'), + function(err, playlist) { + if (err) { + return player.logger.error('error while fetching playlist' + err); + } + + playlist = JSON.parse(playlist); + + // reset playlist position + player.playlistPos = 0; + player.playlist = playlist; + }); + + return; + } + + var backend = this.backends[backendName]; + + if (!backend) { + return player.logger.error('replacePlaylist(): unknown backend ' + backendName); + } + + if (!backend.getPlaylist) { + return player.logger.error('backend ' + backendName + ' does not support playlists'); + } + + backend.getPlaylist(playlistId, function(err, playlist) { + if (err) { + return this.logger.error('error while fetching playlist' + err); + } + + // reset playlist position + player.playlistPos = 0; + player.playlist = playlist; + }); +}; + // make a search query to backends Player.prototype.searchBackends = function(query, callback) { var resultCnt = 0; @@ -444,7 +605,7 @@ Player.prototype.removeFromQueue = function(pos, cnt, onlyRemove) { } if (!onlyRemove) { - this.onQueueModify(); + this.refreshPlaylist(); this.callHooks('postSongsRemoved', [pos, cnt]); } @@ -465,7 +626,7 @@ Player.prototype.moveInQueue = function(from, to, cnt) { Array.prototype.splice.apply(this.queue, [to, 0].concat(songs)); this.callHooks('sortQueue'); - this.onQueueModify(); + this.refreshPlaylist(); this.callHooks('postSongsMoved', [songs, from, to, cnt]); return songs; @@ -503,7 +664,7 @@ Player.prototype.addToQueue = function(songs, pos) { }, this); this.callHooks('sortQueue'); - this.onQueueModify(); + this.refreshPlaylist(); this.callHooks('postSongsQueued', [songs, pos]); }; @@ -514,7 +675,7 @@ Player.prototype.shuffleQueue = function() { this.queue.unshift(temp); this.callHooks('onQueueShuffled', [this.queue]); - this.onQueueModify(); + this.refreshPlaylist(); }; // cnt can be negative to go back or zero to restart current song @@ -547,7 +708,7 @@ Player.prototype.skipSongs = function(cnt) { this.playbackStart = null; clearTimeout(this.songEndTimeout); this.songEndTimeout = null; - this.onQueueModify(); + this.refreshPlaylist(); }; // TODO: userID does not belong into core...? diff --git a/package.json b/package.json index d726f5f..b14d827 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "async": "^0.9.0", "mkdirp": "^0.5.0", + "node-uuid": "^1.4.4", "npm": "^2.7.1", "underscore": "^1.7.0", "winston": "^0.9.0", From e86724893c067366eafc74e710e5a991718f1a4a Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 00:36:33 +0200 Subject: [PATCH 025/103] more WIP --- lib/player.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/player.js b/lib/player.js index e9a555b..a04568e 100644 --- a/lib/player.js +++ b/lib/player.js @@ -395,6 +395,11 @@ Player.prototype.insertSongs = function(at, songs) { } }; +Player.prototype.getNowPlaying = function() { + var song = this.playlist[this.playlistPos]; + return song ? song.uuid : undefined; +}; + /** * Removes songs from queue or playlist * @param {String} at - Start removing at song with this UUID @@ -405,21 +410,24 @@ Player.prototype.removeSongs = function(at, cnt) { var player = this; var pos = player.findSongPos(at); + var oldPlaylistPos = player.playlistPos; + var oldNowPlaying = player.getNowPlaying(); var deleted = 0; // delete items before playlist pos - if (pos < player.playlistPos) { - deleted += player.playlist.splice(pos, Math.min(cnt, player.playlistPos - pos)).length; + if (pos < oldPlaylistPos) { + deleted += player.playlist.splice(pos, Math.min(cnt, oldPlaylistPos - pos)).length; + player.playlistPos -= deleted; } // delete now playing item - if (pos + cnt >= player.playlistPos) { + if (pos + cnt >= oldPlaylistPos) { deleted += player.playlist.splice(pos, 1).length; } // delete items after old now playing item - if (pos + cnt > player.playlistPos) { + if (pos + cnt > oldPlaylistPos) { player.playlist.splice(pos, cnt - deleted); } @@ -428,9 +436,11 @@ Player.prototype.removeSongs = function(at, cnt) { // starting delete from queue, now playing can't change if (pos.listIndex === 0) { // but may need to prepare if we remove the first item - prepareSong(player.queue[0] || player.playlist[player.playlistPos + 1]); + prepareSong(player.queue[0] || player.playlist[oldPlaylistPos + 1]); } } else { + if('np song changed') { + } } }; From c0505cf5b71caf5cf2c8932fc09b87756813a78e Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 16:50:42 +0200 Subject: [PATCH 026/103] simplify playlist logic by only using one list --- lib/player.js | 152 +++++++++++++++++++------------------------------- 1 file changed, 56 insertions(+), 96 deletions(-) diff --git a/lib/player.js b/lib/player.js index a04568e..46b974c 100644 --- a/lib/player.js +++ b/lib/player.js @@ -11,10 +11,8 @@ function Player(options) { _.bindAll.apply(_, [this].concat(_.functions(this))); this.config = options.config || require('./config').getConfig(); this.logger = options.logger || labeledLogger('core'); - this.playedQueue = options.playedQueue || []; - this.queue = options.queue || []; this.playlist = options.playlist || []; - this.playlistPos = options.playlistPos || 0; + this.curPlaylistPos = options.curPlaylistPos || 0; this.plugins = options.plugins || {}; this.backends = options.backends || {}; this.songsPreparing = options.songsPreparing || {}; @@ -318,130 +316,92 @@ Player.prototype.refreshPlaylist = function() { this.callHooks('postQueueModify', [this.queue]); }; -Player.prototype.findSongPos = function(at) { - var player = this; - - // found in which list - var listType = 'playlist'; - // at which position - var listIndex = 0; - - if (at === -1) { - // value of -1 is beginning of queue - listType = 'queue'; - } else if (!at) { - // falsy value is beginning of playlist - listType = 'playlist'; - } else { - // other values assumed to be UUIDs: search playlist and queue - listIndex = _.findIndex(player.playlist, function(song) { - return song.uuid === at; - }); - - if (listIndex === -1) { - // song was not found in playlist, search queue - listType = 'queue'; - listIndex = _.findIndex(player.queue, function(song) { - return song.uuid === at; - }); - } - - if (listIndex === -1) { - // if still not found, bail out - return; - } +/** + * Find index of song in playlist + * @param {String} at - Look for song with this UUID + */ +Player.prototype.findSongIndex = function(at) { + return _.findIndex(player.playlist, function(song) { + return song.uuid === at; + }); +}; - return { - listIndex: listIndex, - listType: listType - }; - } +/** + * Returns currently playing song + */ +Player.prototype.getNowPlaying = function() { + return this.playlist[this.curPlaylistPos]; }; /** * Insert songs into queue or playlist * @param {String} at - Insert songs after song with this UUID - * (null = start of playlist, -1 = start of queue) + * (-1 = start of playlist) * @param {Object[]} songs - List of songs to insert */ -Player.prototype.insertSongs = function(at, songs) { +Player.prototype.insertSongs = function(at, songs, callback) { var player = this; - var pos = player.findSongPos(at); + var pos; + if (at === -1) { + pos = 0; + } else { + pos = player.findSongPos(at); + + if (pos < 0) { + return callback(new Error('Song with UUID ' + at + ' not found!')); + } + + pos++; // insert after song + } // insert the songs - var args = [pos.listIndex, 0].concat(songs); - Array.prototype.splice.apply(player[pos.listType], args); + var args = [pos, 0].concat(songs); + Array.prototype.splice.apply(player.playlist, args); // sync player state with changes - if (pos.listType === 'playlist') { - if (pos.listIndex <= player.playlistPos) { - // songs inserted before playlist pos, add to it the no. of songs - player.playlistPos += songs.length; - } else { - // songs inserted after playlist pos, check if next song changed - if (pos.listIndex === player.playlistPos + 1 && !player.queue.length) { - // inserted songs immediately after playlist pos and queue is empty, - // next song must have changed so prepare it - prepareSong(player.playlist[player.playlistPos + 1]); - } - } - } else { - // queue modified, only way the next song changed is if we inserted at - // index zero - if (pos.listIndex === 0) { - prepareSong(player.queue[0]); - } + if (pos <= player.curPlaylistPos) { + // songs inserted before curPlaylistPos, increment it + player.curPlaylistPos += songs.length; + } else if (pos === player.curPlaylistPos + 1) { + // new next song, prepare it + prepareSong(player.playlist[player.curPlaylistPos + 1]); } -}; -Player.prototype.getNowPlaying = function() { - var song = this.playlist[this.playlistPos]; - return song ? song.uuid : undefined; + callback(); }; /** * Removes songs from queue or playlist * @param {String} at - Start removing at song with this UUID - * (null = start of playlist, -1 = start of queue) * @param {Number} cnt - Number of songs to delete */ -Player.prototype.removeSongs = function(at, cnt) { +Player.prototype.removeSongs = function(at, cnt, callback) { var player = this; var pos = player.findSongPos(at); - var oldPlaylistPos = player.playlistPos; - var oldNowPlaying = player.getNowPlaying(); - - var deleted = 0; - - // delete items before playlist pos - if (pos < oldPlaylistPos) { - deleted += player.playlist.splice(pos, Math.min(cnt, oldPlaylistPos - pos)).length; - player.playlistPos -= deleted; - } - - // delete now playing item - if (pos + cnt >= oldPlaylistPos) { - deleted += player.playlist.splice(pos, 1).length; + if (pos < 0) { + return callback(new Error('Song with UUID ' + at + ' not found!')); } - // delete items after old now playing item - if (pos + cnt > oldPlaylistPos) { - player.playlist.splice(pos, cnt - deleted); - } + player.playlist.splice(pos, cnt); - // sync player state with changes - if (pos.listType === 'queue') { - // starting delete from queue, now playing can't change - if (pos.listIndex === 0) { - // but may need to prepare if we remove the first item - prepareSong(player.queue[0] || player.playlist[oldPlaylistPos + 1]); - } - } else { - if('np song changed') { + if (pos <= player.curPlaylistPos) { + if (pos + cnt >= player.curPlaylistPos) { + // removed now playing, change to first song after splice + player.curPlaylistPos = pos; + prepareSong(player.playlist[player.curPlaylistPos]); + // TODO: prepare song and switch + } else { + // removed songs before now playing, update playlist pos + player.curPlaylistPos -= cnt; } + } else if (pos === player.curPlaylistPos + 1) { + // new next song, prepare it + prepareSong(player.playlist[player.curPlaylistPos + 1]); } + + callback(); }; // find song from queue From 3142263904bd255b69652f1a083d2356389a4f24 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 17:42:24 +0200 Subject: [PATCH 027/103] WIP: playlist refactor --- lib/player.js | 187 ++++++++++++++++++++++++-------------------------- 1 file changed, 90 insertions(+), 97 deletions(-) diff --git a/lib/player.js b/lib/player.js index 46b974c..daa668f 100644 --- a/lib/player.js +++ b/lib/player.js @@ -59,19 +59,19 @@ Player.prototype.numHooks = function(hook) { }; Player.prototype.endOfSong = function() { - var np = this.queue[0]; + //var np = this.queue[0]; + var player = this; - this.logger.info('end of song ' + np.songID); - this.callHooks('onSongEnd', [np]); + player.curPlaylistPos++; - this.playedQueue.push(this.queue[0]); - this.playedQueue = _.last(this.playedQueue, this.config.playedQueueSize); + player.logger.info('end of song ' + np.songID); + player.callHooks('onSongEnd', [np]); - this.playbackPosition = null; - this.playbackStart = null; - this.queue[0] = null; - this.songEndTimeout = null; - this.refreshPlaylist(); + player.playbackPosition = null; + player.playbackStart = null; + player.queue[0] = null; + player.songEndTimeout = null; + player.refreshPlaylist(); }; // start or resume playback of now playing song. @@ -174,11 +174,11 @@ Player.prototype.prepareProgCallback = function(song, newData, done, asyncCallba } // start playback if it hasn't been started yet - if (this.queue[0] && - this.queue[0].backendName === song.backendName && - this.queue[0].songID === song.songID && - !this.playbackStart && newData) { - this.startPlayback(); + // TODO: not if paused + if (player.getNowPlaying() && + player.getNowPlaying().uuid === song.uuid + !player.playbackStart && newData) { + player.startPlayback(); } // tell plugins that new data is available for this song, and @@ -228,94 +228,75 @@ Player.prototype.prepareErrCallback = function(song, err, asyncCallback) { delete(this.songsPreparing[song.backendName][song.songID]); }; -// TODO: get rid of the callback hell, use promises? -Player.prototype.prepareSong = function(song, asyncCallback) { +Player.prototype.prepareSong = function(song, callback) { + var player = this; + if (!song) { - this.logger.warn('prepareSong() without song'); - asyncCallback(true); - return; + return callback(new Error('prepareSong() without song')); } - if (!this.backends[song.backendName]) { - this.prepareError(song, 'prepareSong() with unknown backend ' + song.backendName); - asyncCallback(true); - return; + if (!player.backends[song.backendName]) { + return callback(new Error('prepareSong() without unknown backend: ' + song.backendName)); } - if (this.backends[song.backendName].isPrepared(song)) { + if (player.backends[song.backendName].isPrepared(song)) { // start playback if it hasn't been started yet - if (this.queue[0] && - this.queue[0].backendName === song.backendName && - this.queue[0].songID === song.songID && - !this.playbackStart) { - this.startPlayback(); + // TODO: not if paused + if (player.getNowPlaying() && + player.getNowPlaying().uuid === song.uuid + !player.playbackStart) { + player.startPlayback(); } // song is already prepared, ok to prepare more songs - asyncCallback(); - } else if (this.songsPreparing[song.backendName][song.songID]) { + callback(); + } else if (player.songsPreparing[song.backendName][song.songID]) { // this song is already preparing, so don't yet prepare next song - asyncCallback(true); + callback(true); } else { // song is not prepared and not currently preparing: let backend prepare it - this.logger.debug('DEBUG: prepareSong() ' + song.songID); - this.songsPreparing[song.backendName][song.songID] = song; + player.logger.debug('DEBUG: prepareSong() ' + song.songID); + player.songsPreparing[song.backendName][song.songID] = song; - song.cancelPrepare = this.backends[song.backendName].prepareSong( + song.cancelPrepare = player.backends[song.backendName].prepareSong( song, - _.partial(this.prepareProgCallback, _, _, _, asyncCallback), - _.partial(this.prepareErrCallback, _, _, asyncCallback) + _.partial(player.prepareProgCallback, _, _, _, callback), + _.partial(player.prepareErrCallback, _, _, callback) ); - this.setPrepareTimeout(song); + player.setPrepareTimeout(song); } }; -// prepare now playing and queued songs for playback +/** + * Prepare now playing and next song for playback + */ Player.prototype.prepareSongs = function() { + var player = this; + async.series([ - _.bind(function(callback) { - // prepare now-playing song if it exists and if not prepared - if (this.queue[0]) { - this.prepareSong(this.queue[0], callback); + function(callback) { + // prepare now-playing song + var song = player.playlist[player.curPlaylistPos]; + if (song) { + player.prepareSong(song, callback); } else { + // bail out callback(true); } - }, this), - _.bind(function(callback) { - // prepare next song in queue if it exists and if not prepared - if (this.queue[1]) { - this.prepareSong(this.queue[1], callback); + }), + function(callback) { + // prepare next song in playlist + var song = player.playlist[player.curPlaylistPos + 1]; + if (song) { + player.prepareSong(song, callback); } else { + // bail out callback(true); } - }, this) + }) ]); }; -// to be called whenever the queue has been modified -// this function will: -// - play back the first song in the queue if no song is playing -// - call prepareSongs() -Player.prototype.refreshPlaylist = function() { - console.log('DEPRECATED'); - this.callHooks('preQueueModify', [this.queue]); - - // set next song as now playing - if (!this.queue[0]) { - this.queue.shift(); - } - - if (!this.queue.length) { - // if the queue is now empty, do nothing - this.callHooks('onEndOfQueue'); - this.logger.info('end of queue, waiting for more songs'); - } else { - // else prepare songs - this.prepareSongs(); - } - this.callHooks('postQueueModify', [this.queue]); -}; - /** * Find index of song in playlist * @param {String} at - Look for song with this UUID @@ -326,6 +307,16 @@ Player.prototype.findSongIndex = function(at) { }); }; +/** + * Find song in playlist + * @param {String} at - Look for song with this UUID + */ +Player.prototype.findSong = function(at) { + return _.find(player.playlist, function(song) { + return song.uuid === at; + }); +}; + /** * Returns currently playing song */ @@ -344,8 +335,10 @@ Player.prototype.insertSongs = function(at, songs, callback) { var pos; if (at === -1) { + // insert at start of playlist pos = 0; } else { + // insert song after song with UUID pos = player.findSongPos(at); if (pos < 0) { @@ -355,7 +348,12 @@ Player.prototype.insertSongs = function(at, songs, callback) { pos++; // insert after song } - // insert the songs + // generate UUIDs for each song + _.each(songs, function(song) { + song.uuid = uuid.v4(); + }); + + // perform insertion var args = [pos, 0].concat(songs); Array.prototype.splice.apply(player.playlist, args); @@ -365,7 +363,7 @@ Player.prototype.insertSongs = function(at, songs, callback) { player.curPlaylistPos += songs.length; } else if (pos === player.curPlaylistPos + 1) { // new next song, prepare it - prepareSong(player.playlist[player.curPlaylistPos + 1]); + prepareSongs(); } callback(); @@ -384,38 +382,33 @@ Player.prototype.removeSongs = function(at, cnt, callback) { return callback(new Error('Song with UUID ' + at + ' not found!')); } + // cancel preparing all songs to be deleted + for (var i = pos; i < pos + cnt && i < player.playlist.length) { + var song = player.playlist[i]; + if (song.cancelPrepare) { + song.cancelPrepare('Song removed.'); + } + } + player.playlist.splice(pos, cnt); if (pos <= player.curPlaylistPos) { if (pos + cnt >= player.curPlaylistPos) { // removed now playing, change to first song after splice player.curPlaylistPos = pos; - prepareSong(player.playlist[player.curPlaylistPos]); - // TODO: prepare song and switch + prepareSongs(); } else { // removed songs before now playing, update playlist pos player.curPlaylistPos -= cnt; } } else if (pos === player.curPlaylistPos + 1) { - // new next song, prepare it - prepareSong(player.playlist[player.curPlaylistPos + 1]); + // new next song, make sure it's prepared + prepareSongs(); } callback(); }; -// find song from queue -Player.prototype.searchQueue = function(backendName, songID) { - for (var i = 0; i < this.queue.length; i++) { - if (this.queue[i].songID === songID && - this.queue[i].backendName === backendName) { - return this.queue[i]; - } - } - - return null; -}; - Player.prototype.getPlaylists = function(callback) { var resultCnt = 0; var allResults = {}; @@ -445,14 +438,14 @@ Player.prototype.getPlaylists = function(callback) { }); }; -Player.prototype.replacePlaylist = function(backendName, playlistId) { +Player.prototype.replacePlaylist = function(backendName, playlistId, callback) { var player = this; if (backendName === 'core') { fs.readFile(path.join(config.getBaseDir(), 'playlists', playlistId + '.json'), function(err, playlist) { if (err) { - return player.logger.error('error while fetching playlist' + err); + return callback(new Error('Error while fetching playlist' + err)); } playlist = JSON.parse(playlist); @@ -468,16 +461,16 @@ Player.prototype.replacePlaylist = function(backendName, playlistId) { var backend = this.backends[backendName]; if (!backend) { - return player.logger.error('replacePlaylist(): unknown backend ' + backendName); + return callback(new Error('Unknown backend ' + backendName)); } if (!backend.getPlaylist) { - return player.logger.error('backend ' + backendName + ' does not support playlists'); + return callback(new Error('Backend ' + backendName + ' does not support playlists')); } backend.getPlaylist(playlistId, function(err, playlist) { if (err) { - return this.logger.error('error while fetching playlist' + err); + return callback(new Error('Error while fetching playlist' + err)); } // reset playlist position From f491ab1fda2bb7e3a88c02863c8de76b1a1fe16c Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 19:46:08 +0200 Subject: [PATCH 028/103] it runs! --- lib/player.js | 83 ++++++++++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/lib/player.js b/lib/player.js index daa668f..e423005 100644 --- a/lib/player.js +++ b/lib/player.js @@ -69,72 +69,77 @@ Player.prototype.endOfSong = function() { player.playbackPosition = null; player.playbackStart = null; - player.queue[0] = null; player.songEndTimeout = null; - player.refreshPlaylist(); + player.prepareSongs(); }; // start or resume playback of now playing song. // if pos is undefined, playback continues (or starts from 0 if !playbackPosition) Player.prototype.startPlayback = function(pos) { - var np = this.queue[0]; + var player = this; + var np = player.getNowPlaying(); + if (!np) { - this.logger.verbose('startPlayback called, but hit end of queue'); + player.logger.verbose('startPlayback called, but hit end of queue'); return; } if (!_.isUndefined(pos) && !_.isNull(pos)) { - this.logger.info('playing song: ' + np.songID + ', from pos: ' + pos); + player.logger.info('playing song: ' + np.songID + ', from pos: ' + pos); } else { - this.logger.info('playing song: ' + np.songID); + player.logger.info('playing song: ' + np.songID); } - var oldPlaybackStart = this.playbackStart; - this.playbackStart = new Date().getTime(); // song is playing while this is truthy + var oldPlaybackStart = player.playbackStart; + player.playbackStart = new Date().getTime(); // song is playing while this is truthy // where did the song start playing from at playbackStart? if (!_.isUndefined(pos) && !_.isNull(pos)) { - this.playbackPosition = pos; - } else if (!this.playbackPosition) { - this.playbackPosition = 0; + player.playbackPosition = pos; + } else if (!player.playbackPosition) { + player.playbackPosition = 0; } if (oldPlaybackStart) { - this.callHooks('onSongSeek', [np]); + player.callHooks('onSongSeek', [np]); } else { - this.callHooks('onSongChange', [np]); + player.callHooks('onSongChange', [np]); } - var durationLeft = parseInt(np.duration) - this.playbackPosition + this.config.songDelayMs; - if (this.songEndTimeout) { - this.logger.debug('songEndTimeout was cleared'); - clearTimeout(this.songEndTimeout); - this.songEndTimeout = null; + var durationLeft = parseInt(np.duration) - player.playbackPosition + player.config.songDelayMs; + if (player.songEndTimeout) { + player.logger.debug('songEndTimeout was cleared'); + clearTimeout(player.songEndTimeout); + player.songEndTimeout = null; } - this.songEndTimeout = setTimeout(this.endOfSong, durationLeft); + player.songEndTimeout = setTimeout(player.endOfSong, durationLeft); }; Player.prototype.pausePlayback = function() { + var player = this; + // update position - this.playbackPosition += new Date().getTime() - this.playbackStart; - this.playbackStart = null; + player.playbackPosition += new Date().getTime() - player.playbackStart; + player.playbackStart = null; - clearTimeout(this.songEndTimeout); - this.songEndTimeout = null; - this.callHooks('onSongPause', [this.nowPlaying]); + clearTimeout(player.songEndTimeout); + player.songEndTimeout = null; + player.callHooks('onSongPause', [player.nowPlaying]); }; // TODO: proper song object with constructor? Player.prototype.setPrepareTimeout = function(song) { + var player = this; + if (song.prepareTimeout) { clearTimeout(song.prepareTimeout); } - song.prepareTimeout = setTimeout(_.bind(function() { - this.logger.info('prepare timeout for song: ' + song.songID + ', removing'); + song.prepareTimeout = setTimeout(function() { + player.logger.info('prepare timeout for song: ' + song.songID + ', removing'); song.cancelPrepare('prepare timeout'); song.prepareTimeout = null; - }, this), this.config.songPrepareTimeout); + }, player.config.songPrepareTimeout); Object.defineProperty(song, 'prepareTimeout', { enumerable: false, @@ -144,6 +149,7 @@ Player.prototype.setPrepareTimeout = function(song) { Player.prototype.prepareError = function(song, err) { // remove all instances of this song + /* for (var i = this.queue.length - 1; i >= 0; i--) { if (this.queue[i].songID === song.songID && this.queue[i].backendName === song.backendName) { @@ -154,11 +160,12 @@ Player.prototype.prepareError = function(song, err) { } } } + */ this.callHooks('onSongPrepareError', [song, err]); }; -Player.prototype.prepareProgCallback = function(song, newData, done, asyncCallback) { +Player.prototype.prepareProgCallback = function(song, newData, done, callback) { /* progress callback * when this is called, new song data has been flushed to disk */ @@ -176,7 +183,7 @@ Player.prototype.prepareProgCallback = function(song, newData, done, asyncCallba // start playback if it hasn't been started yet // TODO: not if paused if (player.getNowPlaying() && - player.getNowPlaying().uuid === song.uuid + player.getNowPlaying().uuid === song.uuid && !player.playbackStart && newData) { player.startPlayback(); } @@ -200,14 +207,14 @@ Player.prototype.prepareProgCallback = function(song, newData, done, asyncCallba clearTimeout(song.prepareTimeout); song.prepareTimeout = null; - asyncCallback(); + callback(); } else { // reset prepare timeout this.setPrepareTimeout(song); } }; -Player.prototype.prepareErrCallback = function(song, err, asyncCallback) { +Player.prototype.prepareErrCallback = function(song, err, callback) { /* error callback */ // don't let anything run cancelPrepare anymore @@ -219,9 +226,9 @@ Player.prototype.prepareErrCallback = function(song, err, asyncCallback) { // abort preparing more songs; current song will be deleted -> // onQueueModified is called -> song preparation is triggered again - asyncCallback(true); + callback(true); - // TODO: investigate this, should probably be above asyncCallback + // TODO: investigate this, should probably be above callback this.prepareError(song, err); song.songData = undefined; @@ -242,7 +249,7 @@ Player.prototype.prepareSong = function(song, callback) { // start playback if it hasn't been started yet // TODO: not if paused if (player.getNowPlaying() && - player.getNowPlaying().uuid === song.uuid + player.getNowPlaying().uuid === song.uuid && !player.playbackStart) { player.startPlayback(); } @@ -250,7 +257,7 @@ Player.prototype.prepareSong = function(song, callback) { // song is already prepared, ok to prepare more songs callback(); } else if (player.songsPreparing[song.backendName][song.songID]) { - // this song is already preparing, so don't yet prepare next song + // this song is still preparing, so don't yet prepare next song callback(true); } else { // song is not prepared and not currently preparing: let backend prepare it @@ -283,7 +290,7 @@ Player.prototype.prepareSongs = function() { // bail out callback(true); } - }), + }, function(callback) { // prepare next song in playlist var song = player.playlist[player.curPlaylistPos + 1]; @@ -293,7 +300,7 @@ Player.prototype.prepareSongs = function() { // bail out callback(true); } - }) + } ]); }; @@ -383,7 +390,7 @@ Player.prototype.removeSongs = function(at, cnt, callback) { } // cancel preparing all songs to be deleted - for (var i = pos; i < pos + cnt && i < player.playlist.length) { + for (var i = pos; i < pos + cnt && i < player.playlist.length; i++) { var song = player.playlist[i]; if (song.cancelPrepare) { song.cancelPrepare('Song removed.'); From b940dd03d9cc68f47023316ff7ef18945e4a5ca5 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 20:33:52 +0200 Subject: [PATCH 029/103] WIP --- lib/player.js | 51 ++++++++++++++++++--------------------------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/lib/player.js b/lib/player.js index e423005..be187af 100644 --- a/lib/player.js +++ b/lib/player.js @@ -550,10 +550,12 @@ Player.prototype.removeFromQueue = function(pos, cnt, onlyRemove) { // signal prepareError function not to run removeFromQueue again // TODO: try getting rid of this ugly hack (beingDeleted)... // TODO: more non enumerable properties, especially plugins? + /* Object.defineProperty(song, 'beingDeleted', { enumerable: false, writable: true }); + */ song.beingDeleted = true; if (song.cancelPrepare) { @@ -638,54 +640,37 @@ Player.prototype.addToQueue = function(songs, pos) { this.callHooks('postSongsQueued', [songs, pos]); }; +// TODO: this can't be undone :( Player.prototype.shuffleQueue = function() { // don't change now playing - var temp = this.queue.shift(); - this.queue = _.shuffle(this.queue); - this.queue.unshift(temp); + var temp = this.playlist.shift(); + this.playlist = _.shuffle(this.playlist); + this.playlist.unshift(temp); - this.callHooks('onQueueShuffled', [this.queue]); + this.callHooks('onQueueShuffled', [this.playlist]); this.refreshPlaylist(); }; // cnt can be negative to go back or zero to restart current song Player.prototype.skipSongs = function(cnt) { - this.npIsPlaying = false; - - // TODO: this could be replaced with a splice? - for (var i = 0; i < Math.abs(cnt); i++) { - if (cnt > 0) { - if (this.queue[0]) { - this.playedQueue.push(this.queue[0]); - } - - this.queue.shift(); - } else if (cnt < 0) { - if (this.playedQueue.length) { - this.queue.unshift(this.playedQueue.pop()); - } - } - - // ran out of songs while skipping, stop - if (!this.queue[0]) { - break; - } - } + var player = this; - this.playedQueue = _.last(this.playedQueue, this.config.playedQueueSize); + player.curPlaylistPos = Math.min(player.playlist.length, player.curPlaylistPos + cnt); - this.playbackPosition = null; - this.playbackStart = null; - clearTimeout(this.songEndTimeout); - this.songEndTimeout = null; - this.refreshPlaylist(); + player.playbackPosition = null; + player.playbackStart = null; + clearTimeout(player.songEndTimeout); + player.songEndTimeout = null; + player.prepareSongs(); }; // TODO: userID does not belong into core...? Player.prototype.setVolume = function(newVol, userID) { + var player = this; + newVol = Math.min(1, Math.max(0, newVol)); - this.volume = newVol; - this.callHooks('onVolumeChange', [newVol, userID]); + player.volume = newVol; + player.callHooks('onVolumeChange', [newVol, userID]); }; module.exports = Player; From 51aa30ebd4112ed5c1174e4c07ce716f8a5ebf5d Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 13 Nov 2015 22:54:39 +0200 Subject: [PATCH 030/103] first song plays fine --- lib/player.js | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/player.js b/lib/player.js index be187af..3e9dcee 100644 --- a/lib/player.js +++ b/lib/player.js @@ -12,7 +12,7 @@ function Player(options) { this.config = options.config || require('./config').getConfig(); this.logger = options.logger || labeledLogger('core'); this.playlist = options.playlist || []; - this.curPlaylistPos = options.curPlaylistPos || 0; + this.curPlaylistPos = options.curPlaylistPos || -1; this.plugins = options.plugins || {}; this.backends = options.backends || {}; this.songsPreparing = options.songsPreparing || {}; @@ -59,10 +59,15 @@ Player.prototype.numHooks = function(hook) { }; Player.prototype.endOfSong = function() { - //var np = this.queue[0]; var player = this; + var np = player.playlist[player.curPlaylistPos]; - player.curPlaylistPos++; + if (player.curPlaylistPos === playlist.length - 1) { + // end of playlist + player.curPlaylistPos = -1; + } else { + player.curPlaylistPos++; + } player.logger.info('end of song ' + np.songID); player.callHooks('onSongEnd', [np]); @@ -77,10 +82,11 @@ Player.prototype.endOfSong = function() { // if pos is undefined, playback continues (or starts from 0 if !playbackPosition) Player.prototype.startPlayback = function(pos) { var player = this; + var np = player.getNowPlaying(); if (!np) { - player.logger.verbose('startPlayback called, but hit end of queue'); + player.logger.verbose('startPlayback called, but no song at curPlaylistPos'); return; } @@ -365,12 +371,16 @@ Player.prototype.insertSongs = function(at, songs, callback) { Array.prototype.splice.apply(player.playlist, args); // sync player state with changes - if (pos <= player.curPlaylistPos) { + if (player.curPlaylistPos === -1) { + // playlist was empty, start playing first song + player.curPlaylistPos++; + player.prepareSongs(); + } else if (pos <= player.curPlaylistPos) { // songs inserted before curPlaylistPos, increment it player.curPlaylistPos += songs.length; } else if (pos === player.curPlaylistPos + 1) { // new next song, prepare it - prepareSongs(); + player.prepareSongs(); } callback(); @@ -403,14 +413,14 @@ Player.prototype.removeSongs = function(at, cnt, callback) { if (pos + cnt >= player.curPlaylistPos) { // removed now playing, change to first song after splice player.curPlaylistPos = pos; - prepareSongs(); + player.prepareSongs(); } else { // removed songs before now playing, update playlist pos player.curPlaylistPos -= cnt; } } else if (pos === player.curPlaylistPos + 1) { // new next song, make sure it's prepared - prepareSongs(); + player.prepareSongs(); } callback(); From 0573945a3df93fae06dc5ffea6a922da55249233 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 17:10:12 +0200 Subject: [PATCH 031/103] playlist concepts --- playlist-wip.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 playlist-wip.md diff --git a/playlist-wip.md b/playlist-wip.md new file mode 100644 index 0000000..d7eb236 --- /dev/null +++ b/playlist-wip.md @@ -0,0 +1,45 @@ +# Nodeplayer playlist concepts +## Definitions +### Playlist + +- Generic list of songs that can be added to the playback queue. +- Core contains playlist management functionality for: + - Creating playlists + - Adding/removing songs to/from playlists + - Editing playlists + - Listing playlists +- Backends can provide playlists, containing songs from that backend. + - These will start out as read-only to keep things simple. +- Plugins can provide autogenerated playlists, for example based on ratings. + - These are also read-only by the user, plugin can modify. + +### Playback queue + +- Contains list of songs that the player will play / has played. +- Keeps track of where playback currently is. +- Keeps track of which playlist a song was added from (if any). This is simply + a field in the song object containing the name and backend of the playlist. + - This makes it possible for clients to display songs differently depending + on which playlist they were added from. + - One time use, 'pseudo-playlists', are also possible. nodeplayer blindly + trusts which playlist a song was added from, the client can decide. + - Pseudo-playlists can be used by clients to implement functionality such + as queuing songs before the rest of the playback queue. This simulates + something like the WinAmp queue, which is always played before the rest + of the playback queue. Let's call this the 'pre-queue'. + - This is done by having the client reserve a certain playlist name + for the pre-queue, say '__prequeue'. + - The client checks the current playback queue at the current playback + position. + - If the current song was added from a playlist '__prequeue', find + the first song after it that is not part of __prequeue, insert + song before it. + - If the current song is not part of __prequeue, insert song into + the playback queue immediately after the current song and mark it + as __prequeue. + - Implementing pre-queue functionality like this makes playlist handling + code much much simpler. E.g. song preparing doesn't need to care about + a separate queue and playlist, it only needs to know about the one and + only playback queue. + - Plugins can restrict how the playback queue is managed, e.g. partyplay + only allows appending into __prequeue. From e752aebb46bbc56293061891f4fa2ad312a3864c Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 19:07:27 +0200 Subject: [PATCH 032/103] WIP: refactor playback queue handling into queue.js --- lib/player.js | 116 -------------------------------------------- lib/queue.js | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 116 deletions(-) create mode 100644 lib/queue.js diff --git a/lib/player.js b/lib/player.js index 3e9dcee..da1702f 100644 --- a/lib/player.js +++ b/lib/player.js @@ -310,122 +310,6 @@ Player.prototype.prepareSongs = function() { ]); }; -/** - * Find index of song in playlist - * @param {String} at - Look for song with this UUID - */ -Player.prototype.findSongIndex = function(at) { - return _.findIndex(player.playlist, function(song) { - return song.uuid === at; - }); -}; - -/** - * Find song in playlist - * @param {String} at - Look for song with this UUID - */ -Player.prototype.findSong = function(at) { - return _.find(player.playlist, function(song) { - return song.uuid === at; - }); -}; - -/** - * Returns currently playing song - */ -Player.prototype.getNowPlaying = function() { - return this.playlist[this.curPlaylistPos]; -}; - -/** - * Insert songs into queue or playlist - * @param {String} at - Insert songs after song with this UUID - * (-1 = start of playlist) - * @param {Object[]} songs - List of songs to insert - */ -Player.prototype.insertSongs = function(at, songs, callback) { - var player = this; - - var pos; - if (at === -1) { - // insert at start of playlist - pos = 0; - } else { - // insert song after song with UUID - pos = player.findSongPos(at); - - if (pos < 0) { - return callback(new Error('Song with UUID ' + at + ' not found!')); - } - - pos++; // insert after song - } - - // generate UUIDs for each song - _.each(songs, function(song) { - song.uuid = uuid.v4(); - }); - - // perform insertion - var args = [pos, 0].concat(songs); - Array.prototype.splice.apply(player.playlist, args); - - // sync player state with changes - if (player.curPlaylistPos === -1) { - // playlist was empty, start playing first song - player.curPlaylistPos++; - player.prepareSongs(); - } else if (pos <= player.curPlaylistPos) { - // songs inserted before curPlaylistPos, increment it - player.curPlaylistPos += songs.length; - } else if (pos === player.curPlaylistPos + 1) { - // new next song, prepare it - player.prepareSongs(); - } - - callback(); -}; - -/** - * Removes songs from queue or playlist - * @param {String} at - Start removing at song with this UUID - * @param {Number} cnt - Number of songs to delete - */ -Player.prototype.removeSongs = function(at, cnt, callback) { - var player = this; - - var pos = player.findSongPos(at); - if (pos < 0) { - return callback(new Error('Song with UUID ' + at + ' not found!')); - } - - // cancel preparing all songs to be deleted - for (var i = pos; i < pos + cnt && i < player.playlist.length; i++) { - var song = player.playlist[i]; - if (song.cancelPrepare) { - song.cancelPrepare('Song removed.'); - } - } - - player.playlist.splice(pos, cnt); - - if (pos <= player.curPlaylistPos) { - if (pos + cnt >= player.curPlaylistPos) { - // removed now playing, change to first song after splice - player.curPlaylistPos = pos; - player.prepareSongs(); - } else { - // removed songs before now playing, update playlist pos - player.curPlaylistPos -= cnt; - } - } else if (pos === player.curPlaylistPos + 1) { - // new next song, make sure it's prepared - player.prepareSongs(); - } - - callback(); -}; - Player.prototype.getPlaylists = function(callback) { var resultCnt = 0; var allResults = {}; diff --git a/lib/queue.js b/lib/queue.js new file mode 100644 index 0000000..f49b990 --- /dev/null +++ b/lib/queue.js @@ -0,0 +1,130 @@ +var _ = require('underscore'); + +function Queue(modifyCallback) { + if (!modifyCallback) { + return new Error('Queue constructor called without a modifyCallback!'); + } + + this.songs = []; + this.curQueuePos = -1; + this.playbackStart = -1; + this.playbackPosition = -1; + this.modifyCallback = modifyCallback; +} + +/** + * Find index of song in queue + * @param {String} at - Look for song with this UUID + */ +Queue.prototype.findSongIndex = function(at) { + return _.findIndex(this.songs, function(song) { + return song.uuid === at; + }); +}; + +/** + * Find song in queue + * @param {String} at - Look for song with this UUID + */ +Queue.prototype.findSong = function(at) { + return _.find(this.songs, function(song) { + return song.uuid === at; + }); +}; + +/** + * Returns currently playing song + */ +Queue.prototype.getNowPlaying = function() { + return this.songs[this.curQueuePos]; +}; + +/** + * Insert songs into queue + * @param {String} at - Insert songs after song with this UUID + * (-1 = start of queue) + * @param {Object[]} songs - List of songs to insert + */ +Queue.prototype.insertSongs = function(at, songs, callback) { + var player = this; + + var pos; + if (at === '-1') { + // insert at start of queue + pos = 0; + } else { + // insert song after song with UUID + pos = player.findSongPos(at); + + if (pos < 0) { + return callback(new Error('Song with UUID ' + at + ' not found!')); + } + + pos++; // insert after song + } + + // generate UUIDs for each song + _.each(songs, function(song) { + song.uuid = uuid.v4(); + }); + + // perform insertion + var args = [pos, 0].concat(songs); + Array.prototype.splice.apply(this.songs, args); + + // sync player state with changes + if (player.curQueuePos === -1) { + // queue was empty, start playing first song + player.curQueuePos++; + player.prepareSongs(); + } else if (pos <= player.curQueuePos) { + // songs inserted before curQueuePos, increment it + player.curQueuePos += songs.length; + } else if (pos === player.curQueuePos + 1) { + // new next song, prepare it + player.prepareSongs(); + } + + callback(); +}; + +/** + * Removes songs from queue + * @param {String} at - Start removing at song with this UUID + * @param {Number} cnt - Number of songs to delete + */ +Queue.prototype.removeSongs = function(at, cnt, callback) { + var player = this; + + var pos = player.findSongPos(at); + if (pos < 0) { + return callback(new Error('Song with UUID ' + at + ' not found!')); + } + + // cancel preparing all songs to be deleted + for (var i = pos; i < pos + cnt && i < this.songs.length; i++) { + var song = this.songs[i]; + if (song.cancelPrepare) { + song.cancelPrepare('Song removed.'); + } + } + + this.songs.splice(pos, cnt); + + if (pos <= player.curQueuePos) { + if (pos + cnt >= player.curQueuePos) { + // removed now playing, change to first song after splice + player.curQueuePos = pos; + player.prepareSongs(); + } else { + // removed songs before now playing, update queue pos + player.curQueuePos -= cnt; + } + } else if (pos === player.curQueuePos + 1) { + // new next song, make sure it's prepared + player.prepareSongs(); + } + + callback(); +}; + From faf0f3daf0320d2d82eed7c6ea1672ca4f40a4a9 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 17:11:37 +0200 Subject: [PATCH 033/103] clarification --- playlist-wip.md | 1 + 1 file changed, 1 insertion(+) diff --git a/playlist-wip.md b/playlist-wip.md index d7eb236..a913b8e 100644 --- a/playlist-wip.md +++ b/playlist-wip.md @@ -3,6 +3,7 @@ ### Playlist - Generic list of songs that can be added to the playback queue. +- Can *not* be played directly, must *always* be added to playback queue first. - Core contains playlist management functionality for: - Creating playlists - Adding/removing songs to/from playlists From b8e05b13394d6976abafdd95fbfd8337e463bd0f Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 17:14:42 +0200 Subject: [PATCH 034/103] clarification --- playlist-wip.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/playlist-wip.md b/playlist-wip.md index a913b8e..cb44915 100644 --- a/playlist-wip.md +++ b/playlist-wip.md @@ -40,7 +40,7 @@ as __prequeue. - Implementing pre-queue functionality like this makes playlist handling code much much simpler. E.g. song preparing doesn't need to care about - a separate queue and playlist, it only needs to know about the one and - only playback queue. + a separate queue and playlist, it only needs to care about one array + which is the playback queue. - Plugins can restrict how the playback queue is managed, e.g. partyplay only allows appending into __prequeue. From 4831197c36f27c185656697fdd3853959585b5b3 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 17:18:17 +0200 Subject: [PATCH 035/103] clarification --- playlist-wip.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/playlist-wip.md b/playlist-wip.md index cb44915..ec9fd56 100644 --- a/playlist-wip.md +++ b/playlist-wip.md @@ -30,8 +30,8 @@ of the playback queue. Let's call this the 'pre-queue'. - This is done by having the client reserve a certain playlist name for the pre-queue, say '__prequeue'. - - The client checks the current playback queue at the current playback - position. + - Pre-queuing songs works like this: The client checks the current + playback queue at the current playback position. - If the current song was added from a playlist '__prequeue', find the first song after it that is not part of __prequeue, insert song before it. @@ -40,7 +40,7 @@ as __prequeue. - Implementing pre-queue functionality like this makes playlist handling code much much simpler. E.g. song preparing doesn't need to care about - a separate queue and playlist, it only needs to care about one array + a separate queue and playlists, it only needs to care about one array which is the playback queue. - Plugins can restrict how the playback queue is managed, e.g. partyplay only allows appending into __prequeue. From 35db869594019484bb31a116a9c00a158b57379c Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 21:10:05 +0200 Subject: [PATCH 036/103] WIP: refactor and document, comment out tests for now --- lib/player.js | 132 ++------------------------------------------------ lib/queue.js | 120 ++++++++++++++++++++++++++++++++------------- test/test.js | 2 + 3 files changed, 93 insertions(+), 161 deletions(-) diff --git a/lib/player.js b/lib/player.js index da1702f..25e0d51 100644 --- a/lib/player.js +++ b/lib/player.js @@ -3,6 +3,7 @@ var _ = require('underscore'); var async = require('async'); var labeledLogger = require('./logger'); var uuid = require('node-uuid'); +var Queue = require('./queue'); function Player(options) { options = options || {}; @@ -11,7 +12,7 @@ function Player(options) { _.bindAll.apply(_, [this].concat(_.functions(this))); this.config = options.config || require('./config').getConfig(); this.logger = options.logger || labeledLogger('core'); - this.playlist = options.playlist || []; + this.queue = options.queue || new Queue(this.callHooks); this.curPlaylistPos = options.curPlaylistPos || -1; this.plugins = options.plugins || {}; this.backends = options.backends || {}; @@ -60,9 +61,9 @@ Player.prototype.numHooks = function(hook) { Player.prototype.endOfSong = function() { var player = this; - var np = player.playlist[player.curPlaylistPos]; + var np = player.queue.getNowPlaying(); - if (player.curPlaylistPos === playlist.length - 1) { + if (player.curPlaylistPos === player.queue.getLength() - 1) { // end of playlist player.curPlaylistPos = -1; } else { @@ -420,131 +421,6 @@ Player.prototype.searchBackends = function(query, callback) { }, this); }; -// get rid of song in queue -// cnt can be left out for deleting only one song -Player.prototype.removeFromQueue = function(pos, cnt, onlyRemove) { - var retval = []; - if (!cnt) { - cnt = 1; - } - pos = Math.max(0, parseInt(pos)); - - if (!onlyRemove) { - this.callHooks('preSongsRemoved', [pos, cnt]); - } - - // remove songs from queue - if (pos + cnt > 0) { - if (this.queue.length) { - // stop preparing songs we are about to remove - // we want to limit this to this.queue.length if cnt is very large - for (var i = 0; i < Math.min(this.queue.length, pos + cnt); i++) { - var song = this.queue[i]; - - // signal prepareError function not to run removeFromQueue again - // TODO: try getting rid of this ugly hack (beingDeleted)... - // TODO: more non enumerable properties, especially plugins? - /* - Object.defineProperty(song, 'beingDeleted', { - enumerable: false, - writable: true - }); - */ - - song.beingDeleted = true; - if (song.cancelPrepare) { - song.cancelPrepare('song deleted'); - delete(song.cancelPrepare); - } - } - - retval = this.queue.splice(pos, cnt); - - if (pos === 0) { - // now playing was deleted - this.playbackPosition = null; - this.playbackStart = null; - clearTimeout(this.songEndTimeout); - this.songEndTimeout = null; - } - } - } - - if (!onlyRemove) { - this.refreshPlaylist(); - this.callHooks('postSongsRemoved', [pos, cnt]); - } - - return retval; -}; - -Player.prototype.moveInQueue = function(from, to, cnt) { - if (!cnt || cnt < 1) { - cnt = 1; - } - if (from < 0 || from + cnt > this.queue.length || to + cnt > this.queue.length) { - return null; - } - - this.callHooks('preSongsMoved', [from, to, cnt]); - - var songs = this.removeFromQueue(from, cnt, true); - Array.prototype.splice.apply(this.queue, [to, 0].concat(songs)); - - this.callHooks('sortQueue'); - this.refreshPlaylist(); - this.callHooks('postSongsMoved', [songs, from, to, cnt]); - - return songs; -}; - -// add songs to the queue, at optional position -Player.prototype.addToQueue = function(songs, pos) { - if (!pos) { - pos = this.queue.length; - } - if (pos < 0) { - pos = 1; - } - pos = Math.min(pos, this.queue.length); - - this.callHooks('preSongsQueued', [songs, pos]); - _.each(songs, function(song) { - // check that required fields are provided - if (!song.title || !song.songID || !song.backendName || !song.duration) { - this.logger.info('required song fields not provided: ' + song.songID); - return; - //return 'required song fields not provided'; // TODO: this ain't gonna work - } - - var err = this.callHooks('preSongQueued', [song]); - if (err) { - this.logger.error('not adding song to queue: ' + err); - } else { - song.timeAdded = new Date().getTime(); - - this.queue.splice(pos++, 0, song); - this.logger.info('added song to queue: ' + song.songID); - this.callHooks('postSongQueued', [song]); - } - }, this); - - this.callHooks('sortQueue'); - this.refreshPlaylist(); - this.callHooks('postSongsQueued', [songs, pos]); -}; - -// TODO: this can't be undone :( -Player.prototype.shuffleQueue = function() { - // don't change now playing - var temp = this.playlist.shift(); - this.playlist = _.shuffle(this.playlist); - this.playlist.unshift(temp); - - this.callHooks('onQueueShuffled', [this.playlist]); - this.refreshPlaylist(); -}; - // cnt can be negative to go back or zero to restart current song Player.prototype.skipSongs = function(cnt) { var player = this; diff --git a/lib/queue.js b/lib/queue.js index f49b990..5571354 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -1,20 +1,30 @@ var _ = require('underscore'); -function Queue(modifyCallback) { - if (!modifyCallback) { - return new Error('Queue constructor called without a modifyCallback!'); +/** + * Constructor + * @param {Function} modifyCallback - Called whenever the queue is modified + * @returns {Error} - in case of errors + */ +function Queue(callHooks) { + if (!callHooks || !_.isFunction(callHooks)) { + return new Error('Queue constructor called without a callHooks function!'); } + this.unshuffledSongs = undefined; this.songs = []; this.curQueuePos = -1; this.playbackStart = -1; this.playbackPosition = -1; - this.modifyCallback = modifyCallback; + this.callHooks = callHooks; } +// TODO: hooks +// TODO: moveSongs + /** * Find index of song in queue * @param {String} at - Look for song with this UUID + * @returns {Number} - Index of song, -1 if not found */ Queue.prototype.findSongIndex = function(at) { return _.findIndex(this.songs, function(song) { @@ -25,6 +35,7 @@ Queue.prototype.findSongIndex = function(at) { /** * Find song in queue * @param {String} at - Look for song with this UUID + * @returns {Song|undefined} - Song object, undefined if not found */ Queue.prototype.findSong = function(at) { return _.find(this.songs, function(song) { @@ -34,30 +45,38 @@ Queue.prototype.findSong = function(at) { /** * Returns currently playing song + * @returns {Song|undefined} - Song object, undefined if no now playing song */ Queue.prototype.getNowPlaying = function() { return this.songs[this.curQueuePos]; }; +/** + * Returns queue length + * @returns {Number} - Queue length + */ +Queue.prototype.getLength = function() { + return this.songs.length; +}; + /** * Insert songs into queue * @param {String} at - Insert songs after song with this UUID * (-1 = start of queue) * @param {Object[]} songs - List of songs to insert + * @return {Error} - in case of errors */ -Queue.prototype.insertSongs = function(at, songs, callback) { - var player = this; - +Queue.prototype.insertSongs = function(at, songs) { var pos; if (at === '-1') { // insert at start of queue pos = 0; } else { // insert song after song with UUID - pos = player.findSongPos(at); + pos = this.findSongPos(at); if (pos < 0) { - return callback(new Error('Song with UUID ' + at + ' not found!')); + return new Error('Song with UUID ' + at + ' not found!'); } pos++; // insert after song @@ -72,33 +91,30 @@ Queue.prototype.insertSongs = function(at, songs, callback) { var args = [pos, 0].concat(songs); Array.prototype.splice.apply(this.songs, args); - // sync player state with changes - if (player.curQueuePos === -1) { + // sync queue & player state with changes + if (this.curQueuePos === -1) { // queue was empty, start playing first song - player.curQueuePos++; - player.prepareSongs(); - } else if (pos <= player.curQueuePos) { + this.curQueuePos++; + this.callHooks('queueModified'); + } else if (pos <= this.curQueuePos) { // songs inserted before curQueuePos, increment it - player.curQueuePos += songs.length; - } else if (pos === player.curQueuePos + 1) { + this.curQueuePos += songs.length; + } else if (pos === this.curQueuePos + 1) { // new next song, prepare it - player.prepareSongs(); + this.callHooks('queueModified'); } - - callback(); }; /** * Removes songs from queue * @param {String} at - Start removing at song with this UUID * @param {Number} cnt - Number of songs to delete + * @return {Song[] | Error} - List of removed songs, Error in case of errors */ -Queue.prototype.removeSongs = function(at, cnt, callback) { - var player = this; - - var pos = player.findSongPos(at); +Queue.prototype.removeSongs = function(at, cnt) { + var pos = this.findSongPos(at); if (pos < 0) { - return callback(new Error('Song with UUID ' + at + ' not found!')); + return new Error('Song with UUID ' + at + ' not found!'); } // cancel preparing all songs to be deleted @@ -109,22 +125,60 @@ Queue.prototype.removeSongs = function(at, cnt, callback) { } } - this.songs.splice(pos, cnt); + var removed = this.songs.splice(pos, cnt); - if (pos <= player.curQueuePos) { - if (pos + cnt >= player.curQueuePos) { + if (pos <= this.curQueuePos) { + if (pos + cnt >= this.curQueuePos) { // removed now playing, change to first song after splice - player.curQueuePos = pos; - player.prepareSongs(); + this.curQueuePos = pos; + this.callHooks('queueModified'); } else { // removed songs before now playing, update queue pos - player.curQueuePos -= cnt; + this.curQueuePos -= cnt; } - } else if (pos === player.curQueuePos + 1) { + } else if (pos === this.curQueuePos + 1) { // new next song, make sure it's prepared - player.prepareSongs(); + this.callHooks('queueModified'); + } + + return removed; +}; + +/** + * Toggle queue shuffling + */ +Queue.prototype.shuffle = function() { + var nowPlaying; + + if (this.unshuffledSongs) { + // unshuffle + + // store now playing + nowPlaying = this.getNowPlaying(); + + // restore unshuffled list + this.songs = this.unshuffledSongs; + + // find new now playing index by UUID, update curQueuePos + this.curQueuePos = this.findSongIndex(nowPlaying.uuid); + + this.unshuffledSongs = undefined; + } else { + // shuffle + + // store copy of current songs array + this.unshuffledSongs = this.songs.slice(); + + // store now playing + nowPlaying = this.songs.splice(this.curQueuePos, 1); + + this.songs = _.shuffle(this.songs); + + // re-insert now playing + this.songs.splice(this.curQueuePos, 0, nowPlaying); } - callback(); + this.callHooks('queueModified'); }; +module.exports = Queue; diff --git a/test/test.js b/test/test.js index 51437f9..44855ca 100644 --- a/test/test.js +++ b/test/test.js @@ -53,6 +53,7 @@ describe('Player', function() { }); }); + /* describe('#skipSongs()', function() { var player; var playedQueueSize = 3; // TODO: better handling of config variables here @@ -558,4 +559,5 @@ describe('Player', function() { player.queue.should.deep.equal(exampleQueue); }); }); + */ }); From 3c6036f3fde083ab5652b6280c5876fca2916a8c Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 26 Nov 2015 21:25:18 +0200 Subject: [PATCH 037/103] s/curPlaylistPos/queue.curQueuePos --- lib/player.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/player.js b/lib/player.js index 25e0d51..63aacf3 100644 --- a/lib/player.js +++ b/lib/player.js @@ -63,11 +63,11 @@ Player.prototype.endOfSong = function() { var player = this; var np = player.queue.getNowPlaying(); - if (player.curPlaylistPos === player.queue.getLength() - 1) { + if (player.queue.curQueuePos === player.queue.getLength() - 1) { // end of playlist - player.curPlaylistPos = -1; + player.queue.curQueuePos = -1; } else { - player.curPlaylistPos++; + player.queue.curQueuePos++; } player.logger.info('end of song ' + np.songID); From 5c54ab8c42e41bc5e2ef83f6bebee48bdc84cd74 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 27 Nov 2015 15:16:11 +0200 Subject: [PATCH 038/103] renames --- lib/player.js | 124 ++++++++++++++++++++++++-------------------------- 1 file changed, 60 insertions(+), 64 deletions(-) diff --git a/lib/player.js b/lib/player.js index 63aacf3..47ddc91 100644 --- a/lib/player.js +++ b/lib/player.js @@ -13,15 +13,11 @@ function Player(options) { this.config = options.config || require('./config').getConfig(); this.logger = options.logger || labeledLogger('core'); this.queue = options.queue || new Queue(this.callHooks); - this.curPlaylistPos = options.curPlaylistPos || -1; this.plugins = options.plugins || {}; this.backends = options.backends || {}; this.songsPreparing = options.songsPreparing || {}; this.volume = options.volume || 1; this.songEndTimeout = options.songEndTimeout || null; - this.playbackState = { - // TODO: move playbackStart, playbackPosition etc here - }; } // call hook function in all modules @@ -61,22 +57,22 @@ Player.prototype.numHooks = function(hook) { Player.prototype.endOfSong = function() { var player = this; - var np = player.queue.getNowPlaying(); + var np = this.queue.getNowPlaying(); - if (player.queue.curQueuePos === player.queue.getLength() - 1) { + if (this.queue.curQueuePos === this.queue.getLength() - 1) { // end of playlist - player.queue.curQueuePos = -1; + this.queue.curQueuePos = -1; } else { - player.queue.curQueuePos++; + this.queue.curQueuePos++; } - player.logger.info('end of song ' + np.songID); - player.callHooks('onSongEnd', [np]); + this.logger.info('end of song ' + np.songID); + this.callHooks('onSongEnd', [np]); - player.playbackPosition = null; - player.playbackStart = null; - player.songEndTimeout = null; - player.prepareSongs(); + this.queue.playbackPosition = null; + this.queue.playbackStart = null; + this.queue.songEndTimeout = null; + this.prepareSongs(); }; // start or resume playback of now playing song. @@ -84,54 +80,54 @@ Player.prototype.endOfSong = function() { Player.prototype.startPlayback = function(pos) { var player = this; - var np = player.getNowPlaying(); + var np = this.queue.getNowPlaying(); if (!np) { - player.logger.verbose('startPlayback called, but no song at curPlaylistPos'); + this.logger.verbose('startPlayback called, but no song at curPlaylistPos'); return; } if (!_.isUndefined(pos) && !_.isNull(pos)) { - player.logger.info('playing song: ' + np.songID + ', from pos: ' + pos); + this.logger.info('playing song: ' + np.songID + ', from pos: ' + pos); } else { - player.logger.info('playing song: ' + np.songID); + this.logger.info('playing song: ' + np.songID); } - var oldPlaybackStart = player.playbackStart; - player.playbackStart = new Date().getTime(); // song is playing while this is truthy + var oldPlaybackStart = this.queue.playbackStart; + this.queue.playbackStart = new Date().getTime(); // song is playing while this is truthy // where did the song start playing from at playbackStart? if (!_.isUndefined(pos) && !_.isNull(pos)) { - player.playbackPosition = pos; - } else if (!player.playbackPosition) { - player.playbackPosition = 0; + this.queue.playbackPosition = pos; + } else if (!this.queue.playbackPosition) { + this.queue.playbackPosition = 0; } if (oldPlaybackStart) { - player.callHooks('onSongSeek', [np]); + this.callHooks('onSongSeek', [np]); } else { - player.callHooks('onSongChange', [np]); + this.callHooks('onSongChange', [np]); } - var durationLeft = parseInt(np.duration) - player.playbackPosition + player.config.songDelayMs; - if (player.songEndTimeout) { - player.logger.debug('songEndTimeout was cleared'); - clearTimeout(player.songEndTimeout); - player.songEndTimeout = null; + var durationLeft = parseInt(np.duration) - this.queue.playbackPosition + this.config.songDelayMs; + if (this.songEndTimeout) { + this.logger.debug('songEndTimeout was cleared'); + clearTimeout(this.songEndTimeout); + this.songEndTimeout = null; } - player.songEndTimeout = setTimeout(player.endOfSong, durationLeft); + this.songEndTimeout = setTimeout(this.queue.endOfSong, durationLeft); }; Player.prototype.pausePlayback = function() { var player = this; // update position - player.playbackPosition += new Date().getTime() - player.playbackStart; - player.playbackStart = null; + player.queue.playbackPosition += new Date().getTime() - player.queue.playbackStart; + player.queue.playbackStart = null; clearTimeout(player.songEndTimeout); player.songEndTimeout = null; - player.callHooks('onSongPause', [player.nowPlaying]); + player.callHooks('onSongPause', [player.queue.getNowPlaying()]); }; // TODO: proper song object with constructor? @@ -146,7 +142,7 @@ Player.prototype.setPrepareTimeout = function(song) { player.logger.info('prepare timeout for song: ' + song.songID + ', removing'); song.cancelPrepare('prepare timeout'); song.prepareTimeout = null; - }, player.config.songPrepareTimeout); + }, this.config.songPrepareTimeout); Object.defineProperty(song, 'prepareTimeout', { enumerable: false, @@ -189,9 +185,9 @@ Player.prototype.prepareProgCallback = function(song, newData, done, callback) { // start playback if it hasn't been started yet // TODO: not if paused - if (player.getNowPlaying() && - player.getNowPlaying().uuid === song.uuid && - !player.playbackStart && newData) { + if (this.queue.getNowPlaying() && + this.queue.getNowPlaying().uuid === song.uuid && + !this.queue.playbackStart && newData) { player.startPlayback(); } @@ -248,36 +244,36 @@ Player.prototype.prepareSong = function(song, callback) { if (!song) { return callback(new Error('prepareSong() without song')); } - if (!player.backends[song.backendName]) { + if (!this.backends[song.backendName]) { return callback(new Error('prepareSong() without unknown backend: ' + song.backendName)); } - if (player.backends[song.backendName].isPrepared(song)) { + if (this.backends[song.backendName].isPrepared(song)) { // start playback if it hasn't been started yet // TODO: not if paused - if (player.getNowPlaying() && - player.getNowPlaying().uuid === song.uuid && - !player.playbackStart) { - player.startPlayback(); + if (this.queue.getNowPlaying() && + this.queue.getNowPlaying().uuid === song.uuid && + !this.queue.playbackStart) { + this.startPlayback(); } // song is already prepared, ok to prepare more songs callback(); - } else if (player.songsPreparing[song.backendName][song.songID]) { + } else if (this.songsPreparing[song.backendName][song.songID]) { // this song is still preparing, so don't yet prepare next song callback(true); } else { // song is not prepared and not currently preparing: let backend prepare it - player.logger.debug('DEBUG: prepareSong() ' + song.songID); - player.songsPreparing[song.backendName][song.songID] = song; + this.logger.debug('DEBUG: prepareSong() ' + song.songID); + this.songsPreparing[song.backendName][song.songID] = song; - song.cancelPrepare = player.backends[song.backendName].prepareSong( + song.cancelPrepare = this.backends[song.backendName].prepareSong( song, - _.partial(player.prepareProgCallback, _, _, _, callback), - _.partial(player.prepareErrCallback, _, _, callback) + _.partial(this.prepareProgCallback, _, _, _, callback), + _.partial(this.prepareErrCallback, _, _, callback) ); - player.setPrepareTimeout(song); + this.setPrepareTimeout(song); } }; @@ -290,7 +286,7 @@ Player.prototype.prepareSongs = function() { async.series([ function(callback) { // prepare now-playing song - var song = player.playlist[player.curPlaylistPos]; + var song = player.queue.getNowPlaying(); if (song) { player.prepareSong(song, callback); } else { @@ -300,7 +296,7 @@ Player.prototype.prepareSongs = function() { }, function(callback) { // prepare next song in playlist - var song = player.playlist[player.curPlaylistPos + 1]; + var song = player.queue.songs[player.queue.curQueuePos + 1]; if (song) { player.prepareSong(song, callback); } else { @@ -316,7 +312,7 @@ Player.prototype.getPlaylists = function(callback) { var allResults = {}; var player = this; - _.each(player.backends, function(backend) { + _.each(this.backends, function(backend) { if (!backend.getPlaylists) { resultCnt++; @@ -340,6 +336,7 @@ Player.prototype.getPlaylists = function(callback) { }); }; +/* Player.prototype.replacePlaylist = function(backendName, playlistId, callback) { var player = this; @@ -380,6 +377,7 @@ Player.prototype.replacePlaylist = function(backendName, playlistId, callback) { player.playlist = playlist; }); }; +*/ // make a search query to backends Player.prototype.searchBackends = function(query, callback) { @@ -425,22 +423,20 @@ Player.prototype.searchBackends = function(query, callback) { Player.prototype.skipSongs = function(cnt) { var player = this; - player.curPlaylistPos = Math.min(player.playlist.length, player.curPlaylistPos + cnt); + this.queue.curQueuePos = Math.min(this.queue.length, this.queue.curQueuePos + cnt); - player.playbackPosition = null; - player.playbackStart = null; - clearTimeout(player.songEndTimeout); - player.songEndTimeout = null; - player.prepareSongs(); + this.queue.playbackPosition = null; + this.queue.playbackStart = null; + clearTimeout(this.songEndTimeout); + this.songEndTimeout = null; + this.prepareSongs(); }; // TODO: userID does not belong into core...? Player.prototype.setVolume = function(newVol, userID) { - var player = this; - newVol = Math.min(1, Math.max(0, newVol)); - player.volume = newVol; - player.callHooks('onVolumeChange', [newVol, userID]); + this.volume = newVol; + this.callHooks('onVolumeChange', [newVol, userID]); }; module.exports = Player; From 7994bf234a98fa2c5c0216217fc8623ee6691fb4 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 27 Nov 2015 15:24:42 +0200 Subject: [PATCH 039/103] tests are passing --- lib/player.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/player.js b/lib/player.js index 47ddc91..c2c9405 100644 --- a/lib/player.js +++ b/lib/player.js @@ -109,7 +109,9 @@ Player.prototype.startPlayback = function(pos) { this.callHooks('onSongChange', [np]); } - var durationLeft = parseInt(np.duration) - this.queue.playbackPosition + this.config.songDelayMs; + var durationLeft = parseInt(np.duration) - + this.queue.playbackPosition + this.config.songDelayMs; + if (this.songEndTimeout) { this.logger.debug('songEndTimeout was cleared'); clearTimeout(this.songEndTimeout); @@ -188,7 +190,7 @@ Player.prototype.prepareProgCallback = function(song, newData, done, callback) { if (this.queue.getNowPlaying() && this.queue.getNowPlaying().uuid === song.uuid && !this.queue.playbackStart && newData) { - player.startPlayback(); + this.startPlayback(); } // tell plugins that new data is available for this song, and From 41ed3096fae4741a88603e51f64f4b6c76f33098 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sat, 28 Nov 2015 21:53:53 +0200 Subject: [PATCH 040/103] move playback vars into player --- lib/player.js | 16 +++++++++++ lib/queue.js | 76 ++++++++++++++++++--------------------------------- 2 files changed, 43 insertions(+), 49 deletions(-) diff --git a/lib/player.js b/lib/player.js index c2c9405..b20237e 100644 --- a/lib/player.js +++ b/lib/player.js @@ -13,6 +13,7 @@ function Player(options) { this.config = options.config || require('./config').getConfig(); this.logger = options.logger || labeledLogger('core'); this.queue = options.queue || new Queue(this.callHooks); + this.nowPlaying = options.nowPlaying || null; this.plugins = options.plugins || {}; this.backends = options.backends || {}; this.songsPreparing = options.songsPreparing || {}; @@ -55,6 +56,21 @@ Player.prototype.numHooks = function(hook) { return cnt; }; +/** + * Returns currently playing song + * @returns {Song|null} - Song object, null if no now playing song + */ +Player.prototype.getNowPlaying = function() { + return this.nowPlaying ? this.queue.findSong(this.nowPlaying.uuid) : null; +}; + +/** + * Change to song + * @param {String} uuid - UUID of song to change to, if not found in queue, now playing is removed + */ +Player.prototype.changeSong = function() { +}; + Player.prototype.endOfSong = function() { var player = this; var np = this.queue.getNowPlaying(); diff --git a/lib/queue.js b/lib/queue.js index 5571354..0943b79 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -2,20 +2,17 @@ var _ = require('underscore'); /** * Constructor - * @param {Function} modifyCallback - Called whenever the queue is modified + * @param {Player} player - Parent player object reference * @returns {Error} - in case of errors */ -function Queue(callHooks) { - if (!callHooks || !_.isFunction(callHooks)) { - return new Error('Queue constructor called without a callHooks function!'); +function Queue(player) { + if (!player || !_.isObject(player)) { + return new Error('Queue constructor called without player reference!'); } - this.unshuffledSongs = undefined; + this.unshuffledSongs = null; this.songs = []; - this.curQueuePos = -1; - this.playbackStart = -1; - this.playbackPosition = -1; - this.callHooks = callHooks; + this.player = player; } // TODO: hooks @@ -35,7 +32,7 @@ Queue.prototype.findSongIndex = function(at) { /** * Find song in queue * @param {String} at - Look for song with this UUID - * @returns {Song|undefined} - Song object, undefined if not found + * @returns {Song|null} - Song object, null if not found */ Queue.prototype.findSong = function(at) { return _.find(this.songs, function(song) { @@ -43,14 +40,6 @@ Queue.prototype.findSong = function(at) { }); }; -/** - * Returns currently playing song - * @returns {Song|undefined} - Song object, undefined if no now playing song - */ -Queue.prototype.getNowPlaying = function() { - return this.songs[this.curQueuePos]; -}; - /** * Returns queue length * @returns {Number} - Queue length @@ -61,19 +50,19 @@ Queue.prototype.getLength = function() { /** * Insert songs into queue - * @param {String} at - Insert songs after song with this UUID - * (-1 = start of queue) + * @param {String | null} at - Insert songs after song with this UUID + * (null = start of queue) * @param {Object[]} songs - List of songs to insert * @return {Error} - in case of errors */ Queue.prototype.insertSongs = function(at, songs) { var pos; - if (at === '-1') { + if (at === null) { // insert at start of queue pos = 0; } else { // insert song after song with UUID - pos = this.findSongPos(at); + pos = this.findSongIndex(at); if (pos < 0) { return new Error('Song with UUID ' + at + ' not found!'); @@ -91,18 +80,7 @@ Queue.prototype.insertSongs = function(at, songs) { var args = [pos, 0].concat(songs); Array.prototype.splice.apply(this.songs, args); - // sync queue & player state with changes - if (this.curQueuePos === -1) { - // queue was empty, start playing first song - this.curQueuePos++; - this.callHooks('queueModified'); - } else if (pos <= this.curQueuePos) { - // songs inserted before curQueuePos, increment it - this.curQueuePos += songs.length; - } else if (pos === this.curQueuePos + 1) { - // new next song, prepare it - this.callHooks('queueModified'); - } + this.player.prepareSongs(); }; /** @@ -112,7 +90,7 @@ Queue.prototype.insertSongs = function(at, songs) { * @return {Song[] | Error} - List of removed songs, Error in case of errors */ Queue.prototype.removeSongs = function(at, cnt) { - var pos = this.findSongPos(at); + var pos = this.findSongIndex(at); if (pos < 0) { return new Error('Song with UUID ' + at + ' not found!'); } @@ -125,20 +103,20 @@ Queue.prototype.removeSongs = function(at, cnt) { } } + // store index of now playing song + var np = this.player.nowPlaying; + var npIndex = np ? this.findSongIndex(np.uuid) : -1; + + // perform deletion var removed = this.songs.splice(pos, cnt); - if (pos <= this.curQueuePos) { - if (pos + cnt >= this.curQueuePos) { - // removed now playing, change to first song after splice - this.curQueuePos = pos; - this.callHooks('queueModified'); - } else { - // removed songs before now playing, update queue pos - this.curQueuePos -= cnt; - } - } else if (pos === this.curQueuePos + 1) { - // new next song, make sure it's prepared - this.callHooks('queueModified'); + // was now playing removed? + if (pos <= npIndex && pos + cnt >= npIndex) { + // change to first song after splice + var newNp = this.songs[pos]; + this.player.changeSong(newNp ? newNp.uuid : null); + } else { + this.player.prepareSongs(); } return removed; @@ -162,7 +140,7 @@ Queue.prototype.shuffle = function() { // find new now playing index by UUID, update curQueuePos this.curQueuePos = this.findSongIndex(nowPlaying.uuid); - this.unshuffledSongs = undefined; + this.unshuffledSongs = null; } else { // shuffle @@ -178,7 +156,7 @@ Queue.prototype.shuffle = function() { this.songs.splice(this.curQueuePos, 0, nowPlaying); } - this.callHooks('queueModified'); + this.player.prepareSongs(); }; module.exports = Queue; From 6e2cdc0011383464d337cc845a567495fe4d4810 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sun, 29 Nov 2015 02:21:11 +0200 Subject: [PATCH 041/103] split backend and song objects into own modules & constructors --- lib/backend.js | 24 ++++++++++++ lib/player.js | 101 +++++++++++++++++++++++++++++++++++++++---------- lib/queue.js | 24 ++++++------ lib/song.js | 91 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 32 deletions(-) create mode 100644 lib/backend.js create mode 100644 lib/song.js diff --git a/lib/backend.js b/lib/backend.js new file mode 100644 index 0000000..514fe9c --- /dev/null +++ b/lib/backend.js @@ -0,0 +1,24 @@ +var _ = require('underscore'); + +/** + * Constructor + * @param {String} name - Name of backend plugin that provides songs to nodeplayer + * @param {Function} callback - Called once the backend has loaded + */ + +function Backend(name, callback) { + this.name = name; + this.songsPreparing = []; + this.plugin = require('nodeplayer-backend-' + name); +}; + +/** + * Synchronously(!) returns whether the song with songId is prepared or not + * @param {String} songId - Backend identifies song by this ID + * @returns {Boolean} - true if song is prepared, false if not + */ +Backend.prototype.songPrepared = function(songId) { + return this.plugin.songPrepared(songId); +}; + +module.exports = Backend; diff --git a/lib/player.js b/lib/player.js index b20237e..f417905 100644 --- a/lib/player.js +++ b/lib/player.js @@ -2,7 +2,6 @@ var _ = require('underscore'); var async = require('async'); var labeledLogger = require('./logger'); -var uuid = require('node-uuid'); var Queue = require('./queue'); function Player(options) { @@ -12,8 +11,10 @@ function Player(options) { _.bindAll.apply(_, [this].concat(_.functions(this))); this.config = options.config || require('./config').getConfig(); this.logger = options.logger || labeledLogger('core'); - this.queue = options.queue || new Queue(this.callHooks); + this.queue = options.queue || new Queue(this); this.nowPlaying = options.nowPlaying || null; + this.play = options.play || false; + this.repeat = options.repeat || false; this.plugins = options.plugins || {}; this.backends = options.backends || {}; this.songsPreparing = options.songsPreparing || {}; @@ -64,30 +65,93 @@ Player.prototype.getNowPlaying = function() { return this.nowPlaying ? this.queue.findSong(this.nowPlaying.uuid) : null; }; +// TODO: handling of pause in a good way? +/** + * Stop playback of current song + * @param {Boolean} [pause=false] - If true, don't reset song position + */ +Player.prototype.stopPlayback = function(pause) { + this.logger.info('playback ' + (pause ? 'paused.' : 'stopped.')); + this.play = false; + + if (this.nowPlaying) { + this.nowPlaying.playback = { + startTime: 0, + startPos: pause ? position : 0 + }; + } +}; + +/** + * Start playing now playing song, at optional position + * @param {Number} [position=0] - Position at which playback is started + */ +Player.prototype.startPlayback = function(position) { + position = position || 0; + + if (!this.nowPlaying) { + // find first song in queue + this.nowPlaying = this.queue.songs[0]; + + if (!this.nowPlaying) { + return this.logger.error('queue is empty! not starting playback.'); + } + } + + this.nowPlaying.prepare(function(err) { + if (err) { + return this.logger.error('error while preparing now playing: ' + err); + } + + this.nowPlaying.playback = { + startTime: new Date(), + startPos: position + }; + + this.logger.info('playback started.'); + this.play = true; + }); +}; + /** * Change to song - * @param {String} uuid - UUID of song to change to, if not found in queue, now playing is removed + * @param {String} uuid - UUID of song to change to, if not found in queue, now + * playing is removed, playback stopped */ -Player.prototype.changeSong = function() { +Player.prototype.changeSong = function(uuid) { + this.logger.verbose('changing song to: ' + uuid); + clearTimeout(this.songEndTimeout); + + this.nowPlaying = this.queue.findSong(uuid); + + if (!this.nowPlaying) { + this.logger.info('song not found: ' + uuid); + this.stopPlayback(); + } + + this.startPlayback(); + this.logger.info('changed song to: ' + uuid); }; -Player.prototype.endOfSong = function() { - var player = this; +Player.prototype.songEnd = function() { var np = this.queue.getNowPlaying(); + var npIndex = ? np ? this.queue.findSongIndex(np.uuid) : -1; + + this.logger.info('end of song ' + np.uuid); + this.callHooks('onSongEnd', [np]); - if (this.queue.curQueuePos === this.queue.getLength() - 1) { - // end of playlist - this.queue.curQueuePos = -1; + var nextSong = this.queue.songs[npIndex + 1]; + if (!nextSong) { + this.logger.info('hit end of queue.'); + + if (this.repeat) { + this.logger.info('repeat is on, restarting playback from start of queue.'); + this.changeSong(this.queue.uuidAtIndex(0)); + } } else { - this.queue.curQueuePos++; + this.changeSong(nextSong.uuid); } - this.logger.info('end of song ' + np.songID); - this.callHooks('onSongEnd', [np]); - - this.queue.playbackPosition = null; - this.queue.playbackStart = null; - this.queue.songEndTimeout = null; this.prepareSongs(); }; @@ -257,8 +321,6 @@ Player.prototype.prepareErrCallback = function(song, err, callback) { }; Player.prototype.prepareSong = function(song, callback) { - var player = this; - if (!song) { return callback(new Error('prepareSong() without song')); } @@ -314,7 +376,8 @@ Player.prototype.prepareSongs = function() { }, function(callback) { // prepare next song in playlist - var song = player.queue.songs[player.queue.curQueuePos + 1]; + var np = player.queue.getNowPlaying(); + var song = player.queue.songs[player.queue.findSongIndex(np) + 1]; if (song) { player.prepareSong(song, callback); } else { diff --git a/lib/queue.js b/lib/queue.js index 0943b79..24617bd 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -37,7 +37,17 @@ Queue.prototype.findSongIndex = function(at) { Queue.prototype.findSong = function(at) { return _.find(this.songs, function(song) { return song.uuid === at; - }); + }) || null; +}; + +/** + * Find song UUID at given index + * @param {Number} index - Look for song at this index + * @returns {String|null} - UUID, null if not found + */ +Queue.prototype.uuidAtIndex = function(index) { + var song = this.songs[index]; + return song ? song.uuid : null; }; /** @@ -131,15 +141,9 @@ Queue.prototype.shuffle = function() { if (this.unshuffledSongs) { // unshuffle - // store now playing - nowPlaying = this.getNowPlaying(); - // restore unshuffled list this.songs = this.unshuffledSongs; - // find new now playing index by UUID, update curQueuePos - this.curQueuePos = this.findSongIndex(nowPlaying.uuid); - this.unshuffledSongs = null; } else { // shuffle @@ -147,13 +151,7 @@ Queue.prototype.shuffle = function() { // store copy of current songs array this.unshuffledSongs = this.songs.slice(); - // store now playing - nowPlaying = this.songs.splice(this.curQueuePos, 1); - this.songs = _.shuffle(this.songs); - - // re-insert now playing - this.songs.splice(this.curQueuePos, 0, nowPlaying); } this.player.prepareSongs(); diff --git a/lib/song.js b/lib/song.js new file mode 100644 index 0000000..42075ea --- /dev/null +++ b/lib/song.js @@ -0,0 +1,91 @@ +var _ = require('underscore'); +var uuid = require('node-uuid'); + +/** + * Constructor + * @param {Song} song - Song details + * @param {Backend} backend - Backend providing the audio + * @returns {Error} - in case of errors + */ +function Song(song, backend) { + // make sure we have a reference to backend + if (!backend || !_.isObject(backend)) { + return new Error('Song constructor called without backend!'); + } + + if (!song.duration || !_.isNumber(song.duration)) { + return new Error('Song constructor called without duration!'); + } + if (!song.title || !_.isString(song.title)) { + return new Error('Song constructor called without title!'); + } + if (!song.songId || !_.isString(song.songId)) { + return new Error('Song constructor called without songId!'); + } + if (!song.backendName || !_.isString(song.backendName)) { + return new Error('Song constructor called without backendName!'); + } + if (!song.score || !_.isNumber(song.score)) { + return new Error('Song constructor called without score!'); + } + if (!song.format || !_.isString(song.format)) { + return new Error('Song constructor called without format!'); + } + + this.uuid = uuid.v4(); + + this.title = song.title; + this.artist = song.artist; + this.album = song.album; + this.albumArt = { + lq: song.albumArt ? song.albumArt.lq : null, + hq: song.albumArt ? song.albumArt.hq : null + }; + this.duration = song.duration; + this.songId = song.songId; + this.score = song.score; + this.format = song.format; + + this.backend = song.backend; +} + +/** + * Return details of the song + * @returns {Song} - simplified Song object + */ +Song.prototype.details = function() { + return { + uuid: this.uuid, + title: this.title, + artist: this.artist, + album: this.album, + albumArt: this.albumArt, + duration: this.duration, + songId: this.songId, + score: this.score, + format: this.format, + backendName: this.backend.name + }; +}; + +/** + * Synchronously(!) returns whether the song is prepared or not + * @returns {Boolean} - true if song is prepared, false if not + */ +Song.prototype.isPrepared = function() { + return this.backend.songPrepared(this.songId); +}; + +/** + * Prepare song for playback + * @param {Function} callback - Called when song is ready or if an error occurred + */ +Song.prototype.prepare = function(callback) { + if (this.isPrepared()) { + callback(); + } else { + // TODO: move Player.prototype.prepareSong logic here + } +}; + +module.exports = Song; From 4de15267d377d5ae6932e1d66b1bc577b6fdd659 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sun, 29 Nov 2015 02:23:53 +0200 Subject: [PATCH 042/103] fix build --- lib/backend.js | 2 +- lib/player.js | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index 514fe9c..8743a03 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -10,7 +10,7 @@ function Backend(name, callback) { this.name = name; this.songsPreparing = []; this.plugin = require('nodeplayer-backend-' + name); -}; +} /** * Synchronously(!) returns whether the song with songId is prepared or not diff --git a/lib/player.js b/lib/player.js index f417905..ebfe65b 100644 --- a/lib/player.js +++ b/lib/player.js @@ -74,10 +74,11 @@ Player.prototype.stopPlayback = function(pause) { this.logger.info('playback ' + (pause ? 'paused.' : 'stopped.')); this.play = false; - if (this.nowPlaying) { - this.nowPlaying.playback = { + var np = this.nowPlaying; + if (np) { + np.playback = { startTime: 0, - startPos: pause ? position : 0 + startPos: pause ? np.startPos + (new Date().getTime() - np.startTime) : 0 }; } }; @@ -135,7 +136,7 @@ Player.prototype.changeSong = function(uuid) { Player.prototype.songEnd = function() { var np = this.queue.getNowPlaying(); - var npIndex = ? np ? this.queue.findSongIndex(np.uuid) : -1; + var npIndex = np ? this.queue.findSongIndex(np.uuid) : -1; this.logger.info('end of song ' + np.uuid); this.callHooks('onSongEnd', [np]); From 9083c19694a0b866ffb8a0b13c94a9b5ebfabfbc Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sat, 5 Dec 2015 15:43:09 +0200 Subject: [PATCH 043/103] insertion creates new Song objects --- lib/queue.js | 15 ++++++++++++--- lib/song.js | 13 ++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/queue.js b/lib/queue.js index 24617bd..166358b 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -1,4 +1,5 @@ var _ = require('underscore'); +var Song = require('./song'); /** * Constructor @@ -81,10 +82,18 @@ Queue.prototype.insertSongs = function(at, songs) { pos++; // insert after song } - // generate UUIDs for each song - _.each(songs, function(song) { - song.uuid = uuid.v4(); + // generate Song objects of each song + songs = _.map(songs, function(song) { + return new Song(song, this.backends[song.backendName]); + }, this); + + // make sure there were no errors while creating Song objects + var err = _.find(songs, function(song) { + return song instanceof Error; }); + if (err) { + return err; + } // perform insertion var args = [pos, 0].concat(songs); diff --git a/lib/song.js b/lib/song.js index 42075ea..f9dfa10 100644 --- a/lib/song.js +++ b/lib/song.js @@ -10,7 +10,7 @@ var uuid = require('node-uuid'); function Song(song, backend) { // make sure we have a reference to backend if (!backend || !_.isObject(backend)) { - return new Error('Song constructor called without backend!'); + return new Error('Song constructor called with invalid backend!'); } if (!song.duration || !_.isNumber(song.duration)) { @@ -22,9 +22,6 @@ function Song(song, backend) { if (!song.songId || !_.isString(song.songId)) { return new Error('Song constructor called without songId!'); } - if (!song.backendName || !_.isString(song.backendName)) { - return new Error('Song constructor called without backendName!'); - } if (!song.score || !_.isNumber(song.score)) { return new Error('Song constructor called without score!'); } @@ -46,7 +43,13 @@ function Song(song, backend) { this.score = song.score; this.format = song.format; - this.backend = song.backend; + // NOTE: internally to the Song we store a reference to the backend. + // However when accessing the Song from the outside, we return only the + // backend's name inside a backendName field. + // + // Any functions requiring access to the backend should be implemented as + // members of the Song (e.g. isPrepared, prepareSong) + this.backend = backend; } /** From 55cd94d9fb04ab1a3f8f6e4974ed106d563a032e Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sat, 5 Dec 2015 17:07:31 +0200 Subject: [PATCH 044/103] move plugin/backend init code into separate module --- bin/nodeplayer | 6 --- index.js | 102 +-------------------------------------------- lib/modules.js | 109 +++++++++++++++++++++++++++++++++++++++++++++++++ lib/player.js | 18 +++++++- 4 files changed, 127 insertions(+), 108 deletions(-) create mode 100644 lib/modules.js diff --git a/bin/nodeplayer b/bin/nodeplayer index 5966008..e38cba2 100755 --- a/bin/nodeplayer +++ b/bin/nodeplayer @@ -2,9 +2,3 @@ var argv = require('yargs').argv; var nodeplayer = require('../'); -var logger = nodeplayer.labeledLogger('core'); - -var core = new nodeplayer.Core(); -core.initModules(argv.u, function() { - logger.info('ready'); -}); diff --git a/index.js b/index.js index 0aca9d5..1b66de9 100644 --- a/index.js +++ b/index.js @@ -1,107 +1,7 @@ 'use strict'; -var _ = require('underscore'); -var npm = require('npm'); -var async = require('async'); -var labeledLogger = require('./lib/logger'); var Player = require('./lib/player'); var nodeplayerConfig = require('./lib/config'); -var config = nodeplayerConfig.getConfig(); -var logger = labeledLogger('core'); - -function Core() { - this.player = new Player(); -} - -Core.prototype.checkModule = function(module) { - try { - require.resolve(module); - return true; - } catch (e) { - return false; - } -}; - -Core.prototype.installModule = function(moduleName, callback) { - logger.info('installing module: ' + moduleName); - npm.load({}, function(err) { - npm.commands.install(__dirname, [moduleName], function(err) { - if (err) { - logger.error(moduleName + ' installation failed:', err); - callback(); - } else { - logger.info(moduleName + ' successfully installed'); - callback(); - } - }); - }); -}; - -// make sure all modules are installed, installs missing ones, then calls loadCallback -Core.prototype.installModules = function(modules, moduleType, update, loadCallback) { - async.eachSeries(modules, _.bind(function(moduleShortName, callback) { - var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; - if (!this.checkModule(moduleName) || update) { - // perform install / update - this.installModule(moduleName, callback); - } else { - // skip already installed - callback(); - } - }, this), loadCallback); -}; - -Core.prototype.initModule = function(moduleShortName, moduleType, callback) { - var moduleTypeCapital = moduleType.charAt(0).toUpperCase() + moduleType.slice(1); - var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; - var module = require(moduleName); - - var moduleLogger = labeledLogger(moduleShortName); - module.init(this.player, moduleLogger, _.bind(function(err) { - if (!err) { - this[moduleType + 's'][moduleShortName] = module; - if (moduleType === 'backend') { - this.songsPreparing[moduleShortName] = {}; - } - - moduleLogger.info(moduleType + ' module initialized'); - this.callHooks('on' + moduleTypeCapital + 'Initialized', [moduleShortName]); - } else { - moduleLogger.error('while initializing: ' + err); - this.callHooks('on' + moduleTypeCapital + 'InitError', [moduleShortName]); - } - callback(err); - }, this.player)); -}; - -Core.prototype.initModules = function(update, callback) { - async.eachSeries(['plugin', 'backend'], _.bind(function(moduleType, installCallback) { - // first install missing modules - this.installModules(config[moduleType + 's'], moduleType, update, installCallback); - }, this), _.bind(function() { - // then initialize modules, first all plugins in series, then all backends in parallel - async.eachSeries(['plugin', 'backend'], _.bind(function(moduleType, typeCallback) { - var moduleTypeCapital = moduleType.charAt(0).toUpperCase() + moduleType.slice(1); - - (moduleType === 'plugin' ? async.eachSeries : async.each) - (config[moduleType + 's'], _.bind(function(moduleName, moduleCallback) { - if (this.checkModule('nodeplayer-' + moduleType + '-' + moduleName)) { - this.initModule(moduleName, moduleType, moduleCallback); - } - }, this), _.bind(function(err) { - logger.info('all ' + moduleType + ' modules initialized'); - this.callHooks('on' + moduleTypeCapital + 'sInitialized'); - typeCallback(); - }, this.player)); - }, this), function() { - callback(); - }); - }, this)); -}; - -exports.Player = Player; -exports.labeledLogger = labeledLogger; +exports.player = new Player(); exports.config = nodeplayerConfig; - -exports.Core = Core; diff --git a/lib/modules.js b/lib/modules.js new file mode 100644 index 0000000..07eccff --- /dev/null +++ b/lib/modules.js @@ -0,0 +1,109 @@ +var npm = require('npm'); +var async = require('async'); +var labeledLogger = require('./logger'); +var nodeplayerConfig = require('./config'); +var config = nodeplayerConfig.getConfig(); + +var logger = labeledLogger('modules'); + +var checkModule = function(module) { + try { + require.resolve(module); + return true; + } catch (e) { + return false; + } +}; + +// install a single module +var installModule = function(moduleName, callback) { + logger.info('installing module: ' + moduleName); + npm.load({}, function(err) { + npm.commands.install(__dirname, [moduleName], function(err) { + if (err) { + logger.error(moduleName + ' installation failed:', err); + callback(); + } else { + logger.info(moduleName + ' successfully installed'); + callback(); + } + }); + }); +}; + +// make sure all modules are installed, installs missing ones, then calls done +var installModules = function(modules, moduleType, forceUpdate, done) { + async.eachSeries(modules, function(moduleShortName, callback) { + var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; + if (!checkModule(moduleName) || forceUpdate) { + // perform install / update + installModule(moduleName, callback); + } else { + // skip already installed + callback(); + } + }, done); +}; + +var initModule = function(moduleShortName, moduleType, callback) { + var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; + var module = require(moduleName); + + module.init(function(err) { + callback(err, module); + }); +}; + +exports.loadBackends = function(backends, forceUpdate, done) { + // first install missing backends + installModules(backends, 'backend', forceUpdate, function() { + // then initialize all backends in parallel + async.map(backends, function(backend, callback) { + var moduleLogger = labeledLogger(backend); + var moduleName = 'nodeplayer-backend-' + backend; + if(moduleName) { + require(moduleName).init(function(err) { + if (err) { + moduleLogger.error('while initializing: ' + err); + } + callback(); + }); + } else { + // skip module whose installation failed + moduleLogger.info('not loading backend: ' + backend); + callback(); + } + }, function(err) { + logger.info('all backend modules initialized'); + done(); + }); + }); +}; + +exports.loadPlugins = function(plugins, vars, forceUpdate, done) { + // first install missing plugins + installModules(plugins, 'plugin', forceUpdate, function() { + // then initialize all plugins in series + async.mapSeries(plugins, function(plugin, callback) { + var moduleLogger = labeledLogger(plugin); + var moduleName = 'nodeplayer-plugin-' + plugin; + if(checkModule(moduleName)) { + require(moduleName).init(vars, function(err) { + if (err) { + moduleLogger.error('while initializing: ' + err); + callback(); + } else { + callback(null, plugin); + } + }); + } else { + // skip module whose installation failed + moduleLogger.info('not loading plugin: ' + plugin); + callback(); + } + }, function(err, results) { + logger.info('all plugin modules initialized'); + done(results); + }); + }); +}; diff --git a/lib/player.js b/lib/player.js index ebfe65b..3d660d8 100644 --- a/lib/player.js +++ b/lib/player.js @@ -3,6 +3,7 @@ var _ = require('underscore'); var async = require('async'); var labeledLogger = require('./logger'); var Queue = require('./queue'); +var modules = require('./modules'); function Player(options) { options = options || {}; @@ -16,10 +17,25 @@ function Player(options) { this.play = options.play || false; this.repeat = options.repeat || false; this.plugins = options.plugins || {}; - this.backends = options.backends || {}; this.songsPreparing = options.songsPreparing || {}; this.volume = options.volume || 1; this.songEndTimeout = options.songEndTimeout || null; + this.pluginVars = options.pluginVars || {}; + + var player = this; + var config = player.config; + var forceUpdate = false; + + async.series([ + function(callback) { + modules.loadPlugins(config.plugins, player.pluginVars, forceUpdate, callback); + }, function(callback) { + modules.loadBackends(config.backends, forceUpdate, callback); + } + ], function() { + player.callHooks('onPluginsInitialized'); + player.callHooks('onBackendsInitialized'); + }); } // call hook function in all modules From 656df22b986bff05c32385e4c86658e6c1625ba7 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sat, 5 Dec 2015 17:17:36 +0200 Subject: [PATCH 045/103] only bin/nodeplayer starts a new Player() instance, be more verbose on start up --- bin/nodeplayer | 4 ++-- index.js | 4 +++- lib/modules.js | 11 +++++++---- lib/player.js | 1 + 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/bin/nodeplayer b/bin/nodeplayer index e38cba2..2a84fbf 100755 --- a/bin/nodeplayer +++ b/bin/nodeplayer @@ -1,4 +1,4 @@ #!/usr/bin/env node -var argv = require('yargs').argv; -var nodeplayer = require('../'); +var Player = require('../lib/player'); +new Player(); diff --git a/index.js b/index.js index 1b66de9..de6934b 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,8 @@ var Player = require('./lib/player'); var nodeplayerConfig = require('./lib/config'); +var labeledLogger = require('./lib/logger'); -exports.player = new Player(); +exports.Player = Player; exports.config = nodeplayerConfig; +exports.logger = labeledLogger; diff --git a/lib/modules.js b/lib/modules.js index 07eccff..27e3999 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -65,6 +65,8 @@ exports.loadBackends = function(backends, forceUpdate, done) { require(moduleName).init(function(err) { if (err) { moduleLogger.error('while initializing: ' + err); + } else { + moduleLogger.verbose('backend initialized'); } callback(); }); @@ -91,19 +93,20 @@ exports.loadPlugins = function(plugins, vars, forceUpdate, done) { require(moduleName).init(vars, function(err) { if (err) { moduleLogger.error('while initializing: ' + err); - callback(); } else { - callback(null, plugin); + moduleLogger.verbose('plugin initialized'); } + + callback(); }); } else { // skip module whose installation failed moduleLogger.info('not loading plugin: ' + plugin); callback(); } - }, function(err, results) { + }, function(err) { logger.info('all plugin modules initialized'); - done(results); + done(); }); }); }; diff --git a/lib/player.js b/lib/player.js index 3d660d8..11ff094 100644 --- a/lib/player.js +++ b/lib/player.js @@ -35,6 +35,7 @@ function Player(options) { ], function() { player.callHooks('onPluginsInitialized'); player.callHooks('onBackendsInitialized'); + player.logger.info('ready'); }); } From 312c0abb2dcf585496fde849c56c1c290f08972b Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sat, 5 Dec 2015 17:19:31 +0200 Subject: [PATCH 046/103] fix build --- lib/modules.js | 4 ++-- test/test.js | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/modules.js b/lib/modules.js index 27e3999..bb23f7b 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -61,7 +61,7 @@ exports.loadBackends = function(backends, forceUpdate, done) { async.map(backends, function(backend, callback) { var moduleLogger = labeledLogger(backend); var moduleName = 'nodeplayer-backend-' + backend; - if(moduleName) { + if (moduleName) { require(moduleName).init(function(err) { if (err) { moduleLogger.error('while initializing: ' + err); @@ -89,7 +89,7 @@ exports.loadPlugins = function(plugins, vars, forceUpdate, done) { async.mapSeries(plugins, function(plugin, callback) { var moduleLogger = labeledLogger(plugin); var moduleName = 'nodeplayer-plugin-' + plugin; - if(checkModule(moduleName)) { + if (checkModule(moduleName)) { require(moduleName).init(vars, function(err) { if (err) { moduleLogger.error('while initializing: ' + err); diff --git a/test/test.js b/test/test.js index 44855ca..1cd04ed 100644 --- a/test/test.js +++ b/test/test.js @@ -30,6 +30,7 @@ describe('exampleQueue', function() { // TODO: test error cases also describe('Player', function() { + /* describe('#setVolume()', function() { var player; @@ -52,6 +53,7 @@ describe('Player', function() { player.volume.should.equal(0.5).and.be.a('number'); }); }); + */ /* describe('#skipSongs()', function() { From 69e61ff22b6a433bf5994a6288d43a9410d00cfe Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sun, 6 Dec 2015 21:41:56 +0200 Subject: [PATCH 047/103] builtin plugins & backends --- lib/backend.js | 11 +- lib/backends/index.js | 4 + lib/backends/local.js | 346 +++++++++++++++++++++++++++++++++++++++++ lib/config.js | 4 +- lib/modules.js | 67 ++++++-- lib/player.js | 32 +++- lib/plugin.js | 11 ++ lib/plugins/express.js | 47 ++++++ lib/plugins/index.js | 5 + lib/plugins/rest.js | 195 +++++++++++++++++++++++ lib/queue.js | 2 +- package.json | 3 + 12 files changed, 702 insertions(+), 25 deletions(-) create mode 100644 lib/backends/index.js create mode 100644 lib/backends/local.js create mode 100644 lib/plugin.js create mode 100644 lib/plugins/express.js create mode 100644 lib/plugins/index.js create mode 100644 lib/plugins/rest.js diff --git a/lib/backend.js b/lib/backend.js index 8743a03..7817479 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -2,14 +2,10 @@ var _ = require('underscore'); /** * Constructor - * @param {String} name - Name of backend plugin that provides songs to nodeplayer - * @param {Function} callback - Called once the backend has loaded */ - -function Backend(name, callback) { - this.name = name; +function Backend() { + this.name = this.constructor.name.toLowerCase(); this.songsPreparing = []; - this.plugin = require('nodeplayer-backend-' + name); } /** @@ -18,7 +14,8 @@ function Backend(name, callback) { * @returns {Boolean} - true if song is prepared, false if not */ Backend.prototype.songPrepared = function(songId) { - return this.plugin.songPrepared(songId); + // TODO: move to module + return this.module.songPrepared(songId); }; module.exports = Backend; diff --git a/lib/backends/index.js b/lib/backends/index.js new file mode 100644 index 0000000..1ddfb11 --- /dev/null +++ b/lib/backends/index.js @@ -0,0 +1,4 @@ +var Backends = []; +//Backends.push(require('./local')); + +module.exports = Backends; diff --git a/lib/backends/local.js b/lib/backends/local.js new file mode 100644 index 0000000..974f857 --- /dev/null +++ b/lib/backends/local.js @@ -0,0 +1,346 @@ +'use strict'; + +var MODULE_NAME = 'file'; +var MODULE_TYPE = 'backend'; + +var walk = require('walk'); +var probe = require('node-ffprobe'); +var path = require('path'); +var mkdirp = require('mkdirp'); +var url = require('url'); +var fs = require('fs'); +var async = require('async'); +var ffmpeg = require('fluent-ffmpeg'); +var watch = require('node-watch'); +var _ = require('underscore'); +var escapeStringRegexp = require('escape-string-regexp'); + +var nodeplayerConfig = require('nodeplayer').config; +var coreConfig = nodeplayerConfig.getConfig(); +var defaultConfig = require('./default-config.js'); +var config = nodeplayerConfig.getConfig(MODULE_TYPE + '-' + MODULE_NAME, defaultConfig); + +var fileBackend = {}; +fileBackend.name = MODULE_NAME; + +var logger = require('nodeplayer').logger(MODULE_NAME); +var walker; +var db; +var medialibraryPath; + +// TODO: seeking +var encodeSong = function(origStream, seek, song, progCallback, errCallback) { + var incompletePath = coreConfig.songCachePath + '/file/incomplete/' + song.songID + '.opus'; + var incompleteStream = fs.createWriteStream(incompletePath, {flags: 'w'}); + var encodedPath = coreConfig.songCachePath + '/file/' + song.songID + '.opus'; + + var command = ffmpeg(origStream) + .noVideo() + //.inputFormat('mp3') + //.inputOption('-ac 2') + .audioCodec('libopus') + .audioBitrate('192') + .format('opus') + .on('error', function(err) { + logger.error('file: error while transcoding ' + song.songID + ': ' + err); + if (fs.existsSync(incompletePath)) { + fs.unlinkSync(incompletePath); + } + errCallback(song, err); + }); + + var opusStream = command.pipe(null, {end: true}); + opusStream.on('data', function(chunk) { + incompleteStream.write(chunk, undefined, function() { + progCallback(song, chunk, false); + }); + }); + opusStream.on('end', function() { + incompleteStream.end(undefined, undefined, function() { + logger.verbose('transcoding ended for ' + song.songID); + + // TODO: we don't know if transcoding ended successfully or not, + // and there might be a race condition between errCallback deleting + // the file and us trying to move it to the songCache + + // atomically move result to encodedPath + if (fs.existsSync(incompletePath)) { + fs.renameSync(incompletePath, encodedPath); + } + + progCallback(song, null, true); + }); + }); + + logger.verbose('transcoding ' + song.songID + '...'); + return function(err) { + command.kill(); + logger.verbose('file: canceled preparing: ' + song.songID + ': ' + err); + if (fs.existsSync(incompletePath)) { + fs.unlinkSync(incompletePath); + } + errCallback(song, 'canceled preparing: ' + song.songID + ': ' + err); + }; +}; + +// cache songID to disk. +// on success: progCallback must be called with true as argument +// on failure: errCallback must be called with error message +// returns a function that cancels preparing +fileBackend.prepareSong = function(song, progCallback, errCallback) { + var filePath = coreConfig.songCachePath + '/file/' + song.songID + '.opus'; + + if (fs.existsSync(filePath)) { + progCallback(song, null, true); + } else { + var cancelEncode = null; + var canceled = false; + var cancelPreparing = function() { + canceled = true; + if (cancelEncode) { + cancelEncode(); + } + }; + + db.collection('songs').findById(song.songID, function(err, item) { + if (canceled) { + errCallback(song, 'song was canceled before encoding started'); + } else if (item) { + var readStream = fs.createReadStream(item.file); + cancelEncode = encodeSong(readStream, 0, song, progCallback, errCallback); + readStream.on('error', function(err) { + errCallback(song, err); + }); + } else { + errCallback(song, 'song not found in local db'); + } + }); + + return cancelEncode; + } +}; + +fileBackend.isPrepared = function(song) { + var filePath = coreConfig.songCachePath + '/file/' + song.songID + '.opus'; + return fs.existsSync(filePath); +}; + +fileBackend.search = function(query, callback, errCallback) { + var q; + if (query.any) { + q = { + $or: [ + {artist: new RegExp(escapeStringRegexp(query.any), 'i')}, + {title: new RegExp(escapeStringRegexp(query.any), 'i')}, + {album: new RegExp(escapeStringRegexp(query.any), 'i')} + ] + }; + } else { + q = { + $and: [] + }; + + _.keys(query).forEach(function(key) { + var criterion = {}; + criterion[key] = new RegExp(escapeStringRegexp(query[key]), 'i'); + q.$and.push(criterion); + }); + } + + logger.verbose('Got query: '); + logger.verbose(q); + + db.collection('songs').find(q).toArray(function(err, items) { + // Also filter away special chars? (Remix) ?= Remix åäö日本穂? + /* + var termsArr = query.terms.split(' '); + termsArr.forEach(function(e, i, arr) {arr[i] = e.toLowerCase();}); + for (var i in items) { + items[i].score = 0; + var words = []; + if (items[i].title) { + words = words.concat(items[i].title.split(' ')); + } + if (items[i].artist) { + words = words.concat(items[i].artist.split(' ')); + } + if (items[i].album) { + words = words.concat(items[i].album.split(' ')); + } + words.forEach(function(e, i, arr) {arr[i] = e.toLowerCase();}); + for (var ii in words) { + if (termsArr.indexOf(words[ii]) >= 0) { + items[i].score++; + } + } + } + items.sort(function(a, b) { + return b.score - a.score; // sort by score + }); + */ + var results = {}; + results.songs = {}; + + var numItems = items.length; + var cur = 0; + for (var song in items) { + results.songs[items[song]._id.toString()] = { + artist: items[song].artist, + title: items[song].title, + album: items[song].album, + albumArt: null, // TODO: can we add this? + duration: items[song].duration, + songID: items[song]._id.toString(), + score: config.maxScore * (numItems - cur) / numItems, + backendName: MODULE_NAME, + format: 'opus' + }; + cur++; + if (Object.keys(results.songs).length > coreConfig.searchResultCnt) { break; } + } + callback(results); + }); +}; +var probeCallback = function(err, probeData, next) { + var formats = config.importFormats; + if (probeData) { + // ignore camel case rule here as we can't do anything about probeData + //jscs:disable requireCamelCaseOrUpperCaseIdentifiers + if (formats.indexOf(probeData.format.format_name) >= 0) { // Format is supported + //jscs:enable requireCamelCaseOrUpperCaseIdentifiers + var song = { + title: '', + artist: '', + album: '', + duration: '0', + }; + + // some tags may be in mixed/all caps, let's convert every tag to lower case + var key; + var keys = Object.keys(probeData.metadata); + var n = keys.length; + var metadata = {}; + while (n--) { + key = keys[n]; + metadata[key.toLowerCase()] = probeData.metadata[key]; + } + + // try a best guess based on filename in case tags are unavailable + var basename = path.basename(probeData.file); + basename = path.basename(probeData.file, path.extname(basename)); + var splitTitle = basename.split(/\s-\s(.+)?/); + + if (!_.isUndefined(metadata.title)) { + song.title = metadata.title; + } else { + song.title = splitTitle[1]; + } + if (!_.isUndefined(metadata.artist)) { + song.artist = metadata.artist; + } else { + song.artist = splitTitle[0]; + } + if (!_.isUndefined(metadata.album)) { + song.album = metadata.album; + } + + song.file = probeData.file; + + song.duration = probeData.format.duration * 1000; + db.collection('songs').update({file: probeData.file}, {'$set':song}, {upsert: true}, + function(err, result) { + if (result == 1) { + logger.debug('Upserted: ' + probeData.file); + } else { + logger.error('error while updating db: ' + err); + } + + next(); + }); + } else { + logger.verbose('format not supported, skipping...'); + next(); + } + } else { + logger.error('error while probing:' + err); + next(); + } +}; + +fileBackend.init = function(callback) { + mkdirp.sync(coreConfig.songCachePath + '/file/incomplete'); + + //jscs:disable requireCamelCaseOrUpperCaseIdentifiers + db = require('mongoskin').db(config.mongo, {native_parser:true, safe:true}); + //jscs:enable requireCamelCaseOrUpperCaseIdentifiers + + var importPath = config.importPath; + + // Adds text index to database for title, artist and album fields + // TODO: better handling and error checking + var cb = function(err, index) { + if (err) { + logger.error(err); + logger.error('Forgot to setup mongodb?'); + } else if (index) { + logger.silly('index: ' + index); + } + }; + db.collection('songs').ensureIndex({title: 'text', artist: 'text', album: 'text'}, cb); + + var options = { + followLinks: config.followSymlinks + }; + + // create async.js queue to limit concurrent probes + var q = async.queue(function(task, callback) { + probe(task.filename, function(err, probeData) { + probeCallback(err, probeData, function() { + logger.silly('q.length(): ' + q.length(), 'q.running(): ' + q.running()); + callback(); + }); + }); + }, config.concurrentProbes); + + // walk the filesystem and scan files + // TODO: also check through entire DB to see that all files still exist on the filesystem + if (config.rescanAtStart) { + logger.info('Scanning directory: ' + importPath); + walker = walk.walk(importPath, options); + var startTime = new Date(); + var scanned = 0; + walker.on('file', function(root, fileStats, next) { + var filename = path.join(root, fileStats.name); + logger.verbose('Scanning: ' + filename); + scanned++; + q.push({ + filename: filename + }); + next(); + }); + walker.on('end', function() { + logger.verbose('Scanned files: ' + scanned); + logger.verbose('Done in: ' + Math.round((new Date() - startTime) / 1000) + ' seconds'); + }); + } + + // set fs watcher on media directory + // TODO: add a debounce so if the file keeps changing we don't probe it multiple times + watch(importPath, {recursive: true, followSymlinks: config.followSymlinks}, function(filename) { + if (fs.existsSync(filename)) { + logger.debug(filename + ' modified or created, queued for probing'); + q.unshift({ + filename: filename + }); + } else { + logger.debug(filename + ' deleted'); + db.collection('songs').remove({file: filename}, function(err, items) { + logger.debug(filename + ' deleted from db: ' + err + ', ' + items); + }); + } + }); + + // callback right away, as we can scan for songs in the background + callback(); +}; +module.exports = fileBackend; diff --git a/lib/config.js b/lib/config.js index fc37d3d..0f76445 100644 --- a/lib/config.js +++ b/lib/config.js @@ -71,12 +71,14 @@ exports.getDefaultConfig = function() { }; // path and defaults are optional, if undefined then values corresponding to core config are used -exports.getConfig = function(moduleName, defaults) { +exports.getConfig = function(module, defaults) { if (process.env.NODE_ENV === 'test') { // unit tests should always use default config return (defaults || defaultConfig); } + var moduleName = module ? module.name : null + var configPath = getConfigDir() + path.sep + (moduleName || 'core') + '.json'; try { diff --git a/lib/modules.js b/lib/modules.js index bb23f7b..1c759da 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -2,8 +2,11 @@ var npm = require('npm'); var async = require('async'); var labeledLogger = require('./logger'); var nodeplayerConfig = require('./config'); +var BuiltinPlugins = require('./plugins'); +var BuiltinBackends = require('./backends'); var config = nodeplayerConfig.getConfig(); +var _ = require('underscore'); var logger = labeledLogger('modules'); var checkModule = function(module) { @@ -54,7 +57,7 @@ var initModule = function(moduleShortName, moduleType, callback) { }); }; -exports.loadBackends = function(backends, forceUpdate, done) { +exports.loadBackends = function(backends, forceUpdate, callHooks, done) { // first install missing backends installModules(backends, 'backend', forceUpdate, function() { // then initialize all backends in parallel @@ -62,27 +65,30 @@ exports.loadBackends = function(backends, forceUpdate, done) { var moduleLogger = labeledLogger(backend); var moduleName = 'nodeplayer-backend-' + backend; if (moduleName) { - require(moduleName).init(function(err) { + var Module = require(moduleName); + var instance = new Module(function(err) { if (err) { moduleLogger.error('while initializing: ' + err); + callback(); } else { moduleLogger.verbose('backend initialized'); + callback(null, instance); } - callback(); }); } else { // skip module whose installation failed moduleLogger.info('not loading backend: ' + backend); callback(); } - }, function(err) { + }, function(err, results) { logger.info('all backend modules initialized'); - done(); + results = _.filter(results, _.identity); + done(results); }); }); }; -exports.loadPlugins = function(plugins, vars, forceUpdate, done) { +exports.loadPlugins = function(plugins, vars, forceUpdate, callHooks, done) { // first install missing plugins installModules(plugins, 'plugin', forceUpdate, function() { // then initialize all plugins in series @@ -90,23 +96,62 @@ exports.loadPlugins = function(plugins, vars, forceUpdate, done) { var moduleLogger = labeledLogger(plugin); var moduleName = 'nodeplayer-plugin-' + plugin; if (checkModule(moduleName)) { - require(moduleName).init(vars, function(err) { + var Module = require(moduleName); + var instance = new Module(vars, function(err) { if (err) { moduleLogger.error('while initializing: ' + err); + callback(); } else { moduleLogger.verbose('plugin initialized'); + callHooks('onPluginInitialized', plugin); + callback(null, instance); } - - callback(); }); } else { // skip module whose installation failed moduleLogger.info('not loading plugin: ' + plugin); callback(); } - }, function(err) { + }, function(err, results) { logger.info('all plugin modules initialized'); - done(); + results = _.filter(results, _.identity); + done(results); + }); + }); +}; + +exports.loadBuiltinPlugins = function(vars, callHooks, done) { + async.mapSeries(BuiltinPlugins, function(Plugin, callback) { + new Plugin(vars, function(err, plugin) { + var moduleLogger = labeledLogger(plugin.name + ' (builtin)'); + + if (err) { + moduleLogger.error('while initializing: ' + err); + return callback(); + } + moduleLogger.verbose('plugin initialized'); + callHooks('onPluginInitialized', plugin.name); + callback(null, plugin); + }); + }, function(err, results) { + done(results); + }); +}; + +exports.loadBuiltinBackends = function(callHooks, done) { + async.mapSeries(BuiltinBackends, function(Backend, callback) { + new Backend(function(err, backend) { + var moduleLogger = labeledLogger(backend.name + ' (builtin)'); + + if (err) { + moduleLogger.error('while initializing: ' + err); + return callback(); + } + callHooks('onBackendInitialized', backend.name); + moduleLogger.verbose('backend initialized'); + callback(null, backend); }); + }, function(err, results) { + done(results); }); }; diff --git a/lib/player.js b/lib/player.js index 11ff094..2aa8c62 100644 --- a/lib/player.js +++ b/lib/player.js @@ -17,7 +17,8 @@ function Player(options) { this.play = options.play || false; this.repeat = options.repeat || false; this.plugins = options.plugins || {}; - this.songsPreparing = options.songsPreparing || {}; + this.backends = options.backends || {}; + //this.songsPreparing = options.songsPreparing || {}; this.volume = options.volume || 1; this.songEndTimeout = options.songEndTimeout || null; this.pluginVars = options.pluginVars || {}; @@ -26,16 +27,37 @@ function Player(options) { var config = player.config; var forceUpdate = false; + // initialize plugins & backends async.series([ function(callback) { - modules.loadPlugins(config.plugins, player.pluginVars, forceUpdate, callback); + modules.loadBuiltinPlugins(player.callHooks, player.pluginVars, function(plugins) { + player.plugins = plugins; + player.callHooks('onBuiltinPluginsInitialized'); + callback(); + }); }, function(callback) { - modules.loadBackends(config.backends, forceUpdate, callback); + modules.loadPlugins(config.plugins, player.pluginVars, forceUpdate, player.callHooks, + function(results) { + player.plugins = player.plugins.concat(results); + player.callHooks('onPluginsInitialized'); + callback(); + }); + }, function(callback) { + modules.loadBuiltinBackends(player.callHooks, function(backends) { + player.backends = backends; + player.callHooks('onBuiltinBackendsInitialized'); + callback(); + }); + }, function(callback) { + modules.loadBackends(config.backends, forceUpdate, player.callHooks, function(results) { + player.backends = player.backends.concat(results); + player.callHooks('onBackendsInitialized'); + callback(); + }); } ], function() { - player.callHooks('onPluginsInitialized'); - player.callHooks('onBackendsInitialized'); player.logger.info('ready'); + player.callHooks('onReady'); }); } diff --git a/lib/plugin.js b/lib/plugin.js new file mode 100644 index 0000000..968f032 --- /dev/null +++ b/lib/plugin.js @@ -0,0 +1,11 @@ +/** + * Constructor + */ +function Plugin() { + this.name = this.constructor.name.toLowerCase(); +} + +Plugin.prototype.registerHook = function(hook, callback) { +}; + +module.exports = Plugin; diff --git a/lib/plugins/express.js b/lib/plugins/express.js new file mode 100644 index 0000000..6f1ccc0 --- /dev/null +++ b/lib/plugins/express.js @@ -0,0 +1,47 @@ +'use strict'; + +var express = require('express'); +var bodyParser = require('body-parser'); +var cookieParser = require('cookie-parser'); +var https = require('https'); +var http = require('http'); +var fs = require('fs'); + +var util = require('util'); +var Plugin = require('../plugin'); + +function Express(vars, callback) { + Plugin.apply(this); + + var config = require('../config').getConfig(this); + vars.app = express(); + + var options = {}; + if (config.tls) { + options = { + tls: config.tls, + key: config.key ? fs.readFileSync(config.key) : undefined, + cert: config.cert ? fs.readFileSync(config.cert) : undefined, + ca: config.ca ? fs.readFileSync(config.ca) : undefined, + requestCert: config.requestCert, + rejectUnauthorized: config.rejectUnauthorized + }; + // TODO: deprecated! + vars.app.set('tls', true); + vars.httpServer = https.createServer(options, vars.app) + .listen(process.env.PORT || config.port); + } else { + vars.httpServer = http.createServer(vars.app) + .listen(process.env.PORT || config.port); + } + + vars.app.use(cookieParser()); + vars.app.use(bodyParser.json({limit: '100mb'})); + vars.app.use(bodyParser.urlencoded({extended: true})); + + callback(null, this); +} + +util.inherits(Express, Plugin); + +module.exports = Express; diff --git a/lib/plugins/index.js b/lib/plugins/index.js new file mode 100644 index 0000000..689e836 --- /dev/null +++ b/lib/plugins/index.js @@ -0,0 +1,5 @@ +var Plugins = []; +Plugins.push(require('./express')); +Plugins.push(require('./rest')); // NOTE: must be initialized after express + +module.exports = Plugins; diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js new file mode 100644 index 0000000..bc65ca7 --- /dev/null +++ b/lib/plugins/rest.js @@ -0,0 +1,195 @@ +'use strict'; + +var _ = require('underscore'); + +var Plugin = require('../plugin'); + +function Rest(vars, callback) { + Plugin.apply(this); + + // TODO: unified config with core + var config = require('../config').getConfig(this); + + if (!vars.app) { + callback('module must be initialized after express module!'); + } else { + vars.app.use(function(req, res, next) { + res.sendRes = function(err, data) { + if (err) { + res.status(404).send(err); + } else { + res.send(data || 'ok'); + } + }; + next(); + }); + + vars.app.get('/queue', function(req, res) { + res.json({ + songs: player.queue.songs, + curQueuePos: player.queue.curQueuePos, + curSongPos: player.queue.playbackStart ? + (new Date().getTime() - player.queue.playbackStart) : -1 + }); + }); + + // TODO: error handling + vars.app.post('/queue/song', function(req, res) { + player.insertSongs(-1, req.body, res.sendRes); + }); + vars.app.post('/queue/song/:at', function(req, res) { + player.insertSongs(req.params.at, req.body, res.sendRes); + }); + + /* + player.app.post('/queue/move/:pos', function(req, res) { + var err = player.moveInQueue( + parseInt(req.params.pos), + parseInt(req.body.to), + parseInt(req.body.cnt) + ); + sendResponse(res, 'success', err); + }); + */ + + vars.app.delete('/queue/song/:at', function(req, res) { + player.removeSongs(req.params.at, parseInt(req.query.cnt) || 1, res.sendRes); + }); + + vars.app.post('/playctl/:play', function(req, res) { + player.startPlayback(parseInt(req.body.position) || 0); + res.sendRes(null, 'ok'); + }); + + vars.app.post('/playctl/:pause', function(req, res) { + player.pausePlayback(); + res.sendRes(null, 'ok'); + }); + + vars.app.post('/playctl/:skip', function(req, res) { + player.skipSongs(parseInt(req.body.cnt)); + res.sendRes(null, 'ok'); + }); + + vars.app.post('/playctl/:shuffle', function(req, res) { + player.shuffleQueue(); + res.sendRes(null, 'ok'); + }); + + vars.app.post('/volume', function(req, res) { + player.setVolume(parseInt(req.body)); + res.send('success'); + }); + + // search for songs, search terms in query params + vars.app.get('/search', function(req, res) { + logger.verbose('got search request: ' + req.query); + + player.searchBackends(req.query, function(results) { + res.json(results); + }); + }); + + callback(); + } +} +// called when nodeplayer is started to initialize the backend +// do any necessary initialization here +exports.init = function(vars, callback) { +}; + +var pendingRequests = {}; +exports.onPrepareProgress = function(song, chunk, done) { + if (!pendingRequests[song.backendName]) { + return; + } + + _.each(pendingRequests[song.backendName][song.songID], function(res) { + if (chunk) { + res.write(chunk); + } + if (done) { + res.end(); + pendingRequests[song.backendName][song.songID] = []; + } + }); +}; + +exports.onBackendInitialized = function(backendName) { + pendingRequests[backendName] = {}; + + // provide API path for music data, might block while song is preparing + vars.app.get('/song/' + backendName + '/:fileName', function(req, res, next) { + var songID = req.params.fileName.substring(0, req.params.fileName.lastIndexOf('.')); + var songFormat = req.params.fileName.substring(req.params.fileName.lastIndexOf('.') + 1); + + var song = { + songID: songID, + format: songFormat + }; + + if (player.backends[backendName].isPrepared(song)) { + // song should be available on disk + res.sendFile('/' + backendName + '/' + songID + '.' + songFormat, { + root: coreConfig.songCachePath + }); + } else if (player.songsPreparing[backendName] && + player.songsPreparing[backendName][songID]) { + // song is preparing + var preparingSong = player.songsPreparing[backendName][songID]; + + // try finding out length of song + var queuedSong = player.searchQueue(backendName, songID); + if (queuedSong) { + res.setHeader('X-Content-Duration', queuedSong.duration / 1000); + } + + res.setHeader('Transfer-Encoding', 'chunked'); + res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); + res.setHeader('Accept-Ranges', 'bytes'); + + var range = [0]; + if (req.headers.range) { + // partial request + + range = req.headers.range.substr(req.headers.range.indexOf('=') + 1).split('-'); + res.statusCode = 206; + + // a best guess for the header + var end; + var dataLen = preparingSong.songData ? preparingSong.songData.length : 0; + if (range[1]) { + end = Math.min(range[1], dataLen - 1); + } else { + end = dataLen - 1; + } + + // TODO: we might be lying here if the code below sends whole song + res.setHeader('Content-Range', 'bytes ' + range[0] + '-' + end + '/*'); + } + + // TODO: we can be smarter here: currently most corner cases lead to sending entire + // song even if only part of it was requested. Also the range end is currently ignored + + // skip to start of requested range if we have enough data, otherwise serve whole song + if (range[0] < preparingSong.songData.length) { + res.write(preparingSong.songData.slice(range[0])); + } else { + res.write(preparingSong.songData); + } + + pendingRequests[backendName][song.songID] = + pendingRequests[backendName][song.songID] || []; + + pendingRequests[backendName][song.songID].push(res); + } else { + res.status(404).end('404 song not found'); + } + }); + + callback(null, this); +}; + +util.inherits(Rest, Plugin); + +module.exports = Rest; diff --git a/lib/queue.js b/lib/queue.js index 166358b..b563db1 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -84,7 +84,7 @@ Queue.prototype.insertSongs = function(at, songs) { // generate Song objects of each song songs = _.map(songs, function(song) { - return new Song(song, this.backends[song.backendName]); + return new Song(song, this.player.backends[song.backendName]); }, this); // make sure there were no errors while creating Song objects diff --git a/package.json b/package.json index b14d827..1885494 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "license": "MIT", "dependencies": { "async": "^0.9.0", + "body-parser": "^1.14.1", + "cookie-parser": "^1.4.0", + "express": "^4.13.3", "mkdirp": "^0.5.0", "node-uuid": "^1.4.4", "npm": "^2.7.1", From 79a22aa73a2b562ac6f1e67c6e8b19901dd23557 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sun, 6 Dec 2015 22:12:05 +0200 Subject: [PATCH 048/103] fix build --- lib/config.js | 2 +- lib/modules.js | 18 +-- lib/player.js | 8 +- lib/plugin.js | 6 + lib/plugins/express.js | 3 +- lib/plugins/rest.js | 298 ++++++++++++++++++++--------------------- 6 files changed, 171 insertions(+), 164 deletions(-) diff --git a/lib/config.js b/lib/config.js index 0f76445..bfc7714 100644 --- a/lib/config.js +++ b/lib/config.js @@ -77,7 +77,7 @@ exports.getConfig = function(module, defaults) { return (defaults || defaultConfig); } - var moduleName = module ? module.name : null + var moduleName = module ? module.name : null; var configPath = getConfigDir() + path.sep + (moduleName || 'core') + '.json'; diff --git a/lib/modules.js b/lib/modules.js index 1c759da..8aec159 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -57,7 +57,7 @@ var initModule = function(moduleShortName, moduleType, callback) { }); }; -exports.loadBackends = function(backends, forceUpdate, callHooks, done) { +exports.loadBackends = function(player, backends, forceUpdate, done) { // first install missing backends installModules(backends, 'backend', forceUpdate, function() { // then initialize all backends in parallel @@ -88,7 +88,7 @@ exports.loadBackends = function(backends, forceUpdate, callHooks, done) { }); }; -exports.loadPlugins = function(plugins, vars, forceUpdate, callHooks, done) { +exports.loadPlugins = function(player, plugins, forceUpdate, done) { // first install missing plugins installModules(plugins, 'plugin', forceUpdate, function() { // then initialize all plugins in series @@ -97,13 +97,13 @@ exports.loadPlugins = function(plugins, vars, forceUpdate, callHooks, done) { var moduleName = 'nodeplayer-plugin-' + plugin; if (checkModule(moduleName)) { var Module = require(moduleName); - var instance = new Module(vars, function(err) { + var instance = new Module(player, function(err) { if (err) { moduleLogger.error('while initializing: ' + err); callback(); } else { moduleLogger.verbose('plugin initialized'); - callHooks('onPluginInitialized', plugin); + player.callHooks('onPluginInitialized', plugin); callback(null, instance); } }); @@ -120,9 +120,9 @@ exports.loadPlugins = function(plugins, vars, forceUpdate, callHooks, done) { }); }; -exports.loadBuiltinPlugins = function(vars, callHooks, done) { +exports.loadBuiltinPlugins = function(player, done) { async.mapSeries(BuiltinPlugins, function(Plugin, callback) { - new Plugin(vars, function(err, plugin) { + new Plugin(player, function(err, plugin) { var moduleLogger = labeledLogger(plugin.name + ' (builtin)'); if (err) { @@ -130,7 +130,7 @@ exports.loadBuiltinPlugins = function(vars, callHooks, done) { return callback(); } moduleLogger.verbose('plugin initialized'); - callHooks('onPluginInitialized', plugin.name); + player.callHooks('onPluginInitialized', plugin.name); callback(null, plugin); }); }, function(err, results) { @@ -138,7 +138,7 @@ exports.loadBuiltinPlugins = function(vars, callHooks, done) { }); }; -exports.loadBuiltinBackends = function(callHooks, done) { +exports.loadBuiltinBackends = function(player, done) { async.mapSeries(BuiltinBackends, function(Backend, callback) { new Backend(function(err, backend) { var moduleLogger = labeledLogger(backend.name + ' (builtin)'); @@ -147,7 +147,7 @@ exports.loadBuiltinBackends = function(callHooks, done) { moduleLogger.error('while initializing: ' + err); return callback(); } - callHooks('onBackendInitialized', backend.name); + player.callHooks('onBackendInitialized', backend.name); moduleLogger.verbose('backend initialized'); callback(null, backend); }); diff --git a/lib/player.js b/lib/player.js index 2aa8c62..bccc831 100644 --- a/lib/player.js +++ b/lib/player.js @@ -30,26 +30,26 @@ function Player(options) { // initialize plugins & backends async.series([ function(callback) { - modules.loadBuiltinPlugins(player.callHooks, player.pluginVars, function(plugins) { + modules.loadBuiltinPlugins(player, function(plugins) { player.plugins = plugins; player.callHooks('onBuiltinPluginsInitialized'); callback(); }); }, function(callback) { - modules.loadPlugins(config.plugins, player.pluginVars, forceUpdate, player.callHooks, + modules.loadPlugins(player, config.plugins, forceUpdate, function(results) { player.plugins = player.plugins.concat(results); player.callHooks('onPluginsInitialized'); callback(); }); }, function(callback) { - modules.loadBuiltinBackends(player.callHooks, function(backends) { + modules.loadBuiltinBackends(player, function(backends) { player.backends = backends; player.callHooks('onBuiltinBackendsInitialized'); callback(); }); }, function(callback) { - modules.loadBackends(config.backends, forceUpdate, player.callHooks, function(results) { + modules.loadBackends(player, config.backends, forceUpdate, function(results) { player.backends = player.backends.concat(results); player.callHooks('onBackendsInitialized'); callback(); diff --git a/lib/plugin.js b/lib/plugin.js index 968f032..e7bfc80 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -1,11 +1,17 @@ /** * Constructor */ + +var labeledLogger = require('./logger'); + function Plugin() { this.name = this.constructor.name.toLowerCase(); + this.hooks = {}; + this.log = labeledLogger(this.name); } Plugin.prototype.registerHook = function(hook, callback) { + this.hooks.hook = callback; }; module.exports = Plugin; diff --git a/lib/plugins/express.js b/lib/plugins/express.js index 6f1ccc0..b570fc5 100644 --- a/lib/plugins/express.js +++ b/lib/plugins/express.js @@ -13,7 +13,8 @@ var Plugin = require('../plugin'); function Express(vars, callback) { Plugin.apply(this); - var config = require('../config').getConfig(this); + // NOTE: no argument passed so we get the core's config + var config = require('../config').getConfig(); vars.app = express(); var options = {}; diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index bc65ca7..2ad95f2 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -2,193 +2,193 @@ var _ = require('underscore'); +var util = require('util'); var Plugin = require('../plugin'); -function Rest(vars, callback) { +function Rest(player, callback) { Plugin.apply(this); - // TODO: unified config with core - var config = require('../config').getConfig(this); + // NOTE: no argument passed so we get the core's config + var config = require('../config').getConfig(); + var self = this; - if (!vars.app) { - callback('module must be initialized after express module!'); - } else { - vars.app.use(function(req, res, next) { - res.sendRes = function(err, data) { - if (err) { - res.status(404).send(err); - } else { - res.send(data || 'ok'); - } - }; - next(); - }); + if (!player.app) { + return callback('module must be initialized after express module!'); + } - vars.app.get('/queue', function(req, res) { - res.json({ - songs: player.queue.songs, - curQueuePos: player.queue.curQueuePos, - curSongPos: player.queue.playbackStart ? - (new Date().getTime() - player.queue.playbackStart) : -1 - }); - }); + player.app.use(function(req, res, next) { + res.sendRes = function(err, data) { + if (err) { + res.status(404).send(err); + } else { + res.send(data || 'ok'); + } + }; + next(); + }); - // TODO: error handling - vars.app.post('/queue/song', function(req, res) { - player.insertSongs(-1, req.body, res.sendRes); - }); - vars.app.post('/queue/song/:at', function(req, res) { - player.insertSongs(req.params.at, req.body, res.sendRes); + player.app.get('/queue', function(req, res) { + res.json({ + songs: player.queue.songs, + curQueuePos: player.queue.curQueuePos, + curSongPos: player.queue.playbackStart ? + (new Date().getTime() - player.queue.playbackStart) : -1 }); + }); - /* - player.app.post('/queue/move/:pos', function(req, res) { - var err = player.moveInQueue( - parseInt(req.params.pos), - parseInt(req.body.to), - parseInt(req.body.cnt) - ); - sendResponse(res, 'success', err); - }); - */ + // TODO: error handling + player.app.post('/queue/song', function(req, res) { + player.insertSongs(-1, req.body, res.sendRes); + }); + player.app.post('/queue/song/:at', function(req, res) { + player.insertSongs(req.params.at, req.body, res.sendRes); + }); - vars.app.delete('/queue/song/:at', function(req, res) { - player.removeSongs(req.params.at, parseInt(req.query.cnt) || 1, res.sendRes); - }); + /* + player.app.post('/queue/move/:pos', function(req, res) { + var err = player.moveInQueue( + parseInt(req.params.pos), + parseInt(req.body.to), + parseInt(req.body.cnt) + ); + sendResponse(res, 'success', err); + }); + */ - vars.app.post('/playctl/:play', function(req, res) { - player.startPlayback(parseInt(req.body.position) || 0); - res.sendRes(null, 'ok'); - }); + player.app.delete('/queue/song/:at', function(req, res) { + player.removeSongs(req.params.at, parseInt(req.query.cnt) || 1, res.sendRes); + }); - vars.app.post('/playctl/:pause', function(req, res) { - player.pausePlayback(); - res.sendRes(null, 'ok'); - }); + player.app.post('/playctl/:play', function(req, res) { + player.startPlayback(parseInt(req.body.position) || 0); + res.sendRes(null, 'ok'); + }); - vars.app.post('/playctl/:skip', function(req, res) { - player.skipSongs(parseInt(req.body.cnt)); - res.sendRes(null, 'ok'); - }); + player.app.post('/playctl/:pause', function(req, res) { + player.pausePlayback(); + res.sendRes(null, 'ok'); + }); - vars.app.post('/playctl/:shuffle', function(req, res) { - player.shuffleQueue(); - res.sendRes(null, 'ok'); - }); + player.app.post('/playctl/:skip', function(req, res) { + player.skipSongs(parseInt(req.body.cnt)); + res.sendRes(null, 'ok'); + }); - vars.app.post('/volume', function(req, res) { - player.setVolume(parseInt(req.body)); - res.send('success'); - }); + player.app.post('/playctl/:shuffle', function(req, res) { + player.shuffleQueue(); + res.sendRes(null, 'ok'); + }); - // search for songs, search terms in query params - vars.app.get('/search', function(req, res) { - logger.verbose('got search request: ' + req.query); + player.app.post('/volume', function(req, res) { + player.setVolume(parseInt(req.body)); + res.send('success'); + }); - player.searchBackends(req.query, function(results) { - res.json(results); - }); - }); + // search for songs, search terms in query params + player.app.get('/search', function(req, res) { + self.log.verbose('got search request: ' + req.query); - callback(); - } -} -// called when nodeplayer is started to initialize the backend -// do any necessary initialization here -exports.init = function(vars, callback) { -}; - -var pendingRequests = {}; -exports.onPrepareProgress = function(song, chunk, done) { - if (!pendingRequests[song.backendName]) { - return; - } + player.searchBackends(req.query, function(results) { + res.json(results); + }); + }); - _.each(pendingRequests[song.backendName][song.songID], function(res) { - if (chunk) { - res.write(chunk); - } - if (done) { - res.end(); - pendingRequests[song.backendName][song.songID] = []; + this.pendingRequests = {}; + var rest = this; + this.registerHook('onPrepareProgress', function(song, chunk, done) { + if (!rest.pendingRequests[song.backendName]) { + return; } + + _.each(rest.pendingRequests[song.backendName][song.songID], function(res) { + if (chunk) { + res.write(chunk); + } + if (done) { + res.end(); + rest.pendingRequests[song.backendName][song.songID] = []; + } + }); }); -}; -exports.onBackendInitialized = function(backendName) { - pendingRequests[backendName] = {}; + this.registerHook('onBackendInitialized', function(backendName) { + rest.pendingRequests[backendName] = {}; - // provide API path for music data, might block while song is preparing - vars.app.get('/song/' + backendName + '/:fileName', function(req, res, next) { - var songID = req.params.fileName.substring(0, req.params.fileName.lastIndexOf('.')); - var songFormat = req.params.fileName.substring(req.params.fileName.lastIndexOf('.') + 1); + // provide API path for music data, might block while song is preparing + player.app.get('/song/' + backendName + '/:fileName', function(req, res, next) { + var extIndex = req.params.fileName.lastIndexOf('.'); + var songID = req.params.fileName.substring(0, extIndex); + var songFormat = req.params.fileName.substring(extIndex + 1); - var song = { - songID: songID, - format: songFormat - }; + var song = { + songID: songID, + format: songFormat + }; - if (player.backends[backendName].isPrepared(song)) { - // song should be available on disk - res.sendFile('/' + backendName + '/' + songID + '.' + songFormat, { - root: coreConfig.songCachePath - }); - } else if (player.songsPreparing[backendName] && - player.songsPreparing[backendName][songID]) { - // song is preparing - var preparingSong = player.songsPreparing[backendName][songID]; - - // try finding out length of song - var queuedSong = player.searchQueue(backendName, songID); - if (queuedSong) { - res.setHeader('X-Content-Duration', queuedSong.duration / 1000); - } + if (player.backends[backendName].isPrepared(song)) { + // song should be available on disk + res.sendFile('/' + backendName + '/' + songID + '.' + songFormat, { + root: config.songCachePath + }); + } else if (player.songsPreparing[backendName] && + player.songsPreparing[backendName][songID]) { + // song is preparing + var preparingSong = player.songsPreparing[backendName][songID]; + + // try finding out length of song + var queuedSong = player.searchQueue(backendName, songID); + if (queuedSong) { + res.setHeader('X-Content-Duration', queuedSong.duration / 1000); + } - res.setHeader('Transfer-Encoding', 'chunked'); - res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); - res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('Transfer-Encoding', 'chunked'); + res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); + res.setHeader('Accept-Ranges', 'bytes'); - var range = [0]; - if (req.headers.range) { - // partial request + var range = [0]; + if (req.headers.range) { + // partial request - range = req.headers.range.substr(req.headers.range.indexOf('=') + 1).split('-'); - res.statusCode = 206; + range = req.headers.range.substr(req.headers.range.indexOf('=') + 1).split('-'); + res.statusCode = 206; - // a best guess for the header - var end; - var dataLen = preparingSong.songData ? preparingSong.songData.length : 0; - if (range[1]) { - end = Math.min(range[1], dataLen - 1); - } else { - end = dataLen - 1; + // a best guess for the header + var end; + var dataLen = preparingSong.songData ? preparingSong.songData.length : 0; + if (range[1]) { + end = Math.min(range[1], dataLen - 1); + } else { + end = dataLen - 1; + } + + // TODO: we might be lying here if the code below sends whole song + res.setHeader('Content-Range', 'bytes ' + range[0] + '-' + end + '/*'); } - // TODO: we might be lying here if the code below sends whole song - res.setHeader('Content-Range', 'bytes ' + range[0] + '-' + end + '/*'); - } + // TODO: we can be smarter here: currently most corner cases + // lead to sending entire song even if only part of it was + // requested. Also the range end is currently ignored - // TODO: we can be smarter here: currently most corner cases lead to sending entire - // song even if only part of it was requested. Also the range end is currently ignored + // skip to start of requested range if we have enough data, + // otherwise serve whole song + if (range[0] < preparingSong.songData.length) { + res.write(preparingSong.songData.slice(range[0])); + } else { + res.write(preparingSong.songData); + } - // skip to start of requested range if we have enough data, otherwise serve whole song - if (range[0] < preparingSong.songData.length) { - res.write(preparingSong.songData.slice(range[0])); + rest.pendingRequests[backendName][song.songID] = + rest.pendingRequests[backendName][song.songID] || []; + + rest.pendingRequests[backendName][song.songID].push(res); } else { - res.write(preparingSong.songData); + res.status(404).end('404 song not found'); } - - pendingRequests[backendName][song.songID] = - pendingRequests[backendName][song.songID] || []; - - pendingRequests[backendName][song.songID].push(res); - } else { - res.status(404).end('404 song not found'); - } + }); }); callback(null, this); -}; +} util.inherits(Rest, Plugin); From a02954d85599922a6bc63c2576435dbf3f7dad94 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sun, 6 Dec 2015 22:13:59 +0200 Subject: [PATCH 049/103] fix typo --- lib/plugin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plugin.js b/lib/plugin.js index e7bfc80..519b7c7 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -11,7 +11,7 @@ function Plugin() { } Plugin.prototype.registerHook = function(hook, callback) { - this.hooks.hook = callback; + this.hooks[hook] = callback; }; module.exports = Plugin; From 3051669c9ba3c04bcdcfd994e12bc1ad1f10b487 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sun, 6 Dec 2015 22:24:35 +0200 Subject: [PATCH 050/103] playlist field, docs --- lib/backend.js | 2 +- lib/plugin.js | 2 +- lib/song.js | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index 7817479..6d51745 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -1,7 +1,7 @@ var _ = require('underscore'); /** - * Constructor + * Super constructor for backends */ function Backend() { this.name = this.constructor.name.toLowerCase(); diff --git a/lib/plugin.js b/lib/plugin.js index 519b7c7..3aa5022 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -1,5 +1,5 @@ /** - * Constructor + * Super constructor for plugins */ var labeledLogger = require('./logger'); diff --git a/lib/song.js b/lib/song.js index f9dfa10..cfeb529 100644 --- a/lib/song.js +++ b/lib/song.js @@ -50,6 +50,9 @@ function Song(song, backend) { // Any functions requiring access to the backend should be implemented as // members of the Song (e.g. isPrepared, prepareSong) this.backend = backend; + + // optional fields + this.playlist = song.playlist; } /** @@ -67,7 +70,8 @@ Song.prototype.details = function() { songId: this.songId, score: this.score, format: this.format, - backendName: this.backend.name + backendName: this.backend.name, + playlist: this.playlist }; }; @@ -76,7 +80,7 @@ Song.prototype.details = function() { * @returns {Boolean} - true if song is prepared, false if not */ Song.prototype.isPrepared = function() { - return this.backend.songPrepared(this.songId); + return this.backend.songPrepared(this); }; /** @@ -84,11 +88,7 @@ Song.prototype.isPrepared = function() { * @param {Function} callback - Called when song is ready or if an error occurred */ Song.prototype.prepare = function(callback) { - if (this.isPrepared()) { - callback(); - } else { - // TODO: move Player.prototype.prepareSong logic here - } + return this.backend.prepare(this, callback); }; module.exports = Song; From 68159fd028af0c278b5adf5456b0c40b0d2af399 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sun, 6 Dec 2015 22:50:09 +0200 Subject: [PATCH 051/103] move song encoding into backend super constructor --- lib/backend.js | 103 ++++++++++++++++++++++++++++++++++++++++-- lib/backends/local.js | 51 --------------------- lib/plugin.js | 5 +- 3 files changed, 101 insertions(+), 58 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index 6d51745..7c496f7 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -1,4 +1,7 @@ var _ = require('underscore'); +var path = require('path'); +var config = require('./config').getConfig(); +var labeledLogger = require('./logger'); /** * Super constructor for backends @@ -6,16 +9,108 @@ var _ = require('underscore'); function Backend() { this.name = this.constructor.name.toLowerCase(); this.songsPreparing = []; + this.log = labeledLogger(this.name); } +/** + * Callback for reporting encoding progress + * @callback encodeCallback + * @param {Error} err - Was there an error? + * @param {Buffer} chunk - New data since last call + * @param {Bool} done - True if this was the last chunk + */ + +/** + * Encode stream as opus + * @param {Stream} stream - Input stream + * @param {Number} seek - Skip to this position in song (TODO) + * @param {Song} song - Song object whose audio is being encoded + * @param {encodeCallback} callback - Callback for reporting encoding progress + * @returns {Function} - Can be called to terminate encoding + */ +Backend.prototype.encodeSong = function(stream, seek, song, callback) { + var self = this; + + var incompletePath = path.join(config.songCachePath, 'file', 'incomplete', + song.songId + '.opus'); + + var incompleteStream = fs.createWriteStream(incompletePath, {flags: 'w'}); + + var encodedPath = path.join(config.songCachePath, 'file', + song.songId + '.opus'); + + var command = ffmpeg(stream) + .noVideo() + //.inputFormat('mp3') + //.inputOption('-ac 2') + .audioCodec('libopus') + .audioBitrate('192') + .format('opus') + .on('error', function(err) { + self.log.error('file: error while transcoding ' + song.songId + ': ' + err); + if (fs.existsSync(incompletePath)) { + fs.unlinkSync(incompletePath); + } + callback(err); + }); + + var opusStream = command.pipe(null, {end: true}); + opusStream.on('data', function(chunk) { + incompleteStream.write(chunk, undefined, function() { + callback(null, chunk, false); + }); + }); + opusStream.on('end', function() { + incompleteStream.end(undefined, undefined, function() { + self.log.verbose('transcoding ended for ' + song.songId); + + // TODO: we don't know if transcoding ended successfully or not, + // and there might be a race condition between errCallback deleting + // the file and us trying to move it to the songCache + // TODO: is this still the case? + + // atomically move result to encodedPath + if (fs.existsSync(incompletePath)) { + fs.renameSync(incompletePath, encodedPath); + } + + callback(null, null, true); + }); + }); + + self.log.verbose('transcoding ' + song.songId + '...'); + + // return a function which can be used for terminating encoding + return function(err) { + command.kill(); + self.log.verbose('file: canceled preparing: ' + song.songId + ': ' + err); + if (fs.existsSync(incompletePath)) { + fs.unlinkSync(incompletePath); + } + callback(new Error('canceled preparing: ' + song.songId + ': ' + err)); + }; +}; + +// dummy functions + /** * Synchronously(!) returns whether the song with songId is prepared or not - * @param {String} songId - Backend identifies song by this ID + * @param {Song} song - Query concerns this song * @returns {Boolean} - true if song is prepared, false if not */ -Backend.prototype.songPrepared = function(songId) { - // TODO: move to module - return this.module.songPrepared(songId); +Backend.prototype.songPrepared = function(song) { + this.log.error('FATAL: backend does not implement songPrepared()!'); + return false; +}; + +/** + * Prepare song for playback + * @param {Song} song - Query concerns this song + * @param {Function} callback - Called when song is ready or on error + */ +Backend.prototype.prepare = function(song, callback) { + this.log.error('FATAL: backend does not implement prepare()!'); + callback(new Error('FATAL: backend does not implement prepare()!')); }; module.exports = Backend; diff --git a/lib/backends/local.js b/lib/backends/local.js index 974f857..a5b5292 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -30,57 +30,6 @@ var medialibraryPath; // TODO: seeking var encodeSong = function(origStream, seek, song, progCallback, errCallback) { - var incompletePath = coreConfig.songCachePath + '/file/incomplete/' + song.songID + '.opus'; - var incompleteStream = fs.createWriteStream(incompletePath, {flags: 'w'}); - var encodedPath = coreConfig.songCachePath + '/file/' + song.songID + '.opus'; - - var command = ffmpeg(origStream) - .noVideo() - //.inputFormat('mp3') - //.inputOption('-ac 2') - .audioCodec('libopus') - .audioBitrate('192') - .format('opus') - .on('error', function(err) { - logger.error('file: error while transcoding ' + song.songID + ': ' + err); - if (fs.existsSync(incompletePath)) { - fs.unlinkSync(incompletePath); - } - errCallback(song, err); - }); - - var opusStream = command.pipe(null, {end: true}); - opusStream.on('data', function(chunk) { - incompleteStream.write(chunk, undefined, function() { - progCallback(song, chunk, false); - }); - }); - opusStream.on('end', function() { - incompleteStream.end(undefined, undefined, function() { - logger.verbose('transcoding ended for ' + song.songID); - - // TODO: we don't know if transcoding ended successfully or not, - // and there might be a race condition between errCallback deleting - // the file and us trying to move it to the songCache - - // atomically move result to encodedPath - if (fs.existsSync(incompletePath)) { - fs.renameSync(incompletePath, encodedPath); - } - - progCallback(song, null, true); - }); - }); - - logger.verbose('transcoding ' + song.songID + '...'); - return function(err) { - command.kill(); - logger.verbose('file: canceled preparing: ' + song.songID + ': ' + err); - if (fs.existsSync(incompletePath)) { - fs.unlinkSync(incompletePath); - } - errCallback(song, 'canceled preparing: ' + song.songID + ': ' + err); - }; }; // cache songID to disk. diff --git a/lib/plugin.js b/lib/plugin.js index 3aa5022..31abc9e 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -1,9 +1,8 @@ +var labeledLogger = require('./logger'); + /** * Super constructor for plugins */ - -var labeledLogger = require('./logger'); - function Plugin() { this.name = this.constructor.name.toLowerCase(); this.hooks = {}; From 9ff7d91501722aa68deb72bc689729511d9ec0bf Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sun, 6 Dec 2015 22:52:46 +0200 Subject: [PATCH 052/103] comment --- lib/backend.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/backend.js b/lib/backend.js index 7c496f7..de18826 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -73,6 +73,7 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { if (fs.existsSync(incompletePath)) { fs.renameSync(incompletePath, encodedPath); } + // TODO: (and what if this fails?) callback(null, null, true); }); From 20c60d7955d6d369e97908c4834a067f75b8015f Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 7 Dec 2015 01:47:06 +0200 Subject: [PATCH 053/103] s/vars/player --- lib/plugins/express.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/plugins/express.js b/lib/plugins/express.js index b570fc5..2a8d69d 100644 --- a/lib/plugins/express.js +++ b/lib/plugins/express.js @@ -10,12 +10,12 @@ var fs = require('fs'); var util = require('util'); var Plugin = require('../plugin'); -function Express(vars, callback) { +function Express(player, callback) { Plugin.apply(this); // NOTE: no argument passed so we get the core's config var config = require('../config').getConfig(); - vars.app = express(); + player.app = express(); var options = {}; if (config.tls) { @@ -28,17 +28,17 @@ function Express(vars, callback) { rejectUnauthorized: config.rejectUnauthorized }; // TODO: deprecated! - vars.app.set('tls', true); - vars.httpServer = https.createServer(options, vars.app) + player.app.set('tls', true); + player.httpServer = https.createServer(options, player.app) .listen(process.env.PORT || config.port); } else { - vars.httpServer = http.createServer(vars.app) + player.httpServer = http.createServer(player.app) .listen(process.env.PORT || config.port); } - vars.app.use(cookieParser()); - vars.app.use(bodyParser.json({limit: '100mb'})); - vars.app.use(bodyParser.urlencoded({extended: true})); + player.app.use(cookieParser()); + player.app.use(bodyParser.json({limit: '100mb'})); + player.app.use(bodyParser.urlencoded({extended: true})); callback(null, this); } From f1747b2c9ed53ea00e8d2012d3d9acb1a9650e5d Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 7 Dec 2015 02:38:07 +0200 Subject: [PATCH 054/103] WIP: get local backend running again --- lib/backend.js | 18 ++- lib/backends/index.js | 2 +- lib/backends/local.js | 326 ++++++++++++++++++++---------------------- lib/config.js | 38 +++-- lib/song.js | 4 +- package.json | 3 +- 6 files changed, 205 insertions(+), 186 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index de18826..0693ba7 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -99,14 +99,14 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { * @param {Song} song - Query concerns this song * @returns {Boolean} - true if song is prepared, false if not */ -Backend.prototype.songPrepared = function(song) { +Backend.prototype.isPrepared = function(song) { this.log.error('FATAL: backend does not implement songPrepared()!'); return false; }; /** * Prepare song for playback - * @param {Song} song - Query concerns this song + * @param {Song} song - Song to prepare * @param {Function} callback - Called when song is ready or on error */ Backend.prototype.prepare = function(song, callback) { @@ -114,4 +114,18 @@ Backend.prototype.prepare = function(song, callback) { callback(new Error('FATAL: backend does not implement prepare()!')); }; +/** + * Search for songs + * @param {Object} query - Search terms + * @param {String} [query.artist] - Artist + * @param {String} [query.title] - Title + * @param {String} [query.album] - Album + * @param {Boolean} [query.any] - Match any of the above, otherwise all fields have to match + * @param {Function} callback - Called with error or results + */ +Backend.prototype.search = function() { + this.log.error('FATAL: backend does not implement search()!'); + callback(new Error('FATAL: backend does not implement search()!')); +}; + module.exports = Backend; diff --git a/lib/backends/index.js b/lib/backends/index.js index 1ddfb11..ef8282c 100644 --- a/lib/backends/index.js +++ b/lib/backends/index.js @@ -1,4 +1,4 @@ var Backends = []; -//Backends.push(require('./local')); +Backends.push(require('./local')); module.exports = Backends; diff --git a/lib/backends/local.js b/lib/backends/local.js index a5b5292..645818d 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -1,155 +1,14 @@ 'use strict'; -var MODULE_NAME = 'file'; -var MODULE_TYPE = 'backend'; - -var walk = require('walk'); -var probe = require('node-ffprobe'); var path = require('path'); var mkdirp = require('mkdirp'); -var url = require('url'); -var fs = require('fs'); +var mongoose = require('mongoose'); var async = require('async'); -var ffmpeg = require('fluent-ffmpeg'); -var watch = require('node-watch'); -var _ = require('underscore'); -var escapeStringRegexp = require('escape-string-regexp'); - -var nodeplayerConfig = require('nodeplayer').config; -var coreConfig = nodeplayerConfig.getConfig(); -var defaultConfig = require('./default-config.js'); -var config = nodeplayerConfig.getConfig(MODULE_TYPE + '-' + MODULE_NAME, defaultConfig); - -var fileBackend = {}; -fileBackend.name = MODULE_NAME; - -var logger = require('nodeplayer').logger(MODULE_NAME); -var walker; -var db; -var medialibraryPath; - -// TODO: seeking -var encodeSong = function(origStream, seek, song, progCallback, errCallback) { -}; - -// cache songID to disk. -// on success: progCallback must be called with true as argument -// on failure: errCallback must be called with error message -// returns a function that cancels preparing -fileBackend.prepareSong = function(song, progCallback, errCallback) { - var filePath = coreConfig.songCachePath + '/file/' + song.songID + '.opus'; - - if (fs.existsSync(filePath)) { - progCallback(song, null, true); - } else { - var cancelEncode = null; - var canceled = false; - var cancelPreparing = function() { - canceled = true; - if (cancelEncode) { - cancelEncode(); - } - }; - - db.collection('songs').findById(song.songID, function(err, item) { - if (canceled) { - errCallback(song, 'song was canceled before encoding started'); - } else if (item) { - var readStream = fs.createReadStream(item.file); - cancelEncode = encodeSong(readStream, 0, song, progCallback, errCallback); - readStream.on('error', function(err) { - errCallback(song, err); - }); - } else { - errCallback(song, 'song not found in local db'); - } - }); - - return cancelEncode; - } -}; - -fileBackend.isPrepared = function(song) { - var filePath = coreConfig.songCachePath + '/file/' + song.songID + '.opus'; - return fs.existsSync(filePath); -}; - -fileBackend.search = function(query, callback, errCallback) { - var q; - if (query.any) { - q = { - $or: [ - {artist: new RegExp(escapeStringRegexp(query.any), 'i')}, - {title: new RegExp(escapeStringRegexp(query.any), 'i')}, - {album: new RegExp(escapeStringRegexp(query.any), 'i')} - ] - }; - } else { - q = { - $and: [] - }; - - _.keys(query).forEach(function(key) { - var criterion = {}; - criterion[key] = new RegExp(escapeStringRegexp(query[key]), 'i'); - q.$and.push(criterion); - }); - } - - logger.verbose('Got query: '); - logger.verbose(q); - db.collection('songs').find(q).toArray(function(err, items) { - // Also filter away special chars? (Remix) ?= Remix åäö日本穂? - /* - var termsArr = query.terms.split(' '); - termsArr.forEach(function(e, i, arr) {arr[i] = e.toLowerCase();}); - for (var i in items) { - items[i].score = 0; - var words = []; - if (items[i].title) { - words = words.concat(items[i].title.split(' ')); - } - if (items[i].artist) { - words = words.concat(items[i].artist.split(' ')); - } - if (items[i].album) { - words = words.concat(items[i].album.split(' ')); - } - words.forEach(function(e, i, arr) {arr[i] = e.toLowerCase();}); - for (var ii in words) { - if (termsArr.indexOf(words[ii]) >= 0) { - items[i].score++; - } - } - } - items.sort(function(a, b) { - return b.score - a.score; // sort by score - }); - */ - var results = {}; - results.songs = {}; +var util = require('util'); +var Backend = require('../backend'); - var numItems = items.length; - var cur = 0; - for (var song in items) { - results.songs[items[song]._id.toString()] = { - artist: items[song].artist, - title: items[song].title, - album: items[song].album, - albumArt: null, // TODO: can we add this? - duration: items[song].duration, - songID: items[song]._id.toString(), - score: config.maxScore * (numItems - cur) / numItems, - backendName: MODULE_NAME, - format: 'opus' - }; - cur++; - if (Object.keys(results.songs).length > coreConfig.searchResultCnt) { break; } - } - callback(results); - }); -}; +/* var probeCallback = function(err, probeData, next) { var formats = config.importFormats; if (probeData) { @@ -215,27 +74,41 @@ var probeCallback = function(err, probeData, next) { next(); } }; +*/ -fileBackend.init = function(callback) { - mkdirp.sync(coreConfig.songCachePath + '/file/incomplete'); +// database model +var Song = mongoose.model('Song', { + title: String, + artist: String, + album: String, + albumArt: { + lq: String, + hq: String + }, + duration: Number, + format: String +}); - //jscs:disable requireCamelCaseOrUpperCaseIdentifiers - db = require('mongoskin').db(config.mongo, {native_parser:true, safe:true}); - //jscs:enable requireCamelCaseOrUpperCaseIdentifiers +function Local(callback) { + Backend.apply(this); - var importPath = config.importPath; + // NOTE: no argument passed so we get the core's config + var config = require('../config').getConfig(); + this.songCachePath = config.songCachePath; - // Adds text index to database for title, artist and album fields - // TODO: better handling and error checking - var cb = function(err, index) { - if (err) { - logger.error(err); - logger.error('Forgot to setup mongodb?'); - } else if (index) { - logger.silly('index: ' + index); - } - }; - db.collection('songs').ensureIndex({title: 'text', artist: 'text', album: 'text'}, cb); + // make sure all necessary directories exist + mkdirp.sync(path.join(this.songCachePath, 'file', 'incomplete')); + + // connect to the database + mongoose.connect(config.mongo); + + var db = mongoose.connection; + db.on('error', function(err) { + return callback(err); + }); + db.once('open', function() { + return callback(); + }); var options = { followLinks: config.followSymlinks @@ -254,8 +127,8 @@ fileBackend.init = function(callback) { // walk the filesystem and scan files // TODO: also check through entire DB to see that all files still exist on the filesystem if (config.rescanAtStart) { - logger.info('Scanning directory: ' + importPath); - walker = walk.walk(importPath, options); + logger.info('Scanning directory: ' + config.importPath); + walker = walk.walk(config.importPath, options); var startTime = new Date(); var scanned = 0; walker.on('file', function(root, fileStats, next) { @@ -275,7 +148,10 @@ fileBackend.init = function(callback) { // set fs watcher on media directory // TODO: add a debounce so if the file keeps changing we don't probe it multiple times - watch(importPath, {recursive: true, followSymlinks: config.followSymlinks}, function(filename) { + watch(config.importPath, { + recursive: true, + followSymlinks: config.followSymlinks + }, function(filename) { if (fs.existsSync(filename)) { logger.debug(filename + ' modified or created, queued for probing'); q.unshift({ @@ -288,8 +164,122 @@ fileBackend.init = function(callback) { }); } }); +} - // callback right away, as we can scan for songs in the background - callback(); +/** + * Synchronously(!) returns whether the song is prepared or not + * @param {Song} song - Song to check + * @returns {Boolean} - true if song is prepared, false if not + */ +Local.prototype.isPrepared = function(song) { + var filePath = path.join(this.songCachePath, 'file', song.songId + '.opus'); + return fs.existsSync(filePath); }; -module.exports = fileBackend; + +/** + * Prepare song for playback + * @param {Song} song - Song to prepare + * @param {Function} callback - Called when song is ready or if an error occurred + */ +Local.prototype.prepare = function(song, callback) { + var filePath = coreConfig.songCachePath + '/file/' + song.songId + '.opus'; + + if (fs.existsSync(filePath)) { + callback(null, null, true); + } else { + var cancelEncode = null; + var canceled = false; + var cancelPreparing = function() { + canceled = true; + if (cancelEncode) { + cancelEncode(); + } + }; + + db.collection('songs').findById(song.songId, function(err, item) { + if (canceled) { + callback(new Error('song was canceled before encoding started')); + } else if (item) { + var readStream = fs.createReadStream(item.file); + cancelEncode = encodeSong(readStream, 0, song, callback); + readStream.on('error', function(err) { + callback(err); + }); + } else { + callback('song not found in local db: ' + song.songId); + } + }); + + return cancelEncode; + } +}; + +/** + * Search for songs + * @param {Object} query - Search terms + * @param {String} [query.artist] - Artist + * @param {String} [query.title] - Title + * @param {String} [query.album] - Album + * @param {Boolean} [query.any] - Match any of the above, otherwise all fields have to match + * @param {Function} callback - Called when song is ready or if an error occurred + */ +Local.prototype.search = function(query, callback) { + var q; + if (query.any) { + q = { + $or: [ + {artist: new RegExp(escapeStringRegexp(query.any), 'i')}, + {title: new RegExp(escapeStringRegexp(query.any), 'i')}, + {album: new RegExp(escapeStringRegexp(query.any), 'i')} + ] + }; + } else { + q = { + $and: [] + }; + + _.keys(query).forEach(function(key) { + var criterion = {}; + criterion[key] = new RegExp(escapeStringRegexp(query[key]), 'i'); + q.$and.push(criterion); + }); + } + + this.log.verbose('Got query: '); + this.log.verbose(q); + + db.collection('songs').find(q).toArray(function(err, items) { + if (err) { + return callback(err); + } + + var results = {}; + results.songs = {}; + + var numItems = items.length; + var cur = 0; + for (var song in items) { + results.songs[items[song]._id.toString()] = { + artist: items[song].artist, + title: items[song].title, + album: items[song].album, + albumArt: null, // TODO: can we add this? + duration: items[song].duration, + songId: items[song]._id.toString(), + score: config.maxScore * (numItems - cur) / numItems, + backendName: MODULE_NAME, + format: 'opus' + }; + cur++; + + if (Object.keys(results.songs).length > coreConfig.searchResultCnt) { + break; + } + } + callback(null, results); + }); +}; + +util.inherits(Local, Backend); + +module.exports = Local; diff --git a/lib/config.js b/lib/config.js index bfc7714..1f9e32b 100644 --- a/lib/config.js +++ b/lib/config.js @@ -13,15 +13,6 @@ function getHomeDir() { } exports.getHomeDir = getHomeDir; -function getConfigDir() { - if (process.platform === 'win32') { - return process.env.USERPROFILE + '\\nodeplayer\\config'; - } else { - return process.env.HOME + '/.nodeplayer/config'; - } -} -exports.getConfigDir = getConfigDir; - function getBaseDir() { if (process.platform === 'win32') { return process.env.USERPROFILE + '\\nodeplayer'; @@ -56,13 +47,36 @@ defaultConfig.logColorize = true; defaultConfig.logExceptions = false; // disabled for now because it looks terrible defaultConfig.logJson = false; -defaultConfig.songCachePath = getBaseDir() + path.sep + 'song-cache'; +defaultConfig.songCachePath = path.join(getBaseDir(), 'song-cache'); defaultConfig.searchResultCnt = 10; defaultConfig.playedQueueSize = 100; defaultConfig.songDelayMs = 1000; // add delay between songs to prevent skips defaultConfig.songPrepareTimeout = 10000; // cancel preparation if no progress +// built-in express plugin +defaultConfig.port = 8080; +defaultConfig.tls = false; +defaultConfig.key = path.join(getBaseDir(), 'nodeplayer-key.pem'); +defaultConfig.cert = path.join(getBaseDir(), 'nodeplayer-cert.pem'); +defaultConfig.ca = path.join(getBaseDir(), 'nodeplayer-ca.pem'); +defaultConfig.requestCert = false; +defaultConfig.rejectUnauthorized = true; + +// built-in local file backend +defaultConfig.mongo = 'mongodb://localhost:27017/nodeplayer-backend-file'; +defaultConfig.rescanAtStart = false; +defaultConfig.importPath = path.join(getHomeDir(), 'music'); +defaultConfig.importFormats = [ + 'mp3', + 'flac', + 'ogg', + 'opus' +]; +defaultConfig.concurrentProbes = 4; +defaultConfig.followSymlinks = true; +defaultConfig.maxScore = 10; // FIXME: ATM the search algo can return VERY irrelevant results + // hostname of the server, may be used as a default value by other plugins defaultConfig.hostname = os.hostname(); @@ -79,7 +93,7 @@ exports.getConfig = function(module, defaults) { var moduleName = module ? module.name : null; - var configPath = getConfigDir() + path.sep + (moduleName || 'core') + '.json'; + var configPath = path.join(getBaseDir(), 'config', (moduleName || 'core') + '.json'); try { var userConfig = require(configPath); @@ -99,7 +113,7 @@ exports.getConfig = function(module, defaults) { 'will be written into:'); console.warn(configPath); - mkdirp.sync(getConfigDir()); + mkdirp.sync(path.join(getBaseDir(), 'config')); fs.writeFileSync(configPath, JSON.stringify(defaults || defaultConfig, undefined, 4)); console.warn('\nFile created. Go edit it NOW!'); diff --git a/lib/song.js b/lib/song.js index cfeb529..c169f7a 100644 --- a/lib/song.js +++ b/lib/song.js @@ -80,12 +80,12 @@ Song.prototype.details = function() { * @returns {Boolean} - true if song is prepared, false if not */ Song.prototype.isPrepared = function() { - return this.backend.songPrepared(this); + return this.backend.isPrepared(this); }; /** * Prepare song for playback - * @param {Function} callback - Called when song is ready or if an error occurred + * @param {Function} callback - Called when song is ready or on error */ Song.prototype.prepare = function(callback) { return this.backend.prepare(this, callback); diff --git a/package.json b/package.json index 1885494..4873784 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "body-parser": "^1.14.1", "cookie-parser": "^1.4.0", "express": "^4.13.3", - "mkdirp": "^0.5.0", + "mkdirp": "^0.5.1", + "mongoose": "^4.2.9", "node-uuid": "^1.4.4", "npm": "^2.7.1", "underscore": "^1.7.0", From 48e478fb77e7ec675363161c6a49fba40f3e5a2a Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 14 Dec 2015 01:16:23 +0200 Subject: [PATCH 055/103] probe and db insert works --- lib/backends/local.js | 110 +++++++++++++++++++++++++++++++++--------- lib/config.js | 7 +-- package.json | 2 + 3 files changed, 89 insertions(+), 30 deletions(-) diff --git a/lib/backends/local.js b/lib/backends/local.js index 645818d..e6e1c6c 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -4,6 +4,9 @@ var path = require('path'); var mkdirp = require('mkdirp'); var mongoose = require('mongoose'); var async = require('async'); +var walk = require('walk'); +var ffprobe = require('node-ffprobe'); +var _ = require('underscore'); var util = require('util'); var Backend = require('../backend'); @@ -58,19 +61,19 @@ var probeCallback = function(err, probeData, next) { db.collection('songs').update({file: probeData.file}, {'$set':song}, {upsert: true}, function(err, result) { if (result == 1) { - logger.debug('Upserted: ' + probeData.file); + self.log.debug('Upserted: ' + probeData.file); } else { - logger.error('error while updating db: ' + err); + self.log.error('error while updating db: ' + err); } next(); }); } else { - logger.verbose('format not supported, skipping...'); + self.log.verbose('format not supported, skipping...'); next(); } } else { - logger.error('error while probing:' + err); + self.log.error('error while probing:' + err); next(); } }; @@ -85,16 +88,31 @@ var Song = mongoose.model('Song', { lq: String, hq: String }, - duration: Number, - format: String + duration: { + type: Number, + required: true + }, + format: { + type: String, + required: true + }, + filename: { + type: String, + unique: true, + required: true, + dropDups: true + } }); function Local(callback) { Backend.apply(this); + var self = this; + // NOTE: no argument passed so we get the core's config var config = require('../config').getConfig(); this.songCachePath = config.songCachePath; + this.importFormats = config.importFormats; // make sure all necessary directories exist mkdirp.sync(path.join(this.songCachePath, 'file', 'incomplete')); @@ -104,36 +122,74 @@ function Local(callback) { var db = mongoose.connection; db.on('error', function(err) { - return callback(err); + return callback(err, self); }); db.once('open', function() { - return callback(); + return callback(null, self); }); var options = { followLinks: config.followSymlinks }; + var insertSong = function(probeData, done) { + var song = new Song({ + title: probeData.metadata.TITLE, + artist: probeData.metadata.ARTIST, + album: probeData.metadata.ALBUM, + //albumArt: {} // TODO + duration: probeData.format.duration * 1000, + format: probeData.format.format_name, + filename: probeData.file + }); + + song = song.toObject(); + + delete song._id; + + Song.findOneAndUpdate({ + filename: probeData.file + }, song, {upsert: true}, function(err) { + if (err) { + self.log.error('while inserting song: ' + probeData.file + ', ' + err); + } + done(); + }); + }; + // create async.js queue to limit concurrent probes - var q = async.queue(function(task, callback) { - probe(task.filename, function(err, probeData) { - probeCallback(err, probeData, function() { - logger.silly('q.length(): ' + q.length(), 'q.running(): ' + q.running()); - callback(); - }); + var q = async.queue(function(task, done) { + ffprobe(task.filename, function(err, probeData) { + if (!probeData) { + return done(); + } + + var validStreams = false; + + if (_.contains(self.importFormats, probeData.format.format_name)) { + validStreams = true; + } + + if (validStreams) { + insertSong(probeData, done); + } else { + self.log.info('skipping file of unknown format: ' + task.filename); + done(); + } }); }, config.concurrentProbes); // walk the filesystem and scan files // TODO: also check through entire DB to see that all files still exist on the filesystem + // TODO: filter by allowed filename extensions if (config.rescanAtStart) { - logger.info('Scanning directory: ' + config.importPath); - walker = walk.walk(config.importPath, options); + self.log.info('Scanning directory: ' + config.importPath); + var walker = walk.walk(config.importPath, options); var startTime = new Date(); var scanned = 0; walker.on('file', function(root, fileStats, next) { var filename = path.join(root, fileStats.name); - logger.verbose('Scanning: ' + filename); + self.log.verbose('Scanning: ' + filename); scanned++; q.push({ filename: filename @@ -141,31 +197,35 @@ function Local(callback) { next(); }); walker.on('end', function() { - logger.verbose('Scanned files: ' + scanned); - logger.verbose('Done in: ' + Math.round((new Date() - startTime) / 1000) + ' seconds'); + self.log.verbose('Scanned files: ' + scanned); + self.log.verbose('Done in: ' + Math.round((new Date() - startTime) / 1000) + ' seconds'); }); } + // TODO: fs watch // set fs watcher on media directory // TODO: add a debounce so if the file keeps changing we don't probe it multiple times + /* watch(config.importPath, { recursive: true, followSymlinks: config.followSymlinks }, function(filename) { if (fs.existsSync(filename)) { - logger.debug(filename + ' modified or created, queued for probing'); + self.log.debug(filename + ' modified or created, queued for probing'); q.unshift({ filename: filename }); } else { - logger.debug(filename + ' deleted'); + self.log.debug(filename + ' deleted'); db.collection('songs').remove({file: filename}, function(err, items) { - logger.debug(filename + ' deleted from db: ' + err + ', ' + items); + self.log.debug(filename + ' deleted from db: ' + err + ', ' + items); }); } }); + */ } + /** * Synchronously(!) returns whether the song is prepared or not * @param {Song} song - Song to check @@ -182,6 +242,8 @@ Local.prototype.isPrepared = function(song) { * @param {Function} callback - Called when song is ready or if an error occurred */ Local.prototype.prepare = function(song, callback) { + var self = this; + var filePath = coreConfig.songCachePath + '/file/' + song.songId + '.opus'; if (fs.existsSync(filePath)) { @@ -200,8 +262,8 @@ Local.prototype.prepare = function(song, callback) { if (canceled) { callback(new Error('song was canceled before encoding started')); } else if (item) { - var readStream = fs.createReadStream(item.file); - cancelEncode = encodeSong(readStream, 0, song, callback); + var readStream = fs.createReadStream(item.filename); + cancelEncode = self.encodeSong(readStream, 0, song, callback); readStream.on('error', function(err) { callback(err); }); diff --git a/lib/config.js b/lib/config.js index 1f9e32b..8e7f6ee 100644 --- a/lib/config.js +++ b/lib/config.js @@ -24,7 +24,7 @@ exports.getBaseDir = getBaseDir; var defaultConfig = {}; -// backends are sources of music, default backends don't require API keys +// backends are sources of music defaultConfig.backends = [ 'youtube' ]; @@ -34,11 +34,6 @@ defaultConfig.backends = [ // NOTE: ordering is important here, plugins that require another plugin will // complain if order is wrong. defaultConfig.plugins = [ - 'storequeue', - 'express', - 'socketio', - 'passport', - 'rest', 'weblistener' ]; diff --git a/package.json b/package.json index 4873784..f05c70b 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ "express": "^4.13.3", "mkdirp": "^0.5.1", "mongoose": "^4.2.9", + "node-ffprobe": "^1.2.2", "node-uuid": "^1.4.4", "npm": "^2.7.1", "underscore": "^1.7.0", + "walk": "^2.3.9", "winston": "^0.9.0", "yargs": "^3.6.0" }, From 68062ec8b362d855e75313fc243b0bba2221909e Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 14 Dec 2015 01:22:15 +0200 Subject: [PATCH 056/103] fix build --- lib/backends/local.js | 26 +++++++++++++++++--------- package.json | 1 + 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/backends/local.js b/lib/backends/local.js index e6e1c6c..89dfd81 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -1,16 +1,21 @@ 'use strict'; var path = require('path'); +var fs = require('fs'); var mkdirp = require('mkdirp'); var mongoose = require('mongoose'); var async = require('async'); var walk = require('walk'); var ffprobe = require('node-ffprobe'); var _ = require('underscore'); +var escapeStringRegexp = require('escape-string-regexp'); var util = require('util'); var Backend = require('../backend'); +// external libraries use lower_case extensively here +//jscs:disable requireCamelCaseOrUpperCaseIdentifiers + /* var probeCallback = function(err, probeData, next) { var formats = config.importFormats; @@ -58,7 +63,7 @@ var probeCallback = function(err, probeData, next) { song.file = probeData.file; song.duration = probeData.format.duration * 1000; - db.collection('songs').update({file: probeData.file}, {'$set':song}, {upsert: true}, + Song.update({file: probeData.file}, {'$set':song}, {upsert: true}, function(err, result) { if (result == 1) { self.log.debug('Upserted: ' + probeData.file); @@ -111,6 +116,7 @@ function Local(callback) { // NOTE: no argument passed so we get the core's config var config = require('../config').getConfig(); + this.config = config; this.songCachePath = config.songCachePath; this.importFormats = config.importFormats; @@ -198,7 +204,8 @@ function Local(callback) { }); walker.on('end', function() { self.log.verbose('Scanned files: ' + scanned); - self.log.verbose('Done in: ' + Math.round((new Date() - startTime) / 1000) + ' seconds'); + self.log.verbose('Done in: ' + + Math.round((new Date() - startTime) / 1000) + ' seconds'); }); } @@ -225,7 +232,6 @@ function Local(callback) { */ } - /** * Synchronously(!) returns whether the song is prepared or not * @param {Song} song - Song to check @@ -244,7 +250,7 @@ Local.prototype.isPrepared = function(song) { Local.prototype.prepare = function(song, callback) { var self = this; - var filePath = coreConfig.songCachePath + '/file/' + song.songId + '.opus'; + var filePath = self.songCachePath + '/file/' + song.songId + '.opus'; if (fs.existsSync(filePath)) { callback(null, null, true); @@ -258,7 +264,7 @@ Local.prototype.prepare = function(song, callback) { } }; - db.collection('songs').findById(song.songId, function(err, item) { + Song.findById(song.songId, function(err, item) { if (canceled) { callback(new Error('song was canceled before encoding started')); } else if (item) { @@ -286,6 +292,8 @@ Local.prototype.prepare = function(song, callback) { * @param {Function} callback - Called when song is ready or if an error occurred */ Local.prototype.search = function(query, callback) { + var self = this; + var q; if (query.any) { q = { @@ -310,7 +318,7 @@ Local.prototype.search = function(query, callback) { this.log.verbose('Got query: '); this.log.verbose(q); - db.collection('songs').find(q).toArray(function(err, items) { + Song.find(q).toArray(function(err, items) { if (err) { return callback(err); } @@ -328,13 +336,13 @@ Local.prototype.search = function(query, callback) { albumArt: null, // TODO: can we add this? duration: items[song].duration, songId: items[song]._id.toString(), - score: config.maxScore * (numItems - cur) / numItems, - backendName: MODULE_NAME, + score: self.config.maxScore * (numItems - cur) / numItems, + backendName: 'local', format: 'opus' }; cur++; - if (Object.keys(results.songs).length > coreConfig.searchResultCnt) { + if (Object.keys(results.songs).length > self.config.searchResultCnt) { break; } } diff --git a/package.json b/package.json index f05c70b..0ba3909 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "async": "^0.9.0", "body-parser": "^1.14.1", "cookie-parser": "^1.4.0", + "escape-string-regexp": "^1.0.3", "express": "^4.13.3", "mkdirp": "^0.5.1", "mongoose": "^4.2.9", From 911a7bd552e997beecca6168d2b487dba0aeb834 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 14 Dec 2015 01:41:22 +0200 Subject: [PATCH 057/103] util.inherits must be called immediately after constructor --- lib/backend.js | 2 +- lib/backends/local.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index 0693ba7..4f4fa3d 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -123,7 +123,7 @@ Backend.prototype.prepare = function(song, callback) { * @param {Boolean} [query.any] - Match any of the above, otherwise all fields have to match * @param {Function} callback - Called with error or results */ -Backend.prototype.search = function() { +Backend.prototype.search = function(query, callback) { this.log.error('FATAL: backend does not implement search()!'); callback(new Error('FATAL: backend does not implement search()!')); }; diff --git a/lib/backends/local.js b/lib/backends/local.js index 89dfd81..9a9a6ec 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -232,6 +232,9 @@ function Local(callback) { */ } +// must be called immediately after constructor +util.inherits(Local, Backend); + /** * Synchronously(!) returns whether the song is prepared or not * @param {Song} song - Song to check @@ -350,6 +353,4 @@ Local.prototype.search = function(query, callback) { }); }; -util.inherits(Local, Backend); - module.exports = Local; From 762df9b28d43283608d2d79032f6ad8becd6ef64 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 14 Dec 2015 01:53:12 +0200 Subject: [PATCH 058/103] fix search, s/songID/songId --- lib/backends/local.js | 41 +++++++++++++++++++---------------------- lib/player.js | 24 ++++++++++++------------ lib/plugins/rest.js | 24 ++++++++++++------------ 3 files changed, 43 insertions(+), 46 deletions(-) diff --git a/lib/backends/local.js b/lib/backends/local.js index 9a9a6ec..a274a27 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -318,10 +318,7 @@ Local.prototype.search = function(query, callback) { }); } - this.log.verbose('Got query: '); - this.log.verbose(q); - - Song.find(q).toArray(function(err, items) { + Song.find(q).exec(function(err, items) { if (err) { return callback(err); } @@ -331,25 +328,25 @@ Local.prototype.search = function(query, callback) { var numItems = items.length; var cur = 0; - for (var song in items) { - results.songs[items[song]._id.toString()] = { - artist: items[song].artist, - title: items[song].title, - album: items[song].album, - albumArt: null, // TODO: can we add this? - duration: items[song].duration, - songId: items[song]._id.toString(), - score: self.config.maxScore * (numItems - cur) / numItems, - backendName: 'local', - format: 'opus' - }; - cur++; - - if (Object.keys(results.songs).length > self.config.searchResultCnt) { - break; + items.forEach(function(song) { + if (Object.keys(results.songs).length <= self.config.searchResultCnt) { + song = song.toObject(); + + results.songs[song._id] = { + artist: song.artist, + title: song.title, + album: song.album, + albumArt: null, // TODO: can we add this? + duration: song.duration, + songId: song._id, + score: self.config.maxScore * (numItems - cur) / numItems, + backendName: 'local', + format: 'opus' + }; + cur++; } - } - callback(null, results); + }); + callback(results); }); }; diff --git a/lib/player.js b/lib/player.js index bccc831..c96404f 100644 --- a/lib/player.js +++ b/lib/player.js @@ -208,9 +208,9 @@ Player.prototype.startPlayback = function(pos) { } if (!_.isUndefined(pos) && !_.isNull(pos)) { - this.logger.info('playing song: ' + np.songID + ', from pos: ' + pos); + this.logger.info('playing song: ' + np.songId + ', from pos: ' + pos); } else { - this.logger.info('playing song: ' + np.songID); + this.logger.info('playing song: ' + np.songId); } var oldPlaybackStart = this.queue.playbackStart; @@ -261,7 +261,7 @@ Player.prototype.setPrepareTimeout = function(song) { } song.prepareTimeout = setTimeout(function() { - player.logger.info('prepare timeout for song: ' + song.songID + ', removing'); + player.logger.info('prepare timeout for song: ' + song.songId + ', removing'); song.cancelPrepare('prepare timeout'); song.prepareTimeout = null; }, this.config.songPrepareTimeout); @@ -276,11 +276,11 @@ Player.prototype.prepareError = function(song, err) { // remove all instances of this song /* for (var i = this.queue.length - 1; i >= 0; i--) { - if (this.queue[i].songID === song.songID && + if (this.queue[i].songId === song.songId && this.queue[i].backendName === song.backendName) { if (!song.beingDeleted) { this.logger.error('preparing song failed! (' + err + '), removing from queue: ' + - song.songID); + song.songId); this.removeFromQueue(i); } } @@ -325,8 +325,8 @@ Player.prototype.prepareProgCallback = function(song, newData, done, callback) { delete(song.cancelPrepare); // song data should now be available on disk, don't keep it in memory - this.songsPreparing[song.backendName][song.songID].songData = undefined; - delete(this.songsPreparing[song.backendName][song.songID]); + this.songsPreparing[song.backendName][song.songId].songData = undefined; + delete(this.songsPreparing[song.backendName][song.songId]); // clear prepare timeout clearTimeout(song.prepareTimeout); @@ -357,7 +357,7 @@ Player.prototype.prepareErrCallback = function(song, err, callback) { this.prepareError(song, err); song.songData = undefined; - delete(this.songsPreparing[song.backendName][song.songID]); + delete(this.songsPreparing[song.backendName][song.songId]); }; Player.prototype.prepareSong = function(song, callback) { @@ -379,13 +379,13 @@ Player.prototype.prepareSong = function(song, callback) { // song is already prepared, ok to prepare more songs callback(); - } else if (this.songsPreparing[song.backendName][song.songID]) { + } else if (this.songsPreparing[song.backendName][song.songId]) { // this song is still preparing, so don't yet prepare next song callback(true); } else { // song is not prepared and not currently preparing: let backend prepare it - this.logger.debug('DEBUG: prepareSong() ' + song.songID); - this.songsPreparing[song.backendName][song.songID] = song; + this.logger.debug('DEBUG: prepareSong() ' + song.songId); + this.songsPreparing[song.backendName][song.songId] = song; song.cancelPrepare = this.backends[song.backendName].prepareSong( song, @@ -518,7 +518,7 @@ Player.prototype.searchBackends = function(query, callback) { _.each(tempSongs, function(song) { var err = this.callHooks('preAddSearchResult', [song]); if (!err) { - allResults[backend.name].songs[song.songID] = song; + allResults[backend.name].songs[song.songId] = song; } else { this.logger.error('preAddSearchResult hook error: ' + err); } diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index 2ad95f2..3b2a353 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -86,7 +86,7 @@ function Rest(player, callback) { // search for songs, search terms in query params player.app.get('/search', function(req, res) { - self.log.verbose('got search request: ' + req.query); + self.log.verbose('got search request: ' + JSON.stringify(req.query)); player.searchBackends(req.query, function(results) { res.json(results); @@ -100,13 +100,13 @@ function Rest(player, callback) { return; } - _.each(rest.pendingRequests[song.backendName][song.songID], function(res) { + _.each(rest.pendingRequests[song.backendName][song.songId], function(res) { if (chunk) { res.write(chunk); } if (done) { res.end(); - rest.pendingRequests[song.backendName][song.songID] = []; + rest.pendingRequests[song.backendName][song.songId] = []; } }); }); @@ -117,26 +117,26 @@ function Rest(player, callback) { // provide API path for music data, might block while song is preparing player.app.get('/song/' + backendName + '/:fileName', function(req, res, next) { var extIndex = req.params.fileName.lastIndexOf('.'); - var songID = req.params.fileName.substring(0, extIndex); + var songId = req.params.fileName.substring(0, extIndex); var songFormat = req.params.fileName.substring(extIndex + 1); var song = { - songID: songID, + songId: songId, format: songFormat }; if (player.backends[backendName].isPrepared(song)) { // song should be available on disk - res.sendFile('/' + backendName + '/' + songID + '.' + songFormat, { + res.sendFile('/' + backendName + '/' + songId + '.' + songFormat, { root: config.songCachePath }); } else if (player.songsPreparing[backendName] && - player.songsPreparing[backendName][songID]) { + player.songsPreparing[backendName][songId]) { // song is preparing - var preparingSong = player.songsPreparing[backendName][songID]; + var preparingSong = player.songsPreparing[backendName][songId]; // try finding out length of song - var queuedSong = player.searchQueue(backendName, songID); + var queuedSong = player.searchQueue(backendName, songId); if (queuedSong) { res.setHeader('X-Content-Duration', queuedSong.duration / 1000); } @@ -177,10 +177,10 @@ function Rest(player, callback) { res.write(preparingSong.songData); } - rest.pendingRequests[backendName][song.songID] = - rest.pendingRequests[backendName][song.songID] || []; + rest.pendingRequests[backendName][song.songId] = + rest.pendingRequests[backendName][song.songId] || []; - rest.pendingRequests[backendName][song.songID].push(res); + rest.pendingRequests[backendName][song.songIsongId].push(res); } else { res.status(404).end('404 song not found'); } From 34fbc86524a69431387d63d2680395d4483977ef Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 21 Dec 2015 00:37:41 +0200 Subject: [PATCH 059/103] adding and listing a song works --- lib/modules.js | 10 ++++++---- lib/player.js | 36 ++++++++++++++++++++---------------- lib/plugins/rest.js | 12 ++++++++---- lib/queue.js | 35 ++++++++++++++++++++++++----------- lib/song.js | 18 +++++++++--------- 5 files changed, 67 insertions(+), 44 deletions(-) diff --git a/lib/modules.js b/lib/modules.js index 8aec159..f6019c1 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -57,6 +57,7 @@ var initModule = function(moduleShortName, moduleType, callback) { }); }; +// TODO: this probably doesn't work exports.loadBackends = function(player, backends, forceUpdate, done) { // first install missing backends installModules(backends, 'backend', forceUpdate, function() { @@ -88,6 +89,7 @@ exports.loadBackends = function(player, backends, forceUpdate, done) { }); }; +// TODO: this probably doesn't work exports.loadPlugins = function(player, plugins, forceUpdate, done) { // first install missing plugins installModules(plugins, 'plugin', forceUpdate, function() { @@ -131,10 +133,10 @@ exports.loadBuiltinPlugins = function(player, done) { } moduleLogger.verbose('plugin initialized'); player.callHooks('onPluginInitialized', plugin.name); - callback(null, plugin); + callback(null, [plugin.name, plugin]); }); }, function(err, results) { - done(results); + done(_.object(results)); }); }; @@ -149,9 +151,9 @@ exports.loadBuiltinBackends = function(player, done) { } player.callHooks('onBackendInitialized', backend.name); moduleLogger.verbose('backend initialized'); - callback(null, backend); + callback(null, [backend.name, backend]); }); }, function(err, results) { - done(results); + done(_.object(results)); }); }; diff --git a/lib/player.js b/lib/player.js index c96404f..c7182bf 100644 --- a/lib/player.js +++ b/lib/player.js @@ -38,7 +38,7 @@ function Player(options) { }, function(callback) { modules.loadPlugins(player, config.plugins, forceUpdate, function(results) { - player.plugins = player.plugins.concat(results); + player.plugins = _.extend(player.plugins, results); player.callHooks('onPluginsInitialized'); callback(); }); @@ -50,7 +50,7 @@ function Player(options) { }); }, function(callback) { modules.loadBackends(player, config.backends, forceUpdate, function(results) { - player.backends = player.backends.concat(results); + player.backends = _.extend(player.backends, results); player.callHooks('onBackendsInitialized'); callback(); }); @@ -174,7 +174,7 @@ Player.prototype.changeSong = function(uuid) { }; Player.prototype.songEnd = function() { - var np = this.queue.getNowPlaying(); + var np = this.getNowPlaying(); var npIndex = np ? this.queue.findSongIndex(np.uuid) : -1; this.logger.info('end of song ' + np.uuid); @@ -200,7 +200,7 @@ Player.prototype.songEnd = function() { Player.prototype.startPlayback = function(pos) { var player = this; - var np = this.queue.getNowPlaying(); + var np = this.getNowPlaying(); if (!np) { this.logger.verbose('startPlayback called, but no song at curPlaylistPos'); @@ -249,7 +249,7 @@ Player.prototype.pausePlayback = function() { clearTimeout(player.songEndTimeout); player.songEndTimeout = null; - player.callHooks('onSongPause', [player.queue.getNowPlaying()]); + player.callHooks('onSongPause', [player.getNowPlaying()]); }; // TODO: proper song object with constructor? @@ -307,8 +307,8 @@ Player.prototype.prepareProgCallback = function(song, newData, done, callback) { // start playback if it hasn't been started yet // TODO: not if paused - if (this.queue.getNowPlaying() && - this.queue.getNowPlaying().uuid === song.uuid && + if (this.getNowPlaying() && + this.getNowPlaying().uuid === song.uuid && !this.queue.playbackStart && newData) { this.startPlayback(); } @@ -371,8 +371,8 @@ Player.prototype.prepareSong = function(song, callback) { if (this.backends[song.backendName].isPrepared(song)) { // start playback if it hasn't been started yet // TODO: not if paused - if (this.queue.getNowPlaying() && - this.queue.getNowPlaying().uuid === song.uuid && + if (this.getNowPlaying() && + this.getNowPlaying().uuid === song.uuid && !this.queue.playbackStart) { this.startPlayback(); } @@ -403,12 +403,17 @@ Player.prototype.prepareSong = function(song, callback) { Player.prototype.prepareSongs = function() { var player = this; + var currentSong; async.series([ function(callback) { // prepare now-playing song - var song = player.queue.getNowPlaying(); - if (song) { - player.prepareSong(song, callback); + currentSong = player.getNowPlaying(); + if (currentSong) { + player.prepareSong(currentSong, callback); + } else if (player.queue.getLength()) { + // songs exist in queue, prepare first one + currentSong = player.queue.songs[0]; + player.prepareSong(currentSong, callback); } else { // bail out callback(true); @@ -416,10 +421,9 @@ Player.prototype.prepareSongs = function() { }, function(callback) { // prepare next song in playlist - var np = player.queue.getNowPlaying(); - var song = player.queue.songs[player.queue.findSongIndex(np) + 1]; - if (song) { - player.prepareSong(song, callback); + var nextSong = player.queue.songs[player.queue.findSongIndex(currentSong) + 1]; + if (nextSong) { + player.prepareSong(nextSong, callback); } else { // bail out callback(true); diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index 3b2a353..1f477fd 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -29,8 +29,8 @@ function Rest(player, callback) { player.app.get('/queue', function(req, res) { res.json({ - songs: player.queue.songs, - curQueuePos: player.queue.curQueuePos, + songs: player.queue.serialize(), + nowPlaying: player.nowPlaying ? player.nowPlaying.serialize() : null, curSongPos: player.queue.playbackStart ? (new Date().getTime() - player.queue.playbackStart) : -1 }); @@ -38,10 +38,14 @@ function Rest(player, callback) { // TODO: error handling player.app.post('/queue/song', function(req, res) { - player.insertSongs(-1, req.body, res.sendRes); + var err = player.queue.insertSongs(null, req.body); + + res.sendRes(err); }); player.app.post('/queue/song/:at', function(req, res) { - player.insertSongs(req.params.at, req.body, res.sendRes); + var err = player.queue.insertSongs(req.params.at, req.body); + + res.sendRes(err); }); /* diff --git a/lib/queue.js b/lib/queue.js index b563db1..1bbfe82 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -8,7 +8,7 @@ var Song = require('./song'); */ function Queue(player) { if (!player || !_.isObject(player)) { - return new Error('Queue constructor called without player reference!'); + throw new Error('Queue constructor called without player reference!'); } this.unshuffledSongs = null; @@ -19,6 +19,18 @@ function Queue(player) { // TODO: hooks // TODO: moveSongs +/** + * Get serialized list of songs in queue + * @return {[SerializedSong]} - List of songs in serialized format + */ +Queue.prototype.serialize = function() { + var serialized = _.map(this.songs, function(song) { + return song.serialize(); + }); + + return serialized; +}; + /** * Find index of song in queue * @param {String} at - Look for song with this UUID @@ -76,7 +88,7 @@ Queue.prototype.insertSongs = function(at, songs) { pos = this.findSongIndex(at); if (pos < 0) { - return new Error('Song with UUID ' + at + ' not found!'); + return 'Song with UUID ' + at + ' not found!'; } pos++; // insert after song @@ -84,16 +96,17 @@ Queue.prototype.insertSongs = function(at, songs) { // generate Song objects of each song songs = _.map(songs, function(song) { - return new Song(song, this.player.backends[song.backendName]); + var backend = this.player.backends[song.backendName]; + if (!backend) { + throw new Error('Song constructor called with invalid backend: ' + song.backendName); + return null; + } + + return new Song(song, backend); }, this); - // make sure there were no errors while creating Song objects - var err = _.find(songs, function(song) { - return song instanceof Error; - }); - if (err) { - return err; - } + // if we're still continuing regardless of errors above, remove invalid songs + songs = _.filter(songs, _.identity); // perform insertion var args = [pos, 0].concat(songs); @@ -111,7 +124,7 @@ Queue.prototype.insertSongs = function(at, songs) { Queue.prototype.removeSongs = function(at, cnt) { var pos = this.findSongIndex(at); if (pos < 0) { - return new Error('Song with UUID ' + at + ' not found!'); + return 'Song with UUID ' + at + ' not found!'; } // cancel preparing all songs to be deleted diff --git a/lib/song.js b/lib/song.js index c169f7a..3fe26d4 100644 --- a/lib/song.js +++ b/lib/song.js @@ -10,23 +10,23 @@ var uuid = require('node-uuid'); function Song(song, backend) { // make sure we have a reference to backend if (!backend || !_.isObject(backend)) { - return new Error('Song constructor called with invalid backend!'); + throw new Error('Song constructor called with invalid backend: ' + backend); } if (!song.duration || !_.isNumber(song.duration)) { - return new Error('Song constructor called without duration!'); + throw new Error('Song constructor called without duration!'); } if (!song.title || !_.isString(song.title)) { - return new Error('Song constructor called without title!'); + throw new Error('Song constructor called without title!'); } if (!song.songId || !_.isString(song.songId)) { - return new Error('Song constructor called without songId!'); + throw new Error('Song constructor called without songId!'); } if (!song.score || !_.isNumber(song.score)) { - return new Error('Song constructor called without score!'); + throw new Error('Song constructor called without score!'); } if (!song.format || !_.isString(song.format)) { - return new Error('Song constructor called without format!'); + throw new Error('Song constructor called without format!'); } this.uuid = uuid.v4(); @@ -56,10 +56,10 @@ function Song(song, backend) { } /** - * Return details of the song - * @returns {Song} - simplified Song object + * Return serialized details of the song + * @returns {SerializedSong} - serialized Song object */ -Song.prototype.details = function() { +Song.prototype.serialize = function() { return { uuid: this.uuid, title: this.title, From 4783a016ff729e747596ca9b9577569ea7b8de0b Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 21 Dec 2015 01:22:29 +0200 Subject: [PATCH 060/103] fix pause handling --- lib/player.js | 132 +++++--------------------------------------- lib/plugins/rest.js | 26 ++++++--- lib/queue.js | 2 + lib/song.js | 19 ++++++- 4 files changed, 53 insertions(+), 126 deletions(-) diff --git a/lib/player.js b/lib/player.js index c7182bf..c9fb390 100644 --- a/lib/player.js +++ b/lib/player.js @@ -111,13 +111,16 @@ Player.prototype.getNowPlaying = function() { */ Player.prototype.stopPlayback = function(pause) { this.logger.info('playback ' + (pause ? 'paused.' : 'stopped.')); + + clearTimeout(this.songEndTimeout); this.play = false; var np = this.nowPlaying; + var pos = np.playback.startPos + (new Date().getTime() - np.playback.startTime); if (np) { np.playback = { startTime: 0, - startPos: pause ? np.startPos + (new Date().getTime() - np.startTime) : 0 + startPos: pause ? pos : 0 }; } }; @@ -128,6 +131,7 @@ Player.prototype.stopPlayback = function(pause) { */ Player.prototype.startPlayback = function(position) { position = position || 0; + var player = this; if (!this.nowPlaying) { // find first song in queue @@ -140,16 +144,13 @@ Player.prototype.startPlayback = function(position) { this.nowPlaying.prepare(function(err) { if (err) { - return this.logger.error('error while preparing now playing: ' + err); + return player.logger.error('error while preparing now playing: ' + err); } - this.nowPlaying.playback = { - startTime: new Date(), - startPos: position - }; + player.nowPlaying.playbackStarted(position || player.nowPlaying.playback.startPos); - this.logger.info('playback started.'); - this.play = true; + player.logger.info('playback started.'); + player.play = true; }); }; @@ -195,63 +196,6 @@ Player.prototype.songEnd = function() { this.prepareSongs(); }; -// start or resume playback of now playing song. -// if pos is undefined, playback continues (or starts from 0 if !playbackPosition) -Player.prototype.startPlayback = function(pos) { - var player = this; - - var np = this.getNowPlaying(); - - if (!np) { - this.logger.verbose('startPlayback called, but no song at curPlaylistPos'); - return; - } - - if (!_.isUndefined(pos) && !_.isNull(pos)) { - this.logger.info('playing song: ' + np.songId + ', from pos: ' + pos); - } else { - this.logger.info('playing song: ' + np.songId); - } - - var oldPlaybackStart = this.queue.playbackStart; - this.queue.playbackStart = new Date().getTime(); // song is playing while this is truthy - - // where did the song start playing from at playbackStart? - if (!_.isUndefined(pos) && !_.isNull(pos)) { - this.queue.playbackPosition = pos; - } else if (!this.queue.playbackPosition) { - this.queue.playbackPosition = 0; - } - - if (oldPlaybackStart) { - this.callHooks('onSongSeek', [np]); - } else { - this.callHooks('onSongChange', [np]); - } - - var durationLeft = parseInt(np.duration) - - this.queue.playbackPosition + this.config.songDelayMs; - - if (this.songEndTimeout) { - this.logger.debug('songEndTimeout was cleared'); - clearTimeout(this.songEndTimeout); - this.songEndTimeout = null; - } - this.songEndTimeout = setTimeout(this.queue.endOfSong, durationLeft); -}; - -Player.prototype.pausePlayback = function() { - var player = this; - - // update position - player.queue.playbackPosition += new Date().getTime() - player.queue.playbackStart; - player.queue.playbackStart = null; - - clearTimeout(player.songEndTimeout); - player.songEndTimeout = null; - player.callHooks('onSongPause', [player.getNowPlaying()]); -}; - // TODO: proper song object with constructor? Player.prototype.setPrepareTimeout = function(song) { var player = this; @@ -306,8 +250,7 @@ Player.prototype.prepareProgCallback = function(song, newData, done, callback) { } // start playback if it hasn't been started yet - // TODO: not if paused - if (this.getNowPlaying() && + if (this.play && this.getNowPlaying() && this.getNowPlaying().uuid === song.uuid && !this.queue.playbackStart && newData) { this.startPlayback(); @@ -362,16 +305,12 @@ Player.prototype.prepareErrCallback = function(song, err, callback) { Player.prototype.prepareSong = function(song, callback) { if (!song) { - return callback(new Error('prepareSong() without song')); - } - if (!this.backends[song.backendName]) { - return callback(new Error('prepareSong() without unknown backend: ' + song.backendName)); + throw new Error('prepareSong() without song'); } - if (this.backends[song.backendName].isPrepared(song)) { + if (song.isPrepared()) { // start playback if it hasn't been started yet - // TODO: not if paused - if (this.getNowPlaying() && + if (this.play && this.getNowPlaying() && this.getNowPlaying().uuid === song.uuid && !this.queue.playbackStart) { this.startPlayback(); @@ -461,49 +400,6 @@ Player.prototype.getPlaylists = function(callback) { }); }; -/* -Player.prototype.replacePlaylist = function(backendName, playlistId, callback) { - var player = this; - - if (backendName === 'core') { - fs.readFile(path.join(config.getBaseDir(), 'playlists', playlistId + '.json'), - function(err, playlist) { - if (err) { - return callback(new Error('Error while fetching playlist' + err)); - } - - playlist = JSON.parse(playlist); - - // reset playlist position - player.playlistPos = 0; - player.playlist = playlist; - }); - - return; - } - - var backend = this.backends[backendName]; - - if (!backend) { - return callback(new Error('Unknown backend ' + backendName)); - } - - if (!backend.getPlaylist) { - return callback(new Error('Backend ' + backendName + ' does not support playlists')); - } - - backend.getPlaylist(playlistId, function(err, playlist) { - if (err) { - return callback(new Error('Error while fetching playlist' + err)); - } - - // reset playlist position - player.playlistPos = 0; - player.playlist = playlist; - }); -}; -*/ - // make a search query to backends Player.prototype.searchBackends = function(query, callback) { var resultCnt = 0; @@ -545,6 +441,7 @@ Player.prototype.searchBackends = function(query, callback) { }; // cnt can be negative to go back or zero to restart current song +/* Player.prototype.skipSongs = function(cnt) { var player = this; @@ -556,6 +453,7 @@ Player.prototype.skipSongs = function(cnt) { this.songEndTimeout = null; this.prepareSongs(); }; +*/ // TODO: userID does not belong into core...? Player.prototype.setVolume = function(newVol, userID) { diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index 1f477fd..a78da2e 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -28,11 +28,21 @@ function Rest(player, callback) { }); player.app.get('/queue', function(req, res) { + var np = player.nowPlaying; + var pos = 0; + if (np) { + if (np.playback.startTime) { + pos = new Date().getTime() - np.playback.startTime + np.playback.startPos; + } else { + pos = np.playback.startPos; + } + } + res.json({ songs: player.queue.serialize(), - nowPlaying: player.nowPlaying ? player.nowPlaying.serialize() : null, - curSongPos: player.queue.playbackStart ? - (new Date().getTime() - player.queue.playbackStart) : -1 + nowPlaying: np ? np.serialize() : null, + nowPlayingPos: pos, + play: player.play }); }); @@ -63,22 +73,22 @@ function Rest(player, callback) { player.removeSongs(req.params.at, parseInt(req.query.cnt) || 1, res.sendRes); }); - player.app.post('/playctl/:play', function(req, res) { + player.app.post('/playctl/play', function(req, res) { player.startPlayback(parseInt(req.body.position) || 0); res.sendRes(null, 'ok'); }); - player.app.post('/playctl/:pause', function(req, res) { - player.pausePlayback(); + player.app.post('/playctl/stop', function(req, res) { + player.stopPlayback(req.query.pause); res.sendRes(null, 'ok'); }); - player.app.post('/playctl/:skip', function(req, res) { + player.app.post('/playctl/skip', function(req, res) { player.skipSongs(parseInt(req.body.cnt)); res.sendRes(null, 'ok'); }); - player.app.post('/playctl/:shuffle', function(req, res) { + player.app.post('/playctl/shuffle', function(req, res) { player.shuffleQueue(); res.sendRes(null, 'ok'); }); diff --git a/lib/queue.js b/lib/queue.js index 1bbfe82..382766a 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -96,6 +96,8 @@ Queue.prototype.insertSongs = function(at, songs) { // generate Song objects of each song songs = _.map(songs, function(song) { + // TODO: this would be best done in the song constructor, + // effectively making it a SerializedSong object deserializer var backend = this.player.backends[song.backendName]; if (!backend) { throw new Error('Song constructor called with invalid backend: ' + song.backendName); diff --git a/lib/song.js b/lib/song.js index 3fe26d4..1ba95cc 100644 --- a/lib/song.js +++ b/lib/song.js @@ -43,6 +43,11 @@ function Song(song, backend) { this.score = song.score; this.format = song.format; + this.playback = { + startTime: null, + startPos: null + }; + // NOTE: internally to the Song we store a reference to the backend. // However when accessing the Song from the outside, we return only the // backend's name inside a backendName field. @@ -55,6 +60,17 @@ function Song(song, backend) { this.playlist = song.playlist; } +/** + * Return serialized details of the song + * @returns {SerializedSong} - serialized Song object + */ +Song.prototype.playbackStarted = function(pos) { + this.playback = { + startTime: new Date(), + startPos: pos || null + } +}; + /** * Return serialized details of the song * @returns {SerializedSong} - serialized Song object @@ -71,7 +87,8 @@ Song.prototype.serialize = function() { score: this.score, format: this.format, backendName: this.backend.name, - playlist: this.playlist + playlist: this.playlist, + playback: this.playback }; }; From b160585329d622ce256bfeea6718d20201f0a2f2 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 21 Dec 2015 01:26:45 +0200 Subject: [PATCH 061/103] small cleanup + remove dead code --- lib/player.js | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/lib/player.js b/lib/player.js index c9fb390..5f27a54 100644 --- a/lib/player.js +++ b/lib/player.js @@ -101,7 +101,7 @@ Player.prototype.numHooks = function(hook) { * @returns {Song|null} - Song object, null if no now playing song */ Player.prototype.getNowPlaying = function() { - return this.nowPlaying ? this.queue.findSong(this.nowPlaying.uuid) : null; + return this.nowPlaying; }; // TODO: handling of pause in a good way? @@ -217,20 +217,7 @@ Player.prototype.setPrepareTimeout = function(song) { }; Player.prototype.prepareError = function(song, err) { - // remove all instances of this song - /* - for (var i = this.queue.length - 1; i >= 0; i--) { - if (this.queue[i].songId === song.songId && - this.queue[i].backendName === song.backendName) { - if (!song.beingDeleted) { - this.logger.error('preparing song failed! (' + err + '), removing from queue: ' + - song.songId); - this.removeFromQueue(i); - } - } - } - */ - + // TODO: mark song as failed this.callHooks('onSongPrepareError', [song, err]); }; @@ -440,21 +427,6 @@ Player.prototype.searchBackends = function(query, callback) { }, this); }; -// cnt can be negative to go back or zero to restart current song -/* -Player.prototype.skipSongs = function(cnt) { - var player = this; - - this.queue.curQueuePos = Math.min(this.queue.length, this.queue.curQueuePos + cnt); - - this.queue.playbackPosition = null; - this.queue.playbackStart = null; - clearTimeout(this.songEndTimeout); - this.songEndTimeout = null; - this.prepareSongs(); -}; -*/ - // TODO: userID does not belong into core...? Player.prototype.setVolume = function(newVol, userID) { newVol = Math.min(1, Math.max(0, newVol)); From c0fc47f7b596b21aee32039f5117fa8999c4a42d Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 21 Dec 2015 01:28:21 +0200 Subject: [PATCH 062/103] fix build --- lib/queue.js | 4 ---- lib/song.js | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/queue.js b/lib/queue.js index 382766a..e98ad11 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -101,15 +101,11 @@ Queue.prototype.insertSongs = function(at, songs) { var backend = this.player.backends[song.backendName]; if (!backend) { throw new Error('Song constructor called with invalid backend: ' + song.backendName); - return null; } return new Song(song, backend); }, this); - // if we're still continuing regardless of errors above, remove invalid songs - songs = _.filter(songs, _.identity); - // perform insertion var args = [pos, 0].concat(songs); Array.prototype.splice.apply(this.songs, args); diff --git a/lib/song.js b/lib/song.js index 1ba95cc..e98a000 100644 --- a/lib/song.js +++ b/lib/song.js @@ -68,7 +68,7 @@ Song.prototype.playbackStarted = function(pos) { this.playback = { startTime: new Date(), startPos: pos || null - } + }; }; /** From ed98e4de8c57bdb14ce8946f78a2d12a42669f75 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 9 Jun 2016 18:19:08 +0300 Subject: [PATCH 063/103] Update dependencies --- package.json | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 0ba3909..f3c6ab4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodeplayer", - "version": "0.2.0", + "version": "0.2.1", "description": "simple, modular music player written in node.js", "main": "index.js", "preferGlobal": true, @@ -19,29 +19,29 @@ }, "license": "MIT", "dependencies": { - "async": "^0.9.0", - "body-parser": "^1.14.1", - "cookie-parser": "^1.4.0", - "escape-string-regexp": "^1.0.3", - "express": "^4.13.3", + "async": "^2.0.0-rc.6", + "body-parser": "^1.15.1", + "cookie-parser": "^1.4.3", + "escape-string-regexp": "^1.0.5", + "express": "^4.13.4", "mkdirp": "^0.5.1", - "mongoose": "^4.2.9", + "mongoose": "^4.4.20", "node-ffprobe": "^1.2.2", - "node-uuid": "^1.4.4", - "npm": "^2.7.1", - "underscore": "^1.7.0", + "node-uuid": "^1.4.7", + "npm": "^3.9.5", + "underscore": "^1.8.3", "walk": "^2.3.9", - "winston": "^0.9.0", - "yargs": "^3.6.0" + "winston": "^2.2.0", + "yargs": "^4.7.1" }, "devDependencies": { "chai": "*", - "coveralls": "^2.11.2", + "coveralls": "^2.11.9", "istanbul": "*", - "jscs": "^1.11.3", + "jscs": "^3.0.4", "mocha": "*", - "mocha-jscs": "^1.0.2", - "mocha-jshint": "^1.0.0", - "nodeplayer-backend-dummy": "^0.1.9" + "mocha-jscs": "^5.0.1", + "mocha-jshint": "^2.3.1", + "nodeplayer-backend-dummy": "^0.1.999" } } From 67d86d442ece37c5002eb26d4e997e70b9988c31 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 9 Jun 2016 18:27:50 +0300 Subject: [PATCH 064/103] Avoid duplicate loggers, log when initializing modules --- lib/backend.js | 3 ++- lib/modules.js | 16 ++++++++-------- lib/plugin.js | 3 ++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index 4f4fa3d..3fbd557 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -8,8 +8,9 @@ var labeledLogger = require('./logger'); */ function Backend() { this.name = this.constructor.name.toLowerCase(); - this.songsPreparing = []; this.log = labeledLogger(this.name); + this.log.info('initializing...'); + this.songsPreparing = []; } /** diff --git a/lib/modules.js b/lib/modules.js index f6019c1..adada19 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -58,6 +58,7 @@ var initModule = function(moduleShortName, moduleType, callback) { }; // TODO: this probably doesn't work +// needs rewrite exports.loadBackends = function(player, backends, forceUpdate, done) { // first install missing backends installModules(backends, 'backend', forceUpdate, function() { @@ -90,6 +91,7 @@ exports.loadBackends = function(player, backends, forceUpdate, done) { }; // TODO: this probably doesn't work +// needs rewrite exports.loadPlugins = function(player, plugins, forceUpdate, done) { // first install missing plugins installModules(plugins, 'plugin', forceUpdate, function() { @@ -125,13 +127,12 @@ exports.loadPlugins = function(player, plugins, forceUpdate, done) { exports.loadBuiltinPlugins = function(player, done) { async.mapSeries(BuiltinPlugins, function(Plugin, callback) { new Plugin(player, function(err, plugin) { - var moduleLogger = labeledLogger(plugin.name + ' (builtin)'); - if (err) { - moduleLogger.error('while initializing: ' + err); + plugin.log.error('while initializing: ' + err); return callback(); } - moduleLogger.verbose('plugin initialized'); + + plugin.log.verbose('plugin initialized'); player.callHooks('onPluginInitialized', plugin.name); callback(null, [plugin.name, plugin]); }); @@ -143,14 +144,13 @@ exports.loadBuiltinPlugins = function(player, done) { exports.loadBuiltinBackends = function(player, done) { async.mapSeries(BuiltinBackends, function(Backend, callback) { new Backend(function(err, backend) { - var moduleLogger = labeledLogger(backend.name + ' (builtin)'); - if (err) { - moduleLogger.error('while initializing: ' + err); + backend.log.error('while initializing: ' + err); return callback(); } + player.callHooks('onBackendInitialized', backend.name); - moduleLogger.verbose('backend initialized'); + backend.log.verbose('backend initialized'); callback(null, [backend.name, backend]); }); }, function(err, results) { diff --git a/lib/plugin.js b/lib/plugin.js index 31abc9e..22645dd 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -5,8 +5,9 @@ var labeledLogger = require('./logger'); */ function Plugin() { this.name = this.constructor.name.toLowerCase(); - this.hooks = {}; this.log = labeledLogger(this.name); + this.log.info('initializing...'); + this.hooks = {}; } Plugin.prototype.registerHook = function(hook, callback) { From e7dad7924fb4183cb0bbf36747815deec9a8406f Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Thu, 9 Jun 2016 18:28:44 +0300 Subject: [PATCH 065/103] Express module: announce which port we're listening on --- lib/plugins/express.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/plugins/express.js b/lib/plugins/express.js index 2a8d69d..88a96a3 100644 --- a/lib/plugins/express.js +++ b/lib/plugins/express.js @@ -13,11 +13,14 @@ var Plugin = require('../plugin'); function Express(player, callback) { Plugin.apply(this); + var self = this; + // NOTE: no argument passed so we get the core's config var config = require('../config').getConfig(); player.app = express(); var options = {}; + var port = process.env.PORT || config.port; if (config.tls) { options = { tls: config.tls, @@ -30,11 +33,12 @@ function Express(player, callback) { // TODO: deprecated! player.app.set('tls', true); player.httpServer = https.createServer(options, player.app) - .listen(process.env.PORT || config.port); + .listen(port); } else { player.httpServer = http.createServer(player.app) - .listen(process.env.PORT || config.port); + .listen(port); } + self.log.info('listening on port ' + port); player.app.use(cookieParser()); player.app.use(bodyParser.json({limit: '100mb'})); From e292f2c0fa1547dd6d2e59a14310802292f89538 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Tue, 21 Jun 2016 20:03:44 +0300 Subject: [PATCH 066/103] WIP: refactor --- lib/backend.js | 21 +++++++++++---- lib/backends/local.js | 58 ++++++++++++++++++---------------------- lib/player.js | 61 ++++++++++++++++++++++++------------------- lib/plugins/rest.js | 4 +-- lib/song.js | 9 ++++++- 5 files changed, 85 insertions(+), 68 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index 3fbd557..506da25 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -1,5 +1,7 @@ var _ = require('underscore'); var path = require('path'); +var fs = require('fs'); +var ffmpeg = require('fluent-ffmpeg'); var config = require('./config').getConfig(); var labeledLogger = require('./logger'); @@ -10,14 +12,14 @@ function Backend() { this.name = this.constructor.name.toLowerCase(); this.log = labeledLogger(this.name); this.log.info('initializing...'); - this.songsPreparing = []; + this.songsPreparing = {}; } /** * Callback for reporting encoding progress * @callback encodeCallback - * @param {Error} err - Was there an error? - * @param {Buffer} chunk - New data since last call + * @param {Error} err - If truthy, an error occurred and preparation cannot continue + * @param {Buffer} chunk - New song data since last call * @param {Bool} done - True if this was the last chunk */ @@ -26,7 +28,7 @@ function Backend() { * @param {Stream} stream - Input stream * @param {Number} seek - Skip to this position in song (TODO) * @param {Song} song - Song object whose audio is being encoded - * @param {encodeCallback} callback - Callback for reporting encoding progress + * @param {encodeCallback} callback - Called when song is ready or on error * @returns {Function} - Can be called to terminate encoding */ Backend.prototype.encodeSong = function(stream, seek, song, callback) { @@ -108,13 +110,22 @@ Backend.prototype.isPrepared = function(song) { /** * Prepare song for playback * @param {Song} song - Song to prepare - * @param {Function} callback - Called when song is ready or on error + * @param {encodeCallback} callback - Called when song is ready or on error */ Backend.prototype.prepare = function(song, callback) { this.log.error('FATAL: backend does not implement prepare()!'); callback(new Error('FATAL: backend does not implement prepare()!')); }; +/** + * Cancel song preparation if applicable + * @param {Song} song - Song to prepare + */ +Backend.prototype.cancelPrepare = function(song) { + this.log.error('FATAL: backend does not implement cancelPrepare()!'); + callback(new Error('FATAL: backend does not implement cancelPrepare()!')); +}; + /** * Search for songs * @param {Object} query - Search terms diff --git a/lib/backends/local.js b/lib/backends/local.js index a274a27..771ae7c 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -63,7 +63,7 @@ var probeCallback = function(err, probeData, next) { song.file = probeData.file; song.duration = probeData.format.duration * 1000; - Song.update({file: probeData.file}, {'$set':song}, {upsert: true}, + SongModel.update({file: probeData.file}, {'$set':song}, {upsert: true}, function(err, result) { if (result == 1) { self.log.debug('Upserted: ' + probeData.file); @@ -85,7 +85,7 @@ var probeCallback = function(err, probeData, next) { */ // database model -var Song = mongoose.model('Song', { +var SongModel = mongoose.model('Song', { title: String, artist: String, album: String, @@ -139,7 +139,7 @@ function Local(callback) { }; var insertSong = function(probeData, done) { - var song = new Song({ + var song = new SongModel({ title: probeData.metadata.TITLE, artist: probeData.metadata.ARTIST, album: probeData.metadata.ALBUM, @@ -153,7 +153,7 @@ function Local(callback) { delete song._id; - Song.findOneAndUpdate({ + SongModel.findOneAndUpdate({ filename: probeData.file }, song, {upsert: true}, function(err) { if (err) { @@ -235,39 +235,38 @@ function Local(callback) { // must be called immediately after constructor util.inherits(Local, Backend); -/** - * Synchronously(!) returns whether the song is prepared or not - * @param {Song} song - Song to check - * @returns {Boolean} - true if song is prepared, false if not - */ Local.prototype.isPrepared = function(song) { var filePath = path.join(this.songCachePath, 'file', song.songId + '.opus'); return fs.existsSync(filePath); }; -/** - * Prepare song for playback - * @param {Song} song - Song to prepare - * @param {Function} callback - Called when song is ready or if an error occurred - */ Local.prototype.prepare = function(song, callback) { var self = this; var filePath = self.songCachePath + '/file/' + song.songId + '.opus'; - if (fs.existsSync(filePath)) { + if (self.songsPreparing[song.songId]) { + // song is preparing, caller can drop this request (previous caller will take care of + // handling once preparation is finished) + callback(null, null, false); + } else if (self.isPrepared(song)) { + // song has already prepared, caller can start playing song callback(null, null, true); } else { + // begin preparing song var cancelEncode = null; var canceled = false; - var cancelPreparing = function() { - canceled = true; - if (cancelEncode) { - cancelEncode(); + + self.songsPreparing[song.songId] = { + cancel: function() { + canceled = true; + if (cancelEncode) { + cancelEncode(); + } } }; - Song.findById(song.songId, function(err, item) { + SongModel.findById(song.songId, function(err, item) { if (canceled) { callback(new Error('song was canceled before encoding started')); } else if (item) { @@ -277,23 +276,16 @@ Local.prototype.prepare = function(song, callback) { callback(err); }); } else { - callback('song not found in local db: ' + song.songId); + callback(new Error('song not found in local db: ' + song.songId)); } }); - - return cancelEncode; } }; -/** - * Search for songs - * @param {Object} query - Search terms - * @param {String} [query.artist] - Artist - * @param {String} [query.title] - Title - * @param {String} [query.album] - Album - * @param {Boolean} [query.any] - Match any of the above, otherwise all fields have to match - * @param {Function} callback - Called when song is ready or if an error occurred - */ +Local.prototype.cancelPrepare = function(song) { + self.songsPreparing.push(song.songId); +}; + Local.prototype.search = function(query, callback) { var self = this; @@ -318,7 +310,7 @@ Local.prototype.search = function(query, callback) { }); } - Song.find(q).exec(function(err, items) { + SongModel.find(q).exec(function(err, items) { if (err) { return callback(err); } diff --git a/lib/player.js b/lib/player.js index 5f27a54..d5e3ad9 100644 --- a/lib/player.js +++ b/lib/player.js @@ -10,18 +10,18 @@ function Player(options) { // TODO: some of these should NOT be loaded from config _.bindAll.apply(_, [this].concat(_.functions(this))); - this.config = options.config || require('./config').getConfig(); - this.logger = options.logger || labeledLogger('core'); - this.queue = options.queue || new Queue(this); - this.nowPlaying = options.nowPlaying || null; - this.play = options.play || false; - this.repeat = options.repeat || false; - this.plugins = options.plugins || {}; - this.backends = options.backends || {}; - //this.songsPreparing = options.songsPreparing || {}; - this.volume = options.volume || 1; - this.songEndTimeout = options.songEndTimeout || null; - this.pluginVars = options.pluginVars || {}; + this.config = options.config || require('./config').getConfig(); + this.logger = options.logger || labeledLogger('core'); + this.queue = options.queue || new Queue(this); + this.nowPlaying = options.nowPlaying || null; + this.play = options.play || false; + this.repeat = options.repeat || false; + this.plugins = options.plugins || {}; + this.backends = options.backends || {}; + this.prepareTimeouts = options.prepareTimeouts || {}; + this.volume = options.volume || 1; + this.songEndTimeout = options.songEndTimeout || null; + this.pluginVars = options.pluginVars || {}; var player = this; var config = player.config; @@ -221,7 +221,7 @@ Player.prototype.prepareError = function(song, err) { this.callHooks('onSongPrepareError', [song, err]); }; -Player.prototype.prepareProgCallback = function(song, newData, done, callback) { +Player.prototype.prepareProgCallback = function(song, newData, done) { /* progress callback * when this is called, new song data has been flushed to disk */ @@ -255,14 +255,12 @@ Player.prototype.prepareProgCallback = function(song, newData, done, callback) { delete(song.cancelPrepare); // song data should now be available on disk, don't keep it in memory - this.songsPreparing[song.backendName][song.songId].songData = undefined; - delete(this.songsPreparing[song.backendName][song.songId]); + this.songsPreparing[song.backend.name][song.songId].songData = undefined; + delete(this.songsPreparing[song.backend.name][song.songId]); // clear prepare timeout clearTimeout(song.prepareTimeout); song.prepareTimeout = null; - - callback(); } else { // reset prepare timeout this.setPrepareTimeout(song); @@ -287,10 +285,12 @@ Player.prototype.prepareErrCallback = function(song, err, callback) { this.prepareError(song, err); song.songData = undefined; - delete(this.songsPreparing[song.backendName][song.songId]); + delete(this.songsPreparing[song.backend.name][song.songId]); }; Player.prototype.prepareSong = function(song, callback) { + var self = this; + if (!song) { throw new Error('prepareSong() without song'); } @@ -305,19 +305,23 @@ Player.prototype.prepareSong = function(song, callback) { // song is already prepared, ok to prepare more songs callback(); - } else if (this.songsPreparing[song.backendName][song.songId]) { - // this song is still preparing, so don't yet prepare next song - callback(true); } else { // song is not prepared and not currently preparing: let backend prepare it this.logger.debug('DEBUG: prepareSong() ' + song.songId); - this.songsPreparing[song.backendName][song.songId] = song; - song.cancelPrepare = this.backends[song.backendName].prepareSong( - song, - _.partial(this.prepareProgCallback, _, _, _, callback), - _.partial(this.prepareErrCallback, _, _, callback) - ); + song.prepare(function(err, chunk, done) { + if (err) { + return callback(err); + } + + if (chunk) { + self.prepareProgCallback(song, chunk, done); + } + + if (done) { + callback(); + } + }); this.setPrepareTimeout(song); } @@ -346,6 +350,7 @@ Player.prototype.prepareSongs = function() { } }, function(callback) { + // prepare next song in playlist var nextSong = player.queue.songs[player.queue.findSongIndex(currentSong) + 1]; if (nextSong) { @@ -356,6 +361,8 @@ Player.prototype.prepareSongs = function() { } } ]); + // TODO where to put this + player.prepareErrCallback(); }; Player.prototype.getPlaylists = function(callback) { diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index a78da2e..9b92ad9 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -100,9 +100,9 @@ function Rest(player, callback) { // search for songs, search terms in query params player.app.get('/search', function(req, res) { - self.log.verbose('got search request: ' + JSON.stringify(req.query)); + self.log.verbose('got search request: ' + JSON.stringify(req.body.query)); - player.searchBackends(req.query, function(results) { + player.searchBackends(req.body.query, function(results) { res.json(results); }); }); diff --git a/lib/song.js b/lib/song.js index e98a000..60f072d 100644 --- a/lib/song.js +++ b/lib/song.js @@ -102,10 +102,17 @@ Song.prototype.isPrepared = function() { /** * Prepare song for playback - * @param {Function} callback - Called when song is ready or on error + * @param {encodeCallback} callback - Called when song is ready or on error */ Song.prototype.prepare = function(callback) { return this.backend.prepare(this, callback); }; +/** + * Cancel song preparation if applicable + */ +Song.prototype.cancelPrepare = function() { + this.backend.cancelPrepare(this); +}; + module.exports = Song; From da447b47129bca9798d368cd15538eee64b14196 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 4 Jul 2016 15:54:53 +0300 Subject: [PATCH 067/103] Streaming local files now works --- lib/backends/local.js | 8 ++++---- lib/modules.js | 7 ++++--- lib/player.js | 7 ++++--- lib/plugins/rest.js | 9 +++++---- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/backends/local.js b/lib/backends/local.js index 771ae7c..48e7148 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -121,7 +121,7 @@ function Local(callback) { this.importFormats = config.importFormats; // make sure all necessary directories exist - mkdirp.sync(path.join(this.songCachePath, 'file', 'incomplete')); + mkdirp.sync(path.join(this.songCachePath, 'local', 'incomplete')); // connect to the database mongoose.connect(config.mongo); @@ -236,14 +236,14 @@ function Local(callback) { util.inherits(Local, Backend); Local.prototype.isPrepared = function(song) { - var filePath = path.join(this.songCachePath, 'file', song.songId + '.opus'); + var filePath = path.join(this.songCachePath, 'local', song.songId + '.opus'); return fs.existsSync(filePath); }; Local.prototype.prepare = function(song, callback) { var self = this; - var filePath = self.songCachePath + '/file/' + song.songId + '.opus'; + var filePath = self.songCachePath + '/local/' + song.songId + '.opus'; if (self.songsPreparing[song.songId]) { // song is preparing, caller can drop this request (previous caller will take care of @@ -283,7 +283,7 @@ Local.prototype.prepare = function(song, callback) { }; Local.prototype.cancelPrepare = function(song) { - self.songsPreparing.push(song.songId); + this.songsPreparing.push(song.songId); }; Local.prototype.search = function(query, callback) { diff --git a/lib/modules.js b/lib/modules.js index adada19..659ba80 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -74,6 +74,7 @@ exports.loadBackends = function(player, backends, forceUpdate, done) { callback(); } else { moduleLogger.verbose('backend initialized'); + player.callHooks('onBackendInitialized', [backend]); callback(null, instance); } }); @@ -107,7 +108,7 @@ exports.loadPlugins = function(player, plugins, forceUpdate, done) { callback(); } else { moduleLogger.verbose('plugin initialized'); - player.callHooks('onPluginInitialized', plugin); + player.callHooks('onPluginInitialized', [plugin]); callback(null, instance); } }); @@ -133,7 +134,7 @@ exports.loadBuiltinPlugins = function(player, done) { } plugin.log.verbose('plugin initialized'); - player.callHooks('onPluginInitialized', plugin.name); + player.callHooks('onPluginInitialized', [plugin.name]); callback(null, [plugin.name, plugin]); }); }, function(err, results) { @@ -149,7 +150,7 @@ exports.loadBuiltinBackends = function(player, done) { return callback(); } - player.callHooks('onBackendInitialized', backend.name); + player.callHooks('onBackendInitialized', [backend.name]); backend.log.verbose('backend initialized'); callback(null, [backend.name, backend]); }); diff --git a/lib/player.js b/lib/player.js index d5e3ad9..82a4d16 100644 --- a/lib/player.js +++ b/lib/player.js @@ -74,8 +74,9 @@ Player.prototype.callHooks = function(hook, argv) { (argv ? ', ' + JSON.stringify(argv) + ')' : ')')); _.find(this.plugins, function(plugin) { - if (plugin[hook]) { - err = plugin[hook].apply(null, argv); + if (plugin.hooks[hook]) { + var fun = plugin.hooks[hook]; + err = fun.apply(null, argv); return err; } }); @@ -362,7 +363,7 @@ Player.prototype.prepareSongs = function() { } ]); // TODO where to put this - player.prepareErrCallback(); + //player.prepareErrCallback(); }; Player.prototype.getPlaylists = function(callback) { diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index 9b92ad9..e382962 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -139,15 +139,16 @@ function Rest(player, callback) { format: songFormat }; - if (player.backends[backendName].isPrepared(song)) { + var backend = player.backends[backendName]; + + if (backend.isPrepared(song)) { // song should be available on disk res.sendFile('/' + backendName + '/' + songId + '.' + songFormat, { root: config.songCachePath }); - } else if (player.songsPreparing[backendName] && - player.songsPreparing[backendName][songId]) { + } else if (backend.songsPreparing[songId]) { // song is preparing - var preparingSong = player.songsPreparing[backendName][songId]; + var preparingSong = backend.songsPreparing[songId]; // try finding out length of song var queuedSong = player.searchQueue(backendName, songId); From a8741e7f0ef3612d24757350d9ab5637ddb23b29 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Tue, 5 Jul 2016 10:14:16 +0300 Subject: [PATCH 068/103] WIP: streaming songs while preparing --- lib/backend.js | 8 ++++---- lib/backends/local.js | 3 +++ lib/player.js | 11 ++++------- lib/plugins/rest.js | 30 ++++++++++++++++-------------- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index 506da25..b8ad30e 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -34,12 +34,12 @@ function Backend() { Backend.prototype.encodeSong = function(stream, seek, song, callback) { var self = this; - var incompletePath = path.join(config.songCachePath, 'file', 'incomplete', + var incompletePath = path.join(config.songCachePath, self.name, 'incomplete', song.songId + '.opus'); var incompleteStream = fs.createWriteStream(incompletePath, {flags: 'w'}); - var encodedPath = path.join(config.songCachePath, 'file', + var encodedPath = path.join(config.songCachePath, self.name, song.songId + '.opus'); var command = ffmpeg(stream) @@ -50,7 +50,7 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { .audioBitrate('192') .format('opus') .on('error', function(err) { - self.log.error('file: error while transcoding ' + song.songId + ': ' + err); + self.log.error(self.name + ': error while transcoding ' + song.songId + ': ' + err); if (fs.existsSync(incompletePath)) { fs.unlinkSync(incompletePath); } @@ -87,7 +87,7 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { // return a function which can be used for terminating encoding return function(err) { command.kill(); - self.log.verbose('file: canceled preparing: ' + song.songId + ': ' + err); + self.log.verbose(self.name + ': canceled preparing: ' + song.songId + ': ' + err); if (fs.existsSync(incompletePath)) { fs.unlinkSync(incompletePath); } diff --git a/lib/backends/local.js b/lib/backends/local.js index 48e7148..43a3af7 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -266,6 +266,8 @@ Local.prototype.prepare = function(song, callback) { } }; + self.songsPreparing[song.songId] = _.extend(self.songsPreparing[song.songId], song); + SongModel.findById(song.songId, function(err, item) { if (canceled) { callback(new Error('song was canceled before encoding started')); @@ -283,6 +285,7 @@ Local.prototype.prepare = function(song, callback) { }; Local.prototype.cancelPrepare = function(song) { + // TODO: wat? this.songsPreparing.push(song.songId); }; diff --git a/lib/player.js b/lib/player.js index 82a4d16..0b75692 100644 --- a/lib/player.js +++ b/lib/player.js @@ -1,6 +1,7 @@ 'use strict'; var _ = require('underscore'); var async = require('async'); +var util = require('util'); var labeledLogger = require('./logger'); var Queue = require('./queue'); var modules = require('./modules'); @@ -71,7 +72,7 @@ Player.prototype.callHooks = function(hook, argv) { var err = null; this.logger.silly('callHooks(' + hook + - (argv ? ', ' + JSON.stringify(argv) + ')' : ')')); + (argv ? ', ' + util.inspect(argv) + ')' : ')')); _.find(this.plugins, function(plugin) { if (plugin.hooks[hook]) { @@ -227,10 +228,6 @@ Player.prototype.prepareProgCallback = function(song, newData, done) { * when this is called, new song data has been flushed to disk */ // append new song data to buffer - Object.defineProperty(song, 'songData', { - enumerable: false, - writable: true - }); if (newData) { song.songData = song.songData ? Buffer.concat([song.songData, newData]) : newData; } else if (!song.songData) { @@ -256,8 +253,8 @@ Player.prototype.prepareProgCallback = function(song, newData, done) { delete(song.cancelPrepare); // song data should now be available on disk, don't keep it in memory - this.songsPreparing[song.backend.name][song.songId].songData = undefined; - delete(this.songsPreparing[song.backend.name][song.songId]); + song.backend.songsPreparing[song.songId].songData = undefined; + delete(song.backend.songsPreparing[song.songId]); // clear prepare timeout clearTimeout(song.prepareTimeout); diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index e382962..37b4168 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -110,6 +110,8 @@ function Rest(player, callback) { this.pendingRequests = {}; var rest = this; this.registerHook('onPrepareProgress', function(song, chunk, done) { + song = song.serialize(); + if (!rest.pendingRequests[song.backendName]) { return; } @@ -134,14 +136,9 @@ function Rest(player, callback) { var songId = req.params.fileName.substring(0, extIndex); var songFormat = req.params.fileName.substring(extIndex + 1); - var song = { - songId: songId, - format: songFormat - }; - var backend = player.backends[backendName]; - if (backend.isPrepared(song)) { + if (backend.isPrepared({songId: songId})) { // song should be available on disk res.sendFile('/' + backendName + '/' + songId + '.' + songFormat, { root: config.songCachePath @@ -151,7 +148,10 @@ function Rest(player, callback) { var preparingSong = backend.songsPreparing[songId]; // try finding out length of song - var queuedSong = player.searchQueue(backendName, songId); + var queuedSong = _.find(player.queue.serialize(), function(song) { + return song.songId === songId && song.backendName === backendName; + }); + if (queuedSong) { res.setHeader('X-Content-Duration', queuedSong.duration / 1000); } @@ -186,16 +186,18 @@ function Rest(player, callback) { // skip to start of requested range if we have enough data, // otherwise serve whole song - if (range[0] < preparingSong.songData.length) { - res.write(preparingSong.songData.slice(range[0])); - } else { - res.write(preparingSong.songData); + if (preparingSong.songData) { + if (range[0] < preparingSong.songData.length) { + res.write(preparingSong.songData.slice(range[0])); + } else { + res.write(preparingSong.songData); + } } - rest.pendingRequests[backendName][song.songId] = - rest.pendingRequests[backendName][song.songId] || []; + rest.pendingRequests[backendName][songId] = + rest.pendingRequests[backendName][songId] || []; - rest.pendingRequests[backendName][song.songIsongId].push(res); + rest.pendingRequests[backendName][songId].push(res); } else { res.status(404).end('404 song not found'); } From b0c2fcd77ecd949624363a1202b980af2943146a Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Tue, 5 Jul 2016 10:33:20 +0300 Subject: [PATCH 069/103] Clear prepare timeout after song is prepared --- lib/player.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/player.js b/lib/player.js index 0b75692..dc28948 100644 --- a/lib/player.js +++ b/lib/player.js @@ -198,7 +198,7 @@ Player.prototype.songEnd = function() { this.prepareSongs(); }; -// TODO: proper song object with constructor? +// TODO: move these to song class? Player.prototype.setPrepareTimeout = function(song) { var player = this; @@ -218,6 +218,12 @@ Player.prototype.setPrepareTimeout = function(song) { }); }; +Player.prototype.clearPrepareTimeout = function(song) { + // clear prepare timeout + clearTimeout(song.prepareTimeout); + song.prepareTimeout = null; +}; + Player.prototype.prepareError = function(song, err) { // TODO: mark song as failed this.callHooks('onSongPrepareError', [song, err]); @@ -257,8 +263,7 @@ Player.prototype.prepareProgCallback = function(song, newData, done) { delete(song.backend.songsPreparing[song.songId]); // clear prepare timeout - clearTimeout(song.prepareTimeout); - song.prepareTimeout = null; + this.clearPrepareTimeout(song); } else { // reset prepare timeout this.setPrepareTimeout(song); @@ -271,9 +276,7 @@ Player.prototype.prepareErrCallback = function(song, err, callback) { // don't let anything run cancelPrepare anymore delete(song.cancelPrepare); - // clear prepare timeout - clearTimeout(song.prepareTimeout); - song.prepareTimeout = null; + this.clearPrepareTimeout(song); // abort preparing more songs; current song will be deleted -> // onQueueModified is called -> song preparation is triggered again @@ -317,6 +320,7 @@ Player.prototype.prepareSong = function(song, callback) { } if (done) { + self.clearPrepareTimeout(song); callback(); } }); From 61cc97bf22ff6fef65dbf231c27895aff3411bb3 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Tue, 5 Jul 2016 10:33:34 +0300 Subject: [PATCH 070/103] Move cancelPrepare() into common backend functions --- lib/backend.js | 22 +++++++++++++--------- lib/backends/local.js | 6 +----- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index b8ad30e..7cda4b4 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -95,6 +95,19 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { }; }; +/** + * Cancel song preparation if applicable + * @param {Song} song - Song to cancel + */ +Backend.prototype.cancelPrepare = function(song) { + if (this.songsPreparing[song.songId]) { + this.log.info('Canceling song preparing: ' + song.songId); + this.songsPreparing[song.songId].cancel(); + } else { + this.log.error('cancelPrepare() called on song not in preparation: ' + song.songId); + } +}; + // dummy functions /** @@ -117,15 +130,6 @@ Backend.prototype.prepare = function(song, callback) { callback(new Error('FATAL: backend does not implement prepare()!')); }; -/** - * Cancel song preparation if applicable - * @param {Song} song - Song to prepare - */ -Backend.prototype.cancelPrepare = function(song) { - this.log.error('FATAL: backend does not implement cancelPrepare()!'); - callback(new Error('FATAL: backend does not implement cancelPrepare()!')); -}; - /** * Search for songs * @param {Object} query - Search terms diff --git a/lib/backends/local.js b/lib/backends/local.js index 43a3af7..3b83ec0 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -243,6 +243,7 @@ Local.prototype.isPrepared = function(song) { Local.prototype.prepare = function(song, callback) { var self = this; + // TODO: move most of this into common code inside core var filePath = self.songCachePath + '/local/' + song.songId + '.opus'; if (self.songsPreparing[song.songId]) { @@ -284,11 +285,6 @@ Local.prototype.prepare = function(song, callback) { } }; -Local.prototype.cancelPrepare = function(song) { - // TODO: wat? - this.songsPreparing.push(song.songId); -}; - Local.prototype.search = function(query, callback) { var self = this; From 2517da3fbd269aedc2f0530cce738ee38daccbf5 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Tue, 5 Jul 2016 10:58:59 +0300 Subject: [PATCH 071/103] Local backend: Try to guess metadata from filename if needed --- lib/backends/local.js | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/backends/local.js b/lib/backends/local.js index 3b83ec0..907584c 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -109,6 +109,27 @@ var SongModel = mongoose.model('Song', { } }); +/** + * Try to guess metadata from file path, + * Assumes the following naming conventions: + * /path/to/music/Album/Artist - Title.ext + */ +var guessMetadataFromPath = function(filePath, fileExt) { + var fileName = path.basename(filePath, fileExt); + + // split filename at dashes, trim extra whitespace, e.g: + var splitName = fileName.split('-'); + splitName = _.map(splitName, function(name) { + return name.trim(); + }); + + return { + artist: splitName[0], + title: splitName[1], + album: path.basename(path.dirname(filePath)) + }; +}; + function Local(callback) { Backend.apply(this); @@ -139,10 +160,12 @@ function Local(callback) { }; var insertSong = function(probeData, done) { + var guessMetadata = guessMetadataFromPath(probeData.file, probeData.fileext); + var song = new SongModel({ - title: probeData.metadata.TITLE, - artist: probeData.metadata.ARTIST, - album: probeData.metadata.ALBUM, + title: probeData.metadata.TITLE || guessMetadata.title, + artist: probeData.metadata.ARTIST || guessMetadata.artist, + album: probeData.metadata.ALBUM || guessMetadata.album, //albumArt: {} // TODO duration: probeData.format.duration * 1000, format: probeData.format.format_name, From f56871133afcec1c6a449d2b497316e58b3d6af2 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Tue, 5 Jul 2016 17:54:17 +0300 Subject: [PATCH 072/103] WIP: playback of preparing songs --- lib/backend.js | 7 ++- lib/backends/local.js | 2 + lib/player.js | 13 +--- lib/plugins/rest.js | 138 +++++++++++++++++++++++++++++++----------- package.json | 1 + 5 files changed, 114 insertions(+), 47 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index 7cda4b4..ffc4fd8 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -19,7 +19,7 @@ function Backend() { * Callback for reporting encoding progress * @callback encodeCallback * @param {Error} err - If truthy, an error occurred and preparation cannot continue - * @param {Buffer} chunk - New song data since last call + * @param {Buffer} bytesWritten - How many bytes of the song is available on disk * @param {Bool} done - True if this was the last chunk */ @@ -57,10 +57,13 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { callback(err); }); + song.bytesWritten = 0; + var opusStream = command.pipe(null, {end: true}); opusStream.on('data', function(chunk) { incompleteStream.write(chunk, undefined, function() { - callback(null, chunk, false); + song.bytesWritten += chunk.length; + callback(null, song.bytesWritten, false); }); }); opusStream.on('end', function() { diff --git a/lib/backends/local.js b/lib/backends/local.js index 907584c..6206432 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -123,6 +123,7 @@ var guessMetadataFromPath = function(filePath, fileExt) { return name.trim(); }); + // TODO: compare album name against music dir, leave empty if equal return { artist: splitName[0], title: splitName[1], @@ -282,6 +283,7 @@ Local.prototype.prepare = function(song, callback) { var canceled = false; self.songsPreparing[song.songId] = { + bytesWritten: 0, cancel: function() { canceled = true; if (cancelEncode) { diff --git a/lib/player.js b/lib/player.js index dc28948..6dfa89f 100644 --- a/lib/player.js +++ b/lib/player.js @@ -229,27 +229,20 @@ Player.prototype.prepareError = function(song, err) { this.callHooks('onSongPrepareError', [song, err]); }; -Player.prototype.prepareProgCallback = function(song, newData, done) { +Player.prototype.prepareProgCallback = function(song, bytesWritten, done) { /* progress callback * when this is called, new song data has been flushed to disk */ - // append new song data to buffer - if (newData) { - song.songData = song.songData ? Buffer.concat([song.songData, newData]) : newData; - } else if (!song.songData) { - song.songData = new Buffer(0); - } - // start playback if it hasn't been started yet if (this.play && this.getNowPlaying() && this.getNowPlaying().uuid === song.uuid && - !this.queue.playbackStart && newData) { + !this.queue.playbackStart && bytesWritten) { this.startPlayback(); } // tell plugins that new data is available for this song, and // whether the song is now fully written to disk or not. - this.callHooks('onPrepareProgress', [song, newData, done]); + this.callHooks('onPrepareProgress', [song, bytesWritten, done]); if (done) { // mark song as prepared diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index 37b4168..2aed677 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -1,8 +1,11 @@ 'use strict'; var _ = require('underscore'); +var ts = require('tail-stream'); var util = require('util'); +var path = require('path'); +var fs = require('fs'); var Plugin = require('../plugin'); function Rest(player, callback) { @@ -109,22 +112,56 @@ function Rest(player, callback) { this.pendingRequests = {}; var rest = this; - this.registerHook('onPrepareProgress', function(song, chunk, done) { + this.registerHook('onPrepareProgress', function(song, bytesWritten, done) { song = song.serialize(); if (!rest.pendingRequests[song.backendName]) { return; } - _.each(rest.pendingRequests[song.backendName][song.songId], function(res) { - if (chunk) { - res.write(chunk); + _.each(rest.pendingRequests[song.backendName][song.songId], function(client) { + /* + if (bytesWritten) { + var end = bytesWritten - 1; + if (client.wishRange[1]) { + end = Math.min(client.wishRange[1], bytesWritten - 1); + } + + console.log('end: ' + end + '\tclient.serveRange[1]: ' + client.serveRange[1]); + + if (client.serveRange[1] < end) { + console.log('write'); + client.songStream.write(fs.createReadStream(client.filename, { + start: client.serveRange[1], + end: end + })); + + client.serveRange[1] = end; + } } - if (done) { - res.end(); - rest.pendingRequests[song.backendName][song.songId] = []; + */ + + if (!client.songStream && bytesWritten) { + var filepath = done ? client.filepath : client.incompletePath; + client.songStream = ts.createReadStream(filepath, { + useWatch: false, + beginAt: client.serveRange[0], + endAt: client.serveRange[1] // TODO + }); + + console.log('opened: ' + filepath); + client.songStream.pipe(client.res); + } + + if (client.songStream && done) { + console.log('done'); + client.songStream.end(); } }); + + if (done) { + rest.pendingRequests[song.backendName][song.songId] = []; + } }); this.registerHook('onBackendInitialized', function(backendName) { @@ -137,10 +174,12 @@ function Rest(player, callback) { var songFormat = req.params.fileName.substring(extIndex + 1); var backend = player.backends[backendName]; + var filename = path.join(backendName, songId + '.' + songFormat); + var incomplete = path.join(backendName, 'incomplete', songId + '.' + songFormat); if (backend.isPrepared({songId: songId})) { // song should be available on disk - res.sendFile('/' + backendName + '/' + songId + '.' + songFormat, { + res.sendFile(filename, { root: config.songCachePath }); } else if (backend.songsPreparing[songId]) { @@ -160,44 +199,73 @@ function Rest(player, callback) { res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); res.setHeader('Accept-Ranges', 'bytes'); - var range = [0]; + var haveRange = []; + var wishRange = []; + var serveRange = []; + + haveRange[0] = 0; + haveRange[1] = preparingSong.bytesWritten - 1; + + wishRange[0] = 0; + wishRange[1] = null + + serveRange[0] = 0; + if (req.headers.range) { // partial request - range = req.headers.range.substr(req.headers.range.indexOf('=') + 1).split('-'); - res.statusCode = 206; + wishRange = req.headers.range.substr(req.headers.range.indexOf('=') + 1).split('-'); - // a best guess for the header - var end; - var dataLen = preparingSong.songData ? preparingSong.songData.length : 0; - if (range[1]) { - end = Math.min(range[1], dataLen - 1); - } else { - end = dataLen - 1; + serveRange[0] = wishRange[0]; + + // a best guess for the response header + serveRange[1] = haveRange[1]; + if (wishRange[1]) { + serveRange[1] = Math.min(wishRange[1], haveRange[1]); } - // TODO: we might be lying here if the code below sends whole song - res.setHeader('Content-Range', 'bytes ' + range[0] + '-' + end + '/*'); + res.statusCode = 206; + res.setHeader('Content-Range', 'bytes ' + serveRange[0] + '-' + serveRange[1] + '/*'); + } else { + serveRange[1] = haveRange[1]; } - // TODO: we can be smarter here: currently most corner cases - // lead to sending entire song even if only part of it was - // requested. Also the range end is currently ignored - - // skip to start of requested range if we have enough data, - // otherwise serve whole song - if (preparingSong.songData) { - if (range[0] < preparingSong.songData.length) { - res.write(preparingSong.songData.slice(range[0])); - } else { - res.write(preparingSong.songData); - } + if (!rest.pendingRequests[backendName][songId]) { + rest.pendingRequests[backendName][songId] = []; } - rest.pendingRequests[backendName][songId] = - rest.pendingRequests[backendName][songId] || []; + var client = { + res: res, + serveRange: serveRange, + wishRange: wishRange, + incompletePath: path.join(config.songCachePath, incomplete), + filepath: path.join(config.songCachePath, filename) + }; + + // TODO: memory leak + rest.pendingRequests[backendName][songId].push(client); + + // TODO: If we know that we have already flushed data to disk, + // we could open up the read stream already here instead of waiting + // around for the first flush + + // If we can satisfy the start of the requested range, write as + // much as possible to res immediately + /* + if (haveRange[1] >= wishRange[0]) { + client.songStream.write(fs.createReadStream(client.filename, { + start: serveRange[0], + end: serveRange[1] + })); + } - rest.pendingRequests[backendName][songId].push(res); + // If we couldn't satisfy the entire request, push the client + // into pendingRequests so we can append to the stream later + if (serveRange[1] !== wishRange[1]) { + } else { + client.songStream.end(); + } + */ } else { res.status(404).end('404 song not found'); } diff --git a/package.json b/package.json index f3c6ab4..00ec7f3 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "node-ffprobe": "^1.2.2", "node-uuid": "^1.4.7", "npm": "^3.9.5", + "tail-stream": "/Users/resk/src/tail-stream", "underscore": "^1.8.3", "walk": "^2.3.9", "winston": "^2.2.0", From 2c44c7886be69c5670f461646e91492456b0622d Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Tue, 5 Jul 2016 19:50:54 +0300 Subject: [PATCH 073/103] WIP: Keep song data in memory while preparing, write to disk once done --- lib/backend.js | 36 +++++++------------------ lib/backends/local.js | 8 +++--- lib/plugins/rest.js | 63 +++++++++++++++---------------------------- package.json | 1 - 4 files changed, 35 insertions(+), 73 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index ffc4fd8..484e792 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -19,7 +19,7 @@ function Backend() { * Callback for reporting encoding progress * @callback encodeCallback * @param {Error} err - If truthy, an error occurred and preparation cannot continue - * @param {Buffer} bytesWritten - How many bytes of the song is available on disk + * @param {Buffer} bytesWritten - How many new bytes was written to song.data * @param {Bool} done - True if this was the last chunk */ @@ -34,11 +34,6 @@ function Backend() { Backend.prototype.encodeSong = function(stream, seek, song, callback) { var self = this; - var incompletePath = path.join(config.songCachePath, self.name, 'incomplete', - song.songId + '.opus'); - - var incompleteStream = fs.createWriteStream(incompletePath, {flags: 'w'}); - var encodedPath = path.join(config.songCachePath, self.name, song.songId + '.opus'); @@ -51,35 +46,26 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { .format('opus') .on('error', function(err) { self.log.error(self.name + ': error while transcoding ' + song.songId + ': ' + err); - if (fs.existsSync(incompletePath)) { - fs.unlinkSync(incompletePath); - } + delete song.prepare.data; callback(err); }); - song.bytesWritten = 0; - var opusStream = command.pipe(null, {end: true}); opusStream.on('data', function(chunk) { - incompleteStream.write(chunk, undefined, function() { - song.bytesWritten += chunk.length; - callback(null, song.bytesWritten, false); - }); + // TODO: this could be optimized by using larger buffers + song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); + callback(null, chunk.length, false); }); - opusStream.on('end', function() { - incompleteStream.end(undefined, undefined, function() { + opusStream.on('end', () => { + fs.writeFile(encodedPath, song.prepare.data, (err) => { self.log.verbose('transcoding ended for ' + song.songId); + delete song.prepare; // TODO: we don't know if transcoding ended successfully or not, // and there might be a race condition between errCallback deleting // the file and us trying to move it to the songCache // TODO: is this still the case? - - // atomically move result to encodedPath - if (fs.existsSync(incompletePath)) { - fs.renameSync(incompletePath, encodedPath); - } - // TODO: (and what if this fails?) + // (we no longer save incomplete files on disk) callback(null, null, true); }); @@ -91,9 +77,7 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { return function(err) { command.kill(); self.log.verbose(self.name + ': canceled preparing: ' + song.songId + ': ' + err); - if (fs.existsSync(incompletePath)) { - fs.unlinkSync(incompletePath); - } + delete song.prepare; callback(new Error('canceled preparing: ' + song.songId + ': ' + err)); }; }; diff --git a/lib/backends/local.js b/lib/backends/local.js index 6206432..c9c2897 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -282,17 +282,17 @@ Local.prototype.prepare = function(song, callback) { var cancelEncode = null; var canceled = false; - self.songsPreparing[song.songId] = { - bytesWritten: 0, + song.prepare = { + data: new Buffer(0), cancel: function() { canceled = true; if (cancelEncode) { cancelEncode(); } } - }; + } - self.songsPreparing[song.songId] = _.extend(self.songsPreparing[song.songId], song); + self.songsPreparing[song.songId] = song; SongModel.findById(song.songId, function(err, item) { if (canceled) { diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index 2aed677..fd6d454 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -1,11 +1,9 @@ 'use strict'; var _ = require('underscore'); -var ts = require('tail-stream'); var util = require('util'); var path = require('path'); -var fs = require('fs'); var Plugin = require('../plugin'); function Rest(player, callback) { @@ -113,16 +111,13 @@ function Rest(player, callback) { this.pendingRequests = {}; var rest = this; this.registerHook('onPrepareProgress', function(song, bytesWritten, done) { - song = song.serialize(); - - if (!rest.pendingRequests[song.backendName]) { + if (!rest.pendingRequests[song.backend.name]) { return; } - _.each(rest.pendingRequests[song.backendName][song.songId], function(client) { - /* + _.each(rest.pendingRequests[song.backend.name][song.songId], function(client) { if (bytesWritten) { - var end = bytesWritten - 1; + var end = song.prepare.data.length; if (client.wishRange[1]) { end = Math.min(client.wishRange[1], bytesWritten - 1); } @@ -131,36 +126,20 @@ function Rest(player, callback) { if (client.serveRange[1] < end) { console.log('write'); - client.songStream.write(fs.createReadStream(client.filename, { - start: client.serveRange[1], - end: end - })); - - client.serveRange[1] = end; + client.res.write(song.prepare.data.slice(client.serveRange[1] + 1, end)); } - } - */ - - if (!client.songStream && bytesWritten) { - var filepath = done ? client.filepath : client.incompletePath; - client.songStream = ts.createReadStream(filepath, { - useWatch: false, - beginAt: client.serveRange[0], - endAt: client.serveRange[1] // TODO - }); - console.log('opened: ' + filepath); - client.songStream.pipe(client.res); + client.serveRange[1] = end; } - if (client.songStream && done) { + if (done) { console.log('done'); - client.songStream.end(); + client.res.end(); } }); if (done) { - rest.pendingRequests[song.backendName][song.songId] = []; + rest.pendingRequests[song.backend.name][song.songId] = []; } }); @@ -184,7 +163,7 @@ function Rest(player, callback) { }); } else if (backend.songsPreparing[songId]) { // song is preparing - var preparingSong = backend.songsPreparing[songId]; + var song = backend.songsPreparing[songId]; // try finding out length of song var queuedSong = _.find(player.queue.serialize(), function(song) { @@ -204,7 +183,7 @@ function Rest(player, callback) { var serveRange = []; haveRange[0] = 0; - haveRange[1] = preparingSong.bytesWritten - 1; + haveRange[1] = song.prepare.data.length - 1; wishRange[0] = 0; wishRange[1] = null @@ -230,6 +209,8 @@ function Rest(player, callback) { serveRange[1] = haveRange[1]; } + self.log.debug('request with wishRange: ' + wishRange); + if (!rest.pendingRequests[backendName][songId]) { rest.pendingRequests[backendName][songId] = []; } @@ -238,34 +219,32 @@ function Rest(player, callback) { res: res, serveRange: serveRange, wishRange: wishRange, - incompletePath: path.join(config.songCachePath, incomplete), filepath: path.join(config.songCachePath, filename) }; - // TODO: memory leak - rest.pendingRequests[backendName][songId].push(client); - // TODO: If we know that we have already flushed data to disk, // we could open up the read stream already here instead of waiting // around for the first flush // If we can satisfy the start of the requested range, write as // much as possible to res immediately - /* if (haveRange[1] >= wishRange[0]) { - client.songStream.write(fs.createReadStream(client.filename, { - start: serveRange[0], - end: serveRange[1] - })); + client.res.write(song.prepare.data.slice(serveRange[0], serveRange[1] + 1)); } // If we couldn't satisfy the entire request, push the client // into pendingRequests so we can append to the stream later if (serveRange[1] !== wishRange[1]) { + rest.pendingRequests[backendName][songId].push(client); + + req.on('close', function() { + rest.pendingRequests[backendName][songId].splice( + rest.pendingRequests[backendName][songId].indexOf(client), 1 + ); + }); } else { - client.songStream.end(); + client.res.end(); } - */ } else { res.status(404).end('404 song not found'); } diff --git a/package.json b/package.json index 00ec7f3..f3c6ab4 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "node-ffprobe": "^1.2.2", "node-uuid": "^1.4.7", "npm": "^3.9.5", - "tail-stream": "/Users/resk/src/tail-stream", "underscore": "^1.8.3", "walk": "^2.3.9", "winston": "^2.2.0", From 1b73ede8276472a2051362d4be07f3b7972f9c5c Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Wed, 6 Jul 2016 09:55:55 +0300 Subject: [PATCH 074/103] Keep song data in memory while preparing --- lib/backend.js | 25 ++++++++++++++++++++++++- lib/backends/local.js | 3 ++- lib/plugins/rest.js | 6 +++--- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index 484e792..20856b7 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -53,7 +53,30 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { var opusStream = command.pipe(null, {end: true}); opusStream.on('data', function(chunk) { // TODO: this could be optimized by using larger buffers - song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); + //song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); + + if (chunk.length <= song.prepare.data.length - song.prepare.dataPos) { + // If there's room in the buffer, write chunk into it + chunk.copy(song.prepare.data, song.prepare.dataPos); + song.prepare.dataPos += chunk.length; + } else { + // Otherwise allocate more room, then copy chunk into buffer + + // Make absolutely sure that the chunk will fit inside new buffer + var newSize = Math.max(song.prepare.data.length * 2, + song.prepare.data.length + chunk.length); + + self.log.debug('Allocated new song data buffer of size: ' + newSize); + + var buf = new Buffer.allocUnsafe(newSize); + + song.prepare.data.copy(buf); + song.prepare.data = buf; + + chunk.copy(song.prepare.data, song.prepare.dataPos); + song.prepare.dataPos += chunk.length; + } + callback(null, chunk.length, false); }); opusStream.on('end', () => { diff --git a/lib/backends/local.js b/lib/backends/local.js index c9c2897..b87274c 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -283,7 +283,8 @@ Local.prototype.prepare = function(song, callback) { var canceled = false; song.prepare = { - data: new Buffer(0), + data: new Buffer.allocUnsafe(1024 * 1024), + dataPos: 0, cancel: function() { canceled = true; if (cancelEncode) { diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index fd6d454..79ad7d8 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -117,15 +117,15 @@ function Rest(player, callback) { _.each(rest.pendingRequests[song.backend.name][song.songId], function(client) { if (bytesWritten) { - var end = song.prepare.data.length; + var end = song.prepare.dataPos; if (client.wishRange[1]) { end = Math.min(client.wishRange[1], bytesWritten - 1); } - console.log('end: ' + end + '\tclient.serveRange[1]: ' + client.serveRange[1]); + //console.log('end: ' + end + '\tclient.serveRange[1]: ' + client.serveRange[1]); if (client.serveRange[1] < end) { - console.log('write'); + //console.log('write'); client.res.write(song.prepare.data.slice(client.serveRange[1] + 1, end)); } From 124f6454bfaa47cf50549c034aa308da1d0ade7f Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 18 Jul 2016 19:53:16 +0300 Subject: [PATCH 075/103] WIP: streaming changes --- lib/backend.js | 18 ++++ lib/backends/local.js | 10 +++ lib/plugins/rest.js | 195 ++++++++++++++++++++++-------------------- 3 files changed, 132 insertions(+), 91 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index 20856b7..4511155 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -120,6 +120,24 @@ Backend.prototype.cancelPrepare = function(song) { // dummy functions +/** + * Callback for reporting song duration + * @callback durationCallback + * @param {Error} err - If truthy, an error occurred + * @param {Number} duration - Duration in milliseconds + */ + +/** + * Returns length of song + * @param {Song} song - Query concerns this song + * @param {durationCallback} callback - Called with duration + */ +Backend.prototype.getDuration = function(song, callback) { + var err = 'FATAL: backend does not implement getDuration()!'; + this.log.error(err); + callback(err); +}; + /** * Synchronously(!) returns whether the song with songId is prepared or not * @param {Song} song - Query concerns this song diff --git a/lib/backends/local.js b/lib/backends/local.js index b87274c..1cf3446 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -264,6 +264,16 @@ Local.prototype.isPrepared = function(song) { return fs.existsSync(filePath); }; +Local.prototype.getDuration = (song, callback) => { + SongModel.findById(song.songId, (err, item) => { + if (err) { + return callback(err); + } + + callback(null, item.duration); + }); +}; + Local.prototype.prepare = function(song, callback) { var self = this; diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index 79ad7d8..d6fa5e2 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -2,6 +2,7 @@ var _ = require('underscore'); +var async = require('async'); var util = require('util'); var path = require('path'); var Plugin = require('../plugin'); @@ -156,98 +157,110 @@ function Rest(player, callback) { var filename = path.join(backendName, songId + '.' + songFormat); var incomplete = path.join(backendName, 'incomplete', songId + '.' + songFormat); - if (backend.isPrepared({songId: songId})) { - // song should be available on disk - res.sendFile(filename, { - root: config.songCachePath - }); - } else if (backend.songsPreparing[songId]) { - // song is preparing - var song = backend.songsPreparing[songId]; - - // try finding out length of song - var queuedSong = _.find(player.queue.serialize(), function(song) { - return song.songId === songId && song.backendName === backendName; - }); - - if (queuedSong) { - res.setHeader('X-Content-Duration', queuedSong.duration / 1000); - } - - res.setHeader('Transfer-Encoding', 'chunked'); - res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); - res.setHeader('Accept-Ranges', 'bytes'); - - var haveRange = []; - var wishRange = []; - var serveRange = []; - - haveRange[0] = 0; - haveRange[1] = song.prepare.data.length - 1; - - wishRange[0] = 0; - wishRange[1] = null - - serveRange[0] = 0; - - if (req.headers.range) { - // partial request - - wishRange = req.headers.range.substr(req.headers.range.indexOf('=') + 1).split('-'); - - serveRange[0] = wishRange[0]; - - // a best guess for the response header - serveRange[1] = haveRange[1]; - if (wishRange[1]) { - serveRange[1] = Math.min(wishRange[1], haveRange[1]); + res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); + res.setHeader('Accept-Ranges', 'bytes'); + + var queuedSong = _.find(player.queue.serialize(), function(song) { + return song.songId === songId && song.backendName === backendName; + }); + + async.series([ + (callback) => { + // try finding out length of song + if (queuedSong) { + res.setHeader('X-Content-Duration', queuedSong.duration / 1000); + callback(); + } else { + backend.getDuration({songId: songId}, (err, exactDuration) => { + res.setHeader('X-Content-Duration', exactDuration / 1000); + callback(); + }); } - - res.statusCode = 206; - res.setHeader('Content-Range', 'bytes ' + serveRange[0] + '-' + serveRange[1] + '/*'); - } else { - serveRange[1] = haveRange[1]; - } - - self.log.debug('request with wishRange: ' + wishRange); - - if (!rest.pendingRequests[backendName][songId]) { - rest.pendingRequests[backendName][songId] = []; - } - - var client = { - res: res, - serveRange: serveRange, - wishRange: wishRange, - filepath: path.join(config.songCachePath, filename) - }; - - // TODO: If we know that we have already flushed data to disk, - // we could open up the read stream already here instead of waiting - // around for the first flush - - // If we can satisfy the start of the requested range, write as - // much as possible to res immediately - if (haveRange[1] >= wishRange[0]) { - client.res.write(song.prepare.data.slice(serveRange[0], serveRange[1] + 1)); - } - - // If we couldn't satisfy the entire request, push the client - // into pendingRequests so we can append to the stream later - if (serveRange[1] !== wishRange[1]) { - rest.pendingRequests[backendName][songId].push(client); - - req.on('close', function() { - rest.pendingRequests[backendName][songId].splice( - rest.pendingRequests[backendName][songId].indexOf(client), 1 - ); - }); - } else { - client.res.end(); - } - } else { - res.status(404).end('404 song not found'); - } + }, + (callback) => { + if (backend.isPrepared({songId: songId})) { + // song should be available on disk + res.sendFile(filename, { + root: config.songCachePath + }); + } else if (backend.songsPreparing[songId]) { + // song is preparing + var song = backend.songsPreparing[songId]; + + var haveRange = []; + var wishRange = []; + var serveRange = []; + + haveRange[0] = 0; + haveRange[1] = song.prepare.data.length - 1; + + wishRange[0] = 0; + wishRange[1] = null + + serveRange[0] = 0; + + res.setHeader('Transfer-Encoding', 'chunked'); + + if (req.headers.range) { + // partial request + + wishRange = req.headers.range.substr(req.headers.range.indexOf('=') + 1).split('-'); + + serveRange[0] = wishRange[0]; + + // a best guess for the response header + serveRange[1] = haveRange[1]; + if (wishRange[1]) { + serveRange[1] = Math.min(wishRange[1], haveRange[1]); + } + + res.statusCode = 206; + res.setHeader('Content-Range', 'bytes ' + serveRange[0] + '-' + serveRange[1] + '/*'); + } else { + serveRange[1] = haveRange[1]; + } + + self.log.debug('request with wishRange: ' + wishRange); + + if (!rest.pendingRequests[backendName][songId]) { + rest.pendingRequests[backendName][songId] = []; + } + + var client = { + res: res, + serveRange: serveRange, + wishRange: wishRange, + filepath: path.join(config.songCachePath, filename) + }; + + // TODO: If we know that we have already flushed data to disk, + // we could open up the read stream already here instead of waiting + // around for the first flush + + // If we can satisfy the start of the requested range, write as + // much as possible to res immediately + if (haveRange[1] >= wishRange[0]) { + client.res.write(song.prepare.data.slice(serveRange[0], serveRange[1] + 1)); + } + + // If we couldn't satisfy the entire request, push the client + // into pendingRequests so we can append to the stream later + if (serveRange[1] !== wishRange[1]) { + rest.pendingRequests[backendName][songId].push(client); + + req.on('close', function() { + rest.pendingRequests[backendName][songId].splice( + rest.pendingRequests[backendName][songId].indexOf(client), 1 + ); + }); + } else { + client.res.end(); + } + } else { + res.status(404).end('404 song not found'); + } + }] + ); }); }); From 0ef737b02fb8058f4ba3c1b81eec5b84eaf22d82 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 1 Aug 2016 17:11:03 +0300 Subject: [PATCH 076/103] Run dev environment with node-babel, migrate plugins to ES6 --- .babelrc | 5 + lib/plugin.js | 24 +-- lib/plugins/express.js | 77 ++++---- lib/plugins/index.js | 7 +- lib/plugins/rest.js | 437 ++++++++++++++++++++--------------------- package.json | 7 +- 6 files changed, 281 insertions(+), 276 deletions(-) create mode 100644 .babelrc diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..0b42c4e --- /dev/null +++ b/.babelrc @@ -0,0 +1,5 @@ +{ + "presets": [ + "node6" + ] +} diff --git a/lib/plugin.js b/lib/plugin.js index 22645dd..b2487ad 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -1,17 +1,17 @@ -var labeledLogger = require('./logger'); +const labeledLogger = require('./logger'); /** * Super constructor for plugins */ -function Plugin() { - this.name = this.constructor.name.toLowerCase(); - this.log = labeledLogger(this.name); - this.log.info('initializing...'); - this.hooks = {}; -} - -Plugin.prototype.registerHook = function(hook, callback) { - this.hooks[hook] = callback; -}; +export default class Plugin { + constructor() { + this.name = this.constructor.name.toLowerCase(); + this.log = labeledLogger(this.name); + this.log.info('initializing...'); + this.hooks = {}; + } -module.exports = Plugin; + registerHook(hook, callback) { + this.hooks[hook] = callback; + } +} diff --git a/lib/plugins/express.js b/lib/plugins/express.js index 88a96a3..93e6c38 100644 --- a/lib/plugins/express.js +++ b/lib/plugins/express.js @@ -7,46 +7,41 @@ var https = require('https'); var http = require('http'); var fs = require('fs'); -var util = require('util'); -var Plugin = require('../plugin'); - -function Express(player, callback) { - Plugin.apply(this); - - var self = this; - - // NOTE: no argument passed so we get the core's config - var config = require('../config').getConfig(); - player.app = express(); - - var options = {}; - var port = process.env.PORT || config.port; - if (config.tls) { - options = { - tls: config.tls, - key: config.key ? fs.readFileSync(config.key) : undefined, - cert: config.cert ? fs.readFileSync(config.cert) : undefined, - ca: config.ca ? fs.readFileSync(config.ca) : undefined, - requestCert: config.requestCert, - rejectUnauthorized: config.rejectUnauthorized - }; - // TODO: deprecated! - player.app.set('tls', true); - player.httpServer = https.createServer(options, player.app) - .listen(port); - } else { - player.httpServer = http.createServer(player.app) - .listen(port); +import Plugin from '../plugin'; + +export default class Express extends Plugin { + constructor(player, callback) { + super(); + + // NOTE: no argument passed so we get the core's config + var config = require('../config').getConfig(); + player.app = express(); + + var options = {}; + var port = process.env.PORT || config.port; + if (config.tls) { + options = { + tls: config.tls, + key: config.key ? fs.readFileSync(config.key) : undefined, + cert: config.cert ? fs.readFileSync(config.cert) : undefined, + ca: config.ca ? fs.readFileSync(config.ca) : undefined, + requestCert: config.requestCert, + rejectUnauthorized: config.rejectUnauthorized + }; + // TODO: deprecated! + player.app.set('tls', true); + player.httpServer = https.createServer(options, player.app) + .listen(port); + } else { + player.httpServer = http.createServer(player.app) + .listen(port); + } + this.log.info('listening on port ' + port); + + player.app.use(cookieParser()); + player.app.use(bodyParser.json({limit: '100mb'})); + player.app.use(bodyParser.urlencoded({extended: true})); + + callback(null, this); } - self.log.info('listening on port ' + port); - - player.app.use(cookieParser()); - player.app.use(bodyParser.json({limit: '100mb'})); - player.app.use(bodyParser.urlencoded({extended: true})); - - callback(null, this); } - -util.inherits(Express, Plugin); - -module.exports = Express; diff --git a/lib/plugins/index.js b/lib/plugins/index.js index 689e836..ad8cc07 100644 --- a/lib/plugins/index.js +++ b/lib/plugins/index.js @@ -1,5 +1,8 @@ +import Express from './express'; +import Rest from './rest'; + var Plugins = []; -Plugins.push(require('./express')); -Plugins.push(require('./rest')); // NOTE: must be initialized after express +Plugins.push(Express); +Plugins.push(Rest); // NOTE: must be initialized after express module.exports = Plugins; diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index d6fa5e2..8c0b9fe 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -5,268 +5,265 @@ var _ = require('underscore'); var async = require('async'); var util = require('util'); var path = require('path'); -var Plugin = require('../plugin'); +import Plugin from '../plugin'; -function Rest(player, callback) { - Plugin.apply(this); +export default class Rest extends Plugin { + constructor(player, callback) { + super(); - // NOTE: no argument passed so we get the core's config - var config = require('../config').getConfig(); - var self = this; + // NOTE: no argument passed so we get the core's config + var config = require('../config').getConfig(); - if (!player.app) { - return callback('module must be initialized after express module!'); - } - - player.app.use(function(req, res, next) { - res.sendRes = function(err, data) { - if (err) { - res.status(404).send(err); - } else { - res.send(data || 'ok'); - } - }; - next(); - }); - - player.app.get('/queue', function(req, res) { - var np = player.nowPlaying; - var pos = 0; - if (np) { - if (np.playback.startTime) { - pos = new Date().getTime() - np.playback.startTime + np.playback.startPos; - } else { - pos = np.playback.startPos; - } + if (!player.app) { + return callback('module must be initialized after express module!'); } - res.json({ - songs: player.queue.serialize(), - nowPlaying: np ? np.serialize() : null, - nowPlayingPos: pos, - play: player.play + player.app.use((req, res, next) => { + res.sendRes = (err, data) => { + if (err) { + res.status(404).send(err); + } else { + res.send(data || 'ok'); + } + }; + next(); }); - }); - - // TODO: error handling - player.app.post('/queue/song', function(req, res) { - var err = player.queue.insertSongs(null, req.body); - - res.sendRes(err); - }); - player.app.post('/queue/song/:at', function(req, res) { - var err = player.queue.insertSongs(req.params.at, req.body); - - res.sendRes(err); - }); - - /* - player.app.post('/queue/move/:pos', function(req, res) { - var err = player.moveInQueue( - parseInt(req.params.pos), - parseInt(req.body.to), - parseInt(req.body.cnt) - ); - sendResponse(res, 'success', err); - }); - */ - - player.app.delete('/queue/song/:at', function(req, res) { - player.removeSongs(req.params.at, parseInt(req.query.cnt) || 1, res.sendRes); - }); - - player.app.post('/playctl/play', function(req, res) { - player.startPlayback(parseInt(req.body.position) || 0); - res.sendRes(null, 'ok'); - }); - - player.app.post('/playctl/stop', function(req, res) { - player.stopPlayback(req.query.pause); - res.sendRes(null, 'ok'); - }); - - player.app.post('/playctl/skip', function(req, res) { - player.skipSongs(parseInt(req.body.cnt)); - res.sendRes(null, 'ok'); - }); - - player.app.post('/playctl/shuffle', function(req, res) { - player.shuffleQueue(); - res.sendRes(null, 'ok'); - }); - - player.app.post('/volume', function(req, res) { - player.setVolume(parseInt(req.body)); - res.send('success'); - }); - - // search for songs, search terms in query params - player.app.get('/search', function(req, res) { - self.log.verbose('got search request: ' + JSON.stringify(req.body.query)); - - player.searchBackends(req.body.query, function(results) { - res.json(results); + + player.app.get('/queue', (req, res) => { + var np = player.nowPlaying; + var pos = 0; + if (np) { + if (np.playback.startTime) { + pos = new Date().getTime() - np.playback.startTime + np.playback.startPos; + } else { + pos = np.playback.startPos; + } + } + + res.json({ + songs: player.queue.serialize(), + nowPlaying: np ? np.serialize() : null, + nowPlayingPos: pos, + play: player.play + }); }); - }); - this.pendingRequests = {}; - var rest = this; - this.registerHook('onPrepareProgress', function(song, bytesWritten, done) { - if (!rest.pendingRequests[song.backend.name]) { - return; - } + // TODO: error handling + player.app.post('/queue/song', (req, res) => { + var err = player.queue.insertSongs(null, req.body); - _.each(rest.pendingRequests[song.backend.name][song.songId], function(client) { - if (bytesWritten) { - var end = song.prepare.dataPos; - if (client.wishRange[1]) { - end = Math.min(client.wishRange[1], bytesWritten - 1); - } + res.sendRes(err); + }); + player.app.post('/queue/song/:at', (req, res) => { + var err = player.queue.insertSongs(req.params.at, req.body); - //console.log('end: ' + end + '\tclient.serveRange[1]: ' + client.serveRange[1]); + res.sendRes(err); + }); - if (client.serveRange[1] < end) { - //console.log('write'); - client.res.write(song.prepare.data.slice(client.serveRange[1] + 1, end)); - } + /* + player.app.post('/queue/move/:pos', function(req, res) { + var err = player.moveInQueue( + parseInt(req.params.pos), + parseInt(req.body.to), + parseInt(req.body.cnt) + ); + sendResponse(res, 'success', err); + }); + */ - client.serveRange[1] = end; - } + player.app.delete('/queue/song/:at', (req, res) => { + player.removeSongs(req.params.at, parseInt(req.query.cnt) || 1, res.sendRes); + }); - if (done) { - console.log('done'); - client.res.end(); - } + player.app.post('/playctl/play', (req, res) => { + player.startPlayback(parseInt(req.body.position) || 0); + res.sendRes(null, 'ok'); }); - if (done) { - rest.pendingRequests[song.backend.name][song.songId] = []; - } - }); + player.app.post('/playctl/stop', (req, res) => { + player.stopPlayback(req.query.pause); + res.sendRes(null, 'ok'); + }); - this.registerHook('onBackendInitialized', function(backendName) { - rest.pendingRequests[backendName] = {}; + player.app.post('/playctl/skip', (req, res) => { + player.skipSongs(parseInt(req.body.cnt)); + res.sendRes(null, 'ok'); + }); - // provide API path for music data, might block while song is preparing - player.app.get('/song/' + backendName + '/:fileName', function(req, res, next) { - var extIndex = req.params.fileName.lastIndexOf('.'); - var songId = req.params.fileName.substring(0, extIndex); - var songFormat = req.params.fileName.substring(extIndex + 1); + player.app.post('/playctl/shuffle', (req, res) => { + player.shuffleQueue(); + res.sendRes(null, 'ok'); + }); - var backend = player.backends[backendName]; - var filename = path.join(backendName, songId + '.' + songFormat); - var incomplete = path.join(backendName, 'incomplete', songId + '.' + songFormat); + player.app.post('/volume', (req, res) => { + player.setVolume(parseInt(req.body)); + res.send('success'); + }); - res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); - res.setHeader('Accept-Ranges', 'bytes'); + // search for songs, search terms in query params + player.app.get('/search', (req, res) => { + this.log.verbose('got search request: ' + JSON.stringify(req.body.query)); - var queuedSong = _.find(player.queue.serialize(), function(song) { - return song.songId === songId && song.backendName === backendName; + player.searchBackends(req.body.query, (results) => { + res.json(results); }); + }); - async.series([ - (callback) => { - // try finding out length of song - if (queuedSong) { - res.setHeader('X-Content-Duration', queuedSong.duration / 1000); - callback(); - } else { - backend.getDuration({songId: songId}, (err, exactDuration) => { - res.setHeader('X-Content-Duration', exactDuration / 1000); - callback(); - }); + this.pendingRequests = {}; + var rest = this; + this.registerHook('onPrepareProgress', (song, bytesWritten, done) => { + if (!rest.pendingRequests[song.backend.name]) { + return; + } + + _.each(rest.pendingRequests[song.backend.name][song.songId], (client) => { + if (bytesWritten) { + var end = song.prepare.dataPos; + if (client.wishRange[1]) { + end = Math.min(client.wishRange[1], bytesWritten - 1); } - }, - (callback) => { - if (backend.isPrepared({songId: songId})) { - // song should be available on disk - res.sendFile(filename, { - root: config.songCachePath - }); - } else if (backend.songsPreparing[songId]) { - // song is preparing - var song = backend.songsPreparing[songId]; - var haveRange = []; - var wishRange = []; - var serveRange = []; + //console.log('end: ' + end + '\tclient.serveRange[1]: ' + client.serveRange[1]); + + if (client.serveRange[1] < end) { + //console.log('write'); + client.res.write(song.prepare.data.slice(client.serveRange[1] + 1, end)); + } - haveRange[0] = 0; - haveRange[1] = song.prepare.data.length - 1; + client.serveRange[1] = end; + } - wishRange[0] = 0; - wishRange[1] = null + if (done) { + console.log('done'); + client.res.end(); + } + }); - serveRange[0] = 0; + if (done) { + rest.pendingRequests[song.backend.name][song.songId] = []; + } + }); - res.setHeader('Transfer-Encoding', 'chunked'); + this.registerHook('onBackendInitialized', (backendName) => { + rest.pendingRequests[backendName] = {}; - if (req.headers.range) { - // partial request + // provide API path for music data, might block while song is preparing + player.app.get('/song/' + backendName + '/:fileName', (req, res, next) => { + var extIndex = req.params.fileName.lastIndexOf('.'); + var songId = req.params.fileName.substring(0, extIndex); + var songFormat = req.params.fileName.substring(extIndex + 1); - wishRange = req.headers.range.substr(req.headers.range.indexOf('=') + 1).split('-'); + var backend = player.backends[backendName]; + var filename = path.join(backendName, songId + '.' + songFormat); + var incomplete = path.join(backendName, 'incomplete', songId + '.' + songFormat); - serveRange[0] = wishRange[0]; + res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); + res.setHeader('Accept-Ranges', 'bytes'); - // a best guess for the response header - serveRange[1] = haveRange[1]; - if (wishRange[1]) { - serveRange[1] = Math.min(wishRange[1], haveRange[1]); - } + var queuedSong = _.find(player.queue.serialize(), (song) => { + return song.songId === songId && song.backendName === backendName; + }); - res.statusCode = 206; - res.setHeader('Content-Range', 'bytes ' + serveRange[0] + '-' + serveRange[1] + '/*'); + async.series([ + (callback) => { + // try finding out length of song + if (queuedSong) { + res.setHeader('X-Content-Duration', queuedSong.duration / 1000); + callback(); } else { - serveRange[1] = haveRange[1]; + backend.getDuration({songId: songId}, (err, exactDuration) => { + res.setHeader('X-Content-Duration', exactDuration / 1000); + callback(); + }); } + }, + (callback) => { + if (backend.isPrepared({songId: songId})) { + // song should be available on disk + res.sendFile(filename, { + root: config.songCachePath + }); + } else if (backend.songsPreparing[songId]) { + // song is preparing + var song = backend.songsPreparing[songId]; - self.log.debug('request with wishRange: ' + wishRange); + var haveRange = []; + var wishRange = []; + var serveRange = []; - if (!rest.pendingRequests[backendName][songId]) { - rest.pendingRequests[backendName][songId] = []; - } + haveRange[0] = 0; + haveRange[1] = song.prepare.data.length - 1; - var client = { - res: res, - serveRange: serveRange, - wishRange: wishRange, - filepath: path.join(config.songCachePath, filename) - }; - - // TODO: If we know that we have already flushed data to disk, - // we could open up the read stream already here instead of waiting - // around for the first flush - - // If we can satisfy the start of the requested range, write as - // much as possible to res immediately - if (haveRange[1] >= wishRange[0]) { - client.res.write(song.prepare.data.slice(serveRange[0], serveRange[1] + 1)); - } + wishRange[0] = 0; + wishRange[1] = null - // If we couldn't satisfy the entire request, push the client - // into pendingRequests so we can append to the stream later - if (serveRange[1] !== wishRange[1]) { - rest.pendingRequests[backendName][songId].push(client); + serveRange[0] = 0; - req.on('close', function() { - rest.pendingRequests[backendName][songId].splice( - rest.pendingRequests[backendName][songId].indexOf(client), 1 - ); - }); + res.setHeader('Transfer-Encoding', 'chunked'); + + if (req.headers.range) { + // partial request + + wishRange = req.headers.range.substr(req.headers.range.indexOf('=') + 1).split('-'); + + serveRange[0] = wishRange[0]; + + // a best guess for the response header + serveRange[1] = haveRange[1]; + if (wishRange[1]) { + serveRange[1] = Math.min(wishRange[1], haveRange[1]); + } + + res.statusCode = 206; + res.setHeader('Content-Range', 'bytes ' + serveRange[0] + '-' + serveRange[1] + '/*'); + } else { + serveRange[1] = haveRange[1]; + } + + this.log.debug('request with wishRange: ' + wishRange); + + if (!rest.pendingRequests[backendName][songId]) { + rest.pendingRequests[backendName][songId] = []; + } + + var client = { + res: res, + serveRange: serveRange, + wishRange: wishRange, + filepath: path.join(config.songCachePath, filename) + }; + + // TODO: If we know that we have already flushed data to disk, + // we could open up the read stream already here instead of waiting + // around for the first flush + + // If we can satisfy the start of the requested range, write as + // much as possible to res immediately + if (haveRange[1] >= wishRange[0]) { + client.res.write(song.prepare.data.slice(serveRange[0], serveRange[1] + 1)); + } + + // If we couldn't satisfy the entire request, push the client + // into pendingRequests so we can append to the stream later + if (serveRange[1] !== wishRange[1]) { + rest.pendingRequests[backendName][songId].push(client); + + req.on('close', () => { + rest.pendingRequests[backendName][songId].splice( + rest.pendingRequests[backendName][songId].indexOf(client), 1 + ); + }); + } else { + client.res.end(); + } } else { - client.res.end(); + res.status(404).end('404 song not found'); } - } else { - res.status(404).end('404 song not found'); - } - }] - ); + }] + ); + }); }); - }); - callback(null, this); + callback(null, this); + } } - -util.inherits(Rest, Plugin); - -module.exports = Rest; diff --git a/package.json b/package.json index f3c6ab4..22c2ac8 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,11 @@ "name": "nodeplayer", "version": "0.2.1", "description": "simple, modular music player written in node.js", - "main": "index.js", + "main": "bin/nodeplayer", "preferGlobal": true, "scripts": { "start": "./bin/nodeplayer", + "watch": "nodemon --exec babel-node bin/nodeplayer", "test": "NODE_ENV=test ./node_modules/mocha/bin/mocha", "coverage": "NODE_ENV=test istanbul cover _mocha -- -R spec" }, @@ -20,6 +21,9 @@ "license": "MIT", "dependencies": { "async": "^2.0.0-rc.6", + "babel-cli": "^6.11.4", + "babel-core": "^6.11.4", + "babel-preset-node6": "^11.0.0", "body-parser": "^1.15.1", "cookie-parser": "^1.4.3", "escape-string-regexp": "^1.0.5", @@ -37,6 +41,7 @@ "devDependencies": { "chai": "*", "coveralls": "^2.11.9", + "eslint": "^3.2.0", "istanbul": "*", "jscs": "^3.0.4", "mocha": "*", From 972cb5fe43f386a0c0a72fe357f825961272c0a2 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 5 Sep 2016 13:08:20 +0300 Subject: [PATCH 077/103] Add simple eslint config and make codebase pass --- .eslintrc.json | 13 +++++++++++++ lib/backend.js | 2 +- lib/backends/local.js | 32 ++++++++++++++++---------------- lib/config.js | 8 ++++---- lib/logger.js | 6 +++--- lib/modules.js | 2 +- lib/player.js | 10 +++++----- lib/plugins/express.js | 2 +- lib/plugins/rest.js | 8 ++++---- lib/queue.js | 2 +- lib/song.js | 10 +++++----- package.json | 12 ++++++------ test/eslint.spec.js | 9 +++++++++ test/jscs.spec.js | 1 - test/jshint.spec.js | 1 - 15 files changed, 69 insertions(+), 49 deletions(-) create mode 100644 .eslintrc.json create mode 100644 test/eslint.spec.js delete mode 100644 test/jscs.spec.js delete mode 100644 test/jshint.spec.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..451161f --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,13 @@ +{ + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module" + }, + "env": { + "node": true + }, + "rules": { + "comma-dangle": ["error", "always-multiline"], + "quotes": ["error", "single"] + } +} diff --git a/lib/backend.js b/lib/backend.js index 4511155..e46dab0 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -1,4 +1,4 @@ -var _ = require('underscore'); +var _ = require('lodash'); var path = require('path'); var fs = require('fs'); var ffmpeg = require('fluent-ffmpeg'); diff --git a/lib/backends/local.js b/lib/backends/local.js index 1cf3446..1711f2b 100644 --- a/lib/backends/local.js +++ b/lib/backends/local.js @@ -7,7 +7,7 @@ var mongoose = require('mongoose'); var async = require('async'); var walk = require('walk'); var ffprobe = require('node-ffprobe'); -var _ = require('underscore'); +var _ = require('lodash'); var escapeStringRegexp = require('escape-string-regexp'); var util = require('util'); @@ -91,22 +91,22 @@ var SongModel = mongoose.model('Song', { album: String, albumArt: { lq: String, - hq: String + hq: String, }, duration: { type: Number, - required: true + required: true, }, format: { type: String, - required: true + required: true, }, filename: { type: String, unique: true, required: true, - dropDups: true - } + dropDups: true, + }, }); /** @@ -127,7 +127,7 @@ var guessMetadataFromPath = function(filePath, fileExt) { return { artist: splitName[0], title: splitName[1], - album: path.basename(path.dirname(filePath)) + album: path.basename(path.dirname(filePath)), }; }; @@ -157,7 +157,7 @@ function Local(callback) { }); var options = { - followLinks: config.followSymlinks + followLinks: config.followSymlinks, }; var insertSong = function(probeData, done) { @@ -170,7 +170,7 @@ function Local(callback) { //albumArt: {} // TODO duration: probeData.format.duration * 1000, format: probeData.format.format_name, - filename: probeData.file + filename: probeData.file, }); song = song.toObject(); @@ -178,7 +178,7 @@ function Local(callback) { delete song._id; SongModel.findOneAndUpdate({ - filename: probeData.file + filename: probeData.file, }, song, {upsert: true}, function(err) { if (err) { self.log.error('while inserting song: ' + probeData.file + ', ' + err); @@ -222,7 +222,7 @@ function Local(callback) { self.log.verbose('Scanning: ' + filename); scanned++; q.push({ - filename: filename + filename: filename, }); next(); }); @@ -300,7 +300,7 @@ Local.prototype.prepare = function(song, callback) { if (cancelEncode) { cancelEncode(); } - } + }, } self.songsPreparing[song.songId] = song; @@ -330,12 +330,12 @@ Local.prototype.search = function(query, callback) { $or: [ {artist: new RegExp(escapeStringRegexp(query.any), 'i')}, {title: new RegExp(escapeStringRegexp(query.any), 'i')}, - {album: new RegExp(escapeStringRegexp(query.any), 'i')} - ] + {album: new RegExp(escapeStringRegexp(query.any), 'i')}, + ], }; } else { q = { - $and: [] + $and: [], }; _.keys(query).forEach(function(key) { @@ -368,7 +368,7 @@ Local.prototype.search = function(query, callback) { songId: song._id, score: self.config.maxScore * (numItems - cur) / numItems, backendName: 'local', - format: 'opus' + format: 'opus', }; cur++; } diff --git a/lib/config.js b/lib/config.js index 8e7f6ee..b9ae151 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,4 +1,4 @@ -var _ = require('underscore'); +var _ = require('lodash'); var mkdirp = require('mkdirp'); var fs = require('fs'); var os = require('os'); @@ -26,7 +26,7 @@ var defaultConfig = {}; // backends are sources of music defaultConfig.backends = [ - 'youtube' + 'youtube', ]; // plugins are "everything else", most of the functionality is in plugins @@ -34,7 +34,7 @@ defaultConfig.backends = [ // NOTE: ordering is important here, plugins that require another plugin will // complain if order is wrong. defaultConfig.plugins = [ - 'weblistener' + 'weblistener', ]; defaultConfig.logLevel = 'info'; @@ -66,7 +66,7 @@ defaultConfig.importFormats = [ 'mp3', 'flac', 'ogg', - 'opus' + 'opus', ]; defaultConfig.concurrentProbes = 4; defaultConfig.followSymlinks = true; diff --git a/lib/logger.js b/lib/logger.js index b1f72f9..b638f90 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -10,8 +10,8 @@ module.exports = function(label) { level: config.logLevel, colorize: config.logColorize, handleExceptions: config.logExceptions, - json: config.logJson - }) - ] + json: config.logJson, + }), + ], }); }; diff --git a/lib/modules.js b/lib/modules.js index 659ba80..094dae1 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -6,7 +6,7 @@ var BuiltinPlugins = require('./plugins'); var BuiltinBackends = require('./backends'); var config = nodeplayerConfig.getConfig(); -var _ = require('underscore'); +var _ = require('lodash'); var logger = labeledLogger('modules'); var checkModule = function(module) { diff --git a/lib/player.js b/lib/player.js index 6dfa89f..eabd66e 100644 --- a/lib/player.js +++ b/lib/player.js @@ -1,5 +1,5 @@ 'use strict'; -var _ = require('underscore'); +var _ = require('lodash'); var async = require('async'); var util = require('util'); var labeledLogger = require('./logger'); @@ -55,7 +55,7 @@ function Player(options) { player.callHooks('onBackendsInitialized'); callback(); }); - } + }, ], function() { player.logger.info('ready'); player.callHooks('onReady'); @@ -122,7 +122,7 @@ Player.prototype.stopPlayback = function(pause) { if (np) { np.playback = { startTime: 0, - startPos: pause ? pos : 0 + startPos: pause ? pos : 0, }; } }; @@ -214,7 +214,7 @@ Player.prototype.setPrepareTimeout = function(song) { Object.defineProperty(song, 'prepareTimeout', { enumerable: false, - writable: true + writable: true, }); }; @@ -354,7 +354,7 @@ Player.prototype.prepareSongs = function() { // bail out callback(true); } - } + }, ]); // TODO where to put this //player.prepareErrCallback(); diff --git a/lib/plugins/express.js b/lib/plugins/express.js index 93e6c38..351e33f 100644 --- a/lib/plugins/express.js +++ b/lib/plugins/express.js @@ -26,7 +26,7 @@ export default class Express extends Plugin { cert: config.cert ? fs.readFileSync(config.cert) : undefined, ca: config.ca ? fs.readFileSync(config.ca) : undefined, requestCert: config.requestCert, - rejectUnauthorized: config.rejectUnauthorized + rejectUnauthorized: config.rejectUnauthorized, }; // TODO: deprecated! player.app.set('tls', true); diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js index 8c0b9fe..2e4862e 100644 --- a/lib/plugins/rest.js +++ b/lib/plugins/rest.js @@ -1,6 +1,6 @@ 'use strict'; -var _ = require('underscore'); +var _ = require('lodash'); var async = require('async'); var util = require('util'); @@ -44,7 +44,7 @@ export default class Rest extends Plugin { songs: player.queue.serialize(), nowPlaying: np ? np.serialize() : null, nowPlayingPos: pos, - play: player.play + play: player.play, }); }); @@ -181,7 +181,7 @@ export default class Rest extends Plugin { if (backend.isPrepared({songId: songId})) { // song should be available on disk res.sendFile(filename, { - root: config.songCachePath + root: config.songCachePath, }); } else if (backend.songsPreparing[songId]) { // song is preparing @@ -230,7 +230,7 @@ export default class Rest extends Plugin { res: res, serveRange: serveRange, wishRange: wishRange, - filepath: path.join(config.songCachePath, filename) + filepath: path.join(config.songCachePath, filename), }; // TODO: If we know that we have already flushed data to disk, diff --git a/lib/queue.js b/lib/queue.js index e98ad11..5db5cc5 100644 --- a/lib/queue.js +++ b/lib/queue.js @@ -1,4 +1,4 @@ -var _ = require('underscore'); +var _ = require('lodash'); var Song = require('./song'); /** diff --git a/lib/song.js b/lib/song.js index 60f072d..1ae9763 100644 --- a/lib/song.js +++ b/lib/song.js @@ -1,4 +1,4 @@ -var _ = require('underscore'); +var _ = require('lodash'); var uuid = require('node-uuid'); /** @@ -36,7 +36,7 @@ function Song(song, backend) { this.album = song.album; this.albumArt = { lq: song.albumArt ? song.albumArt.lq : null, - hq: song.albumArt ? song.albumArt.hq : null + hq: song.albumArt ? song.albumArt.hq : null, }; this.duration = song.duration; this.songId = song.songId; @@ -45,7 +45,7 @@ function Song(song, backend) { this.playback = { startTime: null, - startPos: null + startPos: null, }; // NOTE: internally to the Song we store a reference to the backend. @@ -67,7 +67,7 @@ function Song(song, backend) { Song.prototype.playbackStarted = function(pos) { this.playback = { startTime: new Date(), - startPos: pos || null + startPos: pos || null, }; }; @@ -88,7 +88,7 @@ Song.prototype.serialize = function() { format: this.format, backendName: this.backend.name, playlist: this.playlist, - playback: this.playback + playback: this.playback, }; }; diff --git a/package.json b/package.json index 22c2ac8..b335b6c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "./bin/nodeplayer", "watch": "nodemon --exec babel-node bin/nodeplayer", - "test": "NODE_ENV=test ./node_modules/mocha/bin/mocha", + "test": "NODE_ENV=test ./node_modules/mocha/bin/mocha --compilers js:babel-core/register", "coverage": "NODE_ENV=test istanbul cover _mocha -- -R spec" }, "repository": { @@ -28,12 +28,13 @@ "cookie-parser": "^1.4.3", "escape-string-regexp": "^1.0.5", "express": "^4.13.4", + "fluent-ffmpeg": "^2.1.0", + "lodash": "^4.15.0", "mkdirp": "^0.5.1", "mongoose": "^4.4.20", "node-ffprobe": "^1.2.2", "node-uuid": "^1.4.7", "npm": "^3.9.5", - "underscore": "^1.8.3", "walk": "^2.3.9", "winston": "^2.2.0", "yargs": "^4.7.1" @@ -41,12 +42,11 @@ "devDependencies": { "chai": "*", "coveralls": "^2.11.9", - "eslint": "^3.2.0", + "eslint": "^3.4.0", + "eslint-config-google": "^0.6.0", "istanbul": "*", - "jscs": "^3.0.4", "mocha": "*", - "mocha-jscs": "^5.0.1", - "mocha-jshint": "^2.3.1", + "mocha-eslint": "^3.0.1", "nodeplayer-backend-dummy": "^0.1.999" } } diff --git a/test/eslint.spec.js b/test/eslint.spec.js new file mode 100644 index 0000000..06d7e46 --- /dev/null +++ b/test/eslint.spec.js @@ -0,0 +1,9 @@ +var lint = require('mocha-eslint'); + +var paths = [ + 'bin', + 'lib', + 'test', +]; + +lint(paths); diff --git a/test/jscs.spec.js b/test/jscs.spec.js deleted file mode 100644 index 97f9c2f..0000000 --- a/test/jscs.spec.js +++ /dev/null @@ -1 +0,0 @@ -require('mocha-jscs')(); diff --git a/test/jshint.spec.js b/test/jshint.spec.js deleted file mode 100644 index ee8b829..0000000 --- a/test/jshint.spec.js +++ /dev/null @@ -1 +0,0 @@ -require('mocha-jshint')(); From c65d663d2cf19c36352f9445f0caa11db2b449e6 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 5 Sep 2016 13:34:40 +0300 Subject: [PATCH 078/103] Editorconfig --- .editorconfig | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9099689 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true From 5c8253c141f8e6abf157160dbd44b001e30f053b Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 5 Sep 2016 14:40:54 +0300 Subject: [PATCH 079/103] Add google js style preset, make codebase pass, add babel build script --- .eslintrc.json | 9 +- .gitignore | 2 + bin/nodeplayer | 4 - index.js | 6 +- lib/backends/local.js | 380 ---------------------------- lib/logger.js | 17 -- lib/modules.js | 160 ------------ lib/player.js | 439 -------------------------------- lib/plugin.js | 17 -- lib/plugins/express.js | 47 ---- lib/plugins/rest.js | 269 -------------------- lib/queue.js | 180 -------------- lib/song.js | 118 --------- package.json | 7 +- {lib => src}/backend.js | 118 +++++---- {lib => src}/backends/index.js | 0 src/backends/local.js | 382 ++++++++++++++++++++++++++++ {lib => src}/config.js | 106 ++++---- src/index.js | 6 + src/logger.js | 17 ++ src/modules.js | 164 ++++++++++++ src/player.js | 443 +++++++++++++++++++++++++++++++++ src/plugin.js | 16 ++ src/plugins/express.js | 47 ++++ {lib => src}/plugins/index.js | 0 src/plugins/rest.js | 267 ++++++++++++++++++++ src/queue.js | 178 +++++++++++++ src/song.js | 118 +++++++++ test/eslint.spec.js | 6 +- test/test.js | 17 +- 30 files changed, 1780 insertions(+), 1760 deletions(-) delete mode 100755 bin/nodeplayer delete mode 100644 lib/backends/local.js delete mode 100644 lib/logger.js delete mode 100644 lib/modules.js delete mode 100644 lib/player.js delete mode 100644 lib/plugin.js delete mode 100644 lib/plugins/express.js delete mode 100644 lib/plugins/rest.js delete mode 100644 lib/queue.js delete mode 100644 lib/song.js rename {lib => src}/backend.js (53%) rename {lib => src}/backends/index.js (100%) create mode 100644 src/backends/local.js rename {lib => src}/config.js (53%) create mode 100755 src/index.js create mode 100644 src/logger.js create mode 100644 src/modules.js create mode 100644 src/player.js create mode 100644 src/plugin.js create mode 100644 src/plugins/express.js rename {lib => src}/plugins/index.js (100%) create mode 100644 src/plugins/rest.js create mode 100644 src/queue.js create mode 100644 src/song.js diff --git a/.eslintrc.json b/.eslintrc.json index 451161f..1f36657 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,10 +4,15 @@ "sourceType": "module" }, "env": { - "node": true + "node": true, + "mocha": true }, + "extends": "google", "rules": { "comma-dangle": ["error", "always-multiline"], - "quotes": ["error", "single"] + "quotes": ["error", "single"], + "max-len": ["warn", 100, 2], + "new-cap": ["error", { "properties": false }], + "object-curly-spacing": ["error", "always"] } } diff --git a/.gitignore b/.gitignore index d690531..65d0064 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ node_modules songCache cache settings + +dist diff --git a/bin/nodeplayer b/bin/nodeplayer deleted file mode 100755 index 2a84fbf..0000000 --- a/bin/nodeplayer +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node - -var Player = require('../lib/player'); -new Player(); diff --git a/index.js b/index.js index de6934b..9df79cd 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,8 @@ 'use strict'; -var Player = require('./lib/player'); -var nodeplayerConfig = require('./lib/config'); -var labeledLogger = require('./lib/logger'); +var Player = require('./src/player'); +var nodeplayerConfig = require('./src/config'); +var labeledLogger = require('./src/logger'); exports.Player = Player; exports.config = nodeplayerConfig; diff --git a/lib/backends/local.js b/lib/backends/local.js deleted file mode 100644 index 1711f2b..0000000 --- a/lib/backends/local.js +++ /dev/null @@ -1,380 +0,0 @@ -'use strict'; - -var path = require('path'); -var fs = require('fs'); -var mkdirp = require('mkdirp'); -var mongoose = require('mongoose'); -var async = require('async'); -var walk = require('walk'); -var ffprobe = require('node-ffprobe'); -var _ = require('lodash'); -var escapeStringRegexp = require('escape-string-regexp'); - -var util = require('util'); -var Backend = require('../backend'); - -// external libraries use lower_case extensively here -//jscs:disable requireCamelCaseOrUpperCaseIdentifiers - -/* -var probeCallback = function(err, probeData, next) { - var formats = config.importFormats; - if (probeData) { - // ignore camel case rule here as we can't do anything about probeData - //jscs:disable requireCamelCaseOrUpperCaseIdentifiers - if (formats.indexOf(probeData.format.format_name) >= 0) { // Format is supported - //jscs:enable requireCamelCaseOrUpperCaseIdentifiers - var song = { - title: '', - artist: '', - album: '', - duration: '0', - }; - - // some tags may be in mixed/all caps, let's convert every tag to lower case - var key; - var keys = Object.keys(probeData.metadata); - var n = keys.length; - var metadata = {}; - while (n--) { - key = keys[n]; - metadata[key.toLowerCase()] = probeData.metadata[key]; - } - - // try a best guess based on filename in case tags are unavailable - var basename = path.basename(probeData.file); - basename = path.basename(probeData.file, path.extname(basename)); - var splitTitle = basename.split(/\s-\s(.+)?/); - - if (!_.isUndefined(metadata.title)) { - song.title = metadata.title; - } else { - song.title = splitTitle[1]; - } - if (!_.isUndefined(metadata.artist)) { - song.artist = metadata.artist; - } else { - song.artist = splitTitle[0]; - } - if (!_.isUndefined(metadata.album)) { - song.album = metadata.album; - } - - song.file = probeData.file; - - song.duration = probeData.format.duration * 1000; - SongModel.update({file: probeData.file}, {'$set':song}, {upsert: true}, - function(err, result) { - if (result == 1) { - self.log.debug('Upserted: ' + probeData.file); - } else { - self.log.error('error while updating db: ' + err); - } - - next(); - }); - } else { - self.log.verbose('format not supported, skipping...'); - next(); - } - } else { - self.log.error('error while probing:' + err); - next(); - } -}; -*/ - -// database model -var SongModel = mongoose.model('Song', { - title: String, - artist: String, - album: String, - albumArt: { - lq: String, - hq: String, - }, - duration: { - type: Number, - required: true, - }, - format: { - type: String, - required: true, - }, - filename: { - type: String, - unique: true, - required: true, - dropDups: true, - }, -}); - -/** - * Try to guess metadata from file path, - * Assumes the following naming conventions: - * /path/to/music/Album/Artist - Title.ext - */ -var guessMetadataFromPath = function(filePath, fileExt) { - var fileName = path.basename(filePath, fileExt); - - // split filename at dashes, trim extra whitespace, e.g: - var splitName = fileName.split('-'); - splitName = _.map(splitName, function(name) { - return name.trim(); - }); - - // TODO: compare album name against music dir, leave empty if equal - return { - artist: splitName[0], - title: splitName[1], - album: path.basename(path.dirname(filePath)), - }; -}; - -function Local(callback) { - Backend.apply(this); - - var self = this; - - // NOTE: no argument passed so we get the core's config - var config = require('../config').getConfig(); - this.config = config; - this.songCachePath = config.songCachePath; - this.importFormats = config.importFormats; - - // make sure all necessary directories exist - mkdirp.sync(path.join(this.songCachePath, 'local', 'incomplete')); - - // connect to the database - mongoose.connect(config.mongo); - - var db = mongoose.connection; - db.on('error', function(err) { - return callback(err, self); - }); - db.once('open', function() { - return callback(null, self); - }); - - var options = { - followLinks: config.followSymlinks, - }; - - var insertSong = function(probeData, done) { - var guessMetadata = guessMetadataFromPath(probeData.file, probeData.fileext); - - var song = new SongModel({ - title: probeData.metadata.TITLE || guessMetadata.title, - artist: probeData.metadata.ARTIST || guessMetadata.artist, - album: probeData.metadata.ALBUM || guessMetadata.album, - //albumArt: {} // TODO - duration: probeData.format.duration * 1000, - format: probeData.format.format_name, - filename: probeData.file, - }); - - song = song.toObject(); - - delete song._id; - - SongModel.findOneAndUpdate({ - filename: probeData.file, - }, song, {upsert: true}, function(err) { - if (err) { - self.log.error('while inserting song: ' + probeData.file + ', ' + err); - } - done(); - }); - }; - - // create async.js queue to limit concurrent probes - var q = async.queue(function(task, done) { - ffprobe(task.filename, function(err, probeData) { - if (!probeData) { - return done(); - } - - var validStreams = false; - - if (_.contains(self.importFormats, probeData.format.format_name)) { - validStreams = true; - } - - if (validStreams) { - insertSong(probeData, done); - } else { - self.log.info('skipping file of unknown format: ' + task.filename); - done(); - } - }); - }, config.concurrentProbes); - - // walk the filesystem and scan files - // TODO: also check through entire DB to see that all files still exist on the filesystem - // TODO: filter by allowed filename extensions - if (config.rescanAtStart) { - self.log.info('Scanning directory: ' + config.importPath); - var walker = walk.walk(config.importPath, options); - var startTime = new Date(); - var scanned = 0; - walker.on('file', function(root, fileStats, next) { - var filename = path.join(root, fileStats.name); - self.log.verbose('Scanning: ' + filename); - scanned++; - q.push({ - filename: filename, - }); - next(); - }); - walker.on('end', function() { - self.log.verbose('Scanned files: ' + scanned); - self.log.verbose('Done in: ' + - Math.round((new Date() - startTime) / 1000) + ' seconds'); - }); - } - - // TODO: fs watch - // set fs watcher on media directory - // TODO: add a debounce so if the file keeps changing we don't probe it multiple times - /* - watch(config.importPath, { - recursive: true, - followSymlinks: config.followSymlinks - }, function(filename) { - if (fs.existsSync(filename)) { - self.log.debug(filename + ' modified or created, queued for probing'); - q.unshift({ - filename: filename - }); - } else { - self.log.debug(filename + ' deleted'); - db.collection('songs').remove({file: filename}, function(err, items) { - self.log.debug(filename + ' deleted from db: ' + err + ', ' + items); - }); - } - }); - */ -} - -// must be called immediately after constructor -util.inherits(Local, Backend); - -Local.prototype.isPrepared = function(song) { - var filePath = path.join(this.songCachePath, 'local', song.songId + '.opus'); - return fs.existsSync(filePath); -}; - -Local.prototype.getDuration = (song, callback) => { - SongModel.findById(song.songId, (err, item) => { - if (err) { - return callback(err); - } - - callback(null, item.duration); - }); -}; - -Local.prototype.prepare = function(song, callback) { - var self = this; - - // TODO: move most of this into common code inside core - var filePath = self.songCachePath + '/local/' + song.songId + '.opus'; - - if (self.songsPreparing[song.songId]) { - // song is preparing, caller can drop this request (previous caller will take care of - // handling once preparation is finished) - callback(null, null, false); - } else if (self.isPrepared(song)) { - // song has already prepared, caller can start playing song - callback(null, null, true); - } else { - // begin preparing song - var cancelEncode = null; - var canceled = false; - - song.prepare = { - data: new Buffer.allocUnsafe(1024 * 1024), - dataPos: 0, - cancel: function() { - canceled = true; - if (cancelEncode) { - cancelEncode(); - } - }, - } - - self.songsPreparing[song.songId] = song; - - SongModel.findById(song.songId, function(err, item) { - if (canceled) { - callback(new Error('song was canceled before encoding started')); - } else if (item) { - var readStream = fs.createReadStream(item.filename); - cancelEncode = self.encodeSong(readStream, 0, song, callback); - readStream.on('error', function(err) { - callback(err); - }); - } else { - callback(new Error('song not found in local db: ' + song.songId)); - } - }); - } -}; - -Local.prototype.search = function(query, callback) { - var self = this; - - var q; - if (query.any) { - q = { - $or: [ - {artist: new RegExp(escapeStringRegexp(query.any), 'i')}, - {title: new RegExp(escapeStringRegexp(query.any), 'i')}, - {album: new RegExp(escapeStringRegexp(query.any), 'i')}, - ], - }; - } else { - q = { - $and: [], - }; - - _.keys(query).forEach(function(key) { - var criterion = {}; - criterion[key] = new RegExp(escapeStringRegexp(query[key]), 'i'); - q.$and.push(criterion); - }); - } - - SongModel.find(q).exec(function(err, items) { - if (err) { - return callback(err); - } - - var results = {}; - results.songs = {}; - - var numItems = items.length; - var cur = 0; - items.forEach(function(song) { - if (Object.keys(results.songs).length <= self.config.searchResultCnt) { - song = song.toObject(); - - results.songs[song._id] = { - artist: song.artist, - title: song.title, - album: song.album, - albumArt: null, // TODO: can we add this? - duration: song.duration, - songId: song._id, - score: self.config.maxScore * (numItems - cur) / numItems, - backendName: 'local', - format: 'opus', - }; - cur++; - } - }); - callback(results); - }); -}; - -module.exports = Local; diff --git a/lib/logger.js b/lib/logger.js deleted file mode 100644 index b638f90..0000000 --- a/lib/logger.js +++ /dev/null @@ -1,17 +0,0 @@ -'use strict'; -var config = require('./config').getConfig(); -var winston = require('winston'); - -module.exports = function(label) { - return new (winston.Logger)({ - transports: [ - new (winston.transports.Console)({ - label: label, - level: config.logLevel, - colorize: config.logColorize, - handleExceptions: config.logExceptions, - json: config.logJson, - }), - ], - }); -}; diff --git a/lib/modules.js b/lib/modules.js deleted file mode 100644 index 094dae1..0000000 --- a/lib/modules.js +++ /dev/null @@ -1,160 +0,0 @@ -var npm = require('npm'); -var async = require('async'); -var labeledLogger = require('./logger'); -var nodeplayerConfig = require('./config'); -var BuiltinPlugins = require('./plugins'); -var BuiltinBackends = require('./backends'); -var config = nodeplayerConfig.getConfig(); - -var _ = require('lodash'); -var logger = labeledLogger('modules'); - -var checkModule = function(module) { - try { - require.resolve(module); - return true; - } catch (e) { - return false; - } -}; - -// install a single module -var installModule = function(moduleName, callback) { - logger.info('installing module: ' + moduleName); - npm.load({}, function(err) { - npm.commands.install(__dirname, [moduleName], function(err) { - if (err) { - logger.error(moduleName + ' installation failed:', err); - callback(); - } else { - logger.info(moduleName + ' successfully installed'); - callback(); - } - }); - }); -}; - -// make sure all modules are installed, installs missing ones, then calls done -var installModules = function(modules, moduleType, forceUpdate, done) { - async.eachSeries(modules, function(moduleShortName, callback) { - var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; - if (!checkModule(moduleName) || forceUpdate) { - // perform install / update - installModule(moduleName, callback); - } else { - // skip already installed - callback(); - } - }, done); -}; - -var initModule = function(moduleShortName, moduleType, callback) { - var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; - var module = require(moduleName); - - module.init(function(err) { - callback(err, module); - }); -}; - -// TODO: this probably doesn't work -// needs rewrite -exports.loadBackends = function(player, backends, forceUpdate, done) { - // first install missing backends - installModules(backends, 'backend', forceUpdate, function() { - // then initialize all backends in parallel - async.map(backends, function(backend, callback) { - var moduleLogger = labeledLogger(backend); - var moduleName = 'nodeplayer-backend-' + backend; - if (moduleName) { - var Module = require(moduleName); - var instance = new Module(function(err) { - if (err) { - moduleLogger.error('while initializing: ' + err); - callback(); - } else { - moduleLogger.verbose('backend initialized'); - player.callHooks('onBackendInitialized', [backend]); - callback(null, instance); - } - }); - } else { - // skip module whose installation failed - moduleLogger.info('not loading backend: ' + backend); - callback(); - } - }, function(err, results) { - logger.info('all backend modules initialized'); - results = _.filter(results, _.identity); - done(results); - }); - }); -}; - -// TODO: this probably doesn't work -// needs rewrite -exports.loadPlugins = function(player, plugins, forceUpdate, done) { - // first install missing plugins - installModules(plugins, 'plugin', forceUpdate, function() { - // then initialize all plugins in series - async.mapSeries(plugins, function(plugin, callback) { - var moduleLogger = labeledLogger(plugin); - var moduleName = 'nodeplayer-plugin-' + plugin; - if (checkModule(moduleName)) { - var Module = require(moduleName); - var instance = new Module(player, function(err) { - if (err) { - moduleLogger.error('while initializing: ' + err); - callback(); - } else { - moduleLogger.verbose('plugin initialized'); - player.callHooks('onPluginInitialized', [plugin]); - callback(null, instance); - } - }); - } else { - // skip module whose installation failed - moduleLogger.info('not loading plugin: ' + plugin); - callback(); - } - }, function(err, results) { - logger.info('all plugin modules initialized'); - results = _.filter(results, _.identity); - done(results); - }); - }); -}; - -exports.loadBuiltinPlugins = function(player, done) { - async.mapSeries(BuiltinPlugins, function(Plugin, callback) { - new Plugin(player, function(err, plugin) { - if (err) { - plugin.log.error('while initializing: ' + err); - return callback(); - } - - plugin.log.verbose('plugin initialized'); - player.callHooks('onPluginInitialized', [plugin.name]); - callback(null, [plugin.name, plugin]); - }); - }, function(err, results) { - done(_.object(results)); - }); -}; - -exports.loadBuiltinBackends = function(player, done) { - async.mapSeries(BuiltinBackends, function(Backend, callback) { - new Backend(function(err, backend) { - if (err) { - backend.log.error('while initializing: ' + err); - return callback(); - } - - player.callHooks('onBackendInitialized', [backend.name]); - backend.log.verbose('backend initialized'); - callback(null, [backend.name, backend]); - }); - }, function(err, results) { - done(_.object(results)); - }); -}; diff --git a/lib/player.js b/lib/player.js deleted file mode 100644 index eabd66e..0000000 --- a/lib/player.js +++ /dev/null @@ -1,439 +0,0 @@ -'use strict'; -var _ = require('lodash'); -var async = require('async'); -var util = require('util'); -var labeledLogger = require('./logger'); -var Queue = require('./queue'); -var modules = require('./modules'); - -function Player(options) { - options = options || {}; - - // TODO: some of these should NOT be loaded from config - _.bindAll.apply(_, [this].concat(_.functions(this))); - this.config = options.config || require('./config').getConfig(); - this.logger = options.logger || labeledLogger('core'); - this.queue = options.queue || new Queue(this); - this.nowPlaying = options.nowPlaying || null; - this.play = options.play || false; - this.repeat = options.repeat || false; - this.plugins = options.plugins || {}; - this.backends = options.backends || {}; - this.prepareTimeouts = options.prepareTimeouts || {}; - this.volume = options.volume || 1; - this.songEndTimeout = options.songEndTimeout || null; - this.pluginVars = options.pluginVars || {}; - - var player = this; - var config = player.config; - var forceUpdate = false; - - // initialize plugins & backends - async.series([ - function(callback) { - modules.loadBuiltinPlugins(player, function(plugins) { - player.plugins = plugins; - player.callHooks('onBuiltinPluginsInitialized'); - callback(); - }); - }, function(callback) { - modules.loadPlugins(player, config.plugins, forceUpdate, - function(results) { - player.plugins = _.extend(player.plugins, results); - player.callHooks('onPluginsInitialized'); - callback(); - }); - }, function(callback) { - modules.loadBuiltinBackends(player, function(backends) { - player.backends = backends; - player.callHooks('onBuiltinBackendsInitialized'); - callback(); - }); - }, function(callback) { - modules.loadBackends(player, config.backends, forceUpdate, function(results) { - player.backends = _.extend(player.backends, results); - player.callHooks('onBackendsInitialized'); - callback(); - }); - }, - ], function() { - player.logger.info('ready'); - player.callHooks('onReady'); - }); -} - -// call hook function in all modules -// if any hooks return a truthy value, it is an error and we abort -// be very careful with calling hooks from within a hook, infinite loops are possible -Player.prototype.callHooks = function(hook, argv) { - // _.find() used instead of _.each() because we want to break out as soon - // as a hook returns a truthy value (used to indicate an error, e.g. in form - // of a string) - var err = null; - - this.logger.silly('callHooks(' + hook + - (argv ? ', ' + util.inspect(argv) + ')' : ')')); - - _.find(this.plugins, function(plugin) { - if (plugin.hooks[hook]) { - var fun = plugin.hooks[hook]; - err = fun.apply(null, argv); - return err; - } - }); - - return err; -}; - -// returns number of hook functions attached to given hook -Player.prototype.numHooks = function(hook) { - var cnt = 0; - - _.find(this.plugins, function(plugin) { - if (plugin[hook]) { - cnt++; - } - }); - - return cnt; -}; - -/** - * Returns currently playing song - * @returns {Song|null} - Song object, null if no now playing song - */ -Player.prototype.getNowPlaying = function() { - return this.nowPlaying; -}; - -// TODO: handling of pause in a good way? -/** - * Stop playback of current song - * @param {Boolean} [pause=false] - If true, don't reset song position - */ -Player.prototype.stopPlayback = function(pause) { - this.logger.info('playback ' + (pause ? 'paused.' : 'stopped.')); - - clearTimeout(this.songEndTimeout); - this.play = false; - - var np = this.nowPlaying; - var pos = np.playback.startPos + (new Date().getTime() - np.playback.startTime); - if (np) { - np.playback = { - startTime: 0, - startPos: pause ? pos : 0, - }; - } -}; - -/** - * Start playing now playing song, at optional position - * @param {Number} [position=0] - Position at which playback is started - */ -Player.prototype.startPlayback = function(position) { - position = position || 0; - var player = this; - - if (!this.nowPlaying) { - // find first song in queue - this.nowPlaying = this.queue.songs[0]; - - if (!this.nowPlaying) { - return this.logger.error('queue is empty! not starting playback.'); - } - } - - this.nowPlaying.prepare(function(err) { - if (err) { - return player.logger.error('error while preparing now playing: ' + err); - } - - player.nowPlaying.playbackStarted(position || player.nowPlaying.playback.startPos); - - player.logger.info('playback started.'); - player.play = true; - }); -}; - -/** - * Change to song - * @param {String} uuid - UUID of song to change to, if not found in queue, now - * playing is removed, playback stopped - */ -Player.prototype.changeSong = function(uuid) { - this.logger.verbose('changing song to: ' + uuid); - clearTimeout(this.songEndTimeout); - - this.nowPlaying = this.queue.findSong(uuid); - - if (!this.nowPlaying) { - this.logger.info('song not found: ' + uuid); - this.stopPlayback(); - } - - this.startPlayback(); - this.logger.info('changed song to: ' + uuid); -}; - -Player.prototype.songEnd = function() { - var np = this.getNowPlaying(); - var npIndex = np ? this.queue.findSongIndex(np.uuid) : -1; - - this.logger.info('end of song ' + np.uuid); - this.callHooks('onSongEnd', [np]); - - var nextSong = this.queue.songs[npIndex + 1]; - if (!nextSong) { - this.logger.info('hit end of queue.'); - - if (this.repeat) { - this.logger.info('repeat is on, restarting playback from start of queue.'); - this.changeSong(this.queue.uuidAtIndex(0)); - } - } else { - this.changeSong(nextSong.uuid); - } - - this.prepareSongs(); -}; - -// TODO: move these to song class? -Player.prototype.setPrepareTimeout = function(song) { - var player = this; - - if (song.prepareTimeout) { - clearTimeout(song.prepareTimeout); - } - - song.prepareTimeout = setTimeout(function() { - player.logger.info('prepare timeout for song: ' + song.songId + ', removing'); - song.cancelPrepare('prepare timeout'); - song.prepareTimeout = null; - }, this.config.songPrepareTimeout); - - Object.defineProperty(song, 'prepareTimeout', { - enumerable: false, - writable: true, - }); -}; - -Player.prototype.clearPrepareTimeout = function(song) { - // clear prepare timeout - clearTimeout(song.prepareTimeout); - song.prepareTimeout = null; -}; - -Player.prototype.prepareError = function(song, err) { - // TODO: mark song as failed - this.callHooks('onSongPrepareError', [song, err]); -}; - -Player.prototype.prepareProgCallback = function(song, bytesWritten, done) { - /* progress callback - * when this is called, new song data has been flushed to disk */ - - // start playback if it hasn't been started yet - if (this.play && this.getNowPlaying() && - this.getNowPlaying().uuid === song.uuid && - !this.queue.playbackStart && bytesWritten) { - this.startPlayback(); - } - - // tell plugins that new data is available for this song, and - // whether the song is now fully written to disk or not. - this.callHooks('onPrepareProgress', [song, bytesWritten, done]); - - if (done) { - // mark song as prepared - this.callHooks('onSongPrepared', [song]); - - // done preparing, can't cancel anymore - delete(song.cancelPrepare); - - // song data should now be available on disk, don't keep it in memory - song.backend.songsPreparing[song.songId].songData = undefined; - delete(song.backend.songsPreparing[song.songId]); - - // clear prepare timeout - this.clearPrepareTimeout(song); - } else { - // reset prepare timeout - this.setPrepareTimeout(song); - } -}; - -Player.prototype.prepareErrCallback = function(song, err, callback) { - /* error callback */ - - // don't let anything run cancelPrepare anymore - delete(song.cancelPrepare); - - this.clearPrepareTimeout(song); - - // abort preparing more songs; current song will be deleted -> - // onQueueModified is called -> song preparation is triggered again - callback(true); - - // TODO: investigate this, should probably be above callback - this.prepareError(song, err); - - song.songData = undefined; - delete(this.songsPreparing[song.backend.name][song.songId]); -}; - -Player.prototype.prepareSong = function(song, callback) { - var self = this; - - if (!song) { - throw new Error('prepareSong() without song'); - } - - if (song.isPrepared()) { - // start playback if it hasn't been started yet - if (this.play && this.getNowPlaying() && - this.getNowPlaying().uuid === song.uuid && - !this.queue.playbackStart) { - this.startPlayback(); - } - - // song is already prepared, ok to prepare more songs - callback(); - } else { - // song is not prepared and not currently preparing: let backend prepare it - this.logger.debug('DEBUG: prepareSong() ' + song.songId); - - song.prepare(function(err, chunk, done) { - if (err) { - return callback(err); - } - - if (chunk) { - self.prepareProgCallback(song, chunk, done); - } - - if (done) { - self.clearPrepareTimeout(song); - callback(); - } - }); - - this.setPrepareTimeout(song); - } -}; - -/** - * Prepare now playing and next song for playback - */ -Player.prototype.prepareSongs = function() { - var player = this; - - var currentSong; - async.series([ - function(callback) { - // prepare now-playing song - currentSong = player.getNowPlaying(); - if (currentSong) { - player.prepareSong(currentSong, callback); - } else if (player.queue.getLength()) { - // songs exist in queue, prepare first one - currentSong = player.queue.songs[0]; - player.prepareSong(currentSong, callback); - } else { - // bail out - callback(true); - } - }, - function(callback) { - - // prepare next song in playlist - var nextSong = player.queue.songs[player.queue.findSongIndex(currentSong) + 1]; - if (nextSong) { - player.prepareSong(nextSong, callback); - } else { - // bail out - callback(true); - } - }, - ]); - // TODO where to put this - //player.prepareErrCallback(); -}; - -Player.prototype.getPlaylists = function(callback) { - var resultCnt = 0; - var allResults = {}; - var player = this; - - _.each(this.backends, function(backend) { - if (!backend.getPlaylists) { - resultCnt++; - - // got results from all services? - if (resultCnt >= Object.keys(player.backends).length) { - callback(allResults); - } - return; - } - - backend.getPlaylists(function(err, results) { - resultCnt++; - - allResults[backend.name] = results; - - // got results from all services? - if (resultCnt >= Object.keys(player.backends).length) { - callback(allResults); - } - }); - }); -}; - -// make a search query to backends -Player.prototype.searchBackends = function(query, callback) { - var resultCnt = 0; - var allResults = {}; - - _.each(this.backends, function(backend) { - backend.search(query, _.bind(function(results) { - resultCnt++; - - // make a temporary copy of songlist, clear songlist, check - // each song and add them again if they are ok - var tempSongs = _.clone(results.songs); - allResults[backend.name] = results; - allResults[backend.name].songs = {}; - - _.each(tempSongs, function(song) { - var err = this.callHooks('preAddSearchResult', [song]); - if (!err) { - allResults[backend.name].songs[song.songId] = song; - } else { - this.logger.error('preAddSearchResult hook error: ' + err); - } - }, this); - - // got results from all services? - if (resultCnt >= Object.keys(this.backends).length) { - callback(allResults); - } - }, this), _.bind(function(err) { - resultCnt++; - this.logger.error('error while searching ' + backend.name + ': ' + err); - - // got results from all services? - if (resultCnt >= Object.keys(this.backends).length) { - callback(allResults); - } - }, this)); - }, this); -}; - -// TODO: userID does not belong into core...? -Player.prototype.setVolume = function(newVol, userID) { - newVol = Math.min(1, Math.max(0, newVol)); - this.volume = newVol; - this.callHooks('onVolumeChange', [newVol, userID]); -}; - -module.exports = Player; diff --git a/lib/plugin.js b/lib/plugin.js deleted file mode 100644 index b2487ad..0000000 --- a/lib/plugin.js +++ /dev/null @@ -1,17 +0,0 @@ -const labeledLogger = require('./logger'); - -/** - * Super constructor for plugins - */ -export default class Plugin { - constructor() { - this.name = this.constructor.name.toLowerCase(); - this.log = labeledLogger(this.name); - this.log.info('initializing...'); - this.hooks = {}; - } - - registerHook(hook, callback) { - this.hooks[hook] = callback; - } -} diff --git a/lib/plugins/express.js b/lib/plugins/express.js deleted file mode 100644 index 351e33f..0000000 --- a/lib/plugins/express.js +++ /dev/null @@ -1,47 +0,0 @@ -'use strict'; - -var express = require('express'); -var bodyParser = require('body-parser'); -var cookieParser = require('cookie-parser'); -var https = require('https'); -var http = require('http'); -var fs = require('fs'); - -import Plugin from '../plugin'; - -export default class Express extends Plugin { - constructor(player, callback) { - super(); - - // NOTE: no argument passed so we get the core's config - var config = require('../config').getConfig(); - player.app = express(); - - var options = {}; - var port = process.env.PORT || config.port; - if (config.tls) { - options = { - tls: config.tls, - key: config.key ? fs.readFileSync(config.key) : undefined, - cert: config.cert ? fs.readFileSync(config.cert) : undefined, - ca: config.ca ? fs.readFileSync(config.ca) : undefined, - requestCert: config.requestCert, - rejectUnauthorized: config.rejectUnauthorized, - }; - // TODO: deprecated! - player.app.set('tls', true); - player.httpServer = https.createServer(options, player.app) - .listen(port); - } else { - player.httpServer = http.createServer(player.app) - .listen(port); - } - this.log.info('listening on port ' + port); - - player.app.use(cookieParser()); - player.app.use(bodyParser.json({limit: '100mb'})); - player.app.use(bodyParser.urlencoded({extended: true})); - - callback(null, this); - } -} diff --git a/lib/plugins/rest.js b/lib/plugins/rest.js deleted file mode 100644 index 2e4862e..0000000 --- a/lib/plugins/rest.js +++ /dev/null @@ -1,269 +0,0 @@ -'use strict'; - -var _ = require('lodash'); - -var async = require('async'); -var util = require('util'); -var path = require('path'); -import Plugin from '../plugin'; - -export default class Rest extends Plugin { - constructor(player, callback) { - super(); - - // NOTE: no argument passed so we get the core's config - var config = require('../config').getConfig(); - - if (!player.app) { - return callback('module must be initialized after express module!'); - } - - player.app.use((req, res, next) => { - res.sendRes = (err, data) => { - if (err) { - res.status(404).send(err); - } else { - res.send(data || 'ok'); - } - }; - next(); - }); - - player.app.get('/queue', (req, res) => { - var np = player.nowPlaying; - var pos = 0; - if (np) { - if (np.playback.startTime) { - pos = new Date().getTime() - np.playback.startTime + np.playback.startPos; - } else { - pos = np.playback.startPos; - } - } - - res.json({ - songs: player.queue.serialize(), - nowPlaying: np ? np.serialize() : null, - nowPlayingPos: pos, - play: player.play, - }); - }); - - // TODO: error handling - player.app.post('/queue/song', (req, res) => { - var err = player.queue.insertSongs(null, req.body); - - res.sendRes(err); - }); - player.app.post('/queue/song/:at', (req, res) => { - var err = player.queue.insertSongs(req.params.at, req.body); - - res.sendRes(err); - }); - - /* - player.app.post('/queue/move/:pos', function(req, res) { - var err = player.moveInQueue( - parseInt(req.params.pos), - parseInt(req.body.to), - parseInt(req.body.cnt) - ); - sendResponse(res, 'success', err); - }); - */ - - player.app.delete('/queue/song/:at', (req, res) => { - player.removeSongs(req.params.at, parseInt(req.query.cnt) || 1, res.sendRes); - }); - - player.app.post('/playctl/play', (req, res) => { - player.startPlayback(parseInt(req.body.position) || 0); - res.sendRes(null, 'ok'); - }); - - player.app.post('/playctl/stop', (req, res) => { - player.stopPlayback(req.query.pause); - res.sendRes(null, 'ok'); - }); - - player.app.post('/playctl/skip', (req, res) => { - player.skipSongs(parseInt(req.body.cnt)); - res.sendRes(null, 'ok'); - }); - - player.app.post('/playctl/shuffle', (req, res) => { - player.shuffleQueue(); - res.sendRes(null, 'ok'); - }); - - player.app.post('/volume', (req, res) => { - player.setVolume(parseInt(req.body)); - res.send('success'); - }); - - // search for songs, search terms in query params - player.app.get('/search', (req, res) => { - this.log.verbose('got search request: ' + JSON.stringify(req.body.query)); - - player.searchBackends(req.body.query, (results) => { - res.json(results); - }); - }); - - this.pendingRequests = {}; - var rest = this; - this.registerHook('onPrepareProgress', (song, bytesWritten, done) => { - if (!rest.pendingRequests[song.backend.name]) { - return; - } - - _.each(rest.pendingRequests[song.backend.name][song.songId], (client) => { - if (bytesWritten) { - var end = song.prepare.dataPos; - if (client.wishRange[1]) { - end = Math.min(client.wishRange[1], bytesWritten - 1); - } - - //console.log('end: ' + end + '\tclient.serveRange[1]: ' + client.serveRange[1]); - - if (client.serveRange[1] < end) { - //console.log('write'); - client.res.write(song.prepare.data.slice(client.serveRange[1] + 1, end)); - } - - client.serveRange[1] = end; - } - - if (done) { - console.log('done'); - client.res.end(); - } - }); - - if (done) { - rest.pendingRequests[song.backend.name][song.songId] = []; - } - }); - - this.registerHook('onBackendInitialized', (backendName) => { - rest.pendingRequests[backendName] = {}; - - // provide API path for music data, might block while song is preparing - player.app.get('/song/' + backendName + '/:fileName', (req, res, next) => { - var extIndex = req.params.fileName.lastIndexOf('.'); - var songId = req.params.fileName.substring(0, extIndex); - var songFormat = req.params.fileName.substring(extIndex + 1); - - var backend = player.backends[backendName]; - var filename = path.join(backendName, songId + '.' + songFormat); - var incomplete = path.join(backendName, 'incomplete', songId + '.' + songFormat); - - res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); - res.setHeader('Accept-Ranges', 'bytes'); - - var queuedSong = _.find(player.queue.serialize(), (song) => { - return song.songId === songId && song.backendName === backendName; - }); - - async.series([ - (callback) => { - // try finding out length of song - if (queuedSong) { - res.setHeader('X-Content-Duration', queuedSong.duration / 1000); - callback(); - } else { - backend.getDuration({songId: songId}, (err, exactDuration) => { - res.setHeader('X-Content-Duration', exactDuration / 1000); - callback(); - }); - } - }, - (callback) => { - if (backend.isPrepared({songId: songId})) { - // song should be available on disk - res.sendFile(filename, { - root: config.songCachePath, - }); - } else if (backend.songsPreparing[songId]) { - // song is preparing - var song = backend.songsPreparing[songId]; - - var haveRange = []; - var wishRange = []; - var serveRange = []; - - haveRange[0] = 0; - haveRange[1] = song.prepare.data.length - 1; - - wishRange[0] = 0; - wishRange[1] = null - - serveRange[0] = 0; - - res.setHeader('Transfer-Encoding', 'chunked'); - - if (req.headers.range) { - // partial request - - wishRange = req.headers.range.substr(req.headers.range.indexOf('=') + 1).split('-'); - - serveRange[0] = wishRange[0]; - - // a best guess for the response header - serveRange[1] = haveRange[1]; - if (wishRange[1]) { - serveRange[1] = Math.min(wishRange[1], haveRange[1]); - } - - res.statusCode = 206; - res.setHeader('Content-Range', 'bytes ' + serveRange[0] + '-' + serveRange[1] + '/*'); - } else { - serveRange[1] = haveRange[1]; - } - - this.log.debug('request with wishRange: ' + wishRange); - - if (!rest.pendingRequests[backendName][songId]) { - rest.pendingRequests[backendName][songId] = []; - } - - var client = { - res: res, - serveRange: serveRange, - wishRange: wishRange, - filepath: path.join(config.songCachePath, filename), - }; - - // TODO: If we know that we have already flushed data to disk, - // we could open up the read stream already here instead of waiting - // around for the first flush - - // If we can satisfy the start of the requested range, write as - // much as possible to res immediately - if (haveRange[1] >= wishRange[0]) { - client.res.write(song.prepare.data.slice(serveRange[0], serveRange[1] + 1)); - } - - // If we couldn't satisfy the entire request, push the client - // into pendingRequests so we can append to the stream later - if (serveRange[1] !== wishRange[1]) { - rest.pendingRequests[backendName][songId].push(client); - - req.on('close', () => { - rest.pendingRequests[backendName][songId].splice( - rest.pendingRequests[backendName][songId].indexOf(client), 1 - ); - }); - } else { - client.res.end(); - } - } else { - res.status(404).end('404 song not found'); - } - }] - ); - }); - }); - - callback(null, this); - } -} diff --git a/lib/queue.js b/lib/queue.js deleted file mode 100644 index 5db5cc5..0000000 --- a/lib/queue.js +++ /dev/null @@ -1,180 +0,0 @@ -var _ = require('lodash'); -var Song = require('./song'); - -/** - * Constructor - * @param {Player} player - Parent player object reference - * @returns {Error} - in case of errors - */ -function Queue(player) { - if (!player || !_.isObject(player)) { - throw new Error('Queue constructor called without player reference!'); - } - - this.unshuffledSongs = null; - this.songs = []; - this.player = player; -} - -// TODO: hooks -// TODO: moveSongs - -/** - * Get serialized list of songs in queue - * @return {[SerializedSong]} - List of songs in serialized format - */ -Queue.prototype.serialize = function() { - var serialized = _.map(this.songs, function(song) { - return song.serialize(); - }); - - return serialized; -}; - -/** - * Find index of song in queue - * @param {String} at - Look for song with this UUID - * @returns {Number} - Index of song, -1 if not found - */ -Queue.prototype.findSongIndex = function(at) { - return _.findIndex(this.songs, function(song) { - return song.uuid === at; - }); -}; - -/** - * Find song in queue - * @param {String} at - Look for song with this UUID - * @returns {Song|null} - Song object, null if not found - */ -Queue.prototype.findSong = function(at) { - return _.find(this.songs, function(song) { - return song.uuid === at; - }) || null; -}; - -/** - * Find song UUID at given index - * @param {Number} index - Look for song at this index - * @returns {String|null} - UUID, null if not found - */ -Queue.prototype.uuidAtIndex = function(index) { - var song = this.songs[index]; - return song ? song.uuid : null; -}; - -/** - * Returns queue length - * @returns {Number} - Queue length - */ -Queue.prototype.getLength = function() { - return this.songs.length; -}; - -/** - * Insert songs into queue - * @param {String | null} at - Insert songs after song with this UUID - * (null = start of queue) - * @param {Object[]} songs - List of songs to insert - * @return {Error} - in case of errors - */ -Queue.prototype.insertSongs = function(at, songs) { - var pos; - if (at === null) { - // insert at start of queue - pos = 0; - } else { - // insert song after song with UUID - pos = this.findSongIndex(at); - - if (pos < 0) { - return 'Song with UUID ' + at + ' not found!'; - } - - pos++; // insert after song - } - - // generate Song objects of each song - songs = _.map(songs, function(song) { - // TODO: this would be best done in the song constructor, - // effectively making it a SerializedSong object deserializer - var backend = this.player.backends[song.backendName]; - if (!backend) { - throw new Error('Song constructor called with invalid backend: ' + song.backendName); - } - - return new Song(song, backend); - }, this); - - // perform insertion - var args = [pos, 0].concat(songs); - Array.prototype.splice.apply(this.songs, args); - - this.player.prepareSongs(); -}; - -/** - * Removes songs from queue - * @param {String} at - Start removing at song with this UUID - * @param {Number} cnt - Number of songs to delete - * @return {Song[] | Error} - List of removed songs, Error in case of errors - */ -Queue.prototype.removeSongs = function(at, cnt) { - var pos = this.findSongIndex(at); - if (pos < 0) { - return 'Song with UUID ' + at + ' not found!'; - } - - // cancel preparing all songs to be deleted - for (var i = pos; i < pos + cnt && i < this.songs.length; i++) { - var song = this.songs[i]; - if (song.cancelPrepare) { - song.cancelPrepare('Song removed.'); - } - } - - // store index of now playing song - var np = this.player.nowPlaying; - var npIndex = np ? this.findSongIndex(np.uuid) : -1; - - // perform deletion - var removed = this.songs.splice(pos, cnt); - - // was now playing removed? - if (pos <= npIndex && pos + cnt >= npIndex) { - // change to first song after splice - var newNp = this.songs[pos]; - this.player.changeSong(newNp ? newNp.uuid : null); - } else { - this.player.prepareSongs(); - } - - return removed; -}; - -/** - * Toggle queue shuffling - */ -Queue.prototype.shuffle = function() { - var nowPlaying; - - if (this.unshuffledSongs) { - // unshuffle - - // restore unshuffled list - this.songs = this.unshuffledSongs; - - this.unshuffledSongs = null; - } else { - // shuffle - - // store copy of current songs array - this.unshuffledSongs = this.songs.slice(); - - this.songs = _.shuffle(this.songs); - } - - this.player.prepareSongs(); -}; - -module.exports = Queue; diff --git a/lib/song.js b/lib/song.js deleted file mode 100644 index 1ae9763..0000000 --- a/lib/song.js +++ /dev/null @@ -1,118 +0,0 @@ -var _ = require('lodash'); -var uuid = require('node-uuid'); - -/** - * Constructor - * @param {Song} song - Song details - * @param {Backend} backend - Backend providing the audio - * @returns {Error} - in case of errors - */ -function Song(song, backend) { - // make sure we have a reference to backend - if (!backend || !_.isObject(backend)) { - throw new Error('Song constructor called with invalid backend: ' + backend); - } - - if (!song.duration || !_.isNumber(song.duration)) { - throw new Error('Song constructor called without duration!'); - } - if (!song.title || !_.isString(song.title)) { - throw new Error('Song constructor called without title!'); - } - if (!song.songId || !_.isString(song.songId)) { - throw new Error('Song constructor called without songId!'); - } - if (!song.score || !_.isNumber(song.score)) { - throw new Error('Song constructor called without score!'); - } - if (!song.format || !_.isString(song.format)) { - throw new Error('Song constructor called without format!'); - } - - this.uuid = uuid.v4(); - - this.title = song.title; - this.artist = song.artist; - this.album = song.album; - this.albumArt = { - lq: song.albumArt ? song.albumArt.lq : null, - hq: song.albumArt ? song.albumArt.hq : null, - }; - this.duration = song.duration; - this.songId = song.songId; - this.score = song.score; - this.format = song.format; - - this.playback = { - startTime: null, - startPos: null, - }; - - // NOTE: internally to the Song we store a reference to the backend. - // However when accessing the Song from the outside, we return only the - // backend's name inside a backendName field. - // - // Any functions requiring access to the backend should be implemented as - // members of the Song (e.g. isPrepared, prepareSong) - this.backend = backend; - - // optional fields - this.playlist = song.playlist; -} - -/** - * Return serialized details of the song - * @returns {SerializedSong} - serialized Song object - */ -Song.prototype.playbackStarted = function(pos) { - this.playback = { - startTime: new Date(), - startPos: pos || null, - }; -}; - -/** - * Return serialized details of the song - * @returns {SerializedSong} - serialized Song object - */ -Song.prototype.serialize = function() { - return { - uuid: this.uuid, - title: this.title, - artist: this.artist, - album: this.album, - albumArt: this.albumArt, - duration: this.duration, - songId: this.songId, - score: this.score, - format: this.format, - backendName: this.backend.name, - playlist: this.playlist, - playback: this.playback, - }; -}; - -/** - * Synchronously(!) returns whether the song is prepared or not - * @returns {Boolean} - true if song is prepared, false if not - */ -Song.prototype.isPrepared = function() { - return this.backend.isPrepared(this); -}; - -/** - * Prepare song for playback - * @param {encodeCallback} callback - Called when song is ready or on error - */ -Song.prototype.prepare = function(callback) { - return this.backend.prepare(this, callback); -}; - -/** - * Cancel song preparation if applicable - */ -Song.prototype.cancelPrepare = function() { - this.backend.cancelPrepare(this); -}; - -module.exports = Song; diff --git a/package.json b/package.json index b335b6c..0e3c89a 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,10 @@ "main": "bin/nodeplayer", "preferGlobal": true, "scripts": { - "start": "./bin/nodeplayer", - "watch": "nodemon --exec babel-node bin/nodeplayer", + "start": "node dist/index", + "build": "babel src --out-dir dist --source-maps", + "postinstall": "npm run build", + "watch": "nodemon --exec babel-node src/index.js", "test": "NODE_ENV=test ./node_modules/mocha/bin/mocha --compilers js:babel-core/register", "coverage": "NODE_ENV=test istanbul cover _mocha -- -R spec" }, @@ -47,6 +49,7 @@ "istanbul": "*", "mocha": "*", "mocha-eslint": "^3.0.1", + "nodemon": "^1.10.2", "nodeplayer-backend-dummy": "^0.1.999" } } diff --git a/lib/backend.js b/src/backend.js similarity index 53% rename from lib/backend.js rename to src/backend.js index e46dab0..9acf4c3 100644 --- a/lib/backend.js +++ b/src/backend.js @@ -1,4 +1,3 @@ -var _ = require('lodash'); var path = require('path'); var fs = require('fs'); var ffmpeg = require('fluent-ffmpeg'); @@ -9,10 +8,9 @@ var labeledLogger = require('./logger'); * Super constructor for backends */ function Backend() { - this.name = this.constructor.name.toLowerCase(); - this.log = labeledLogger(this.name); - this.log.info('initializing...'); - this.songsPreparing = {}; + this.name = this.constructor.name.toLowerCase(); + this.log = labeledLogger(this.name); + this.songsPreparing = {}; } /** @@ -29,80 +27,80 @@ function Backend() { * @param {Number} seek - Skip to this position in song (TODO) * @param {Song} song - Song object whose audio is being encoded * @param {encodeCallback} callback - Called when song is ready or on error - * @returns {Function} - Can be called to terminate encoding + * @return {Function} - Can be called to terminate encoding */ Backend.prototype.encodeSong = function(stream, seek, song, callback) { - var self = this; + var self = this; - var encodedPath = path.join(config.songCachePath, self.name, + var encodedPath = path.join(config.songCachePath, self.name, song.songId + '.opus'); - var command = ffmpeg(stream) + var command = ffmpeg(stream) .noVideo() - //.inputFormat('mp3') - //.inputOption('-ac 2') + // .inputFormat('mp3') + // .inputOption('-ac 2') .audioCodec('libopus') .audioBitrate('192') .format('opus') .on('error', function(err) { - self.log.error(self.name + ': error while transcoding ' + song.songId + ': ' + err); - delete song.prepare.data; - callback(err); + self.log.error(self.name + ': error while transcoding ' + song.songId + ': ' + err); + delete song.prepare.data; + callback(err); }); - var opusStream = command.pipe(null, {end: true}); - opusStream.on('data', function(chunk) { + var opusStream = command.pipe(null, { end: true }); + opusStream.on('data', function(chunk) { // TODO: this could be optimized by using larger buffers - //song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); + // song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); - if (chunk.length <= song.prepare.data.length - song.prepare.dataPos) { + if (chunk.length <= song.prepare.data.length - song.prepare.dataPos) { // If there's room in the buffer, write chunk into it - chunk.copy(song.prepare.data, song.prepare.dataPos); - song.prepare.dataPos += chunk.length; - } else { + chunk.copy(song.prepare.data, song.prepare.dataPos); + song.prepare.dataPos += chunk.length; + } else { // Otherwise allocate more room, then copy chunk into buffer // Make absolutely sure that the chunk will fit inside new buffer - var newSize = Math.max(song.prepare.data.length * 2, + var newSize = Math.max(song.prepare.data.length * 2, song.prepare.data.length + chunk.length); - self.log.debug('Allocated new song data buffer of size: ' + newSize); + self.log.debug('Allocated new song data buffer of size: ' + newSize); - var buf = new Buffer.allocUnsafe(newSize); + var buf = new Buffer.allocUnsafe(newSize); - song.prepare.data.copy(buf); - song.prepare.data = buf; + song.prepare.data.copy(buf); + song.prepare.data = buf; - chunk.copy(song.prepare.data, song.prepare.dataPos); - song.prepare.dataPos += chunk.length; - } + chunk.copy(song.prepare.data, song.prepare.dataPos); + song.prepare.dataPos += chunk.length; + } - callback(null, chunk.length, false); - }); - opusStream.on('end', () => { - fs.writeFile(encodedPath, song.prepare.data, (err) => { - self.log.verbose('transcoding ended for ' + song.songId); + callback(null, chunk.length, false); + }); + opusStream.on('end', () => { + fs.writeFile(encodedPath, song.prepare.data, err => { + self.log.verbose('transcoding ended for ' + song.songId); - delete song.prepare; + delete song.prepare; // TODO: we don't know if transcoding ended successfully or not, // and there might be a race condition between errCallback deleting // the file and us trying to move it to the songCache // TODO: is this still the case? // (we no longer save incomplete files on disk) - callback(null, null, true); - }); + callback(null, null, true); }); + }); - self.log.verbose('transcoding ' + song.songId + '...'); + self.log.verbose('transcoding ' + song.songId + '...'); // return a function which can be used for terminating encoding - return function(err) { - command.kill(); - self.log.verbose(self.name + ': canceled preparing: ' + song.songId + ': ' + err); - delete song.prepare; - callback(new Error('canceled preparing: ' + song.songId + ': ' + err)); - }; + return function(err) { + command.kill(); + self.log.verbose(self.name + ': canceled preparing: ' + song.songId + ': ' + err); + delete song.prepare; + callback(new Error('canceled preparing: ' + song.songId + ': ' + err)); + }; }; /** @@ -110,12 +108,12 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { * @param {Song} song - Song to cancel */ Backend.prototype.cancelPrepare = function(song) { - if (this.songsPreparing[song.songId]) { - this.log.info('Canceling song preparing: ' + song.songId); - this.songsPreparing[song.songId].cancel(); - } else { - this.log.error('cancelPrepare() called on song not in preparation: ' + song.songId); - } + if (this.songsPreparing[song.songId]) { + this.log.info('Canceling song preparing: ' + song.songId); + this.songsPreparing[song.songId].cancel(); + } else { + this.log.error('cancelPrepare() called on song not in preparation: ' + song.songId); + } }; // dummy functions @@ -133,19 +131,19 @@ Backend.prototype.cancelPrepare = function(song) { * @param {durationCallback} callback - Called with duration */ Backend.prototype.getDuration = function(song, callback) { - var err = 'FATAL: backend does not implement getDuration()!'; - this.log.error(err); - callback(err); + var err = 'FATAL: backend does not implement getDuration()!'; + this.log.error(err); + callback(err); }; /** * Synchronously(!) returns whether the song with songId is prepared or not * @param {Song} song - Query concerns this song - * @returns {Boolean} - true if song is prepared, false if not + * @return {Boolean} - true if song is prepared, false if not */ Backend.prototype.isPrepared = function(song) { - this.log.error('FATAL: backend does not implement songPrepared()!'); - return false; + this.log.error('FATAL: backend does not implement songPrepared()!'); + return false; }; /** @@ -154,8 +152,8 @@ Backend.prototype.isPrepared = function(song) { * @param {encodeCallback} callback - Called when song is ready or on error */ Backend.prototype.prepare = function(song, callback) { - this.log.error('FATAL: backend does not implement prepare()!'); - callback(new Error('FATAL: backend does not implement prepare()!')); + this.log.error('FATAL: backend does not implement prepare()!'); + callback(new Error('FATAL: backend does not implement prepare()!')); }; /** @@ -168,8 +166,8 @@ Backend.prototype.prepare = function(song, callback) { * @param {Function} callback - Called with error or results */ Backend.prototype.search = function(query, callback) { - this.log.error('FATAL: backend does not implement search()!'); - callback(new Error('FATAL: backend does not implement search()!')); + this.log.error('FATAL: backend does not implement search()!'); + callback(new Error('FATAL: backend does not implement search()!')); }; module.exports = Backend; diff --git a/lib/backends/index.js b/src/backends/index.js similarity index 100% rename from lib/backends/index.js rename to src/backends/index.js diff --git a/src/backends/local.js b/src/backends/local.js new file mode 100644 index 0000000..2bd83ff --- /dev/null +++ b/src/backends/local.js @@ -0,0 +1,382 @@ +'use strict'; + +var path = require('path'); +var fs = require('fs'); +var mkdirp = require('mkdirp'); +var mongoose = require('mongoose'); +var async = require('async'); +var walk = require('walk'); +var ffprobe = require('node-ffprobe'); +var _ = require('lodash'); +var escapeStringRegexp = require('escape-string-regexp'); + +var util = require('util'); +var Backend = require('../backend'); + +// external libraries use lower_case extensively here +// jscs:disable requireCamelCaseOrUpperCaseIdentifiers + +/* +var probeCallback = function(err, probeData, next) { + var formats = config.importFormats; + if (probeData) { + // ignore camel case rule here as we can't do anything about probeData + //jscs:disable requireCamelCaseOrUpperCaseIdentifiers + if (formats.indexOf(probeData.format.format_name) >= 0) { // Format is supported + //jscs:enable requireCamelCaseOrUpperCaseIdentifiers + var song = { + title: '', + artist: '', + album: '', + duration: '0', + }; + + // some tags may be in mixed/all caps, let's convert every tag to lower case + var key; + var keys = Object.keys(probeData.metadata); + var n = keys.length; + var metadata = {}; + while (n--) { + key = keys[n]; + metadata[key.toLowerCase()] = probeData.metadata[key]; + } + + // try a best guess based on filename in case tags are unavailable + var basename = path.basename(probeData.file); + basename = path.basename(probeData.file, path.extname(basename)); + var splitTitle = basename.split(/\s-\s(.+)?/); + + if (!_.isUndefined(metadata.title)) { + song.title = metadata.title; + } else { + song.title = splitTitle[1]; + } + if (!_.isUndefined(metadata.artist)) { + song.artist = metadata.artist; + } else { + song.artist = splitTitle[0]; + } + if (!_.isUndefined(metadata.album)) { + song.album = metadata.album; + } + + song.file = probeData.file; + + song.duration = probeData.format.duration * 1000; + SongModel.update({file: probeData.file}, {'$set':song}, {upsert: true}, + function(err, result) { + if (result == 1) { + self.log.debug('Upserted: ' + probeData.file); + } else { + self.log.error('error while updating db: ' + err); + } + + next(); + }); + } else { + self.log.verbose('format not supported, skipping...'); + next(); + } + } else { + self.log.error('error while probing:' + err); + next(); + } +}; +*/ + +// database model +var SongModel = mongoose.model('Song', { + title: String, + artist: String, + album: String, + albumArt: { + lq: String, + hq: String, + }, + duration: { + type: Number, + required: true, + }, + format: { + type: String, + required: true, + }, + filename: { + type: String, + unique: true, + required: true, + dropDups: true, + }, +}); + +/** + * Try to guess metadata from file path, + * Assumes the following naming conventions: + * /path/to/music/Album/Artist - Title.ext + * + * @param {String} filePath - Full file path including filename extension + * @param {String} fileExt - Filename extension + * @return {Metadata} Song metadata + */ +var guessMetadataFromPath = function(filePath, fileExt) { + var fileName = path.basename(filePath, fileExt); + + // split filename at dashes, trim extra whitespace, e.g: + var splitName = fileName.split('-'); + splitName = _.map(splitName, function(name) { + return name.trim(); + }); + + // TODO: compare album name against music dir, leave empty if equal + return { + artist: splitName[0], + title: splitName[1], + album: path.basename(path.dirname(filePath)), + }; +}; + +function Local(callback) { + Backend.apply(this); + + var self = this; + + // NOTE: no argument passed so we get the core's config + var config = require('../config').getConfig(); + this.config = config; + this.songCachePath = config.songCachePath; + this.importFormats = config.importFormats; + + // make sure all necessary directories exist + mkdirp.sync(path.join(this.songCachePath, 'local', 'incomplete')); + + // connect to the database + mongoose.connect(config.mongo); + + var db = mongoose.connection; + db.on('error', function(err) { + return callback(err, self); + }); + db.once('open', function() { + return callback(null, self); + }); + + var options = { + followLinks: config.followSymlinks, + }; + + var insertSong = function(probeData, done) { + var guessMetadata = guessMetadataFromPath(probeData.file, probeData.fileext); + + var song = new SongModel({ + title: probeData.metadata.TITLE || guessMetadata.title, + artist: probeData.metadata.ARTIST || guessMetadata.artist, + album: probeData.metadata.ALBUM || guessMetadata.album, + // albumArt: {} // TODO + duration: probeData.format.duration * 1000, + format: probeData.format.format_name, + filename: probeData.file, + }); + + song = song.toObject(); + + delete song._id; + + SongModel.findOneAndUpdate({ + filename: probeData.file, + }, song, { upsert: true }, function(err) { + if (err) { + self.log.error('while inserting song: ' + probeData.file + ', ' + err); + } + done(); + }); + }; + + // create async.js queue to limit concurrent probes + var q = async.queue(function(task, done) { + ffprobe(task.filename, function(err, probeData) { + if (!probeData) { + return done(); + } + + var validStreams = false; + + if (_.contains(self.importFormats, probeData.format.format_name)) { + validStreams = true; + } + + if (validStreams) { + insertSong(probeData, done); + } else { + self.log.info('skipping file of unknown format: ' + task.filename); + done(); + } + }); + }, config.concurrentProbes); + + // walk the filesystem and scan files + // TODO: also check through entire DB to see that all files still exist on the filesystem + // TODO: filter by allowed filename extensions + if (config.rescanAtStart) { + self.log.info('Scanning directory: ' + config.importPath); + var walker = walk.walk(config.importPath, options); + var startTime = new Date(); + var scanned = 0; + walker.on('file', function(root, fileStats, next) { + var filename = path.join(root, fileStats.name); + self.log.verbose('Scanning: ' + filename); + scanned++; + q.push({ + filename: filename, + }); + next(); + }); + walker.on('end', function() { + self.log.verbose('Scanned files: ' + scanned); + self.log.verbose('Done in: ' + + Math.round((new Date() - startTime) / 1000) + ' seconds'); + }); + } + + // TODO: fs watch + // set fs watcher on media directory + // TODO: add a debounce so if the file keeps changing we don't probe it multiple times + /* + watch(config.importPath, { + recursive: true, + followSymlinks: config.followSymlinks + }, function(filename) { + if (fs.existsSync(filename)) { + self.log.debug(filename + ' modified or created, queued for probing'); + q.unshift({ + filename: filename + }); + } else { + self.log.debug(filename + ' deleted'); + db.collection('songs').remove({file: filename}, function(err, items) { + self.log.debug(filename + ' deleted from db: ' + err + ', ' + items); + }); + } + }); + */ +} + +// must be called immediately after constructor +util.inherits(Local, Backend); + +Local.prototype.isPrepared = function(song) { + var filePath = path.join(this.songCachePath, 'local', song.songId + '.opus'); + return fs.existsSync(filePath); +}; + +Local.prototype.getDuration = (song, callback) => { + SongModel.findById(song.songId, (err, item) => { + if (err) { + return callback(err); + } + + callback(null, item.duration); + }); +}; + +Local.prototype.prepare = function(song, callback) { + var self = this; + + // TODO: move most of this into common code inside core + if (self.songsPreparing[song.songId]) { + // song is preparing, caller can drop this request (previous caller will take care of + // handling once preparation is finished) + callback(null, null, false); + } else if (self.isPrepared(song)) { + // song has already prepared, caller can start playing song + callback(null, null, true); + } else { + // begin preparing song + var cancelEncode = null; + var canceled = false; + + song.prepare = { + data: new Buffer.allocUnsafe(1024 * 1024), + dataPos: 0, + cancel: function() { + canceled = true; + if (cancelEncode) { + cancelEncode(); + } + }, + }; + + self.songsPreparing[song.songId] = song; + + SongModel.findById(song.songId, function(err, item) { + if (canceled) { + callback(new Error('song was canceled before encoding started')); + } else if (item) { + var readStream = fs.createReadStream(item.filename); + cancelEncode = self.encodeSong(readStream, 0, song, callback); + readStream.on('error', function(err) { + callback(err); + }); + } else { + callback(new Error('song not found in local db: ' + song.songId)); + } + }); + } +}; + +Local.prototype.search = function(query, callback) { + var self = this; + + var q; + if (query.any) { + q = { + $or: [ + { artist: new RegExp(escapeStringRegexp(query.any), 'i') }, + { title: new RegExp(escapeStringRegexp(query.any), 'i') }, + { album: new RegExp(escapeStringRegexp(query.any), 'i') }, + ], + }; + } else { + q = { + $and: [], + }; + + _.keys(query).forEach(function(key) { + var criterion = {}; + criterion[key] = new RegExp(escapeStringRegexp(query[key]), 'i'); + q.$and.push(criterion); + }); + } + + SongModel.find(q).exec(function(err, items) { + if (err) { + return callback(err); + } + + var results = {}; + results.songs = {}; + + var numItems = items.length; + var cur = 0; + items.forEach(function(song) { + if (Object.keys(results.songs).length <= self.config.searchResultCnt) { + song = song.toObject(); + + results.songs[song._id] = { + artist: song.artist, + title: song.title, + album: song.album, + albumArt: null, // TODO: can we add this? + duration: song.duration, + songId: song._id, + score: self.config.maxScore * (numItems - cur) / numItems, + backendName: 'local', + format: 'opus', + }; + cur++; + } + }); + callback(results); + }); +}; + +module.exports = Local; diff --git a/lib/config.js b/src/config.js similarity index 53% rename from lib/config.js rename to src/config.js index b9ae151..4172011 100644 --- a/lib/config.js +++ b/src/config.js @@ -5,20 +5,20 @@ var os = require('os'); var path = require('path'); function getHomeDir() { - if (process.platform === 'win32') { - return process.env.USERPROFILE; - } else { - return process.env.HOME; - } + if (process.platform === 'win32') { + return process.env.USERPROFILE; + } + + return process.env.HOME; } exports.getHomeDir = getHomeDir; function getBaseDir() { - if (process.platform === 'win32') { - return process.env.USERPROFILE + '\\nodeplayer'; - } else { - return process.env.HOME + '/.nodeplayer'; - } + if (process.platform === 'win32') { + return path.join(process.env.USERPROFILE, 'nodeplayer'); + } + + return path.join(process.env.HOME, '.nodeplayer'); } exports.getBaseDir = getBaseDir; @@ -26,7 +26,7 @@ var defaultConfig = {}; // backends are sources of music defaultConfig.backends = [ - 'youtube', + 'youtube', ]; // plugins are "everything else", most of the functionality is in plugins @@ -34,7 +34,7 @@ defaultConfig.backends = [ // NOTE: ordering is important here, plugins that require another plugin will // complain if order is wrong. defaultConfig.plugins = [ - 'weblistener', + 'weblistener', ]; defaultConfig.logLevel = 'info'; @@ -63,10 +63,10 @@ defaultConfig.mongo = 'mongodb://localhost:27017/nodeplayer-backend-file'; defaultConfig.rescanAtStart = false; defaultConfig.importPath = path.join(getHomeDir(), 'music'); defaultConfig.importFormats = [ - 'mp3', - 'flac', - 'ogg', - 'opus', + 'mp3', + 'flac', + 'ogg', + 'opus', ]; defaultConfig.concurrentProbes = 4; defaultConfig.followSymlinks = true; @@ -76,60 +76,60 @@ defaultConfig.maxScore = 10; // FIXME: ATM the search algo can return VERY irrel defaultConfig.hostname = os.hostname(); exports.getDefaultConfig = function() { - return defaultConfig; + return defaultConfig; }; // path and defaults are optional, if undefined then values corresponding to core config are used exports.getConfig = function(module, defaults) { - if (process.env.NODE_ENV === 'test') { + if (process.env.NODE_ENV === 'test') { // unit tests should always use default config - return (defaults || defaultConfig); - } + return (defaults || defaultConfig); + } - var moduleName = module ? module.name : null; + var moduleName = module ? module.name : null; - var configPath = path.join(getBaseDir(), 'config', (moduleName || 'core') + '.json'); + var configPath = path.join(getBaseDir(), 'config', (moduleName || 'core') + '.json'); - try { - var userConfig = require(configPath); - var config = _.defaults(userConfig, defaults || defaultConfig); - return config; - } catch (e) { - if (e.code === 'MODULE_NOT_FOUND') { - if (!moduleName) { + try { + var userConfig = require(configPath); + var config = _.defaults(userConfig, defaults || defaultConfig); + return config; + } catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + if (!moduleName) { // only print welcome text for core module first run - console.warn('Welcome to nodeplayer!'); - console.warn('----------------------'); - } - console.warn('\n====================================================================='); - console.warn('We couldn\'t find the user configuration file for module "' + + console.warn('Welcome to nodeplayer!'); + console.warn('----------------------'); + } + console.warn('\n====================================================================='); + console.warn('We couldn\'t find the user configuration file for module "' + (moduleName || 'core') + '",'); - console.warn('so a sample configuration file containing default settings ' + + console.warn('so a sample configuration file containing default settings ' + 'will be written into:'); - console.warn(configPath); + console.warn(configPath); - mkdirp.sync(path.join(getBaseDir(), 'config')); - fs.writeFileSync(configPath, JSON.stringify(defaults || defaultConfig, undefined, 4)); + mkdirp.sync(path.join(getBaseDir(), 'config')); + fs.writeFileSync(configPath, JSON.stringify(defaults || defaultConfig, undefined, 4)); - console.warn('\nFile created. Go edit it NOW!'); - console.warn('Note that the file only needs to contain the configuration ' + + console.warn('\nFile created. Go edit it NOW!'); + console.warn('Note that the file only needs to contain the configuration ' + 'variables that'); - console.warn('you want to override from the defaults. Also note that it ' + + console.warn('you want to override from the defaults. Also note that it ' + 'MUST be valid JSON!'); - console.warn('=====================================================================\n'); + console.warn('=====================================================================\n'); - if (!moduleName) { + if (!moduleName) { // only exit on missing core module config - console.warn('Exiting now. Please re-run nodeplayer when you\'re done ' + + console.warn('Exiting now. Please re-run nodeplayer when you\'re done ' + 'configuring!'); - process.exit(0); - } - - return (defaults || defaultConfig); - } else { - console.warn('Unexpected error while loading configuration for module "' + - (moduleName || 'core') + '":'); - console.warn(e); - } + process.exit(0); + } + + return (defaults || defaultConfig); } + + console.warn('Unexpected error while loading configuration for module "' + + (moduleName || 'core') + '":'); + console.warn(e); + } }; diff --git a/src/index.js b/src/index.js new file mode 100755 index 0000000..8d52e4e --- /dev/null +++ b/src/index.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +var Player = require('./player'); +var p = new Player(); + +p.init(); diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..8971a50 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,17 @@ +'use strict'; +var config = require('./config').getConfig(); +var winston = require('winston'); + +module.exports = function(label) { + return new (winston.Logger)({ + transports: [ + new (winston.transports.Console)({ + label: label, + level: config.logLevel, + colorize: config.logColorize, + handleExceptions: config.logExceptions, + json: config.logJson, + }), + ], + }); +}; diff --git a/src/modules.js b/src/modules.js new file mode 100644 index 0000000..2f2e8ed --- /dev/null +++ b/src/modules.js @@ -0,0 +1,164 @@ +var npm = require('npm'); +var async = require('async'); +var labeledLogger = require('./logger'); +var BuiltinPlugins = require('./plugins'); +var BuiltinBackends = require('./backends'); + +var _ = require('lodash'); +var logger = labeledLogger('modules'); + +var checkModule = function(module) { + try { + require.resolve(module); + return true; + } catch (e) { + return false; + } +}; + +// install a single module +var installModule = function(moduleName, callback) { + logger.info('installing module: ' + moduleName); + npm.load({}, function(err) { + npm.commands.install(__dirname, [moduleName], function(err) { + if (err) { + logger.error(moduleName + ' installation failed:', err); + callback(); + } else { + logger.info(moduleName + ' successfully installed'); + callback(); + } + }); + }); +}; + +// make sure all modules are installed, installs missing ones, then calls done +var installModules = function(modules, moduleType, forceUpdate, done) { + async.eachSeries(modules, function(moduleShortName, callback) { + var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; + if (!checkModule(moduleName) || forceUpdate) { + // perform install / update + installModule(moduleName, callback); + } else { + // skip already installed + callback(); + } + }, done); +}; + +/* +var initModule = function(moduleShortName, moduleType, callback) { + var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; + var module = require(moduleName); + + module.init(function(err) { + callback(err, module); + }); +}; +*/ + +// TODO: this probably doesn't work +// needs rewrite +exports.loadBackends = function(player, backends, forceUpdate, done) { + // first install missing backends + installModules(backends, 'backend', forceUpdate, function() { + // then initialize all backends in parallel + async.map(backends, function(backend, callback) { + var moduleLogger = labeledLogger(backend); + var moduleName = 'nodeplayer-backend-' + backend; + if (moduleName) { + moduleLogger.verbose('initializing...'); + + var Module = require(moduleName); + var instance = new Module(function(err) { + if (err) { + moduleLogger.error('while initializing: ' + err); + callback(); + } else { + moduleLogger.verbose('backend initialized'); + player.callHooks('onBackendInitialized', [backend]); + callback(null, instance); + } + }); + } else { + // skip module whose installation failed + moduleLogger.info('not loading backend: ' + backend); + callback(); + } + }, function(err, results) { + logger.info('all backend modules initialized'); + results = _.filter(results, _.identity); + done(results); + }); + }); +}; + +// TODO: this probably doesn't work +// needs rewrite +exports.loadPlugins = function(player, plugins, forceUpdate, done) { + // first install missing plugins + installModules(plugins, 'plugin', forceUpdate, function() { + // then initialize all plugins in series + async.mapSeries(plugins, function(plugin, callback) { + var moduleLogger = labeledLogger(plugin); + var moduleName = 'nodeplayer-plugin-' + plugin; + if (checkModule(moduleName)) { + moduleLogger.verbose('initializing...'); + + var Module = require(moduleName); + var instance = new Module(player, function(err) { + if (err) { + moduleLogger.error('while initializing: ' + err); + callback(); + } else { + moduleLogger.verbose('plugin initialized'); + player.callHooks('onPluginInitialized', [plugin]); + callback(null, instance); + } + }); + } else { + // skip module whose installation failed + moduleLogger.info('not loading plugin: ' + plugin); + callback(); + } + }, function(err, results) { + logger.info('all plugin modules initialized'); + results = _.filter(results, _.identity); + done(results); + }); + }); +}; + +exports.loadBuiltinPlugins = function(player, done) { + async.mapSeries(BuiltinPlugins, function(Plugin, callback) { + return new Plugin(player, function(err, plugin) { + if (err) { + plugin.log.error('while initializing: ' + err); + return callback(); + } + + plugin.log.verbose('plugin initialized'); + player.callHooks('onPluginInitialized', [plugin.name]); + callback(null, { [plugin.name]: plugin }); + }); + }, function(err, results) { + done(Object.assign({}, ...results)); + }); +}; + +exports.loadBuiltinBackends = function(player, done) { + async.mapSeries(BuiltinBackends, function(Backend, callback) { + return new Backend(function(err, backend) { + if (err) { + backend.log.error('while initializing: ' + err); + return callback(); + } + + player.callHooks('onBackendInitialized', [backend.name]); + backend.log.verbose('backend initialized'); + callback(null, { [backend.name]: backend }); + }); + }, function(err, results) { + done(Object.assign({}, ...results)); + }); +}; diff --git a/src/player.js b/src/player.js new file mode 100644 index 0000000..51dcbc1 --- /dev/null +++ b/src/player.js @@ -0,0 +1,443 @@ +'use strict'; +var _ = require('lodash'); +var async = require('async'); +var util = require('util'); +var labeledLogger = require('./logger'); +var Queue = require('./queue'); +var modules = require('./modules'); + +function Player(options) { + options = options || {}; + + // TODO: some of these should NOT be loaded from config + _.bindAll.apply(_, [this].concat(_.functions(this))); + this.config = options.config || require('./config').getConfig(); + this.logger = options.logger || labeledLogger('core'); + this.queue = options.queue || new Queue(this); + this.nowPlaying = options.nowPlaying || null; + this.play = options.play || false; + this.repeat = options.repeat || false; + this.plugins = options.plugins || {}; + this.backends = options.backends || {}; + this.prepareTimeouts = options.prepareTimeouts || {}; + this.volume = options.volume || 1; + this.songEndTimeout = options.songEndTimeout || null; + this.pluginVars = options.pluginVars || {}; +} + +/** + * Initializes player + */ +Player.prototype.init = function() { + var player = this; + var config = player.config; + var forceUpdate = false; + + // initialize plugins & backends + async.series([ + function(callback) { + modules.loadBuiltinPlugins(player, function(plugins) { + player.plugins = plugins; + player.callHooks('onBuiltinPluginsInitialized'); + callback(); + }); + }, function(callback) { + modules.loadPlugins(player, config.plugins, forceUpdate, function(results) { + player.plugins = _.extend(player.plugins, results); + player.callHooks('onPluginsInitialized'); + callback(); + }); + }, function(callback) { + modules.loadBuiltinBackends(player, function(backends) { + player.backends = backends; + player.callHooks('onBuiltinBackendsInitialized'); + callback(); + }); + }, function(callback) { + modules.loadBackends(player, config.backends, forceUpdate, function(results) { + player.backends = _.extend(player.backends, results); + player.callHooks('onBackendsInitialized'); + callback(); + }); + }, + ], function() { + player.logger.info('ready'); + player.callHooks('onReady'); + }); +}; + +// call hook function in all modules +// if any hooks return a truthy value, it is an error and we abort +// be very careful with calling hooks from within a hook, infinite loops are possible +Player.prototype.callHooks = function(hook, argv) { + // _.find() used instead of _.each() because we want to break out as soon + // as a hook returns a truthy value (used to indicate an error, e.g. in form + // of a string) + var err = null; + + this.logger.silly('callHooks(' + hook + + (argv ? ', ' + util.inspect(argv) + ')' : ')')); + + _.find(this.plugins, function(plugin) { + if (plugin.hooks[hook]) { + var fun = plugin.hooks[hook]; + err = fun.apply(null, argv); + return err; + } + }); + + return err; +}; + +// returns number of hook functions attached to given hook +Player.prototype.numHooks = function(hook) { + var cnt = 0; + + _.find(this.plugins, function(plugin) { + if (plugin[hook]) { + cnt++; + } + }); + + return cnt; +}; + +/** + * Returns currently playing song + * @return {Song|null} - Song object, null if no now playing song + */ +Player.prototype.getNowPlaying = function() { + return this.nowPlaying; +}; + +// TODO: handling of pause in a good way? +/** + * Stop playback of current song + * @param {Boolean} [pause=false] - If true, don't reset song position + */ +Player.prototype.stopPlayback = function(pause) { + this.logger.info('playback ' + (pause ? 'paused.' : 'stopped.')); + + clearTimeout(this.songEndTimeout); + this.play = false; + + var np = this.nowPlaying; + var pos = np.playback.startPos + (new Date().getTime() - np.playback.startTime); + if (np) { + np.playback = { + startTime: 0, + startPos: pause ? pos : 0, + }; + } +}; + +/** + * Start playing now playing song, at optional position + * @param {Number} [position=0] - Position at which playback is started + * @throws {Error} if an error occurred + */ +Player.prototype.startPlayback = function(position) { + position = position || 0; + var player = this; + + if (!this.nowPlaying) { + // find first song in queue + this.nowPlaying = this.queue.songs[0]; + + if (!this.nowPlaying) { + throw new Error('queue is empty! not starting playback.'); + } + } + + this.nowPlaying.prepare(function(err) { + if (err) { + throw new Error('error while preparing now playing: ' + err); + } + + player.nowPlaying.playbackStarted(position || player.nowPlaying.playback.startPos); + + player.logger.info('playback started.'); + player.play = true; + }); +}; + +/** + * Change to song + * @param {String} uuid - UUID of song to change to, if not found in queue, now + * playing is removed, playback stopped + */ +Player.prototype.changeSong = function(uuid) { + this.logger.verbose('changing song to: ' + uuid); + clearTimeout(this.songEndTimeout); + + this.nowPlaying = this.queue.findSong(uuid); + + if (!this.nowPlaying) { + this.logger.info('song not found: ' + uuid); + this.stopPlayback(); + } + + this.startPlayback(); + this.logger.info('changed song to: ' + uuid); +}; + +Player.prototype.songEnd = function() { + var np = this.getNowPlaying(); + var npIndex = np ? this.queue.findSongIndex(np.uuid) : -1; + + this.logger.info('end of song ' + np.uuid); + this.callHooks('onSongEnd', [np]); + + var nextSong = this.queue.songs[npIndex + 1]; + if (nextSong) { + this.changeSong(nextSong.uuid); + } else { + this.logger.info('hit end of queue.'); + + if (this.repeat) { + this.logger.info('repeat is on, restarting playback from start of queue.'); + this.changeSong(this.queue.uuidAtIndex(0)); + } + } + + this.prepareSongs(); +}; + +// TODO: move these to song class? +Player.prototype.setPrepareTimeout = function(song) { + var player = this; + + if (song.prepareTimeout) { + clearTimeout(song.prepareTimeout); + } + + song.prepareTimeout = setTimeout(function() { + player.logger.info('prepare timeout for song: ' + song.songId + ', removing'); + song.cancelPrepare('prepare timeout'); + song.prepareTimeout = null; + }, this.config.songPrepareTimeout); + + Object.defineProperty(song, 'prepareTimeout', { + enumerable: false, + writable: true, + }); +}; + +Player.prototype.clearPrepareTimeout = function(song) { + // clear prepare timeout + clearTimeout(song.prepareTimeout); + song.prepareTimeout = null; +}; + +Player.prototype.prepareError = function(song, err) { + // TODO: mark song as failed + this.callHooks('onSongPrepareError', [song, err]); +}; + +Player.prototype.prepareProgCallback = function(song, bytesWritten, done) { + /* progress callback + * when this is called, new song data has been flushed to disk */ + + // start playback if it hasn't been started yet + if (this.play && this.getNowPlaying() && + this.getNowPlaying().uuid === song.uuid && + !this.queue.playbackStart && bytesWritten) { + this.startPlayback(); + } + + // tell plugins that new data is available for this song, and + // whether the song is now fully written to disk or not. + this.callHooks('onPrepareProgress', [song, bytesWritten, done]); + + if (done) { + // mark song as prepared + this.callHooks('onSongPrepared', [song]); + + // done preparing, can't cancel anymore + delete (song.cancelPrepare); + + // song data should now be available on disk, don't keep it in memory + song.backend.songsPreparing[song.songId].songData = undefined; + delete (song.backend.songsPreparing[song.songId]); + + // clear prepare timeout + this.clearPrepareTimeout(song); + } else { + // reset prepare timeout + this.setPrepareTimeout(song); + } +}; + +Player.prototype.prepareErrCallback = function(song, err, callback) { + /* error callback */ + + // don't let anything run cancelPrepare anymore + delete (song.cancelPrepare); + + this.clearPrepareTimeout(song); + + // abort preparing more songs; current song will be deleted -> + // onQueueModified is called -> song preparation is triggered again + callback(true); + + // TODO: investigate this, should probably be above callback + this.prepareError(song, err); + + song.songData = undefined; + delete (this.songsPreparing[song.backend.name][song.songId]); +}; + +Player.prototype.prepareSong = function(song, callback) { + var self = this; + + if (!song) { + throw new Error('prepareSong() without song'); + } + + if (song.isPrepared()) { + // start playback if it hasn't been started yet + if (this.play && this.getNowPlaying() && + this.getNowPlaying().uuid === song.uuid && + !this.queue.playbackStart) { + this.startPlayback(); + } + + // song is already prepared, ok to prepare more songs + callback(); + } else { + // song is not prepared and not currently preparing: let backend prepare it + this.logger.debug('DEBUG: prepareSong() ' + song.songId); + + song.prepare(function(err, chunk, done) { + if (err) { + return callback(err); + } + + if (chunk) { + self.prepareProgCallback(song, chunk, done); + } + + if (done) { + self.clearPrepareTimeout(song); + callback(); + } + }); + + this.setPrepareTimeout(song); + } +}; + +/** + * Prepare now playing and next song for playback + */ +Player.prototype.prepareSongs = function() { + var player = this; + + var currentSong; + async.series([ + function(callback) { + // prepare now-playing song + currentSong = player.getNowPlaying(); + if (currentSong) { + player.prepareSong(currentSong, callback); + } else if (player.queue.getLength()) { + // songs exist in queue, prepare first one + currentSong = player.queue.songs[0]; + player.prepareSong(currentSong, callback); + } else { + // bail out + callback(true); + } + }, + function(callback) { + // prepare next song in playlist + var nextSong = player.queue.songs[player.queue.findSongIndex(currentSong) + 1]; + if (nextSong) { + player.prepareSong(nextSong, callback); + } else { + // bail out + callback(true); + } + }, + ]); + // TODO where to put this + // player.prepareErrCallback(); +}; + +Player.prototype.getPlaylists = function(callback) { + var resultCnt = 0; + var allResults = {}; + var player = this; + + _.each(this.backends, function(backend) { + if (!backend.getPlaylists) { + resultCnt++; + + // got results from all services? + if (resultCnt >= Object.keys(player.backends).length) { + callback(allResults); + } + return; + } + + backend.getPlaylists(function(err, results) { + resultCnt++; + + allResults[backend.name] = results; + + // got results from all services? + if (resultCnt >= Object.keys(player.backends).length) { + callback(allResults); + } + }); + }); +}; + +// make a search query to backends +Player.prototype.searchBackends = function(query, callback) { + var resultCnt = 0; + var allResults = {}; + + _.each(this.backends, function(backend) { + backend.search(query, _.bind(function(results) { + resultCnt++; + + // make a temporary copy of songlist, clear songlist, check + // each song and add them again if they are ok + var tempSongs = _.clone(results.songs); + allResults[backend.name] = results; + allResults[backend.name].songs = {}; + + _.each(tempSongs, function(song) { + var err = this.callHooks('preAddSearchResult', [song]); + if (err) { + this.logger.error('preAddSearchResult hook error: ' + err); + } else { + allResults[backend.name].songs[song.songId] = song; + } + }, this); + + // got results from all services? + if (resultCnt >= Object.keys(this.backends).length) { + callback(allResults); + } + }, this), _.bind(function(err) { + resultCnt++; + this.logger.error('error while searching ' + backend.name + ': ' + err); + + // got results from all services? + if (resultCnt >= Object.keys(this.backends).length) { + callback(allResults); + } + }, this)); + }, this); +}; + +// TODO: userID does not belong into core...? +Player.prototype.setVolume = function(newVol, userID) { + newVol = Math.min(1, Math.max(0, newVol)); + this.volume = newVol; + this.callHooks('onVolumeChange', [newVol, userID]); +}; + +module.exports = Player; diff --git a/src/plugin.js b/src/plugin.js new file mode 100644 index 0000000..46226db --- /dev/null +++ b/src/plugin.js @@ -0,0 +1,16 @@ +const labeledLogger = require('./logger'); + +/** + * Super constructor for plugins + */ +export default class Plugin { + constructor() { + this.name = this.constructor.name.toLowerCase(); + this.log = labeledLogger(this.name); + this.hooks = {}; + } + + registerHook(hook, callback) { + this.hooks[hook] = callback; + } +} diff --git a/src/plugins/express.js b/src/plugins/express.js new file mode 100644 index 0000000..e9dedcc --- /dev/null +++ b/src/plugins/express.js @@ -0,0 +1,47 @@ +'use strict'; + +var express = require('express'); +var bodyParser = require('body-parser'); +var cookieParser = require('cookie-parser'); +var https = require('https'); +var http = require('http'); +var fs = require('fs'); + +import Plugin from '../plugin'; + +export default class Express extends Plugin { + constructor(player, callback) { + super(); + + // NOTE: no argument passed so we get the core's config + var config = require('../config').getConfig(); + player.app = express(); + + var options = {}; + var port = process.env.PORT || config.port; + if (config.tls) { + options = { + tls: config.tls, + key: config.key ? fs.readFileSync(config.key) : undefined, + cert: config.cert ? fs.readFileSync(config.cert) : undefined, + ca: config.ca ? fs.readFileSync(config.ca) : undefined, + requestCert: config.requestCert, + rejectUnauthorized: config.rejectUnauthorized, + }; + // TODO: deprecated! + player.app.set('tls', true); + player.httpServer = https.createServer(options, player.app) + .listen(port); + } else { + player.httpServer = http.createServer(player.app) + .listen(port); + } + this.log.info('listening on port ' + port); + + player.app.use(cookieParser()); + player.app.use(bodyParser.json({ limit: '100mb' })); + player.app.use(bodyParser.urlencoded({ extended: true })); + + callback(null, this); + } +} diff --git a/lib/plugins/index.js b/src/plugins/index.js similarity index 100% rename from lib/plugins/index.js rename to src/plugins/index.js diff --git a/src/plugins/rest.js b/src/plugins/rest.js new file mode 100644 index 0000000..ca65970 --- /dev/null +++ b/src/plugins/rest.js @@ -0,0 +1,267 @@ +'use strict'; + +var _ = require('lodash'); + +var async = require('async'); +var path = require('path'); +import Plugin from '../plugin'; + +export default class Rest extends Plugin { + constructor(player, callback) { + super(); + + // NOTE: no argument passed so we get the core's config + var config = require('../config').getConfig(); + + if (!player.app) { + return callback('module must be initialized after express module!'); + } + + player.app.use((req, res, next) => { + res.sendRes = (err, data) => { + if (err) { + res.status(404).send(err); + } else { + res.send(data || 'ok'); + } + }; + next(); + }); + + player.app.get('/queue', (req, res) => { + var np = player.nowPlaying; + var pos = 0; + if (np) { + if (np.playback.startTime) { + pos = new Date().getTime() - np.playback.startTime + np.playback.startPos; + } else { + pos = np.playback.startPos; + } + } + + res.json({ + songs: player.queue.serialize(), + nowPlaying: np ? np.serialize() : null, + nowPlayingPos: pos, + play: player.play, + }); + }); + + // TODO: error handling + player.app.post('/queue/song', (req, res) => { + var err = player.queue.insertSongs(null, req.body); + + res.sendRes(err); + }); + player.app.post('/queue/song/:at', (req, res) => { + var err = player.queue.insertSongs(req.params.at, req.body); + + res.sendRes(err); + }); + + /* + player.app.post('/queue/move/:pos', function(req, res) { + var err = player.moveInQueue( + Number(req.params.pos), + Number(req.body.to), + Number(req.body.cnt) + ); + sendResponse(res, 'success', err); + }); + */ + + player.app.delete('/queue/song/:at', (req, res) => { + player.removeSongs(req.params.at, Number(req.query.cnt) || 1, res.sendRes); + }); + + player.app.post('/playctl/play', (req, res) => { + player.startPlayback(Number(req.body.position) || 0); + res.sendRes(null, 'ok'); + }); + + player.app.post('/playctl/stop', (req, res) => { + player.stopPlayback(req.query.pause); + res.sendRes(null, 'ok'); + }); + + player.app.post('/playctl/skip', (req, res) => { + player.skipSongs(Number(req.body.cnt)); + res.sendRes(null, 'ok'); + }); + + player.app.post('/playctl/shuffle', (req, res) => { + player.shuffleQueue(); + res.sendRes(null, 'ok'); + }); + + player.app.post('/volume', (req, res) => { + player.setVolume(Number(req.body)); + res.send('success'); + }); + + // search for songs, search terms in query params + player.app.get('/search', (req, res) => { + this.log.verbose('got search request: ' + JSON.stringify(req.body.query)); + + player.searchBackends(req.body.query, results => { + res.json(results); + }); + }); + + this.pendingRequests = {}; + var rest = this; + this.registerHook('onPrepareProgress', (song, bytesWritten, done) => { + if (!rest.pendingRequests[song.backend.name]) { + return; + } + + _.each(rest.pendingRequests[song.backend.name][song.songId], client => { + if (bytesWritten) { + var end = song.prepare.dataPos; + if (client.wishRange[1]) { + end = Math.min(client.wishRange[1], bytesWritten - 1); + } + + // console.log('end: ' + end + '\tclient.serveRange[1]: ' + client.serveRange[1]); + + if (client.serveRange[1] < end) { + // console.log('write'); + client.res.write(song.prepare.data.slice(client.serveRange[1] + 1, end)); + } + + client.serveRange[1] = end; + } + + if (done) { + console.log('done'); + client.res.end(); + } + }); + + if (done) { + rest.pendingRequests[song.backend.name][song.songId] = []; + } + }); + + this.registerHook('onBackendInitialized', backendName => { + rest.pendingRequests[backendName] = {}; + + // provide API path for music data, might block while song is preparing + player.app.get('/song/' + backendName + '/:fileName', (req, res, next) => { + var extIndex = req.params.fileName.lastIndexOf('.'); + var songId = req.params.fileName.substring(0, extIndex); + var songFormat = req.params.fileName.substring(extIndex + 1); + + var backend = player.backends[backendName]; + var filename = path.join(backendName, songId + '.' + songFormat); + + res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); + res.setHeader('Accept-Ranges', 'bytes'); + + var queuedSong = _.find(player.queue.serialize(), song => { + return song.songId === songId && song.backendName === backendName; + }); + + async.series([ + callback => { + // try finding out length of song + if (queuedSong) { + res.setHeader('X-Content-Duration', queuedSong.duration / 1000); + callback(); + } else { + backend.getDuration({ songId: songId }, (err, exactDuration) => { + res.setHeader('X-Content-Duration', exactDuration / 1000); + callback(); + }); + } + }, + callback => { + if (backend.isPrepared({ songId: songId })) { + // song should be available on disk + res.sendFile(filename, { + root: config.songCachePath, + }); + } else if (backend.songsPreparing[songId]) { + // song is preparing + var song = backend.songsPreparing[songId]; + + var haveRange = []; + var wishRange = []; + var serveRange = []; + + haveRange[0] = 0; + haveRange[1] = song.prepare.data.length - 1; + + wishRange[0] = 0; + wishRange[1] = null; + + serveRange[0] = 0; + + res.setHeader('Transfer-Encoding', 'chunked'); + + if (req.headers.range) { + // partial request + + wishRange = req.headers.range.substr(req.headers.range.indexOf('=') + 1).split('-'); + + serveRange[0] = wishRange[0]; + + // a best guess for the response header + serveRange[1] = haveRange[1]; + if (wishRange[1]) { + serveRange[1] = Math.min(wishRange[1], haveRange[1]); + } + + res.statusCode = 206; + res.setHeader('Content-Range', 'bytes ' + serveRange[0] + '-' + serveRange[1] + '/*'); + } else { + serveRange[1] = haveRange[1]; + } + + this.log.debug('request with wishRange: ' + wishRange); + + if (!rest.pendingRequests[backendName][songId]) { + rest.pendingRequests[backendName][songId] = []; + } + + var client = { + res: res, + serveRange: serveRange, + wishRange: wishRange, + filepath: path.join(config.songCachePath, filename), + }; + + // TODO: If we know that we have already flushed data to disk, + // we could open up the read stream already here instead of waiting + // around for the first flush + + // If we can satisfy the start of the requested range, write as + // much as possible to res immediately + if (haveRange[1] >= wishRange[0]) { + client.res.write(song.prepare.data.slice(serveRange[0], serveRange[1] + 1)); + } + + if (serveRange[1] === wishRange[1]) { + client.res.end(); + } else { + // If we couldn't satisfy the entire request, push the client + // into pendingRequests so we can append to the stream later + rest.pendingRequests[backendName][songId].push(client); + + req.on('close', () => { + rest.pendingRequests[backendName][songId].splice( + rest.pendingRequests[backendName][songId].indexOf(client), 1 + ); + }); + } + } else { + res.status(404).end('404 song not found'); + } + }] + ); + }); + }); + + callback(null, this); + } +} diff --git a/src/queue.js b/src/queue.js new file mode 100644 index 0000000..398cc1a --- /dev/null +++ b/src/queue.js @@ -0,0 +1,178 @@ +var _ = require('lodash'); +var Song = require('./song'); + +/** + * Constructor + * @param {Player} player - Parent player object reference + * @throws {Error} in case of errors + */ +function Queue(player) { + if (!player || !_.isObject(player)) { + throw new Error('Queue constructor called without player reference!'); + } + + this.unshuffledSongs = null; + this.songs = []; + this.player = player; +} + +// TODO: hooks +// TODO: moveSongs + +/** + * Get serialized list of songs in queue + * @return {[SerializedSong]} - List of songs in serialized format + */ +Queue.prototype.serialize = function() { + var serialized = _.map(this.songs, function(song) { + return song.serialize(); + }); + + return serialized; +}; + +/** + * Find index of song in queue + * @param {String} at - Look for song with this UUID + * @return {Number} - Index of song, -1 if not found + */ +Queue.prototype.findSongIndex = function(at) { + return _.findIndex(this.songs, function(song) { + return song.uuid === at; + }); +}; + +/** + * Find song in queue + * @param {String} at - Look for song with this UUID + * @return {Song|null} - Song object, null if not found + */ +Queue.prototype.findSong = function(at) { + return _.find(this.songs, function(song) { + return song.uuid === at; + }) || null; +}; + +/** + * Find song UUID at given index + * @param {Number} index - Look for song at this index + * @return {String|null} - UUID, null if not found + */ +Queue.prototype.uuidAtIndex = function(index) { + var song = this.songs[index]; + return song ? song.uuid : null; +}; + +/** + * Returns queue length + * @return {Number} - Queue length + */ +Queue.prototype.getLength = function() { + return this.songs.length; +}; + +/** + * Insert songs into queue + * @param {String | null} at - Insert songs after song with this UUID + * (null = start of queue) + * @param {Object[]} songs - List of songs to insert + * @return {Error} - in case of errors + */ +Queue.prototype.insertSongs = function(at, songs) { + var pos; + if (at === null) { + // insert at start of queue + pos = 0; + } else { + // insert song after song with UUID + pos = this.findSongIndex(at); + + if (pos < 0) { + return 'Song with UUID ' + at + ' not found!'; + } + + pos++; // insert after song + } + + // generate Song objects of each song + songs = _.map(songs, function(song) { + // TODO: this would be best done in the song constructor, + // effectively making it a SerializedSong object deserializer + var backend = this.player.backends[song.backendName]; + if (!backend) { + throw new Error('Song constructor called with invalid backend: ' + song.backendName); + } + + return new Song(song, backend); + }, this); + + // perform insertion + var args = [pos, 0].concat(songs); + Array.prototype.splice.apply(this.songs, args); + + this.player.prepareSongs(); +}; + +/** + * Removes songs from queue + * @param {String} at - Start removing at song with this UUID + * @param {Number} cnt - Number of songs to delete + * @return {Song[] | Error} - List of removed songs, Error in case of errors + */ +Queue.prototype.removeSongs = function(at, cnt) { + var pos = this.findSongIndex(at); + if (pos < 0) { + return 'Song with UUID ' + at + ' not found!'; + } + + // cancel preparing all songs to be deleted + for (var i = pos; i < pos + cnt && i < this.songs.length; i++) { + var song = this.songs[i]; + if (song.cancelPrepare) { + song.cancelPrepare('Song removed.'); + } + } + + // store index of now playing song + var np = this.player.nowPlaying; + var npIndex = np ? this.findSongIndex(np.uuid) : -1; + + // perform deletion + var removed = this.songs.splice(pos, cnt); + + // was now playing removed? + if (pos <= npIndex && pos + cnt >= npIndex) { + // change to first song after splice + var newNp = this.songs[pos]; + this.player.changeSong(newNp ? newNp.uuid : null); + } else { + this.player.prepareSongs(); + } + + return removed; +}; + +/** + * Toggle queue shuffling + */ +Queue.prototype.shuffle = function() { + if (this.unshuffledSongs) { + // unshuffle + + // restore unshuffled list + this.songs = this.unshuffledSongs; + + this.unshuffledSongs = null; + } else { + // shuffle + + // store copy of current songs array + this.unshuffledSongs = this.songs.slice(); + + this.songs = _.shuffle(this.songs); + } + + this.player.prepareSongs(); +}; + +module.exports = Queue; diff --git a/src/song.js b/src/song.js new file mode 100644 index 0000000..a6cef10 --- /dev/null +++ b/src/song.js @@ -0,0 +1,118 @@ +var _ = require('lodash'); +var uuid = require('node-uuid'); + +/** + * Constructor + * @param {Song} song - Song details + * @param {Backend} backend - Backend providing the audio + * @throws {Error} in case of errors + */ +function Song(song, backend) { + // make sure we have a reference to backend + if (!backend || !_.isObject(backend)) { + throw new Error('Song constructor called with invalid backend: ' + backend); + } + + if (!song.duration || !_.isNumber(song.duration)) { + throw new Error('Song constructor called without duration!'); + } + if (!song.title || !_.isString(song.title)) { + throw new Error('Song constructor called without title!'); + } + if (!song.songId || !_.isString(song.songId)) { + throw new Error('Song constructor called without songId!'); + } + if (!song.score || !_.isNumber(song.score)) { + throw new Error('Song constructor called without score!'); + } + if (!song.format || !_.isString(song.format)) { + throw new Error('Song constructor called without format!'); + } + + this.uuid = uuid.v4(); + + this.title = song.title; + this.artist = song.artist; + this.album = song.album; + this.albumArt = { + lq: song.albumArt ? song.albumArt.lq : null, + hq: song.albumArt ? song.albumArt.hq : null, + }; + this.duration = song.duration; + this.songId = song.songId; + this.score = song.score; + this.format = song.format; + + this.playback = { + startTime: null, + startPos: null, + }; + + // NOTE: internally to the Song we store a reference to the backend. + // However when accessing the Song from the outside, we return only the + // backend's name inside a backendName field. + // + // Any functions requiring access to the backend should be implemented as + // members of the Song (e.g. isPrepared, prepareSong) + this.backend = backend; + + // optional fields + this.playlist = song.playlist; +} + +/** + * Set playback status as started at specified optional position + * @param {Number} [pos] - position to start playing at + */ +Song.prototype.playbackStarted = function(pos) { + this.playback = { + startTime: new Date(), + startPos: pos || null, + }; +}; + +/** + * Return serialized details of the song + * @return {SerializedSong} - serialized Song object + */ +Song.prototype.serialize = function() { + return { + uuid: this.uuid, + title: this.title, + artist: this.artist, + album: this.album, + albumArt: this.albumArt, + duration: this.duration, + songId: this.songId, + score: this.score, + format: this.format, + backendName: this.backend.name, + playlist: this.playlist, + playback: this.playback, + }; +}; + +/** + * Synchronously(!) returns whether the song is prepared or not + * @return {Boolean} - true if song is prepared, false if not + */ +Song.prototype.isPrepared = function() { + return this.backend.isPrepared(this); +}; + +/** + * Prepare song for playback + * @param {encodeCallback} callback - Called when song is ready or on error + */ +Song.prototype.prepare = function(callback) { + this.backend.prepare(this, callback); +}; + +/** + * Cancel song preparation if applicable + */ +Song.prototype.cancelPrepare = function() { + this.backend.cancelPrepare(this); +}; + +module.exports = Song; diff --git a/test/eslint.spec.js b/test/eslint.spec.js index 06d7e46..3640a43 100644 --- a/test/eslint.spec.js +++ b/test/eslint.spec.js @@ -1,9 +1,9 @@ var lint = require('mocha-eslint'); var paths = [ - 'bin', - 'lib', - 'test', + 'bin', + 'src', + 'test', ]; lint(paths); diff --git a/test/test.js b/test/test.js index 1cd04ed..3d193f0 100644 --- a/test/test.js +++ b/test/test.js @@ -1,18 +1,20 @@ 'use strict'; -/*jshint expr: true*/ -var should = require('chai').should(); -var _ = require('underscore'); -var Player = require('../lib/player'); -var dummyBackend = require('nodeplayer-backend-dummy'); -var exampleQueue = require('./exampleQueue.json'); +require('chai').should(); +// var _ = require('underscore'); +// var Player = require('../src/player'); +// var dummyBackend = require('nodeplayer-backend-dummy'); +// var exampleQueue = require('./exampleQueue.json'); process.env.NODE_ENV = 'test'; +/* var dummyClone = function(obj) { return JSON.parse(JSON.stringify(obj)); }; +*/ +/* var dummyLogger = { silly: _.noop, debug: _.noop, @@ -21,12 +23,15 @@ var dummyLogger = { warn: _.noop, error: _.noop, }; +*/ +/* describe('exampleQueue', function() { it('should contain at least 5 items', function() { exampleQueue.length.should.be.above(5); }); }); +*/ // TODO: test error cases also describe('Player', function() { From 848888b40f0e4e6faedfcce9089096856335b53b Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 5 Sep 2016 18:32:52 +0300 Subject: [PATCH 080/103] Convert all function definitions to ES6 arrow syntax --- .eslintrc.json | 3 +- src/backend.js | 18 +- src/backends/local.js | 48 +-- src/config.js | 4 +- src/index.js | 2 +- src/logger.js | 2 +- src/modules.js | 52 +-- src/player.js | 752 +++++++++++++++++++++--------------------- src/plugins/rest.js | 2 +- src/queue.js | 24 +- src/song.js | 10 +- 11 files changed, 460 insertions(+), 457 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 1f36657..5b610b2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -13,6 +13,7 @@ "quotes": ["error", "single"], "max-len": ["warn", 100, 2], "new-cap": ["error", { "properties": false }], - "object-curly-spacing": ["error", "always"] + "object-curly-spacing": ["error", "always"], + "arrow-parens": ["error", "as-needed"] } } diff --git a/src/backend.js b/src/backend.js index 9acf4c3..f377050 100644 --- a/src/backend.js +++ b/src/backend.js @@ -29,7 +29,7 @@ function Backend() { * @param {encodeCallback} callback - Called when song is ready or on error * @return {Function} - Can be called to terminate encoding */ -Backend.prototype.encodeSong = function(stream, seek, song, callback) { +Backend.prototype.encodeSong = (stream, seek, song, callback) => { var self = this; var encodedPath = path.join(config.songCachePath, self.name, @@ -42,14 +42,14 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { .audioCodec('libopus') .audioBitrate('192') .format('opus') - .on('error', function(err) { + .on('error', (err) => { self.log.error(self.name + ': error while transcoding ' + song.songId + ': ' + err); delete song.prepare.data; callback(err); }); var opusStream = command.pipe(null, { end: true }); - opusStream.on('data', function(chunk) { + opusStream.on('data', (chunk) => { // TODO: this could be optimized by using larger buffers // song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); @@ -95,7 +95,7 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { self.log.verbose('transcoding ' + song.songId + '...'); // return a function which can be used for terminating encoding - return function(err) { + return (err) => { command.kill(); self.log.verbose(self.name + ': canceled preparing: ' + song.songId + ': ' + err); delete song.prepare; @@ -107,7 +107,7 @@ Backend.prototype.encodeSong = function(stream, seek, song, callback) { * Cancel song preparation if applicable * @param {Song} song - Song to cancel */ -Backend.prototype.cancelPrepare = function(song) { +Backend.prototype.cancelPrepare = (song) => { if (this.songsPreparing[song.songId]) { this.log.info('Canceling song preparing: ' + song.songId); this.songsPreparing[song.songId].cancel(); @@ -130,7 +130,7 @@ Backend.prototype.cancelPrepare = function(song) { * @param {Song} song - Query concerns this song * @param {durationCallback} callback - Called with duration */ -Backend.prototype.getDuration = function(song, callback) { +Backend.prototype.getDuration = (song, callback) => { var err = 'FATAL: backend does not implement getDuration()!'; this.log.error(err); callback(err); @@ -141,7 +141,7 @@ Backend.prototype.getDuration = function(song, callback) { * @param {Song} song - Query concerns this song * @return {Boolean} - true if song is prepared, false if not */ -Backend.prototype.isPrepared = function(song) { +Backend.prototype.isPrepared = (song) => { this.log.error('FATAL: backend does not implement songPrepared()!'); return false; }; @@ -151,7 +151,7 @@ Backend.prototype.isPrepared = function(song) { * @param {Song} song - Song to prepare * @param {encodeCallback} callback - Called when song is ready or on error */ -Backend.prototype.prepare = function(song, callback) { +Backend.prototype.prepare = (song, callback) => { this.log.error('FATAL: backend does not implement prepare()!'); callback(new Error('FATAL: backend does not implement prepare()!')); }; @@ -165,7 +165,7 @@ Backend.prototype.prepare = function(song, callback) { * @param {Boolean} [query.any] - Match any of the above, otherwise all fields have to match * @param {Function} callback - Called with error or results */ -Backend.prototype.search = function(query, callback) { +Backend.prototype.search = (query, callback) => { this.log.error('FATAL: backend does not implement search()!'); callback(new Error('FATAL: backend does not implement search()!')); }; diff --git a/src/backends/local.js b/src/backends/local.js index 2bd83ff..77cd5d0 100644 --- a/src/backends/local.js +++ b/src/backends/local.js @@ -17,7 +17,7 @@ var Backend = require('../backend'); // jscs:disable requireCamelCaseOrUpperCaseIdentifiers /* -var probeCallback = function(err, probeData, next) { +var probeCallback = (err, probeData, next) => { var formats = config.importFormats; if (probeData) { // ignore camel case rule here as we can't do anything about probeData @@ -64,7 +64,7 @@ var probeCallback = function(err, probeData, next) { song.duration = probeData.format.duration * 1000; SongModel.update({file: probeData.file}, {'$set':song}, {upsert: true}, - function(err, result) { + (err, result) => { if (result == 1) { self.log.debug('Upserted: ' + probeData.file); } else { @@ -118,12 +118,12 @@ var SongModel = mongoose.model('Song', { * @param {String} fileExt - Filename extension * @return {Metadata} Song metadata */ -var guessMetadataFromPath = function(filePath, fileExt) { +var guessMetadataFromPath = (filePath, fileExt) => { var fileName = path.basename(filePath, fileExt); // split filename at dashes, trim extra whitespace, e.g: var splitName = fileName.split('-'); - splitName = _.map(splitName, function(name) { + splitName = _.map(splitName, (name) => { return name.trim(); }); @@ -153,10 +153,10 @@ function Local(callback) { mongoose.connect(config.mongo); var db = mongoose.connection; - db.on('error', function(err) { + db.on('error', (err) => { return callback(err, self); }); - db.once('open', function() { + db.once('open', () => { return callback(null, self); }); @@ -164,7 +164,7 @@ function Local(callback) { followLinks: config.followSymlinks, }; - var insertSong = function(probeData, done) { + var insertSong = (probeData, done) => { var guessMetadata = guessMetadataFromPath(probeData.file, probeData.fileext); var song = new SongModel({ @@ -183,7 +183,7 @@ function Local(callback) { SongModel.findOneAndUpdate({ filename: probeData.file, - }, song, { upsert: true }, function(err) { + }, song, { upsert: true }, (err) => { if (err) { self.log.error('while inserting song: ' + probeData.file + ', ' + err); } @@ -192,15 +192,15 @@ function Local(callback) { }; // create async.js queue to limit concurrent probes - var q = async.queue(function(task, done) { - ffprobe(task.filename, function(err, probeData) { + var q = async.queue((task, done) => { + ffprobe(task.filename, (err, probeData) => { if (!probeData) { return done(); } var validStreams = false; - if (_.contains(self.importFormats, probeData.format.format_name)) { + if (_.includes(self.importFormats, probeData.format.format_name)) { validStreams = true; } @@ -221,7 +221,7 @@ function Local(callback) { var walker = walk.walk(config.importPath, options); var startTime = new Date(); var scanned = 0; - walker.on('file', function(root, fileStats, next) { + walker.on('file', (root, fileStats, next) => { var filename = path.join(root, fileStats.name); self.log.verbose('Scanning: ' + filename); scanned++; @@ -230,7 +230,7 @@ function Local(callback) { }); next(); }); - walker.on('end', function() { + walker.on('end', () => { self.log.verbose('Scanned files: ' + scanned); self.log.verbose('Done in: ' + Math.round((new Date() - startTime) / 1000) + ' seconds'); @@ -244,7 +244,7 @@ function Local(callback) { watch(config.importPath, { recursive: true, followSymlinks: config.followSymlinks - }, function(filename) { + }, (filename) => { if (fs.existsSync(filename)) { self.log.debug(filename + ' modified or created, queued for probing'); q.unshift({ @@ -252,7 +252,7 @@ function Local(callback) { }); } else { self.log.debug(filename + ' deleted'); - db.collection('songs').remove({file: filename}, function(err, items) { + db.collection('songs').remove({file: filename}, (err, items) => { self.log.debug(filename + ' deleted from db: ' + err + ', ' + items); }); } @@ -263,7 +263,7 @@ function Local(callback) { // must be called immediately after constructor util.inherits(Local, Backend); -Local.prototype.isPrepared = function(song) { +Local.prototype.isPrepared = (song) => { var filePath = path.join(this.songCachePath, 'local', song.songId + '.opus'); return fs.existsSync(filePath); }; @@ -278,7 +278,7 @@ Local.prototype.getDuration = (song, callback) => { }); }; -Local.prototype.prepare = function(song, callback) { +Local.prototype.prepare = (song, callback) => { var self = this; // TODO: move most of this into common code inside core @@ -297,7 +297,7 @@ Local.prototype.prepare = function(song, callback) { song.prepare = { data: new Buffer.allocUnsafe(1024 * 1024), dataPos: 0, - cancel: function() { + cancel: () => { canceled = true; if (cancelEncode) { cancelEncode(); @@ -307,13 +307,13 @@ Local.prototype.prepare = function(song, callback) { self.songsPreparing[song.songId] = song; - SongModel.findById(song.songId, function(err, item) { + SongModel.findById(song.songId, (err, item) => { if (canceled) { callback(new Error('song was canceled before encoding started')); } else if (item) { var readStream = fs.createReadStream(item.filename); cancelEncode = self.encodeSong(readStream, 0, song, callback); - readStream.on('error', function(err) { + readStream.on('error', (err) => { callback(err); }); } else { @@ -323,7 +323,7 @@ Local.prototype.prepare = function(song, callback) { } }; -Local.prototype.search = function(query, callback) { +Local.prototype.search = (query, callback) => { var self = this; var q; @@ -340,14 +340,14 @@ Local.prototype.search = function(query, callback) { $and: [], }; - _.keys(query).forEach(function(key) { + _.keys(query).forEach((key) => { var criterion = {}; criterion[key] = new RegExp(escapeStringRegexp(query[key]), 'i'); q.$and.push(criterion); }); } - SongModel.find(q).exec(function(err, items) { + SongModel.find(q).exec((err, items) => { if (err) { return callback(err); } @@ -357,7 +357,7 @@ Local.prototype.search = function(query, callback) { var numItems = items.length; var cur = 0; - items.forEach(function(song) { + items.forEach((song) => { if (Object.keys(results.songs).length <= self.config.searchResultCnt) { song = song.toObject(); diff --git a/src/config.js b/src/config.js index 4172011..a9c6a34 100644 --- a/src/config.js +++ b/src/config.js @@ -75,12 +75,12 @@ defaultConfig.maxScore = 10; // FIXME: ATM the search algo can return VERY irrel // hostname of the server, may be used as a default value by other plugins defaultConfig.hostname = os.hostname(); -exports.getDefaultConfig = function() { +exports.getDefaultConfig = () => { return defaultConfig; }; // path and defaults are optional, if undefined then values corresponding to core config are used -exports.getConfig = function(module, defaults) { +exports.getConfig = (module, defaults) => { if (process.env.NODE_ENV === 'test') { // unit tests should always use default config return (defaults || defaultConfig); diff --git a/src/index.js b/src/index.js index 8d52e4e..119711a 100755 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -var Player = require('./player'); +import Player from './player'; var p = new Player(); p.init(); diff --git a/src/logger.js b/src/logger.js index 8971a50..8b1e748 100644 --- a/src/logger.js +++ b/src/logger.js @@ -2,7 +2,7 @@ var config = require('./config').getConfig(); var winston = require('winston'); -module.exports = function(label) { +module.exports = (label) => { return new (winston.Logger)({ transports: [ new (winston.transports.Console)({ diff --git a/src/modules.js b/src/modules.js index 2f2e8ed..07ed5f3 100644 --- a/src/modules.js +++ b/src/modules.js @@ -7,7 +7,7 @@ var BuiltinBackends = require('./backends'); var _ = require('lodash'); var logger = labeledLogger('modules'); -var checkModule = function(module) { +var checkModule = (module) => { try { require.resolve(module); return true; @@ -17,10 +17,10 @@ var checkModule = function(module) { }; // install a single module -var installModule = function(moduleName, callback) { +var installModule = (moduleName, callback) => { logger.info('installing module: ' + moduleName); - npm.load({}, function(err) { - npm.commands.install(__dirname, [moduleName], function(err) { + npm.load({}, (err) => { + npm.commands.install(__dirname, [moduleName], (err) => { if (err) { logger.error(moduleName + ' installation failed:', err); callback(); @@ -33,8 +33,8 @@ var installModule = function(moduleName, callback) { }; // make sure all modules are installed, installs missing ones, then calls done -var installModules = function(modules, moduleType, forceUpdate, done) { - async.eachSeries(modules, function(moduleShortName, callback) { +var installModules = (modules, moduleType, forceUpdate, done) => { + async.eachSeries(modules, (moduleShortName, callback) => { var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; if (!checkModule(moduleName) || forceUpdate) { // perform install / update @@ -47,11 +47,11 @@ var installModules = function(modules, moduleType, forceUpdate, done) { }; /* -var initModule = function(moduleShortName, moduleType, callback) { +var initModule = (moduleShortName, moduleType, callback) => { var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; var module = require(moduleName); - module.init(function(err) { + module.init((err) => { callback(err, module); }); }; @@ -59,18 +59,18 @@ var initModule = function(moduleShortName, moduleType, callback) { // TODO: this probably doesn't work // needs rewrite -exports.loadBackends = function(player, backends, forceUpdate, done) { +exports.loadBackends = (player, backends, forceUpdate, done) => { // first install missing backends - installModules(backends, 'backend', forceUpdate, function() { + installModules(backends, 'backend', forceUpdate, () => { // then initialize all backends in parallel - async.map(backends, function(backend, callback) { + async.map(backends, (backend, callback) => { var moduleLogger = labeledLogger(backend); var moduleName = 'nodeplayer-backend-' + backend; if (moduleName) { moduleLogger.verbose('initializing...'); var Module = require(moduleName); - var instance = new Module(function(err) { + var instance = new Module((err) => { if (err) { moduleLogger.error('while initializing: ' + err); callback(); @@ -85,7 +85,7 @@ exports.loadBackends = function(player, backends, forceUpdate, done) { moduleLogger.info('not loading backend: ' + backend); callback(); } - }, function(err, results) { + }, (err, results) => { logger.info('all backend modules initialized'); results = _.filter(results, _.identity); done(results); @@ -95,18 +95,18 @@ exports.loadBackends = function(player, backends, forceUpdate, done) { // TODO: this probably doesn't work // needs rewrite -exports.loadPlugins = function(player, plugins, forceUpdate, done) { +exports.loadPlugins = (player, plugins, forceUpdate, done) => { // first install missing plugins - installModules(plugins, 'plugin', forceUpdate, function() { + installModules(plugins, 'plugin', forceUpdate, () => { // then initialize all plugins in series - async.mapSeries(plugins, function(plugin, callback) { + async.mapSeries(plugins, (plugin, callback) => { var moduleLogger = labeledLogger(plugin); var moduleName = 'nodeplayer-plugin-' + plugin; if (checkModule(moduleName)) { moduleLogger.verbose('initializing...'); var Module = require(moduleName); - var instance = new Module(player, function(err) { + var instance = new Module(player, (err) => { if (err) { moduleLogger.error('while initializing: ' + err); callback(); @@ -121,7 +121,7 @@ exports.loadPlugins = function(player, plugins, forceUpdate, done) { moduleLogger.info('not loading plugin: ' + plugin); callback(); } - }, function(err, results) { + }, (err, results) => { logger.info('all plugin modules initialized'); results = _.filter(results, _.identity); done(results); @@ -129,9 +129,9 @@ exports.loadPlugins = function(player, plugins, forceUpdate, done) { }); }; -exports.loadBuiltinPlugins = function(player, done) { - async.mapSeries(BuiltinPlugins, function(Plugin, callback) { - return new Plugin(player, function(err, plugin) { +exports.loadBuiltinPlugins = (player, done) => { + async.mapSeries(BuiltinPlugins, (Plugin, callback) => { + return new Plugin(player, (err, plugin) => { if (err) { plugin.log.error('while initializing: ' + err); return callback(); @@ -141,14 +141,14 @@ exports.loadBuiltinPlugins = function(player, done) { player.callHooks('onPluginInitialized', [plugin.name]); callback(null, { [plugin.name]: plugin }); }); - }, function(err, results) { + }, (err, results) => { done(Object.assign({}, ...results)); }); }; -exports.loadBuiltinBackends = function(player, done) { - async.mapSeries(BuiltinBackends, function(Backend, callback) { - return new Backend(function(err, backend) { +exports.loadBuiltinBackends = (player, done) => { + async.mapSeries(BuiltinBackends, (Backend, callback) => { + return new Backend((err, backend) => { if (err) { backend.log.error('while initializing: ' + err); return callback(); @@ -158,7 +158,7 @@ exports.loadBuiltinBackends = function(player, done) { backend.log.verbose('backend initialized'); callback(null, { [backend.name]: backend }); }); - }, function(err, results) { + }, (err, results) => { done(Object.assign({}, ...results)); }); }; diff --git a/src/player.js b/src/player.js index 51dcbc1..cf9fda6 100644 --- a/src/player.js +++ b/src/player.js @@ -6,438 +6,440 @@ var labeledLogger = require('./logger'); var Queue = require('./queue'); var modules = require('./modules'); -function Player(options) { - options = options || {}; - - // TODO: some of these should NOT be loaded from config - _.bindAll.apply(_, [this].concat(_.functions(this))); - this.config = options.config || require('./config').getConfig(); - this.logger = options.logger || labeledLogger('core'); - this.queue = options.queue || new Queue(this); - this.nowPlaying = options.nowPlaying || null; - this.play = options.play || false; - this.repeat = options.repeat || false; - this.plugins = options.plugins || {}; - this.backends = options.backends || {}; - this.prepareTimeouts = options.prepareTimeouts || {}; - this.volume = options.volume || 1; - this.songEndTimeout = options.songEndTimeout || null; - this.pluginVars = options.pluginVars || {}; -} - -/** - * Initializes player - */ -Player.prototype.init = function() { - var player = this; - var config = player.config; - var forceUpdate = false; - - // initialize plugins & backends - async.series([ - function(callback) { - modules.loadBuiltinPlugins(player, function(plugins) { - player.plugins = plugins; - player.callHooks('onBuiltinPluginsInitialized'); - callback(); - }); - }, function(callback) { - modules.loadPlugins(player, config.plugins, forceUpdate, function(results) { - player.plugins = _.extend(player.plugins, results); - player.callHooks('onPluginsInitialized'); - callback(); - }); - }, function(callback) { - modules.loadBuiltinBackends(player, function(backends) { - player.backends = backends; - player.callHooks('onBuiltinBackendsInitialized'); - callback(); - }); - }, function(callback) { - modules.loadBackends(player, config.backends, forceUpdate, function(results) { - player.backends = _.extend(player.backends, results); - player.callHooks('onBackendsInitialized'); - callback(); - }); - }, - ], function() { - player.logger.info('ready'); - player.callHooks('onReady'); - }); -}; - -// call hook function in all modules -// if any hooks return a truthy value, it is an error and we abort -// be very careful with calling hooks from within a hook, infinite loops are possible -Player.prototype.callHooks = function(hook, argv) { - // _.find() used instead of _.each() because we want to break out as soon - // as a hook returns a truthy value (used to indicate an error, e.g. in form - // of a string) - var err = null; - - this.logger.silly('callHooks(' + hook + - (argv ? ', ' + util.inspect(argv) + ')' : ')')); - - _.find(this.plugins, function(plugin) { - if (plugin.hooks[hook]) { - var fun = plugin.hooks[hook]; - err = fun.apply(null, argv); - return err; - } - }); +export default class Player { + constructor(options) { + options = options || {}; + + // TODO: some of these should NOT be loaded from config + _.bindAll.apply(_, [this].concat(_.functions(this))); + this.config = options.config || require('./config').getConfig(); + this.logger = options.logger || labeledLogger('core'); + this.queue = options.queue || new Queue(this); + this.nowPlaying = options.nowPlaying || null; + this.play = options.play || false; + this.repeat = options.repeat || false; + this.plugins = options.plugins || {}; + this.backends = options.backends || {}; + this.prepareTimeouts = options.prepareTimeouts || {}; + this.volume = options.volume || 1; + this.songEndTimeout = options.songEndTimeout || null; + this.pluginVars = options.pluginVars || {}; + + this.init = this.init.bind(this); + } - return err; -}; + /** + * Initializes player + */ + init() { + var player = this; + var config = player.config; + var forceUpdate = false; + + // initialize plugins & backends + async.series([ + (callback) => { + modules.loadBuiltinPlugins(player, (plugins) => { + player.plugins = plugins; + player.callHooks('onBuiltinPluginsInitialized'); + callback(); + }); + }, (callback) => { + modules.loadPlugins(player, config.plugins, forceUpdate, (results) => { + player.plugins = _.extend(player.plugins, results); + player.callHooks('onPluginsInitialized'); + callback(); + }); + }, (callback) => { + modules.loadBuiltinBackends(player, (backends) => { + player.backends = backends; + player.callHooks('onBuiltinBackendsInitialized'); + callback(); + }); + }, (callback) => { + modules.loadBackends(player, config.backends, forceUpdate, (results) => { + player.backends = _.extend(player.backends, results); + player.callHooks('onBackendsInitialized'); + callback(); + }); + }, + ], () => { + player.logger.info('ready'); + player.callHooks('onReady'); + }); + } -// returns number of hook functions attached to given hook -Player.prototype.numHooks = function(hook) { - var cnt = 0; + // call hook function in all modules + // if any hooks return a truthy value, it is an error and we abort + // be very careful with calling hooks from within a hook, infinite loops are possible + callHooks(hook, argv) { + // _.find() used instead of _.each() because we want to break out as soon + // as a hook returns a truthy value (used to indicate an error, e.g. in form + // of a string) + var err = null; + + this.logger.silly('callHooks(' + hook + + (argv ? ', ' + util.inspect(argv) + ')' : ')')); + + _.find(this.plugins, (plugin) => { + if (plugin.hooks[hook]) { + var fun = plugin.hooks[hook]; + err = fun.apply(null, argv); + return err; + } + }); - _.find(this.plugins, function(plugin) { - if (plugin[hook]) { - cnt++; - } - }); - - return cnt; -}; - -/** - * Returns currently playing song - * @return {Song|null} - Song object, null if no now playing song - */ -Player.prototype.getNowPlaying = function() { - return this.nowPlaying; -}; - -// TODO: handling of pause in a good way? -/** - * Stop playback of current song - * @param {Boolean} [pause=false] - If true, don't reset song position - */ -Player.prototype.stopPlayback = function(pause) { - this.logger.info('playback ' + (pause ? 'paused.' : 'stopped.')); - - clearTimeout(this.songEndTimeout); - this.play = false; - - var np = this.nowPlaying; - var pos = np.playback.startPos + (new Date().getTime() - np.playback.startTime); - if (np) { - np.playback = { - startTime: 0, - startPos: pause ? pos : 0, - }; + return err; } -}; -/** - * Start playing now playing song, at optional position - * @param {Number} [position=0] - Position at which playback is started - * @throws {Error} if an error occurred - */ -Player.prototype.startPlayback = function(position) { - position = position || 0; - var player = this; + // returns number of hook functions attached to given hook + numHooks(hook) { + var cnt = 0; - if (!this.nowPlaying) { - // find first song in queue - this.nowPlaying = this.queue.songs[0]; + _.find(this.plugins, (plugin) => { + if (plugin[hook]) { + cnt++; + } + }); - if (!this.nowPlaying) { - throw new Error('queue is empty! not starting playback.'); - } + return cnt; + } + + /** + * Returns currently playing song + * @return {Song|null} - Song object, null if no now playing song + */ + getNowPlaying() { + return this.nowPlaying; } - this.nowPlaying.prepare(function(err) { - if (err) { - throw new Error('error while preparing now playing: ' + err); + // TODO: handling of pause in a good way? + /** + * Stop playback of current song + * @param {Boolean} [pause=false] - If true, don't reset song position + */ + stopPlayback(pause) { + this.logger.info('playback ' + (pause ? 'paused.' : 'stopped.')); + + clearTimeout(this.songEndTimeout); + this.play = false; + + var np = this.nowPlaying; + var pos = np.playback.startPos + (new Date().getTime() - np.playback.startTime); + if (np) { + np.playback = { + startTime: 0, + startPos: pause ? pos : 0, + }; } + } + + /** + * Start playing now playing song, at optional position + * @param {Number} [position=0] - Position at which playback is started + * @throws {Error} if an error occurred + */ + startPlayback(position) { + position = position || 0; + var player = this; - player.nowPlaying.playbackStarted(position || player.nowPlaying.playback.startPos); + if (!this.nowPlaying) { + // find first song in queue + this.nowPlaying = this.queue.songs[0]; - player.logger.info('playback started.'); - player.play = true; - }); -}; + if (!this.nowPlaying) { + throw new Error('queue is empty! not starting playback.'); + } + } -/** - * Change to song - * @param {String} uuid - UUID of song to change to, if not found in queue, now - * playing is removed, playback stopped - */ -Player.prototype.changeSong = function(uuid) { - this.logger.verbose('changing song to: ' + uuid); - clearTimeout(this.songEndTimeout); + this.nowPlaying.prepare((err) => { + if (err) { + throw new Error('error while preparing now playing: ' + err); + } - this.nowPlaying = this.queue.findSong(uuid); + player.nowPlaying.playbackStarted(position || player.nowPlaying.playback.startPos); - if (!this.nowPlaying) { - this.logger.info('song not found: ' + uuid); - this.stopPlayback(); + player.logger.info('playback started.'); + player.play = true; + }); } - this.startPlayback(); - this.logger.info('changed song to: ' + uuid); -}; + /** + * Change to song + * @param {String} uuid - UUID of song to change to, if not found in queue, now + * playing is removed, playback stopped + */ + changeSong(uuid) { + this.logger.verbose('changing song to: ' + uuid); + clearTimeout(this.songEndTimeout); + + this.nowPlaying = this.queue.findSong(uuid); + + if (!this.nowPlaying) { + this.logger.info('song not found: ' + uuid); + this.stopPlayback(); + } + + this.startPlayback(); + this.logger.info('changed song to: ' + uuid); + } -Player.prototype.songEnd = function() { - var np = this.getNowPlaying(); - var npIndex = np ? this.queue.findSongIndex(np.uuid) : -1; + songEnd() { + var np = this.getNowPlaying(); + var npIndex = np ? this.queue.findSongIndex(np.uuid) : -1; - this.logger.info('end of song ' + np.uuid); - this.callHooks('onSongEnd', [np]); + this.logger.info('end of song ' + np.uuid); + this.callHooks('onSongEnd', [np]); - var nextSong = this.queue.songs[npIndex + 1]; - if (nextSong) { - this.changeSong(nextSong.uuid); - } else { - this.logger.info('hit end of queue.'); + var nextSong = this.queue.songs[npIndex + 1]; + if (nextSong) { + this.changeSong(nextSong.uuid); + } else { + this.logger.info('hit end of queue.'); - if (this.repeat) { - this.logger.info('repeat is on, restarting playback from start of queue.'); - this.changeSong(this.queue.uuidAtIndex(0)); + if (this.repeat) { + this.logger.info('repeat is on, restarting playback from start of queue.'); + this.changeSong(this.queue.uuidAtIndex(0)); + } } + + this.prepareSongs(); } - this.prepareSongs(); -}; + // TODO: move these to song class? + setPrepareTimeout(song) { + var player = this; -// TODO: move these to song class? -Player.prototype.setPrepareTimeout = function(song) { - var player = this; + if (song.prepareTimeout) { + clearTimeout(song.prepareTimeout); + } - if (song.prepareTimeout) { - clearTimeout(song.prepareTimeout); + song.prepareTimeout = setTimeout(() => { + player.logger.info('prepare timeout for song: ' + song.songId + ', removing'); + song.cancelPrepare('prepare timeout'); + song.prepareTimeout = null; + }, this.config.songPrepareTimeout); + + Object.defineProperty(song, 'prepareTimeout', { + enumerable: false, + writable: true, + }); } - song.prepareTimeout = setTimeout(function() { - player.logger.info('prepare timeout for song: ' + song.songId + ', removing'); - song.cancelPrepare('prepare timeout'); + clearPrepareTimeout(song) { + // clear prepare timeout + clearTimeout(song.prepareTimeout); song.prepareTimeout = null; - }, this.config.songPrepareTimeout); - - Object.defineProperty(song, 'prepareTimeout', { - enumerable: false, - writable: true, - }); -}; - -Player.prototype.clearPrepareTimeout = function(song) { - // clear prepare timeout - clearTimeout(song.prepareTimeout); - song.prepareTimeout = null; -}; - -Player.prototype.prepareError = function(song, err) { - // TODO: mark song as failed - this.callHooks('onSongPrepareError', [song, err]); -}; - -Player.prototype.prepareProgCallback = function(song, bytesWritten, done) { - /* progress callback - * when this is called, new song data has been flushed to disk */ - - // start playback if it hasn't been started yet - if (this.play && this.getNowPlaying() && - this.getNowPlaying().uuid === song.uuid && - !this.queue.playbackStart && bytesWritten) { - this.startPlayback(); } - // tell plugins that new data is available for this song, and - // whether the song is now fully written to disk or not. - this.callHooks('onPrepareProgress', [song, bytesWritten, done]); + prepareError(song, err) { + // TODO: mark song as failed + this.callHooks('onSongPrepareError', [song, err]); + } - if (done) { - // mark song as prepared - this.callHooks('onSongPrepared', [song]); + prepareProgCallback(song, bytesWritten, done) { + /* progress callback + * when this is called, new song data has been flushed to disk */ - // done preparing, can't cancel anymore - delete (song.cancelPrepare); + // start playback if it hasn't been started yet + if (this.play && this.getNowPlaying() && + this.getNowPlaying().uuid === song.uuid && + !this.queue.playbackStart && bytesWritten) { + this.startPlayback(); + } - // song data should now be available on disk, don't keep it in memory - song.backend.songsPreparing[song.songId].songData = undefined; - delete (song.backend.songsPreparing[song.songId]); + // tell plugins that new data is available for this song, and + // whether the song is now fully written to disk or not. + this.callHooks('onPrepareProgress', [song, bytesWritten, done]); - // clear prepare timeout - this.clearPrepareTimeout(song); - } else { - // reset prepare timeout - this.setPrepareTimeout(song); - } -}; + if (done) { + // mark song as prepared + this.callHooks('onSongPrepared', [song]); + + // done preparing, can't cancel anymore + delete (song.cancelPrepare); -Player.prototype.prepareErrCallback = function(song, err, callback) { - /* error callback */ + // song data should now be available on disk, don't keep it in memory + song.backend.songsPreparing[song.songId].songData = undefined; + delete (song.backend.songsPreparing[song.songId]); - // don't let anything run cancelPrepare anymore - delete (song.cancelPrepare); + // clear prepare timeout + this.clearPrepareTimeout(song); + } else { + // reset prepare timeout + this.setPrepareTimeout(song); + } + } - this.clearPrepareTimeout(song); + prepareErrCallback(song, err, callback) { + /* error callback */ - // abort preparing more songs; current song will be deleted -> - // onQueueModified is called -> song preparation is triggered again - callback(true); + // don't let anything run cancelPrepare anymore + delete (song.cancelPrepare); - // TODO: investigate this, should probably be above callback - this.prepareError(song, err); + this.clearPrepareTimeout(song); - song.songData = undefined; - delete (this.songsPreparing[song.backend.name][song.songId]); -}; + // abort preparing more songs; current song will be deleted -> + // onQueueModified is called -> song preparation is triggered again + callback(true); -Player.prototype.prepareSong = function(song, callback) { - var self = this; + // TODO: investigate this, should probably be above callback + this.prepareError(song, err); - if (!song) { - throw new Error('prepareSong() without song'); + song.songData = undefined; + delete (this.songsPreparing[song.backend.name][song.songId]); } - if (song.isPrepared()) { - // start playback if it hasn't been started yet - if (this.play && this.getNowPlaying() && - this.getNowPlaying().uuid === song.uuid && - !this.queue.playbackStart) { - this.startPlayback(); - } + prepareSong(song, callback) { + var self = this; - // song is already prepared, ok to prepare more songs - callback(); - } else { - // song is not prepared and not currently preparing: let backend prepare it - this.logger.debug('DEBUG: prepareSong() ' + song.songId); + if (!song) { + throw new Error('prepareSong() without song'); + } - song.prepare(function(err, chunk, done) { - if (err) { - return callback(err); + if (song.isPrepared()) { + // start playback if it hasn't been started yet + if (this.play && this.getNowPlaying() && + this.getNowPlaying().uuid === song.uuid && + !this.queue.playbackStart) { + this.startPlayback(); } - if (chunk) { - self.prepareProgCallback(song, chunk, done); - } + // song is already prepared, ok to prepare more songs + callback(); + } else { + // song is not prepared and not currently preparing: let backend prepare it + this.logger.debug('DEBUG: prepareSong() ' + song.songId); - if (done) { - self.clearPrepareTimeout(song); - callback(); - } - }); + song.prepare((err, chunk, done) => { + if (err) { + return callback(err); + } - this.setPrepareTimeout(song); - } -}; - -/** - * Prepare now playing and next song for playback - */ -Player.prototype.prepareSongs = function() { - var player = this; - - var currentSong; - async.series([ - function(callback) { - // prepare now-playing song - currentSong = player.getNowPlaying(); - if (currentSong) { - player.prepareSong(currentSong, callback); - } else if (player.queue.getLength()) { - // songs exist in queue, prepare first one - currentSong = player.queue.songs[0]; - player.prepareSong(currentSong, callback); - } else { - // bail out - callback(true); - } - }, - function(callback) { - // prepare next song in playlist - var nextSong = player.queue.songs[player.queue.findSongIndex(currentSong) + 1]; - if (nextSong) { - player.prepareSong(nextSong, callback); - } else { - // bail out - callback(true); - } - }, - ]); - // TODO where to put this - // player.prepareErrCallback(); -}; - -Player.prototype.getPlaylists = function(callback) { - var resultCnt = 0; - var allResults = {}; - var player = this; - - _.each(this.backends, function(backend) { - if (!backend.getPlaylists) { - resultCnt++; - - // got results from all services? - if (resultCnt >= Object.keys(player.backends).length) { - callback(allResults); - } - return; + if (chunk) { + self.prepareProgCallback(song, chunk, done); + } + + if (done) { + self.clearPrepareTimeout(song); + callback(); + } + }); + + this.setPrepareTimeout(song); } + } + + /** + * Prepare now playing and next song for playback + */ + prepareSongs() { + var player = this; + + var currentSong; + async.series([ + (callback) => { + // prepare now-playing song + currentSong = player.getNowPlaying(); + if (currentSong) { + player.prepareSong(currentSong, callback); + } else if (player.queue.getLength()) { + // songs exist in queue, prepare first one + currentSong = player.queue.songs[0]; + player.prepareSong(currentSong, callback); + } else { + // bail out + callback(true); + } + }, + (callback) => { + // prepare next song in playlist + var nextSong = player.queue.songs[player.queue.findSongIndex(currentSong) + 1]; + if (nextSong) { + player.prepareSong(nextSong, callback); + } else { + // bail out + callback(true); + } + }, + ]); + // TODO where to put this + // player.prepareErrCallback(); + } - backend.getPlaylists(function(err, results) { - resultCnt++; + getPlaylists(callback) { + var resultCnt = 0; + var allResults = {}; + var player = this; - allResults[backend.name] = results; + _.each(this.backends, (backend) => { + if (!backend.getPlaylists) { + resultCnt++; - // got results from all services? - if (resultCnt >= Object.keys(player.backends).length) { - callback(allResults); + // got results from all services? + if (resultCnt >= Object.keys(player.backends).length) { + callback(allResults); + } + return; } + + backend.getPlaylists((err, results) => { + resultCnt++; + + allResults[backend.name] = results; + + // got results from all services? + if (resultCnt >= Object.keys(player.backends).length) { + callback(allResults); + } + }); }); - }); -}; - -// make a search query to backends -Player.prototype.searchBackends = function(query, callback) { - var resultCnt = 0; - var allResults = {}; - - _.each(this.backends, function(backend) { - backend.search(query, _.bind(function(results) { - resultCnt++; - - // make a temporary copy of songlist, clear songlist, check - // each song and add them again if they are ok - var tempSongs = _.clone(results.songs); - allResults[backend.name] = results; - allResults[backend.name].songs = {}; - - _.each(tempSongs, function(song) { - var err = this.callHooks('preAddSearchResult', [song]); - if (err) { - this.logger.error('preAddSearchResult hook error: ' + err); - } else { - allResults[backend.name].songs[song.songId] = song; + } + + // make a search query to backends + searchBackends(query, callback) { + var resultCnt = 0; + var allResults = {}; + + _.each(this.backends, (backend) => { + backend.search(query, _.bind((results) => { + resultCnt++; + + // make a temporary copy of songlist, clear songlist, check + // each song and add them again if they are ok + var tempSongs = _.clone(results.songs); + allResults[backend.name] = results; + allResults[backend.name].songs = {}; + + _.each(tempSongs, (song) => { + var err = this.callHooks('preAddSearchResult', [song]); + if (err) { + this.logger.error('preAddSearchResult hook error: ' + err); + } else { + allResults[backend.name].songs[song.songId] = song; + } + }, this); + + // got results from all services? + if (resultCnt >= Object.keys(this.backends).length) { + callback(allResults); } - }, this); + }, this), _.bind((err) => { + resultCnt++; + this.logger.error('error while searching ' + backend.name + ': ' + err); - // got results from all services? - if (resultCnt >= Object.keys(this.backends).length) { - callback(allResults); - } - }, this), _.bind(function(err) { - resultCnt++; - this.logger.error('error while searching ' + backend.name + ': ' + err); + // got results from all services? + if (resultCnt >= Object.keys(this.backends).length) { + callback(allResults); + } + }, this)); + }, this); + } - // got results from all services? - if (resultCnt >= Object.keys(this.backends).length) { - callback(allResults); - } - }, this)); - }, this); -}; - -// TODO: userID does not belong into core...? -Player.prototype.setVolume = function(newVol, userID) { - newVol = Math.min(1, Math.max(0, newVol)); - this.volume = newVol; - this.callHooks('onVolumeChange', [newVol, userID]); -}; - -module.exports = Player; + // TODO: userID does not belong into core...? + setVolume(newVol, userID) { + newVol = Math.min(1, Math.max(0, newVol)); + this.volume = newVol; + this.callHooks('onVolumeChange', [newVol, userID]); + } +} diff --git a/src/plugins/rest.js b/src/plugins/rest.js index ca65970..adea697 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -60,7 +60,7 @@ export default class Rest extends Plugin { }); /* - player.app.post('/queue/move/:pos', function(req, res) { + player.app.post('/queue/move/:pos', (req, res) => { var err = player.moveInQueue( Number(req.params.pos), Number(req.body.to), diff --git a/src/queue.js b/src/queue.js index 398cc1a..9f66741 100644 --- a/src/queue.js +++ b/src/queue.js @@ -23,8 +23,8 @@ function Queue(player) { * Get serialized list of songs in queue * @return {[SerializedSong]} - List of songs in serialized format */ -Queue.prototype.serialize = function() { - var serialized = _.map(this.songs, function(song) { +Queue.prototype.serialize = () => { + var serialized = _.map(this.songs, (song) => { return song.serialize(); }); @@ -36,8 +36,8 @@ Queue.prototype.serialize = function() { * @param {String} at - Look for song with this UUID * @return {Number} - Index of song, -1 if not found */ -Queue.prototype.findSongIndex = function(at) { - return _.findIndex(this.songs, function(song) { +Queue.prototype.findSongIndex = (at) => { + return _.findIndex(this.songs, (song) => { return song.uuid === at; }); }; @@ -47,8 +47,8 @@ Queue.prototype.findSongIndex = function(at) { * @param {String} at - Look for song with this UUID * @return {Song|null} - Song object, null if not found */ -Queue.prototype.findSong = function(at) { - return _.find(this.songs, function(song) { +Queue.prototype.findSong = (at) => { + return _.find(this.songs, (song) => { return song.uuid === at; }) || null; }; @@ -58,7 +58,7 @@ Queue.prototype.findSong = function(at) { * @param {Number} index - Look for song at this index * @return {String|null} - UUID, null if not found */ -Queue.prototype.uuidAtIndex = function(index) { +Queue.prototype.uuidAtIndex = (index) => { var song = this.songs[index]; return song ? song.uuid : null; }; @@ -67,7 +67,7 @@ Queue.prototype.uuidAtIndex = function(index) { * Returns queue length * @return {Number} - Queue length */ -Queue.prototype.getLength = function() { +Queue.prototype.getLength = () => { return this.songs.length; }; @@ -78,7 +78,7 @@ Queue.prototype.getLength = function() { * @param {Object[]} songs - List of songs to insert * @return {Error} - in case of errors */ -Queue.prototype.insertSongs = function(at, songs) { +Queue.prototype.insertSongs = (at, songs) => { var pos; if (at === null) { // insert at start of queue @@ -95,7 +95,7 @@ Queue.prototype.insertSongs = function(at, songs) { } // generate Song objects of each song - songs = _.map(songs, function(song) { + songs = _.map(songs, (song) => { // TODO: this would be best done in the song constructor, // effectively making it a SerializedSong object deserializer var backend = this.player.backends[song.backendName]; @@ -119,7 +119,7 @@ Queue.prototype.insertSongs = function(at, songs) { * @param {Number} cnt - Number of songs to delete * @return {Song[] | Error} - List of removed songs, Error in case of errors */ -Queue.prototype.removeSongs = function(at, cnt) { +Queue.prototype.removeSongs = (at, cnt) => { var pos = this.findSongIndex(at); if (pos < 0) { return 'Song with UUID ' + at + ' not found!'; @@ -155,7 +155,7 @@ Queue.prototype.removeSongs = function(at, cnt) { /** * Toggle queue shuffling */ -Queue.prototype.shuffle = function() { +Queue.prototype.shuffle = () => { if (this.unshuffledSongs) { // unshuffle diff --git a/src/song.js b/src/song.js index a6cef10..b531bdb 100644 --- a/src/song.js +++ b/src/song.js @@ -64,7 +64,7 @@ function Song(song, backend) { * Set playback status as started at specified optional position * @param {Number} [pos] - position to start playing at */ -Song.prototype.playbackStarted = function(pos) { +Song.prototype.playbackStarted = (pos) => { this.playback = { startTime: new Date(), startPos: pos || null, @@ -75,7 +75,7 @@ Song.prototype.playbackStarted = function(pos) { * Return serialized details of the song * @return {SerializedSong} - serialized Song object */ -Song.prototype.serialize = function() { +Song.prototype.serialize = () => { return { uuid: this.uuid, title: this.title, @@ -96,7 +96,7 @@ Song.prototype.serialize = function() { * Synchronously(!) returns whether the song is prepared or not * @return {Boolean} - true if song is prepared, false if not */ -Song.prototype.isPrepared = function() { +Song.prototype.isPrepared = () => { return this.backend.isPrepared(this); }; @@ -104,14 +104,14 @@ Song.prototype.isPrepared = function() { * Prepare song for playback * @param {encodeCallback} callback - Called when song is ready or on error */ -Song.prototype.prepare = function(callback) { +Song.prototype.prepare = (callback) => { this.backend.prepare(this, callback); }; /** * Cancel song preparation if applicable */ -Song.prototype.cancelPrepare = function() { +Song.prototype.cancelPrepare = () => { this.backend.cancelPrepare(this); }; From fd175a0084bcf5b310a7b90aaa85eab9901aea8b Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 5 Sep 2016 18:43:39 +0300 Subject: [PATCH 081/103] Refactor remaining prototypes to be ES6 classes --- src/backend.js | 307 +++++++++++++++--------------- src/backends/index.js | 4 +- src/backends/local.js | 428 +++++++++++++++++++++--------------------- src/player.js | 2 +- src/queue.js | 284 ++++++++++++++-------------- src/song.js | 176 ++++++++--------- 6 files changed, 599 insertions(+), 602 deletions(-) diff --git a/src/backend.js b/src/backend.js index f377050..30c88cd 100644 --- a/src/backend.js +++ b/src/backend.js @@ -7,167 +7,168 @@ var labeledLogger = require('./logger'); /** * Super constructor for backends */ -function Backend() { - this.name = this.constructor.name.toLowerCase(); - this.log = labeledLogger(this.name); - this.songsPreparing = {}; -} - -/** - * Callback for reporting encoding progress - * @callback encodeCallback - * @param {Error} err - If truthy, an error occurred and preparation cannot continue - * @param {Buffer} bytesWritten - How many new bytes was written to song.data - * @param {Bool} done - True if this was the last chunk - */ - -/** - * Encode stream as opus - * @param {Stream} stream - Input stream - * @param {Number} seek - Skip to this position in song (TODO) - * @param {Song} song - Song object whose audio is being encoded - * @param {encodeCallback} callback - Called when song is ready or on error - * @return {Function} - Can be called to terminate encoding - */ -Backend.prototype.encodeSong = (stream, seek, song, callback) => { - var self = this; - - var encodedPath = path.join(config.songCachePath, self.name, - song.songId + '.opus'); - - var command = ffmpeg(stream) - .noVideo() - // .inputFormat('mp3') - // .inputOption('-ac 2') - .audioCodec('libopus') - .audioBitrate('192') - .format('opus') - .on('error', (err) => { - self.log.error(self.name + ': error while transcoding ' + song.songId + ': ' + err); - delete song.prepare.data; - callback(err); - }); - - var opusStream = command.pipe(null, { end: true }); - opusStream.on('data', (chunk) => { - // TODO: this could be optimized by using larger buffers - // song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); - - if (chunk.length <= song.prepare.data.length - song.prepare.dataPos) { - // If there's room in the buffer, write chunk into it - chunk.copy(song.prepare.data, song.prepare.dataPos); - song.prepare.dataPos += chunk.length; - } else { - // Otherwise allocate more room, then copy chunk into buffer - - // Make absolutely sure that the chunk will fit inside new buffer - var newSize = Math.max(song.prepare.data.length * 2, - song.prepare.data.length + chunk.length); - - self.log.debug('Allocated new song data buffer of size: ' + newSize); - - var buf = new Buffer.allocUnsafe(newSize); - - song.prepare.data.copy(buf); - song.prepare.data = buf; - - chunk.copy(song.prepare.data, song.prepare.dataPos); - song.prepare.dataPos += chunk.length; - } - - callback(null, chunk.length, false); - }); - opusStream.on('end', () => { - fs.writeFile(encodedPath, song.prepare.data, err => { - self.log.verbose('transcoding ended for ' + song.songId); - - delete song.prepare; - // TODO: we don't know if transcoding ended successfully or not, - // and there might be a race condition between errCallback deleting - // the file and us trying to move it to the songCache - // TODO: is this still the case? - // (we no longer save incomplete files on disk) +export default class Backend { + constructor() { + this.name = this.constructor.name.toLowerCase(); + this.log = labeledLogger(this.name); + this.songsPreparing = {}; + } - callback(null, null, true); + /** + * Callback for reporting encoding progress + * @callback encodeCallback + * @param {Error} err - If truthy, an error occurred and preparation cannot continue + * @param {Buffer} bytesWritten - How many new bytes was written to song.data + * @param {Bool} done - True if this was the last chunk + */ + + /** + * Encode stream as opus + * @param {Stream} stream - Input stream + * @param {Number} seek - Skip to this position in song (TODO) + * @param {Song} song - Song object whose audio is being encoded + * @param {encodeCallback} callback - Called when song is ready or on error + * @return {Function} - Can be called to terminate encoding + */ + encodeSong(stream, seek, song, callback) { + var self = this; + + var encodedPath = path.join(config.songCachePath, self.name, + song.songId + '.opus'); + + var command = ffmpeg(stream) + .noVideo() + // .inputFormat('mp3') + // .inputOption('-ac 2') + .audioCodec('libopus') + .audioBitrate('192') + .format('opus') + .on('error', (err) => { + self.log.error(self.name + ': error while transcoding ' + song.songId + ': ' + err); + delete song.prepare.data; + callback(err); + }); + + var opusStream = command.pipe(null, { end: true }); + opusStream.on('data', (chunk) => { + // TODO: this could be optimized by using larger buffers + // song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); + + if (chunk.length <= song.prepare.data.length - song.prepare.dataPos) { + // If there's room in the buffer, write chunk into it + chunk.copy(song.prepare.data, song.prepare.dataPos); + song.prepare.dataPos += chunk.length; + } else { + // Otherwise allocate more room, then copy chunk into buffer + + // Make absolutely sure that the chunk will fit inside new buffer + var newSize = Math.max(song.prepare.data.length * 2, + song.prepare.data.length + chunk.length); + + self.log.debug('Allocated new song data buffer of size: ' + newSize); + + var buf = new Buffer.allocUnsafe(newSize); + + song.prepare.data.copy(buf); + song.prepare.data = buf; + + chunk.copy(song.prepare.data, song.prepare.dataPos); + song.prepare.dataPos += chunk.length; + } + + callback(null, chunk.length, false); + }); + opusStream.on('end', () => { + fs.writeFile(encodedPath, song.prepare.data, err => { + self.log.verbose('transcoding ended for ' + song.songId); + + delete song.prepare; + // TODO: we don't know if transcoding ended successfully or not, + // and there might be a race condition between errCallback deleting + // the file and us trying to move it to the songCache + // TODO: is this still the case? + // (we no longer save incomplete files on disk) + + callback(null, null, true); + }); }); - }); - - self.log.verbose('transcoding ' + song.songId + '...'); - // return a function which can be used for terminating encoding - return (err) => { - command.kill(); - self.log.verbose(self.name + ': canceled preparing: ' + song.songId + ': ' + err); - delete song.prepare; - callback(new Error('canceled preparing: ' + song.songId + ': ' + err)); - }; -}; + self.log.verbose('transcoding ' + song.songId + '...'); -/** - * Cancel song preparation if applicable - * @param {Song} song - Song to cancel - */ -Backend.prototype.cancelPrepare = (song) => { - if (this.songsPreparing[song.songId]) { - this.log.info('Canceling song preparing: ' + song.songId); - this.songsPreparing[song.songId].cancel(); - } else { - this.log.error('cancelPrepare() called on song not in preparation: ' + song.songId); + // return a function which can be used for terminating encoding + return (err) => { + command.kill(); + self.log.verbose(self.name + ': canceled preparing: ' + song.songId + ': ' + err); + delete song.prepare; + callback(new Error('canceled preparing: ' + song.songId + ': ' + err)); + }; } -}; - -// dummy functions -/** - * Callback for reporting song duration - * @callback durationCallback - * @param {Error} err - If truthy, an error occurred - * @param {Number} duration - Duration in milliseconds - */ + /** + * Cancel song preparation if applicable + * @param {Song} song - Song to cancel + */ + cancelPrepare(song) { + if (this.songsPreparing[song.songId]) { + this.log.info('Canceling song preparing: ' + song.songId); + this.songsPreparing[song.songId].cancel(); + } else { + this.log.error('cancelPrepare() called on song not in preparation: ' + song.songId); + } + } -/** - * Returns length of song - * @param {Song} song - Query concerns this song - * @param {durationCallback} callback - Called with duration - */ -Backend.prototype.getDuration = (song, callback) => { - var err = 'FATAL: backend does not implement getDuration()!'; - this.log.error(err); - callback(err); -}; + // dummy functions + + /** + * Callback for reporting song duration + * @callback durationCallback + * @param {Error} err - If truthy, an error occurred + * @param {Number} duration - Duration in milliseconds + */ + + /** + * Returns length of song + * @param {Song} song - Query concerns this song + * @param {durationCallback} callback - Called with duration + */ + getDuration(song, callback) { + var err = 'FATAL: backend does not implement getDuration()!'; + this.log.error(err); + callback(err); + } -/** - * Synchronously(!) returns whether the song with songId is prepared or not - * @param {Song} song - Query concerns this song - * @return {Boolean} - true if song is prepared, false if not - */ -Backend.prototype.isPrepared = (song) => { - this.log.error('FATAL: backend does not implement songPrepared()!'); - return false; -}; + /** + * Synchronously(!) returns whether the song with songId is prepared or not + * @param {Song} song - Query concerns this song + * @return {Boolean} - true if song is prepared, false if not + */ + isPrepared(song) { + this.log.error('FATAL: backend does not implement songPrepared()!'); + return false; + } -/** - * Prepare song for playback - * @param {Song} song - Song to prepare - * @param {encodeCallback} callback - Called when song is ready or on error - */ -Backend.prototype.prepare = (song, callback) => { - this.log.error('FATAL: backend does not implement prepare()!'); - callback(new Error('FATAL: backend does not implement prepare()!')); -}; + /** + * Prepare song for playback + * @param {Song} song - Song to prepare + * @param {encodeCallback} callback - Called when song is ready or on error + */ + prepare(song, callback) { + this.log.error('FATAL: backend does not implement prepare()!'); + callback(new Error('FATAL: backend does not implement prepare()!')); + } -/** - * Search for songs - * @param {Object} query - Search terms - * @param {String} [query.artist] - Artist - * @param {String} [query.title] - Title - * @param {String} [query.album] - Album - * @param {Boolean} [query.any] - Match any of the above, otherwise all fields have to match - * @param {Function} callback - Called with error or results - */ -Backend.prototype.search = (query, callback) => { - this.log.error('FATAL: backend does not implement search()!'); - callback(new Error('FATAL: backend does not implement search()!')); -}; + /** + * Search for songs + * @param {Object} query - Search terms + * @param {String} [query.artist] - Artist + * @param {String} [query.title] - Title + * @param {String} [query.album] - Album + * @param {Boolean} [query.any] - Match any of the above, otherwise all fields have to match + * @param {Function} callback - Called with error or results + */ + search(query, callback) { + this.log.error('FATAL: backend does not implement search()!'); + callback(new Error('FATAL: backend does not implement search()!')); + } +} -module.exports = Backend; diff --git a/src/backends/index.js b/src/backends/index.js index ef8282c..a90d62f 100644 --- a/src/backends/index.js +++ b/src/backends/index.js @@ -1,4 +1,6 @@ +import Local from './local'; + var Backends = []; -Backends.push(require('./local')); +Backends.push(Local); module.exports = Backends; diff --git a/src/backends/local.js b/src/backends/local.js index 77cd5d0..c21e305 100644 --- a/src/backends/local.js +++ b/src/backends/local.js @@ -11,10 +11,7 @@ var _ = require('lodash'); var escapeStringRegexp = require('escape-string-regexp'); var util = require('util'); -var Backend = require('../backend'); - -// external libraries use lower_case extensively here -// jscs:disable requireCamelCaseOrUpperCaseIdentifiers +import Backend from '../backend'; /* var probeCallback = (err, probeData, next) => { @@ -135,248 +132,245 @@ var guessMetadataFromPath = (filePath, fileExt) => { }; }; -function Local(callback) { - Backend.apply(this); +export default class Local extends Backend { + constructor(callback) { + super(); - var self = this; + var self = this; - // NOTE: no argument passed so we get the core's config - var config = require('../config').getConfig(); - this.config = config; - this.songCachePath = config.songCachePath; - this.importFormats = config.importFormats; + // NOTE: no argument passed so we get the core's config + var config = require('../config').getConfig(); + this.config = config; + this.songCachePath = config.songCachePath; + this.importFormats = config.importFormats; - // make sure all necessary directories exist - mkdirp.sync(path.join(this.songCachePath, 'local', 'incomplete')); + // make sure all necessary directories exist + mkdirp.sync(path.join(this.songCachePath, 'local', 'incomplete')); // connect to the database - mongoose.connect(config.mongo); - - var db = mongoose.connection; - db.on('error', (err) => { - return callback(err, self); - }); - db.once('open', () => { - return callback(null, self); - }); - - var options = { - followLinks: config.followSymlinks, - }; + mongoose.connect(config.mongo); - var insertSong = (probeData, done) => { - var guessMetadata = guessMetadataFromPath(probeData.file, probeData.fileext); - - var song = new SongModel({ - title: probeData.metadata.TITLE || guessMetadata.title, - artist: probeData.metadata.ARTIST || guessMetadata.artist, - album: probeData.metadata.ALBUM || guessMetadata.album, - // albumArt: {} // TODO - duration: probeData.format.duration * 1000, - format: probeData.format.format_name, - filename: probeData.file, + var db = mongoose.connection; + db.on('error', (err) => { + return callback(err, self); }); - - song = song.toObject(); - - delete song._id; - - SongModel.findOneAndUpdate({ - filename: probeData.file, - }, song, { upsert: true }, (err) => { - if (err) { - self.log.error('while inserting song: ' + probeData.file + ', ' + err); - } - done(); + db.once('open', () => { + return callback(null, self); }); - }; - // create async.js queue to limit concurrent probes - var q = async.queue((task, done) => { - ffprobe(task.filename, (err, probeData) => { - if (!probeData) { - return done(); - } + var options = { + followLinks: config.followSymlinks, + }; - var validStreams = false; + var insertSong = (probeData, done) => { + var guessMetadata = guessMetadataFromPath(probeData.file, probeData.fileext); + + var song = new SongModel({ + title: probeData.metadata.TITLE || guessMetadata.title, + artist: probeData.metadata.ARTIST || guessMetadata.artist, + album: probeData.metadata.ALBUM || guessMetadata.album, + // albumArt: {} // TODO + duration: probeData.format.duration * 1000, + format: probeData.format.format_name, + filename: probeData.file, + }); - if (_.includes(self.importFormats, probeData.format.format_name)) { - validStreams = true; - } + song = song.toObject(); + + delete song._id; - if (validStreams) { - insertSong(probeData, done); - } else { - self.log.info('skipping file of unknown format: ' + task.filename); + SongModel.findOneAndUpdate({ + filename: probeData.file, + }, song, { upsert: true }, (err) => { + if (err) { + self.log.error('while inserting song: ' + probeData.file + ', ' + err); + } done(); - } - }); - }, config.concurrentProbes); - - // walk the filesystem and scan files - // TODO: also check through entire DB to see that all files still exist on the filesystem - // TODO: filter by allowed filename extensions - if (config.rescanAtStart) { - self.log.info('Scanning directory: ' + config.importPath); - var walker = walk.walk(config.importPath, options); - var startTime = new Date(); - var scanned = 0; - walker.on('file', (root, fileStats, next) => { - var filename = path.join(root, fileStats.name); - self.log.verbose('Scanning: ' + filename); - scanned++; - q.push({ - filename: filename, }); - next(); - }); - walker.on('end', () => { - self.log.verbose('Scanned files: ' + scanned); - self.log.verbose('Done in: ' + - Math.round((new Date() - startTime) / 1000) + ' seconds'); - }); - } + }; - // TODO: fs watch - // set fs watcher on media directory - // TODO: add a debounce so if the file keeps changing we don't probe it multiple times - /* - watch(config.importPath, { - recursive: true, - followSymlinks: config.followSymlinks - }, (filename) => { - if (fs.existsSync(filename)) { - self.log.debug(filename + ' modified or created, queued for probing'); - q.unshift({ - filename: filename - }); - } else { - self.log.debug(filename + ' deleted'); - db.collection('songs').remove({file: filename}, (err, items) => { - self.log.debug(filename + ' deleted from db: ' + err + ', ' + items); - }); + // create async.js queue to limit concurrent probes + var q = async.queue((task, done) => { + ffprobe(task.filename, (err, probeData) => { + if (!probeData) { + return done(); } - }); - */ -} -// must be called immediately after constructor -util.inherits(Local, Backend); + var validStreams = false; -Local.prototype.isPrepared = (song) => { - var filePath = path.join(this.songCachePath, 'local', song.songId + '.opus'); - return fs.existsSync(filePath); -}; + if (_.includes(self.importFormats, probeData.format.format_name)) { + validStreams = true; + } -Local.prototype.getDuration = (song, callback) => { - SongModel.findById(song.songId, (err, item) => { - if (err) { - return callback(err); + if (validStreams) { + insertSong(probeData, done); + } else { + self.log.info('skipping file of unknown format: ' + task.filename); + done(); + } + }); + }, config.concurrentProbes); + + // walk the filesystem and scan files + // TODO: also check through entire DB to see that all files still exist on the filesystem + // TODO: filter by allowed filename extensions + if (config.rescanAtStart) { + self.log.info('Scanning directory: ' + config.importPath); + var walker = walk.walk(config.importPath, options); + var startTime = new Date(); + var scanned = 0; + walker.on('file', (root, fileStats, next) => { + var filename = path.join(root, fileStats.name); + self.log.verbose('Scanning: ' + filename); + scanned++; + q.push({ + filename: filename, + }); + next(); + }); + walker.on('end', () => { + self.log.verbose('Scanned files: ' + scanned); + self.log.verbose('Done in: ' + + Math.round((new Date() - startTime) / 1000) + ' seconds'); + }); } - callback(null, item.duration); - }); -}; - -Local.prototype.prepare = (song, callback) => { - var self = this; - - // TODO: move most of this into common code inside core - if (self.songsPreparing[song.songId]) { - // song is preparing, caller can drop this request (previous caller will take care of - // handling once preparation is finished) - callback(null, null, false); - } else if (self.isPrepared(song)) { - // song has already prepared, caller can start playing song - callback(null, null, true); - } else { - // begin preparing song - var cancelEncode = null; - var canceled = false; - - song.prepare = { - data: new Buffer.allocUnsafe(1024 * 1024), - dataPos: 0, - cancel: () => { - canceled = true; - if (cancelEncode) { - cancelEncode(); - } - }, - }; + // TODO: fs watch + // set fs watcher on media directory + // TODO: add a debounce so if the file keeps changing we don't probe it multiple times + /* + watch(config.importPath, { + recursive: true, + followSymlinks: config.followSymlinks + }, (filename) => { + if (fs.existsSync(filename)) { + self.log.debug(filename + ' modified or created, queued for probing'); + q.unshift({ + filename: filename + }); + } else { + self.log.debug(filename + ' deleted'); + db.collection('songs').remove({file: filename}, (err, items) => { + self.log.debug(filename + ' deleted from db: ' + err + ', ' + items); + }); + } + }); + */ + } - self.songsPreparing[song.songId] = song; + isPrepared(song) { + var filePath = path.join(this.songCachePath, 'local', song.songId + '.opus'); + return fs.existsSync(filePath); + }; + getDuration(song, callback) { SongModel.findById(song.songId, (err, item) => { - if (canceled) { - callback(new Error('song was canceled before encoding started')); - } else if (item) { - var readStream = fs.createReadStream(item.filename); - cancelEncode = self.encodeSong(readStream, 0, song, callback); - readStream.on('error', (err) => { - callback(err); - }); - } else { - callback(new Error('song not found in local db: ' + song.songId)); + if (err) { + return callback(err); } - }); - } -}; -Local.prototype.search = (query, callback) => { - var self = this; - - var q; - if (query.any) { - q = { - $or: [ - { artist: new RegExp(escapeStringRegexp(query.any), 'i') }, - { title: new RegExp(escapeStringRegexp(query.any), 'i') }, - { album: new RegExp(escapeStringRegexp(query.any), 'i') }, - ], - }; - } else { - q = { - $and: [], - }; - - _.keys(query).forEach((key) => { - var criterion = {}; - criterion[key] = new RegExp(escapeStringRegexp(query[key]), 'i'); - q.$and.push(criterion); + callback(null, item.duration); }); - } + }; - SongModel.find(q).exec((err, items) => { - if (err) { - return callback(err); + prepare(song, callback) { + var self = this; + + // TODO: move most of this into common code inside core + if (self.songsPreparing[song.songId]) { + // song is preparing, caller can drop this request (previous caller will take care of + // handling once preparation is finished) + callback(null, null, false); + } else if (self.isPrepared(song)) { + // song has already prepared, caller can start playing song + callback(null, null, true); + } else { + // begin preparing song + var cancelEncode = null; + var canceled = false; + + song.prepare = { + data: new Buffer.allocUnsafe(1024 * 1024), + dataPos: 0, + cancel: () => { + canceled = true; + if (cancelEncode) { + cancelEncode(); + } + }, + }; + + self.songsPreparing[song.songId] = song; + + SongModel.findById(song.songId, (err, item) => { + if (canceled) { + callback(new Error('song was canceled before encoding started')); + } else if (item) { + var readStream = fs.createReadStream(item.filename); + cancelEncode = self.encodeSong(readStream, 0, song, callback); + readStream.on('error', (err) => { + callback(err); + }); + } else { + callback(new Error('song not found in local db: ' + song.songId)); + } + }); + } + }; + + search(query, callback) { + var self = this; + + var q; + if (query.any) { + q = { + $or: [ + { artist: new RegExp(escapeStringRegexp(query.any), 'i') }, + { title: new RegExp(escapeStringRegexp(query.any), 'i') }, + { album: new RegExp(escapeStringRegexp(query.any), 'i') }, + ], + }; + } else { + q = { + $and: [], + }; + + _.keys(query).forEach((key) => { + var criterion = {}; + criterion[key] = new RegExp(escapeStringRegexp(query[key]), 'i'); + q.$and.push(criterion); + }); } - var results = {}; - results.songs = {}; - - var numItems = items.length; - var cur = 0; - items.forEach((song) => { - if (Object.keys(results.songs).length <= self.config.searchResultCnt) { - song = song.toObject(); - - results.songs[song._id] = { - artist: song.artist, - title: song.title, - album: song.album, - albumArt: null, // TODO: can we add this? - duration: song.duration, - songId: song._id, - score: self.config.maxScore * (numItems - cur) / numItems, - backendName: 'local', - format: 'opus', - }; - cur++; + SongModel.find(q).exec((err, items) => { + if (err) { + return callback(err); } - }); - callback(results); - }); -}; -module.exports = Local; + var results = {}; + results.songs = {}; + + var numItems = items.length; + var cur = 0; + items.forEach((song) => { + if (Object.keys(results.songs).length <= self.config.searchResultCnt) { + song = song.toObject(); + + results.songs[song._id] = { + artist: song.artist, + title: song.title, + album: song.album, + albumArt: null, // TODO: can we add this? + duration: song.duration, + songId: song._id, + score: self.config.maxScore * (numItems - cur) / numItems, + backendName: 'local', + format: 'opus', + }; + cur++; + } + }); + callback(results); + }); + }; +} diff --git a/src/player.js b/src/player.js index cf9fda6..3889631 100644 --- a/src/player.js +++ b/src/player.js @@ -3,7 +3,7 @@ var _ = require('lodash'); var async = require('async'); var util = require('util'); var labeledLogger = require('./logger'); -var Queue = require('./queue'); +import Queue from './queue'; var modules = require('./modules'); export default class Player { diff --git a/src/queue.js b/src/queue.js index 9f66741..ea0b159 100644 --- a/src/queue.js +++ b/src/queue.js @@ -1,178 +1,178 @@ var _ = require('lodash'); -var Song = require('./song'); +import Song from './song'; /** * Constructor * @param {Player} player - Parent player object reference * @throws {Error} in case of errors */ -function Queue(player) { - if (!player || !_.isObject(player)) { - throw new Error('Queue constructor called without player reference!'); - } - - this.unshuffledSongs = null; - this.songs = []; - this.player = player; -} - -// TODO: hooks -// TODO: moveSongs +export default class Queue { + constructor(player) { + if (!player || !_.isObject(player)) { + throw new Error('Queue constructor called without player reference!'); + } -/** - * Get serialized list of songs in queue - * @return {[SerializedSong]} - List of songs in serialized format - */ -Queue.prototype.serialize = () => { - var serialized = _.map(this.songs, (song) => { - return song.serialize(); - }); + this.unshuffledSongs = null; + this.songs = []; + this.player = player; + } - return serialized; -}; + // TODO: hooks + // TODO: moveSongs -/** - * Find index of song in queue - * @param {String} at - Look for song with this UUID - * @return {Number} - Index of song, -1 if not found - */ -Queue.prototype.findSongIndex = (at) => { - return _.findIndex(this.songs, (song) => { - return song.uuid === at; - }); -}; + /** + * Get serialized list of songs in queue + * @return {[SerializedSong]} - List of songs in serialized format + */ + serialize() { + var serialized = _.map(this.songs, (song) => { + return song.serialize(); + }); -/** - * Find song in queue - * @param {String} at - Look for song with this UUID - * @return {Song|null} - Song object, null if not found - */ -Queue.prototype.findSong = (at) => { - return _.find(this.songs, (song) => { - return song.uuid === at; - }) || null; -}; - -/** - * Find song UUID at given index - * @param {Number} index - Look for song at this index - * @return {String|null} - UUID, null if not found - */ -Queue.prototype.uuidAtIndex = (index) => { - var song = this.songs[index]; - return song ? song.uuid : null; -}; + return serialized; + } -/** - * Returns queue length - * @return {Number} - Queue length - */ -Queue.prototype.getLength = () => { - return this.songs.length; -}; + /** + * Find index of song in queue + * @param {String} at - Look for song with this UUID + * @return {Number} - Index of song, -1 if not found + */ + findSongIndex(at) { + return _.findIndex(this.songs, (song) => { + return song.uuid === at; + }); + } -/** - * Insert songs into queue - * @param {String | null} at - Insert songs after song with this UUID - * (null = start of queue) - * @param {Object[]} songs - List of songs to insert - * @return {Error} - in case of errors - */ -Queue.prototype.insertSongs = (at, songs) => { - var pos; - if (at === null) { - // insert at start of queue - pos = 0; - } else { - // insert song after song with UUID - pos = this.findSongIndex(at); + /** + * Find song in queue + * @param {String} at - Look for song with this UUID + * @return {Song|null} - Song object, null if not found + */ + findSong(at) { + return _.find(this.songs, (song) => { + return song.uuid === at; + }) || null; + } - if (pos < 0) { - return 'Song with UUID ' + at + ' not found!'; - } + /** + * Find song UUID at given index + * @param {Number} index - Look for song at this index + * @return {String|null} - UUID, null if not found + */ + uuidAtIndex(index) { + var song = this.songs[index]; + return song ? song.uuid : null; + } - pos++; // insert after song + /** + * Returns queue length + * @return {Number} - Queue length + */ + getLength() { + return this.songs.length; } - // generate Song objects of each song - songs = _.map(songs, (song) => { - // TODO: this would be best done in the song constructor, - // effectively making it a SerializedSong object deserializer - var backend = this.player.backends[song.backendName]; - if (!backend) { - throw new Error('Song constructor called with invalid backend: ' + song.backendName); + /** + * Insert songs into queue + * @param {String | null} at - Insert songs after song with this UUID + * (null = start of queue) + * @param {Object[]} songs - List of songs to insert + * @return {Error} - in case of errors + */ + insertSongs(at, songs) { + var pos; + if (at === null) { + // insert at start of queue + pos = 0; + } else { + // insert song after song with UUID + pos = this.findSongIndex(at); + + if (pos < 0) { + return 'Song with UUID ' + at + ' not found!'; + } + + pos++; // insert after song } - return new Song(song, backend); - }, this); + // generate Song objects of each song + songs = _.map(songs, (song) => { + // TODO: this would be best done in the song constructor, + // effectively making it a SerializedSong object deserializer + var backend = this.player.backends[song.backendName]; + if (!backend) { + throw new Error('Song constructor called with invalid backend: ' + song.backendName); + } - // perform insertion - var args = [pos, 0].concat(songs); - Array.prototype.splice.apply(this.songs, args); + return new Song(song, backend); + }, this); - this.player.prepareSongs(); -}; + // perform insertion + var args = [pos, 0].concat(songs); + Array.prototype.splice.apply(this.songs, args); -/** - * Removes songs from queue - * @param {String} at - Start removing at song with this UUID - * @param {Number} cnt - Number of songs to delete - * @return {Song[] | Error} - List of removed songs, Error in case of errors - */ -Queue.prototype.removeSongs = (at, cnt) => { - var pos = this.findSongIndex(at); - if (pos < 0) { - return 'Song with UUID ' + at + ' not found!'; + this.player.prepareSongs(); } - // cancel preparing all songs to be deleted - for (var i = pos; i < pos + cnt && i < this.songs.length; i++) { - var song = this.songs[i]; - if (song.cancelPrepare) { - song.cancelPrepare('Song removed.'); + /** + * Removes songs from queue + * @param {String} at - Start removing at song with this UUID + * @param {Number} cnt - Number of songs to delete + * @return {Song[] | Error} - List of removed songs, Error in case of errors + */ + removeSongs(at, cnt) { + var pos = this.findSongIndex(at); + if (pos < 0) { + return 'Song with UUID ' + at + ' not found!'; } - } - // store index of now playing song - var np = this.player.nowPlaying; - var npIndex = np ? this.findSongIndex(np.uuid) : -1; + // cancel preparing all songs to be deleted + for (var i = pos; i < pos + cnt && i < this.songs.length; i++) { + var song = this.songs[i]; + if (song.cancelPrepare) { + song.cancelPrepare('Song removed.'); + } + } - // perform deletion - var removed = this.songs.splice(pos, cnt); + // store index of now playing song + var np = this.player.nowPlaying; + var npIndex = np ? this.findSongIndex(np.uuid) : -1; - // was now playing removed? - if (pos <= npIndex && pos + cnt >= npIndex) { - // change to first song after splice - var newNp = this.songs[pos]; - this.player.changeSong(newNp ? newNp.uuid : null); - } else { - this.player.prepareSongs(); - } + // perform deletion + var removed = this.songs.splice(pos, cnt); - return removed; -}; + // was now playing removed? + if (pos <= npIndex && pos + cnt >= npIndex) { + // change to first song after splice + var newNp = this.songs[pos]; + this.player.changeSong(newNp ? newNp.uuid : null); + } else { + this.player.prepareSongs(); + } -/** - * Toggle queue shuffling - */ -Queue.prototype.shuffle = () => { - if (this.unshuffledSongs) { - // unshuffle + return removed; + } - // restore unshuffled list - this.songs = this.unshuffledSongs; + /** + * Toggle queue shuffling + */ + shuffle() { + if (this.unshuffledSongs) { + // unshuffle - this.unshuffledSongs = null; - } else { - // shuffle + // restore unshuffled list + this.songs = this.unshuffledSongs; - // store copy of current songs array - this.unshuffledSongs = this.songs.slice(); + this.unshuffledSongs = null; + } else { + // shuffle - this.songs = _.shuffle(this.songs); - } + // store copy of current songs array + this.unshuffledSongs = this.songs.slice(); - this.player.prepareSongs(); -}; + this.songs = _.shuffle(this.songs); + } -module.exports = Queue; + this.player.prepareSongs(); + } +} diff --git a/src/song.js b/src/song.js index b531bdb..5987cfe 100644 --- a/src/song.js +++ b/src/song.js @@ -7,46 +7,47 @@ var uuid = require('node-uuid'); * @param {Backend} backend - Backend providing the audio * @throws {Error} in case of errors */ -function Song(song, backend) { +export default class Song { + constructor(song, backend) { // make sure we have a reference to backend - if (!backend || !_.isObject(backend)) { - throw new Error('Song constructor called with invalid backend: ' + backend); - } + if (!backend || !_.isObject(backend)) { + throw new Error('Song constructor called with invalid backend: ' + backend); + } - if (!song.duration || !_.isNumber(song.duration)) { - throw new Error('Song constructor called without duration!'); - } - if (!song.title || !_.isString(song.title)) { - throw new Error('Song constructor called without title!'); - } - if (!song.songId || !_.isString(song.songId)) { - throw new Error('Song constructor called without songId!'); - } - if (!song.score || !_.isNumber(song.score)) { - throw new Error('Song constructor called without score!'); - } - if (!song.format || !_.isString(song.format)) { - throw new Error('Song constructor called without format!'); - } + if (!song.duration || !_.isNumber(song.duration)) { + throw new Error('Song constructor called without duration!'); + } + if (!song.title || !_.isString(song.title)) { + throw new Error('Song constructor called without title!'); + } + if (!song.songId || !_.isString(song.songId)) { + throw new Error('Song constructor called without songId!'); + } + if (!song.score || !_.isNumber(song.score)) { + throw new Error('Song constructor called without score!'); + } + if (!song.format || !_.isString(song.format)) { + throw new Error('Song constructor called without format!'); + } - this.uuid = uuid.v4(); + this.uuid = uuid.v4(); - this.title = song.title; - this.artist = song.artist; - this.album = song.album; - this.albumArt = { - lq: song.albumArt ? song.albumArt.lq : null, - hq: song.albumArt ? song.albumArt.hq : null, - }; - this.duration = song.duration; - this.songId = song.songId; - this.score = song.score; - this.format = song.format; + this.title = song.title; + this.artist = song.artist; + this.album = song.album; + this.albumArt = { + lq: song.albumArt ? song.albumArt.lq : null, + hq: song.albumArt ? song.albumArt.hq : null, + }; + this.duration = song.duration; + this.songId = song.songId; + this.score = song.score; + this.format = song.format; - this.playback = { - startTime: null, - startPos: null, - }; + this.playback = { + startTime: null, + startPos: null, + }; // NOTE: internally to the Song we store a reference to the backend. // However when accessing the Song from the outside, we return only the @@ -54,65 +55,64 @@ function Song(song, backend) { // // Any functions requiring access to the backend should be implemented as // members of the Song (e.g. isPrepared, prepareSong) - this.backend = backend; + this.backend = backend; // optional fields - this.playlist = song.playlist; -} + this.playlist = song.playlist; + } -/** - * Set playback status as started at specified optional position - * @param {Number} [pos] - position to start playing at - */ -Song.prototype.playbackStarted = (pos) => { - this.playback = { - startTime: new Date(), - startPos: pos || null, + /** + * Set playback status as started at specified optional position + * @param {Number} [pos] - position to start playing at + */ + playbackStarted(pos) { + this.playback = { + startTime: new Date(), + startPos: pos || null, + }; }; -}; -/** - * Return serialized details of the song - * @return {SerializedSong} - serialized Song object - */ -Song.prototype.serialize = () => { - return { - uuid: this.uuid, - title: this.title, - artist: this.artist, - album: this.album, - albumArt: this.albumArt, - duration: this.duration, - songId: this.songId, - score: this.score, - format: this.format, - backendName: this.backend.name, - playlist: this.playlist, - playback: this.playback, + /** + * Return serialized details of the song + * @return {SerializedSong} - serialized Song object + */ + serialize() { + return { + uuid: this.uuid, + title: this.title, + artist: this.artist, + album: this.album, + albumArt: this.albumArt, + duration: this.duration, + songId: this.songId, + score: this.score, + format: this.format, + backendName: this.backend.name, + playlist: this.playlist, + playback: this.playback, + }; }; -}; - -/** - * Synchronously(!) returns whether the song is prepared or not - * @return {Boolean} - true if song is prepared, false if not - */ -Song.prototype.isPrepared = () => { - return this.backend.isPrepared(this); -}; -/** - * Prepare song for playback - * @param {encodeCallback} callback - Called when song is ready or on error - */ -Song.prototype.prepare = (callback) => { - this.backend.prepare(this, callback); -}; + /** + * Synchronously(!) returns whether the song is prepared or not + * @return {Boolean} - true if song is prepared, false if not + */ + isPrepared() { + return this.backend.isPrepared(this); + }; -/** - * Cancel song preparation if applicable - */ -Song.prototype.cancelPrepare = () => { - this.backend.cancelPrepare(this); -}; + /** + * Prepare song for playback + * @param {encodeCallback} callback - Called when song is ready or on error + */ + prepare(callback) { + this.backend.prepare(this, callback); + }; -module.exports = Song; + /** + * Cancel song preparation if applicable + */ + cancelPrepare() { + this.backend.cancelPrepare(this); + } +} From f2f1a9c70a7930ee0db1bf5d66da03f0a6d517a3 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 5 Sep 2016 18:53:01 +0300 Subject: [PATCH 082/103] Disallow var keyword --- .eslintrc.json | 3 +- src/backend.js | 30 ++++++------- src/backends/index.js | 2 +- src/backends/local.js | 97 +++++++++++++++++++++--------------------- src/config.js | 20 ++++----- src/index.js | 2 +- src/logger.js | 6 +-- src/modules.js | 42 +++++++++--------- src/player.js | 94 ++++++++++++++++++++-------------------- src/plugins/express.js | 18 ++++---- src/plugins/index.js | 2 +- src/plugins/rest.js | 42 +++++++++--------- src/queue.js | 32 +++++++------- src/song.js | 12 +++--- test/eslint.spec.js | 4 +- 15 files changed, 203 insertions(+), 203 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 5b610b2..7ac18a4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,6 +14,7 @@ "max-len": ["warn", 100, 2], "new-cap": ["error", { "properties": false }], "object-curly-spacing": ["error", "always"], - "arrow-parens": ["error", "as-needed"] + "arrow-parens": ["error", "as-needed"], + "no-var": ["error"] } } diff --git a/src/backend.js b/src/backend.js index 30c88cd..5230e0c 100644 --- a/src/backend.js +++ b/src/backend.js @@ -1,8 +1,8 @@ -var path = require('path'); -var fs = require('fs'); -var ffmpeg = require('fluent-ffmpeg'); -var config = require('./config').getConfig(); -var labeledLogger = require('./logger'); +let path = require('path'); +let fs = require('fs'); +let ffmpeg = require('fluent-ffmpeg'); +let config = require('./config').getConfig(); +let labeledLogger = require('./logger'); /** * Super constructor for backends @@ -31,26 +31,26 @@ export default class Backend { * @return {Function} - Can be called to terminate encoding */ encodeSong(stream, seek, song, callback) { - var self = this; + let self = this; - var encodedPath = path.join(config.songCachePath, self.name, + let encodedPath = path.join(config.songCachePath, self.name, song.songId + '.opus'); - var command = ffmpeg(stream) + let command = ffmpeg(stream) .noVideo() // .inputFormat('mp3') // .inputOption('-ac 2') .audioCodec('libopus') .audioBitrate('192') .format('opus') - .on('error', (err) => { + .on('error', err => { self.log.error(self.name + ': error while transcoding ' + song.songId + ': ' + err); delete song.prepare.data; callback(err); }); - var opusStream = command.pipe(null, { end: true }); - opusStream.on('data', (chunk) => { + let opusStream = command.pipe(null, { end: true }); + opusStream.on('data', chunk => { // TODO: this could be optimized by using larger buffers // song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); @@ -62,12 +62,12 @@ export default class Backend { // Otherwise allocate more room, then copy chunk into buffer // Make absolutely sure that the chunk will fit inside new buffer - var newSize = Math.max(song.prepare.data.length * 2, + let newSize = Math.max(song.prepare.data.length * 2, song.prepare.data.length + chunk.length); self.log.debug('Allocated new song data buffer of size: ' + newSize); - var buf = new Buffer.allocUnsafe(newSize); + let buf = new Buffer.allocUnsafe(newSize); song.prepare.data.copy(buf); song.prepare.data = buf; @@ -96,7 +96,7 @@ export default class Backend { self.log.verbose('transcoding ' + song.songId + '...'); // return a function which can be used for terminating encoding - return (err) => { + return err => { command.kill(); self.log.verbose(self.name + ': canceled preparing: ' + song.songId + ': ' + err); delete song.prepare; @@ -132,7 +132,7 @@ export default class Backend { * @param {durationCallback} callback - Called with duration */ getDuration(song, callback) { - var err = 'FATAL: backend does not implement getDuration()!'; + let err = 'FATAL: backend does not implement getDuration()!'; this.log.error(err); callback(err); } diff --git a/src/backends/index.js b/src/backends/index.js index a90d62f..170f7de 100644 --- a/src/backends/index.js +++ b/src/backends/index.js @@ -1,6 +1,6 @@ import Local from './local'; -var Backends = []; +let Backends = []; Backends.push(Local); module.exports = Backends; diff --git a/src/backends/local.js b/src/backends/local.js index c21e305..b2756bd 100644 --- a/src/backends/local.js +++ b/src/backends/local.js @@ -1,16 +1,15 @@ 'use strict'; -var path = require('path'); -var fs = require('fs'); -var mkdirp = require('mkdirp'); -var mongoose = require('mongoose'); -var async = require('async'); -var walk = require('walk'); -var ffprobe = require('node-ffprobe'); -var _ = require('lodash'); -var escapeStringRegexp = require('escape-string-regexp'); - -var util = require('util'); +let path = require('path'); +let fs = require('fs'); +let mkdirp = require('mkdirp'); +let mongoose = require('mongoose'); +let async = require('async'); +let walk = require('walk'); +let ffprobe = require('node-ffprobe'); +let _ = require('lodash'); +let escapeStringRegexp = require('escape-string-regexp'); + import Backend from '../backend'; /* @@ -82,7 +81,7 @@ var probeCallback = (err, probeData, next) => { */ // database model -var SongModel = mongoose.model('Song', { +let SongModel = mongoose.model('Song', { title: String, artist: String, album: String, @@ -115,12 +114,12 @@ var SongModel = mongoose.model('Song', { * @param {String} fileExt - Filename extension * @return {Metadata} Song metadata */ -var guessMetadataFromPath = (filePath, fileExt) => { - var fileName = path.basename(filePath, fileExt); +let guessMetadataFromPath = (filePath, fileExt) => { + let fileName = path.basename(filePath, fileExt); // split filename at dashes, trim extra whitespace, e.g: - var splitName = fileName.split('-'); - splitName = _.map(splitName, (name) => { + let splitName = fileName.split('-'); + splitName = _.map(splitName, name => { return name.trim(); }); @@ -136,10 +135,10 @@ export default class Local extends Backend { constructor(callback) { super(); - var self = this; + let self = this; // NOTE: no argument passed so we get the core's config - var config = require('../config').getConfig(); + let config = require('../config').getConfig(); this.config = config; this.songCachePath = config.songCachePath; this.importFormats = config.importFormats; @@ -150,22 +149,22 @@ export default class Local extends Backend { // connect to the database mongoose.connect(config.mongo); - var db = mongoose.connection; - db.on('error', (err) => { + let db = mongoose.connection; + db.on('error', err => { return callback(err, self); }); db.once('open', () => { return callback(null, self); }); - var options = { + let options = { followLinks: config.followSymlinks, }; - var insertSong = (probeData, done) => { - var guessMetadata = guessMetadataFromPath(probeData.file, probeData.fileext); + let insertSong = (probeData, done) => { + let guessMetadata = guessMetadataFromPath(probeData.file, probeData.fileext); - var song = new SongModel({ + let song = new SongModel({ title: probeData.metadata.TITLE || guessMetadata.title, artist: probeData.metadata.ARTIST || guessMetadata.artist, album: probeData.metadata.ALBUM || guessMetadata.album, @@ -181,7 +180,7 @@ export default class Local extends Backend { SongModel.findOneAndUpdate({ filename: probeData.file, - }, song, { upsert: true }, (err) => { + }, song, { upsert: true }, err => { if (err) { self.log.error('while inserting song: ' + probeData.file + ', ' + err); } @@ -190,13 +189,13 @@ export default class Local extends Backend { }; // create async.js queue to limit concurrent probes - var q = async.queue((task, done) => { + let q = async.queue((task, done) => { ffprobe(task.filename, (err, probeData) => { if (!probeData) { return done(); } - var validStreams = false; + let validStreams = false; if (_.includes(self.importFormats, probeData.format.format_name)) { validStreams = true; @@ -216,11 +215,11 @@ export default class Local extends Backend { // TODO: filter by allowed filename extensions if (config.rescanAtStart) { self.log.info('Scanning directory: ' + config.importPath); - var walker = walk.walk(config.importPath, options); - var startTime = new Date(); - var scanned = 0; + let walker = walk.walk(config.importPath, options); + let startTime = new Date(); + let scanned = 0; walker.on('file', (root, fileStats, next) => { - var filename = path.join(root, fileStats.name); + let filename = path.join(root, fileStats.name); self.log.verbose('Scanning: ' + filename); scanned++; q.push({ @@ -259,9 +258,9 @@ export default class Local extends Backend { } isPrepared(song) { - var filePath = path.join(this.songCachePath, 'local', song.songId + '.opus'); + let filePath = path.join(this.songCachePath, 'local', song.songId + '.opus'); return fs.existsSync(filePath); - }; + } getDuration(song, callback) { SongModel.findById(song.songId, (err, item) => { @@ -271,10 +270,10 @@ export default class Local extends Backend { callback(null, item.duration); }); - }; + } prepare(song, callback) { - var self = this; + let self = this; // TODO: move most of this into common code inside core if (self.songsPreparing[song.songId]) { @@ -286,8 +285,8 @@ export default class Local extends Backend { callback(null, null, true); } else { // begin preparing song - var cancelEncode = null; - var canceled = false; + let cancelEncode = null; + let canceled = false; song.prepare = { data: new Buffer.allocUnsafe(1024 * 1024), @@ -306,9 +305,9 @@ export default class Local extends Backend { if (canceled) { callback(new Error('song was canceled before encoding started')); } else if (item) { - var readStream = fs.createReadStream(item.filename); + let readStream = fs.createReadStream(item.filename); cancelEncode = self.encodeSong(readStream, 0, song, callback); - readStream.on('error', (err) => { + readStream.on('error', err => { callback(err); }); } else { @@ -316,12 +315,12 @@ export default class Local extends Backend { } }); } - }; + } search(query, callback) { - var self = this; + let self = this; - var q; + let q; if (query.any) { q = { $or: [ @@ -335,8 +334,8 @@ export default class Local extends Backend { $and: [], }; - _.keys(query).forEach((key) => { - var criterion = {}; + _.keys(query).forEach(key => { + let criterion = {}; criterion[key] = new RegExp(escapeStringRegexp(query[key]), 'i'); q.$and.push(criterion); }); @@ -347,12 +346,12 @@ export default class Local extends Backend { return callback(err); } - var results = {}; + let results = {}; results.songs = {}; - var numItems = items.length; - var cur = 0; - items.forEach((song) => { + let numItems = items.length; + let cur = 0; + items.forEach(song => { if (Object.keys(results.songs).length <= self.config.searchResultCnt) { song = song.toObject(); @@ -372,5 +371,5 @@ export default class Local extends Backend { }); callback(results); }); - }; + } } diff --git a/src/config.js b/src/config.js index a9c6a34..6249b06 100644 --- a/src/config.js +++ b/src/config.js @@ -1,8 +1,8 @@ -var _ = require('lodash'); -var mkdirp = require('mkdirp'); -var fs = require('fs'); -var os = require('os'); -var path = require('path'); +let _ = require('lodash'); +let mkdirp = require('mkdirp'); +let fs = require('fs'); +let os = require('os'); +let path = require('path'); function getHomeDir() { if (process.platform === 'win32') { @@ -22,7 +22,7 @@ function getBaseDir() { } exports.getBaseDir = getBaseDir; -var defaultConfig = {}; +let defaultConfig = {}; // backends are sources of music defaultConfig.backends = [ @@ -86,13 +86,13 @@ exports.getConfig = (module, defaults) => { return (defaults || defaultConfig); } - var moduleName = module ? module.name : null; + let moduleName = module ? module.name : null; - var configPath = path.join(getBaseDir(), 'config', (moduleName || 'core') + '.json'); + let configPath = path.join(getBaseDir(), 'config', (moduleName || 'core') + '.json'); try { - var userConfig = require(configPath); - var config = _.defaults(userConfig, defaults || defaultConfig); + let userConfig = require(configPath); + let config = _.defaults(userConfig, defaults || defaultConfig); return config; } catch (e) { if (e.code === 'MODULE_NOT_FOUND') { diff --git a/src/index.js b/src/index.js index 119711a..a66595f 100755 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import Player from './player'; -var p = new Player(); +let p = new Player(); p.init(); diff --git a/src/logger.js b/src/logger.js index 8b1e748..d2d0698 100644 --- a/src/logger.js +++ b/src/logger.js @@ -1,8 +1,8 @@ 'use strict'; -var config = require('./config').getConfig(); -var winston = require('winston'); +let config = require('./config').getConfig(); +let winston = require('winston'); -module.exports = (label) => { +module.exports = label => { return new (winston.Logger)({ transports: [ new (winston.transports.Console)({ diff --git a/src/modules.js b/src/modules.js index 07ed5f3..ebb52fe 100644 --- a/src/modules.js +++ b/src/modules.js @@ -1,13 +1,13 @@ -var npm = require('npm'); -var async = require('async'); -var labeledLogger = require('./logger'); -var BuiltinPlugins = require('./plugins'); -var BuiltinBackends = require('./backends'); +let npm = require('npm'); +let async = require('async'); +let labeledLogger = require('./logger'); +let BuiltinPlugins = require('./plugins'); +let BuiltinBackends = require('./backends'); -var _ = require('lodash'); -var logger = labeledLogger('modules'); +let _ = require('lodash'); +let logger = labeledLogger('modules'); -var checkModule = (module) => { +let checkModule = module => { try { require.resolve(module); return true; @@ -17,10 +17,10 @@ var checkModule = (module) => { }; // install a single module -var installModule = (moduleName, callback) => { +let installModule = (moduleName, callback) => { logger.info('installing module: ' + moduleName); - npm.load({}, (err) => { - npm.commands.install(__dirname, [moduleName], (err) => { + npm.load({}, err => { + npm.commands.install(__dirname, [moduleName], err => { if (err) { logger.error(moduleName + ' installation failed:', err); callback(); @@ -33,9 +33,9 @@ var installModule = (moduleName, callback) => { }; // make sure all modules are installed, installs missing ones, then calls done -var installModules = (modules, moduleType, forceUpdate, done) => { +let installModules = (modules, moduleType, forceUpdate, done) => { async.eachSeries(modules, (moduleShortName, callback) => { - var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; + let moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; if (!checkModule(moduleName) || forceUpdate) { // perform install / update installModule(moduleName, callback); @@ -64,13 +64,13 @@ exports.loadBackends = (player, backends, forceUpdate, done) => { installModules(backends, 'backend', forceUpdate, () => { // then initialize all backends in parallel async.map(backends, (backend, callback) => { - var moduleLogger = labeledLogger(backend); - var moduleName = 'nodeplayer-backend-' + backend; + let moduleLogger = labeledLogger(backend); + let moduleName = 'nodeplayer-backend-' + backend; if (moduleName) { moduleLogger.verbose('initializing...'); - var Module = require(moduleName); - var instance = new Module((err) => { + let Module = require(moduleName); + let instance = new Module(err => { if (err) { moduleLogger.error('while initializing: ' + err); callback(); @@ -100,13 +100,13 @@ exports.loadPlugins = (player, plugins, forceUpdate, done) => { installModules(plugins, 'plugin', forceUpdate, () => { // then initialize all plugins in series async.mapSeries(plugins, (plugin, callback) => { - var moduleLogger = labeledLogger(plugin); - var moduleName = 'nodeplayer-plugin-' + plugin; + let moduleLogger = labeledLogger(plugin); + let moduleName = 'nodeplayer-plugin-' + plugin; if (checkModule(moduleName)) { moduleLogger.verbose('initializing...'); - var Module = require(moduleName); - var instance = new Module(player, (err) => { + let Module = require(moduleName); + let instance = new Module(player, err => { if (err) { moduleLogger.error('while initializing: ' + err); callback(); diff --git a/src/player.js b/src/player.js index 3889631..ac09df3 100644 --- a/src/player.js +++ b/src/player.js @@ -1,10 +1,10 @@ 'use strict'; -var _ = require('lodash'); -var async = require('async'); -var util = require('util'); -var labeledLogger = require('./logger'); +let _ = require('lodash'); +let async = require('async'); +let util = require('util'); +let labeledLogger = require('./logger'); import Queue from './queue'; -var modules = require('./modules'); +let modules = require('./modules'); export default class Player { constructor(options) { @@ -32,32 +32,32 @@ export default class Player { * Initializes player */ init() { - var player = this; - var config = player.config; - var forceUpdate = false; + let player = this; + let config = player.config; + let forceUpdate = false; // initialize plugins & backends async.series([ - (callback) => { - modules.loadBuiltinPlugins(player, (plugins) => { + callback => { + modules.loadBuiltinPlugins(player, plugins => { player.plugins = plugins; player.callHooks('onBuiltinPluginsInitialized'); callback(); }); - }, (callback) => { - modules.loadPlugins(player, config.plugins, forceUpdate, (results) => { + }, callback => { + modules.loadPlugins(player, config.plugins, forceUpdate, results => { player.plugins = _.extend(player.plugins, results); player.callHooks('onPluginsInitialized'); callback(); }); - }, (callback) => { - modules.loadBuiltinBackends(player, (backends) => { + }, callback => { + modules.loadBuiltinBackends(player, backends => { player.backends = backends; player.callHooks('onBuiltinBackendsInitialized'); callback(); }); - }, (callback) => { - modules.loadBackends(player, config.backends, forceUpdate, (results) => { + }, callback => { + modules.loadBackends(player, config.backends, forceUpdate, results => { player.backends = _.extend(player.backends, results); player.callHooks('onBackendsInitialized'); callback(); @@ -76,14 +76,14 @@ export default class Player { // _.find() used instead of _.each() because we want to break out as soon // as a hook returns a truthy value (used to indicate an error, e.g. in form // of a string) - var err = null; + let err = null; this.logger.silly('callHooks(' + hook + (argv ? ', ' + util.inspect(argv) + ')' : ')')); - _.find(this.plugins, (plugin) => { + _.find(this.plugins, plugin => { if (plugin.hooks[hook]) { - var fun = plugin.hooks[hook]; + let fun = plugin.hooks[hook]; err = fun.apply(null, argv); return err; } @@ -94,9 +94,9 @@ export default class Player { // returns number of hook functions attached to given hook numHooks(hook) { - var cnt = 0; + let cnt = 0; - _.find(this.plugins, (plugin) => { + _.find(this.plugins, plugin => { if (plugin[hook]) { cnt++; } @@ -124,8 +124,8 @@ export default class Player { clearTimeout(this.songEndTimeout); this.play = false; - var np = this.nowPlaying; - var pos = np.playback.startPos + (new Date().getTime() - np.playback.startTime); + let np = this.nowPlaying; + let pos = np.playback.startPos + (new Date().getTime() - np.playback.startTime); if (np) { np.playback = { startTime: 0, @@ -141,7 +141,7 @@ export default class Player { */ startPlayback(position) { position = position || 0; - var player = this; + let player = this; if (!this.nowPlaying) { // find first song in queue @@ -152,7 +152,7 @@ export default class Player { } } - this.nowPlaying.prepare((err) => { + this.nowPlaying.prepare(err => { if (err) { throw new Error('error while preparing now playing: ' + err); } @@ -185,13 +185,13 @@ export default class Player { } songEnd() { - var np = this.getNowPlaying(); - var npIndex = np ? this.queue.findSongIndex(np.uuid) : -1; + let np = this.getNowPlaying(); + let npIndex = np ? this.queue.findSongIndex(np.uuid) : -1; this.logger.info('end of song ' + np.uuid); this.callHooks('onSongEnd', [np]); - var nextSong = this.queue.songs[npIndex + 1]; + let nextSong = this.queue.songs[npIndex + 1]; if (nextSong) { this.changeSong(nextSong.uuid); } else { @@ -208,7 +208,7 @@ export default class Player { // TODO: move these to song class? setPrepareTimeout(song) { - var player = this; + let player = this; if (song.prepareTimeout) { clearTimeout(song.prepareTimeout); @@ -291,7 +291,7 @@ export default class Player { } prepareSong(song, callback) { - var self = this; + let self = this; if (!song) { throw new Error('prepareSong() without song'); @@ -334,11 +334,11 @@ export default class Player { * Prepare now playing and next song for playback */ prepareSongs() { - var player = this; + let player = this; - var currentSong; + let currentSong; async.series([ - (callback) => { + callback => { // prepare now-playing song currentSong = player.getNowPlaying(); if (currentSong) { @@ -352,9 +352,9 @@ export default class Player { callback(true); } }, - (callback) => { + callback => { // prepare next song in playlist - var nextSong = player.queue.songs[player.queue.findSongIndex(currentSong) + 1]; + let nextSong = player.queue.songs[player.queue.findSongIndex(currentSong) + 1]; if (nextSong) { player.prepareSong(nextSong, callback); } else { @@ -368,11 +368,11 @@ export default class Player { } getPlaylists(callback) { - var resultCnt = 0; - var allResults = {}; - var player = this; + let resultCnt = 0; + let allResults = {}; + let player = this; - _.each(this.backends, (backend) => { + _.each(this.backends, backend => { if (!backend.getPlaylists) { resultCnt++; @@ -398,21 +398,21 @@ export default class Player { // make a search query to backends searchBackends(query, callback) { - var resultCnt = 0; - var allResults = {}; + let resultCnt = 0; + let allResults = {}; - _.each(this.backends, (backend) => { - backend.search(query, _.bind((results) => { + _.each(this.backends, backend => { + backend.search(query, _.bind(results => { resultCnt++; // make a temporary copy of songlist, clear songlist, check // each song and add them again if they are ok - var tempSongs = _.clone(results.songs); + let tempSongs = _.clone(results.songs); allResults[backend.name] = results; allResults[backend.name].songs = {}; - _.each(tempSongs, (song) => { - var err = this.callHooks('preAddSearchResult', [song]); + _.each(tempSongs, song => { + let err = this.callHooks('preAddSearchResult', [song]); if (err) { this.logger.error('preAddSearchResult hook error: ' + err); } else { @@ -424,7 +424,7 @@ export default class Player { if (resultCnt >= Object.keys(this.backends).length) { callback(allResults); } - }, this), _.bind((err) => { + }, this), _.bind(err => { resultCnt++; this.logger.error('error while searching ' + backend.name + ': ' + err); diff --git a/src/plugins/express.js b/src/plugins/express.js index e9dedcc..d5795d7 100644 --- a/src/plugins/express.js +++ b/src/plugins/express.js @@ -1,11 +1,11 @@ 'use strict'; -var express = require('express'); -var bodyParser = require('body-parser'); -var cookieParser = require('cookie-parser'); -var https = require('https'); -var http = require('http'); -var fs = require('fs'); +let express = require('express'); +let bodyParser = require('body-parser'); +let cookieParser = require('cookie-parser'); +let https = require('https'); +let http = require('http'); +let fs = require('fs'); import Plugin from '../plugin'; @@ -14,11 +14,11 @@ export default class Express extends Plugin { super(); // NOTE: no argument passed so we get the core's config - var config = require('../config').getConfig(); + let config = require('../config').getConfig(); player.app = express(); - var options = {}; - var port = process.env.PORT || config.port; + let options = {}; + let port = process.env.PORT || config.port; if (config.tls) { options = { tls: config.tls, diff --git a/src/plugins/index.js b/src/plugins/index.js index ad8cc07..970d598 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -1,7 +1,7 @@ import Express from './express'; import Rest from './rest'; -var Plugins = []; +let Plugins = []; Plugins.push(Express); Plugins.push(Rest); // NOTE: must be initialized after express diff --git a/src/plugins/rest.js b/src/plugins/rest.js index adea697..a93dbf1 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -1,9 +1,9 @@ 'use strict'; -var _ = require('lodash'); +let _ = require('lodash'); -var async = require('async'); -var path = require('path'); +let async = require('async'); +let path = require('path'); import Plugin from '../plugin'; export default class Rest extends Plugin { @@ -11,7 +11,7 @@ export default class Rest extends Plugin { super(); // NOTE: no argument passed so we get the core's config - var config = require('../config').getConfig(); + let config = require('../config').getConfig(); if (!player.app) { return callback('module must be initialized after express module!'); @@ -29,8 +29,8 @@ export default class Rest extends Plugin { }); player.app.get('/queue', (req, res) => { - var np = player.nowPlaying; - var pos = 0; + let np = player.nowPlaying; + let pos = 0; if (np) { if (np.playback.startTime) { pos = new Date().getTime() - np.playback.startTime + np.playback.startPos; @@ -49,12 +49,12 @@ export default class Rest extends Plugin { // TODO: error handling player.app.post('/queue/song', (req, res) => { - var err = player.queue.insertSongs(null, req.body); + let err = player.queue.insertSongs(null, req.body); res.sendRes(err); }); player.app.post('/queue/song/:at', (req, res) => { - var err = player.queue.insertSongs(req.params.at, req.body); + let err = player.queue.insertSongs(req.params.at, req.body); res.sendRes(err); }); @@ -109,7 +109,7 @@ export default class Rest extends Plugin { }); this.pendingRequests = {}; - var rest = this; + let rest = this; this.registerHook('onPrepareProgress', (song, bytesWritten, done) => { if (!rest.pendingRequests[song.backend.name]) { return; @@ -117,7 +117,7 @@ export default class Rest extends Plugin { _.each(rest.pendingRequests[song.backend.name][song.songId], client => { if (bytesWritten) { - var end = song.prepare.dataPos; + let end = song.prepare.dataPos; if (client.wishRange[1]) { end = Math.min(client.wishRange[1], bytesWritten - 1); } @@ -148,17 +148,17 @@ export default class Rest extends Plugin { // provide API path for music data, might block while song is preparing player.app.get('/song/' + backendName + '/:fileName', (req, res, next) => { - var extIndex = req.params.fileName.lastIndexOf('.'); - var songId = req.params.fileName.substring(0, extIndex); - var songFormat = req.params.fileName.substring(extIndex + 1); + let extIndex = req.params.fileName.lastIndexOf('.'); + let songId = req.params.fileName.substring(0, extIndex); + let songFormat = req.params.fileName.substring(extIndex + 1); - var backend = player.backends[backendName]; - var filename = path.join(backendName, songId + '.' + songFormat); + let backend = player.backends[backendName]; + let filename = path.join(backendName, songId + '.' + songFormat); res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); res.setHeader('Accept-Ranges', 'bytes'); - var queuedSong = _.find(player.queue.serialize(), song => { + let queuedSong = _.find(player.queue.serialize(), song => { return song.songId === songId && song.backendName === backendName; }); @@ -183,11 +183,11 @@ export default class Rest extends Plugin { }); } else if (backend.songsPreparing[songId]) { // song is preparing - var song = backend.songsPreparing[songId]; + let song = backend.songsPreparing[songId]; - var haveRange = []; - var wishRange = []; - var serveRange = []; + let haveRange = []; + let wishRange = []; + let serveRange = []; haveRange[0] = 0; haveRange[1] = song.prepare.data.length - 1; @@ -224,7 +224,7 @@ export default class Rest extends Plugin { rest.pendingRequests[backendName][songId] = []; } - var client = { + let client = { res: res, serveRange: serveRange, wishRange: wishRange, diff --git a/src/queue.js b/src/queue.js index ea0b159..3755961 100644 --- a/src/queue.js +++ b/src/queue.js @@ -1,4 +1,4 @@ -var _ = require('lodash'); +let _ = require('lodash'); import Song from './song'; /** @@ -25,7 +25,7 @@ export default class Queue { * @return {[SerializedSong]} - List of songs in serialized format */ serialize() { - var serialized = _.map(this.songs, (song) => { + let serialized = _.map(this.songs, song => { return song.serialize(); }); @@ -38,7 +38,7 @@ export default class Queue { * @return {Number} - Index of song, -1 if not found */ findSongIndex(at) { - return _.findIndex(this.songs, (song) => { + return _.findIndex(this.songs, song => { return song.uuid === at; }); } @@ -49,7 +49,7 @@ export default class Queue { * @return {Song|null} - Song object, null if not found */ findSong(at) { - return _.find(this.songs, (song) => { + return _.find(this.songs, song => { return song.uuid === at; }) || null; } @@ -60,7 +60,7 @@ export default class Queue { * @return {String|null} - UUID, null if not found */ uuidAtIndex(index) { - var song = this.songs[index]; + let song = this.songs[index]; return song ? song.uuid : null; } @@ -80,7 +80,7 @@ export default class Queue { * @return {Error} - in case of errors */ insertSongs(at, songs) { - var pos; + let pos; if (at === null) { // insert at start of queue pos = 0; @@ -96,10 +96,10 @@ export default class Queue { } // generate Song objects of each song - songs = _.map(songs, (song) => { + songs = _.map(songs, song => { // TODO: this would be best done in the song constructor, // effectively making it a SerializedSong object deserializer - var backend = this.player.backends[song.backendName]; + let backend = this.player.backends[song.backendName]; if (!backend) { throw new Error('Song constructor called with invalid backend: ' + song.backendName); } @@ -108,7 +108,7 @@ export default class Queue { }, this); // perform insertion - var args = [pos, 0].concat(songs); + let args = [pos, 0].concat(songs); Array.prototype.splice.apply(this.songs, args); this.player.prepareSongs(); @@ -121,30 +121,30 @@ export default class Queue { * @return {Song[] | Error} - List of removed songs, Error in case of errors */ removeSongs(at, cnt) { - var pos = this.findSongIndex(at); + let pos = this.findSongIndex(at); if (pos < 0) { return 'Song with UUID ' + at + ' not found!'; } // cancel preparing all songs to be deleted - for (var i = pos; i < pos + cnt && i < this.songs.length; i++) { - var song = this.songs[i]; + for (let i = pos; i < pos + cnt && i < this.songs.length; i++) { + let song = this.songs[i]; if (song.cancelPrepare) { song.cancelPrepare('Song removed.'); } } // store index of now playing song - var np = this.player.nowPlaying; - var npIndex = np ? this.findSongIndex(np.uuid) : -1; + let np = this.player.nowPlaying; + let npIndex = np ? this.findSongIndex(np.uuid) : -1; // perform deletion - var removed = this.songs.splice(pos, cnt); + let removed = this.songs.splice(pos, cnt); // was now playing removed? if (pos <= npIndex && pos + cnt >= npIndex) { // change to first song after splice - var newNp = this.songs[pos]; + let newNp = this.songs[pos]; this.player.changeSong(newNp ? newNp.uuid : null); } else { this.player.prepareSongs(); diff --git a/src/song.js b/src/song.js index 5987cfe..8bd8848 100644 --- a/src/song.js +++ b/src/song.js @@ -1,5 +1,5 @@ -var _ = require('lodash'); -var uuid = require('node-uuid'); +let _ = require('lodash'); +let uuid = require('node-uuid'); /** * Constructor @@ -70,7 +70,7 @@ export default class Song { startTime: new Date(), startPos: pos || null, }; - }; + } /** * Return serialized details of the song @@ -91,7 +91,7 @@ export default class Song { playlist: this.playlist, playback: this.playback, }; - }; + } /** * Synchronously(!) returns whether the song is prepared or not @@ -99,7 +99,7 @@ export default class Song { */ isPrepared() { return this.backend.isPrepared(this); - }; + } /** * Prepare song for playback @@ -107,7 +107,7 @@ export default class Song { */ prepare(callback) { this.backend.prepare(this, callback); - }; + } /** * Cancel song preparation if applicable diff --git a/test/eslint.spec.js b/test/eslint.spec.js index 3640a43..12487f2 100644 --- a/test/eslint.spec.js +++ b/test/eslint.spec.js @@ -1,6 +1,6 @@ -var lint = require('mocha-eslint'); +let lint = require('mocha-eslint'); -var paths = [ +let paths = [ 'bin', 'src', 'test', From 75ae35f3374f724a6007e349e005a8b016688554 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 5 Sep 2016 18:55:51 +0300 Subject: [PATCH 083/103] Enforce const on variables that don't change --- .eslintrc.json | 3 ++- src/backend.js | 24 ++++++++--------- src/backends/index.js | 2 +- src/backends/local.js | 58 +++++++++++++++++++++--------------------- src/config.js | 20 +++++++-------- src/index.js | 2 +- src/logger.js | 4 +-- src/modules.js | 38 +++++++++++++-------------- src/player.js | 48 +++++++++++++++++----------------- src/plugins/express.js | 16 ++++++------ src/plugins/index.js | 2 +- src/plugins/rest.js | 36 +++++++++++++------------- src/queue.js | 22 ++++++++-------- src/song.js | 4 +-- test/eslint.spec.js | 4 +-- 15 files changed, 142 insertions(+), 141 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 7ac18a4..2a8da8b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -15,6 +15,7 @@ "new-cap": ["error", { "properties": false }], "object-curly-spacing": ["error", "always"], "arrow-parens": ["error", "as-needed"], - "no-var": ["error"] + "no-var": ["error"], + "prefer-const": ["warn"] } } diff --git a/src/backend.js b/src/backend.js index 5230e0c..255c08d 100644 --- a/src/backend.js +++ b/src/backend.js @@ -1,8 +1,8 @@ -let path = require('path'); -let fs = require('fs'); -let ffmpeg = require('fluent-ffmpeg'); -let config = require('./config').getConfig(); -let labeledLogger = require('./logger'); +const path = require('path'); +const fs = require('fs'); +const ffmpeg = require('fluent-ffmpeg'); +const config = require('./config').getConfig(); +const labeledLogger = require('./logger'); /** * Super constructor for backends @@ -31,12 +31,12 @@ export default class Backend { * @return {Function} - Can be called to terminate encoding */ encodeSong(stream, seek, song, callback) { - let self = this; + const self = this; - let encodedPath = path.join(config.songCachePath, self.name, + const encodedPath = path.join(config.songCachePath, self.name, song.songId + '.opus'); - let command = ffmpeg(stream) + const command = ffmpeg(stream) .noVideo() // .inputFormat('mp3') // .inputOption('-ac 2') @@ -49,7 +49,7 @@ export default class Backend { callback(err); }); - let opusStream = command.pipe(null, { end: true }); + const opusStream = command.pipe(null, { end: true }); opusStream.on('data', chunk => { // TODO: this could be optimized by using larger buffers // song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); @@ -62,12 +62,12 @@ export default class Backend { // Otherwise allocate more room, then copy chunk into buffer // Make absolutely sure that the chunk will fit inside new buffer - let newSize = Math.max(song.prepare.data.length * 2, + const newSize = Math.max(song.prepare.data.length * 2, song.prepare.data.length + chunk.length); self.log.debug('Allocated new song data buffer of size: ' + newSize); - let buf = new Buffer.allocUnsafe(newSize); + const buf = new Buffer.allocUnsafe(newSize); song.prepare.data.copy(buf); song.prepare.data = buf; @@ -132,7 +132,7 @@ export default class Backend { * @param {durationCallback} callback - Called with duration */ getDuration(song, callback) { - let err = 'FATAL: backend does not implement getDuration()!'; + const err = 'FATAL: backend does not implement getDuration()!'; this.log.error(err); callback(err); } diff --git a/src/backends/index.js b/src/backends/index.js index 170f7de..6e1bd2e 100644 --- a/src/backends/index.js +++ b/src/backends/index.js @@ -1,6 +1,6 @@ import Local from './local'; -let Backends = []; +const Backends = []; Backends.push(Local); module.exports = Backends; diff --git a/src/backends/local.js b/src/backends/local.js index b2756bd..d7fc61c 100644 --- a/src/backends/local.js +++ b/src/backends/local.js @@ -1,14 +1,14 @@ 'use strict'; -let path = require('path'); -let fs = require('fs'); -let mkdirp = require('mkdirp'); -let mongoose = require('mongoose'); -let async = require('async'); -let walk = require('walk'); -let ffprobe = require('node-ffprobe'); -let _ = require('lodash'); -let escapeStringRegexp = require('escape-string-regexp'); +const path = require('path'); +const fs = require('fs'); +const mkdirp = require('mkdirp'); +const mongoose = require('mongoose'); +const async = require('async'); +const walk = require('walk'); +const ffprobe = require('node-ffprobe'); +const _ = require('lodash'); +const escapeStringRegexp = require('escape-string-regexp'); import Backend from '../backend'; @@ -81,7 +81,7 @@ var probeCallback = (err, probeData, next) => { */ // database model -let SongModel = mongoose.model('Song', { +const SongModel = mongoose.model('Song', { title: String, artist: String, album: String, @@ -114,8 +114,8 @@ let SongModel = mongoose.model('Song', { * @param {String} fileExt - Filename extension * @return {Metadata} Song metadata */ -let guessMetadataFromPath = (filePath, fileExt) => { - let fileName = path.basename(filePath, fileExt); +const guessMetadataFromPath = (filePath, fileExt) => { + const fileName = path.basename(filePath, fileExt); // split filename at dashes, trim extra whitespace, e.g: let splitName = fileName.split('-'); @@ -135,10 +135,10 @@ export default class Local extends Backend { constructor(callback) { super(); - let self = this; + const self = this; // NOTE: no argument passed so we get the core's config - let config = require('../config').getConfig(); + const config = require('../config').getConfig(); this.config = config; this.songCachePath = config.songCachePath; this.importFormats = config.importFormats; @@ -149,7 +149,7 @@ export default class Local extends Backend { // connect to the database mongoose.connect(config.mongo); - let db = mongoose.connection; + const db = mongoose.connection; db.on('error', err => { return callback(err, self); }); @@ -157,12 +157,12 @@ export default class Local extends Backend { return callback(null, self); }); - let options = { + const options = { followLinks: config.followSymlinks, }; - let insertSong = (probeData, done) => { - let guessMetadata = guessMetadataFromPath(probeData.file, probeData.fileext); + const insertSong = (probeData, done) => { + const guessMetadata = guessMetadataFromPath(probeData.file, probeData.fileext); let song = new SongModel({ title: probeData.metadata.TITLE || guessMetadata.title, @@ -189,7 +189,7 @@ export default class Local extends Backend { }; // create async.js queue to limit concurrent probes - let q = async.queue((task, done) => { + const q = async.queue((task, done) => { ffprobe(task.filename, (err, probeData) => { if (!probeData) { return done(); @@ -215,11 +215,11 @@ export default class Local extends Backend { // TODO: filter by allowed filename extensions if (config.rescanAtStart) { self.log.info('Scanning directory: ' + config.importPath); - let walker = walk.walk(config.importPath, options); - let startTime = new Date(); + const walker = walk.walk(config.importPath, options); + const startTime = new Date(); let scanned = 0; walker.on('file', (root, fileStats, next) => { - let filename = path.join(root, fileStats.name); + const filename = path.join(root, fileStats.name); self.log.verbose('Scanning: ' + filename); scanned++; q.push({ @@ -258,7 +258,7 @@ export default class Local extends Backend { } isPrepared(song) { - let filePath = path.join(this.songCachePath, 'local', song.songId + '.opus'); + const filePath = path.join(this.songCachePath, 'local', song.songId + '.opus'); return fs.existsSync(filePath); } @@ -273,7 +273,7 @@ export default class Local extends Backend { } prepare(song, callback) { - let self = this; + const self = this; // TODO: move most of this into common code inside core if (self.songsPreparing[song.songId]) { @@ -305,7 +305,7 @@ export default class Local extends Backend { if (canceled) { callback(new Error('song was canceled before encoding started')); } else if (item) { - let readStream = fs.createReadStream(item.filename); + const readStream = fs.createReadStream(item.filename); cancelEncode = self.encodeSong(readStream, 0, song, callback); readStream.on('error', err => { callback(err); @@ -318,7 +318,7 @@ export default class Local extends Backend { } search(query, callback) { - let self = this; + const self = this; let q; if (query.any) { @@ -335,7 +335,7 @@ export default class Local extends Backend { }; _.keys(query).forEach(key => { - let criterion = {}; + const criterion = {}; criterion[key] = new RegExp(escapeStringRegexp(query[key]), 'i'); q.$and.push(criterion); }); @@ -346,10 +346,10 @@ export default class Local extends Backend { return callback(err); } - let results = {}; + const results = {}; results.songs = {}; - let numItems = items.length; + const numItems = items.length; let cur = 0; items.forEach(song => { if (Object.keys(results.songs).length <= self.config.searchResultCnt) { diff --git a/src/config.js b/src/config.js index 6249b06..eae97e4 100644 --- a/src/config.js +++ b/src/config.js @@ -1,8 +1,8 @@ -let _ = require('lodash'); -let mkdirp = require('mkdirp'); -let fs = require('fs'); -let os = require('os'); -let path = require('path'); +const _ = require('lodash'); +const mkdirp = require('mkdirp'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); function getHomeDir() { if (process.platform === 'win32') { @@ -22,7 +22,7 @@ function getBaseDir() { } exports.getBaseDir = getBaseDir; -let defaultConfig = {}; +const defaultConfig = {}; // backends are sources of music defaultConfig.backends = [ @@ -86,13 +86,13 @@ exports.getConfig = (module, defaults) => { return (defaults || defaultConfig); } - let moduleName = module ? module.name : null; + const moduleName = module ? module.name : null; - let configPath = path.join(getBaseDir(), 'config', (moduleName || 'core') + '.json'); + const configPath = path.join(getBaseDir(), 'config', (moduleName || 'core') + '.json'); try { - let userConfig = require(configPath); - let config = _.defaults(userConfig, defaults || defaultConfig); + const userConfig = require(configPath); + const config = _.defaults(userConfig, defaults || defaultConfig); return config; } catch (e) { if (e.code === 'MODULE_NOT_FOUND') { diff --git a/src/index.js b/src/index.js index a66595f..97bbd7e 100755 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import Player from './player'; -let p = new Player(); +const p = new Player(); p.init(); diff --git a/src/logger.js b/src/logger.js index d2d0698..07ecee6 100644 --- a/src/logger.js +++ b/src/logger.js @@ -1,6 +1,6 @@ 'use strict'; -let config = require('./config').getConfig(); -let winston = require('winston'); +const config = require('./config').getConfig(); +const winston = require('winston'); module.exports = label => { return new (winston.Logger)({ diff --git a/src/modules.js b/src/modules.js index ebb52fe..6c839b5 100644 --- a/src/modules.js +++ b/src/modules.js @@ -1,13 +1,13 @@ -let npm = require('npm'); -let async = require('async'); -let labeledLogger = require('./logger'); -let BuiltinPlugins = require('./plugins'); -let BuiltinBackends = require('./backends'); +const npm = require('npm'); +const async = require('async'); +const labeledLogger = require('./logger'); +const BuiltinPlugins = require('./plugins'); +const BuiltinBackends = require('./backends'); -let _ = require('lodash'); -let logger = labeledLogger('modules'); +const _ = require('lodash'); +const logger = labeledLogger('modules'); -let checkModule = module => { +const checkModule = module => { try { require.resolve(module); return true; @@ -17,7 +17,7 @@ let checkModule = module => { }; // install a single module -let installModule = (moduleName, callback) => { +const installModule = (moduleName, callback) => { logger.info('installing module: ' + moduleName); npm.load({}, err => { npm.commands.install(__dirname, [moduleName], err => { @@ -33,9 +33,9 @@ let installModule = (moduleName, callback) => { }; // make sure all modules are installed, installs missing ones, then calls done -let installModules = (modules, moduleType, forceUpdate, done) => { +const installModules = (modules, moduleType, forceUpdate, done) => { async.eachSeries(modules, (moduleShortName, callback) => { - let moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; + const moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; if (!checkModule(moduleName) || forceUpdate) { // perform install / update installModule(moduleName, callback); @@ -64,13 +64,13 @@ exports.loadBackends = (player, backends, forceUpdate, done) => { installModules(backends, 'backend', forceUpdate, () => { // then initialize all backends in parallel async.map(backends, (backend, callback) => { - let moduleLogger = labeledLogger(backend); - let moduleName = 'nodeplayer-backend-' + backend; + const moduleLogger = labeledLogger(backend); + const moduleName = 'nodeplayer-backend-' + backend; if (moduleName) { moduleLogger.verbose('initializing...'); - let Module = require(moduleName); - let instance = new Module(err => { + const Module = require(moduleName); + const instance = new Module(err => { if (err) { moduleLogger.error('while initializing: ' + err); callback(); @@ -100,13 +100,13 @@ exports.loadPlugins = (player, plugins, forceUpdate, done) => { installModules(plugins, 'plugin', forceUpdate, () => { // then initialize all plugins in series async.mapSeries(plugins, (plugin, callback) => { - let moduleLogger = labeledLogger(plugin); - let moduleName = 'nodeplayer-plugin-' + plugin; + const moduleLogger = labeledLogger(plugin); + const moduleName = 'nodeplayer-plugin-' + plugin; if (checkModule(moduleName)) { moduleLogger.verbose('initializing...'); - let Module = require(moduleName); - let instance = new Module(player, err => { + const Module = require(moduleName); + const instance = new Module(player, err => { if (err) { moduleLogger.error('while initializing: ' + err); callback(); diff --git a/src/player.js b/src/player.js index ac09df3..eff91d7 100644 --- a/src/player.js +++ b/src/player.js @@ -1,10 +1,10 @@ 'use strict'; -let _ = require('lodash'); -let async = require('async'); -let util = require('util'); -let labeledLogger = require('./logger'); +const _ = require('lodash'); +const async = require('async'); +const util = require('util'); +const labeledLogger = require('./logger'); import Queue from './queue'; -let modules = require('./modules'); +const modules = require('./modules'); export default class Player { constructor(options) { @@ -32,9 +32,9 @@ export default class Player { * Initializes player */ init() { - let player = this; - let config = player.config; - let forceUpdate = false; + const player = this; + const config = player.config; + const forceUpdate = false; // initialize plugins & backends async.series([ @@ -83,7 +83,7 @@ export default class Player { _.find(this.plugins, plugin => { if (plugin.hooks[hook]) { - let fun = plugin.hooks[hook]; + const fun = plugin.hooks[hook]; err = fun.apply(null, argv); return err; } @@ -124,8 +124,8 @@ export default class Player { clearTimeout(this.songEndTimeout); this.play = false; - let np = this.nowPlaying; - let pos = np.playback.startPos + (new Date().getTime() - np.playback.startTime); + const np = this.nowPlaying; + const pos = np.playback.startPos + (new Date().getTime() - np.playback.startTime); if (np) { np.playback = { startTime: 0, @@ -141,7 +141,7 @@ export default class Player { */ startPlayback(position) { position = position || 0; - let player = this; + const player = this; if (!this.nowPlaying) { // find first song in queue @@ -185,13 +185,13 @@ export default class Player { } songEnd() { - let np = this.getNowPlaying(); - let npIndex = np ? this.queue.findSongIndex(np.uuid) : -1; + const np = this.getNowPlaying(); + const npIndex = np ? this.queue.findSongIndex(np.uuid) : -1; this.logger.info('end of song ' + np.uuid); this.callHooks('onSongEnd', [np]); - let nextSong = this.queue.songs[npIndex + 1]; + const nextSong = this.queue.songs[npIndex + 1]; if (nextSong) { this.changeSong(nextSong.uuid); } else { @@ -208,7 +208,7 @@ export default class Player { // TODO: move these to song class? setPrepareTimeout(song) { - let player = this; + const player = this; if (song.prepareTimeout) { clearTimeout(song.prepareTimeout); @@ -291,7 +291,7 @@ export default class Player { } prepareSong(song, callback) { - let self = this; + const self = this; if (!song) { throw new Error('prepareSong() without song'); @@ -334,7 +334,7 @@ export default class Player { * Prepare now playing and next song for playback */ prepareSongs() { - let player = this; + const player = this; let currentSong; async.series([ @@ -354,7 +354,7 @@ export default class Player { }, callback => { // prepare next song in playlist - let nextSong = player.queue.songs[player.queue.findSongIndex(currentSong) + 1]; + const nextSong = player.queue.songs[player.queue.findSongIndex(currentSong) + 1]; if (nextSong) { player.prepareSong(nextSong, callback); } else { @@ -369,8 +369,8 @@ export default class Player { getPlaylists(callback) { let resultCnt = 0; - let allResults = {}; - let player = this; + const allResults = {}; + const player = this; _.each(this.backends, backend => { if (!backend.getPlaylists) { @@ -399,7 +399,7 @@ export default class Player { // make a search query to backends searchBackends(query, callback) { let resultCnt = 0; - let allResults = {}; + const allResults = {}; _.each(this.backends, backend => { backend.search(query, _.bind(results => { @@ -407,12 +407,12 @@ export default class Player { // make a temporary copy of songlist, clear songlist, check // each song and add them again if they are ok - let tempSongs = _.clone(results.songs); + const tempSongs = _.clone(results.songs); allResults[backend.name] = results; allResults[backend.name].songs = {}; _.each(tempSongs, song => { - let err = this.callHooks('preAddSearchResult', [song]); + const err = this.callHooks('preAddSearchResult', [song]); if (err) { this.logger.error('preAddSearchResult hook error: ' + err); } else { diff --git a/src/plugins/express.js b/src/plugins/express.js index d5795d7..b89ff2a 100644 --- a/src/plugins/express.js +++ b/src/plugins/express.js @@ -1,11 +1,11 @@ 'use strict'; -let express = require('express'); -let bodyParser = require('body-parser'); -let cookieParser = require('cookie-parser'); -let https = require('https'); -let http = require('http'); -let fs = require('fs'); +const express = require('express'); +const bodyParser = require('body-parser'); +const cookieParser = require('cookie-parser'); +const https = require('https'); +const http = require('http'); +const fs = require('fs'); import Plugin from '../plugin'; @@ -14,11 +14,11 @@ export default class Express extends Plugin { super(); // NOTE: no argument passed so we get the core's config - let config = require('../config').getConfig(); + const config = require('../config').getConfig(); player.app = express(); let options = {}; - let port = process.env.PORT || config.port; + const port = process.env.PORT || config.port; if (config.tls) { options = { tls: config.tls, diff --git a/src/plugins/index.js b/src/plugins/index.js index 970d598..76439f1 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -1,7 +1,7 @@ import Express from './express'; import Rest from './rest'; -let Plugins = []; +const Plugins = []; Plugins.push(Express); Plugins.push(Rest); // NOTE: must be initialized after express diff --git a/src/plugins/rest.js b/src/plugins/rest.js index a93dbf1..c590cd5 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -1,9 +1,9 @@ 'use strict'; -let _ = require('lodash'); +const _ = require('lodash'); -let async = require('async'); -let path = require('path'); +const async = require('async'); +const path = require('path'); import Plugin from '../plugin'; export default class Rest extends Plugin { @@ -11,7 +11,7 @@ export default class Rest extends Plugin { super(); // NOTE: no argument passed so we get the core's config - let config = require('../config').getConfig(); + const config = require('../config').getConfig(); if (!player.app) { return callback('module must be initialized after express module!'); @@ -29,7 +29,7 @@ export default class Rest extends Plugin { }); player.app.get('/queue', (req, res) => { - let np = player.nowPlaying; + const np = player.nowPlaying; let pos = 0; if (np) { if (np.playback.startTime) { @@ -49,12 +49,12 @@ export default class Rest extends Plugin { // TODO: error handling player.app.post('/queue/song', (req, res) => { - let err = player.queue.insertSongs(null, req.body); + const err = player.queue.insertSongs(null, req.body); res.sendRes(err); }); player.app.post('/queue/song/:at', (req, res) => { - let err = player.queue.insertSongs(req.params.at, req.body); + const err = player.queue.insertSongs(req.params.at, req.body); res.sendRes(err); }); @@ -109,7 +109,7 @@ export default class Rest extends Plugin { }); this.pendingRequests = {}; - let rest = this; + const rest = this; this.registerHook('onPrepareProgress', (song, bytesWritten, done) => { if (!rest.pendingRequests[song.backend.name]) { return; @@ -148,17 +148,17 @@ export default class Rest extends Plugin { // provide API path for music data, might block while song is preparing player.app.get('/song/' + backendName + '/:fileName', (req, res, next) => { - let extIndex = req.params.fileName.lastIndexOf('.'); - let songId = req.params.fileName.substring(0, extIndex); - let songFormat = req.params.fileName.substring(extIndex + 1); + const extIndex = req.params.fileName.lastIndexOf('.'); + const songId = req.params.fileName.substring(0, extIndex); + const songFormat = req.params.fileName.substring(extIndex + 1); - let backend = player.backends[backendName]; - let filename = path.join(backendName, songId + '.' + songFormat); + const backend = player.backends[backendName]; + const filename = path.join(backendName, songId + '.' + songFormat); res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); res.setHeader('Accept-Ranges', 'bytes'); - let queuedSong = _.find(player.queue.serialize(), song => { + const queuedSong = _.find(player.queue.serialize(), song => { return song.songId === songId && song.backendName === backendName; }); @@ -183,11 +183,11 @@ export default class Rest extends Plugin { }); } else if (backend.songsPreparing[songId]) { // song is preparing - let song = backend.songsPreparing[songId]; + const song = backend.songsPreparing[songId]; - let haveRange = []; + const haveRange = []; let wishRange = []; - let serveRange = []; + const serveRange = []; haveRange[0] = 0; haveRange[1] = song.prepare.data.length - 1; @@ -224,7 +224,7 @@ export default class Rest extends Plugin { rest.pendingRequests[backendName][songId] = []; } - let client = { + const client = { res: res, serveRange: serveRange, wishRange: wishRange, diff --git a/src/queue.js b/src/queue.js index 3755961..eeb0971 100644 --- a/src/queue.js +++ b/src/queue.js @@ -1,4 +1,4 @@ -let _ = require('lodash'); +const _ = require('lodash'); import Song from './song'; /** @@ -25,7 +25,7 @@ export default class Queue { * @return {[SerializedSong]} - List of songs in serialized format */ serialize() { - let serialized = _.map(this.songs, song => { + const serialized = _.map(this.songs, song => { return song.serialize(); }); @@ -60,7 +60,7 @@ export default class Queue { * @return {String|null} - UUID, null if not found */ uuidAtIndex(index) { - let song = this.songs[index]; + const song = this.songs[index]; return song ? song.uuid : null; } @@ -99,7 +99,7 @@ export default class Queue { songs = _.map(songs, song => { // TODO: this would be best done in the song constructor, // effectively making it a SerializedSong object deserializer - let backend = this.player.backends[song.backendName]; + const backend = this.player.backends[song.backendName]; if (!backend) { throw new Error('Song constructor called with invalid backend: ' + song.backendName); } @@ -108,7 +108,7 @@ export default class Queue { }, this); // perform insertion - let args = [pos, 0].concat(songs); + const args = [pos, 0].concat(songs); Array.prototype.splice.apply(this.songs, args); this.player.prepareSongs(); @@ -121,30 +121,30 @@ export default class Queue { * @return {Song[] | Error} - List of removed songs, Error in case of errors */ removeSongs(at, cnt) { - let pos = this.findSongIndex(at); + const pos = this.findSongIndex(at); if (pos < 0) { return 'Song with UUID ' + at + ' not found!'; } // cancel preparing all songs to be deleted for (let i = pos; i < pos + cnt && i < this.songs.length; i++) { - let song = this.songs[i]; + const song = this.songs[i]; if (song.cancelPrepare) { song.cancelPrepare('Song removed.'); } } // store index of now playing song - let np = this.player.nowPlaying; - let npIndex = np ? this.findSongIndex(np.uuid) : -1; + const np = this.player.nowPlaying; + const npIndex = np ? this.findSongIndex(np.uuid) : -1; // perform deletion - let removed = this.songs.splice(pos, cnt); + const removed = this.songs.splice(pos, cnt); // was now playing removed? if (pos <= npIndex && pos + cnt >= npIndex) { // change to first song after splice - let newNp = this.songs[pos]; + const newNp = this.songs[pos]; this.player.changeSong(newNp ? newNp.uuid : null); } else { this.player.prepareSongs(); diff --git a/src/song.js b/src/song.js index 8bd8848..20a1589 100644 --- a/src/song.js +++ b/src/song.js @@ -1,5 +1,5 @@ -let _ = require('lodash'); -let uuid = require('node-uuid'); +const _ = require('lodash'); +const uuid = require('node-uuid'); /** * Constructor diff --git a/test/eslint.spec.js b/test/eslint.spec.js index 12487f2..7d5a344 100644 --- a/test/eslint.spec.js +++ b/test/eslint.spec.js @@ -1,6 +1,6 @@ -let lint = require('mocha-eslint'); +const lint = require('mocha-eslint'); -let paths = [ +const paths = [ 'bin', 'src', 'test', From 6f5ae0c5ea68df5a65f277b6e1f8979b5fd3e947 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 5 Sep 2016 19:22:58 +0300 Subject: [PATCH 084/103] More eslint rules, fix comment indentation --- .eslintrc.json | 25 ++++++++-- package.json | 1 + src/backend.js | 44 ++++++++--------- src/backends/local.js | 110 ++++++++++++++++++++--------------------- src/config.js | 23 ++++----- src/logger.js | 8 +-- src/player.js | 80 +++++++++++++++--------------- src/plugins/express.js | 14 +++--- src/plugins/rest.js | 30 +++++------ src/queue.js | 30 +++++------ src/song.js | 26 +++++----- 11 files changed, 205 insertions(+), 186 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 2a8da8b..b1e95ea 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -7,15 +7,32 @@ "node": true, "mocha": true }, - "extends": "google", + "extends": [ + "eslint:recommended", + "google" + ], "rules": { "comma-dangle": ["error", "always-multiline"], "quotes": ["error", "single"], "max-len": ["warn", 100, 2], + "indent": ["error", 2], + "linebreak-style": "error", + "no-multiple-empty-lines": ["error", { "max": 1 }], + "no-trailing-spaces": "error", + "eol-last": "error", "new-cap": ["error", { "properties": false }], - "object-curly-spacing": ["error", "always"], "arrow-parens": ["error", "as-needed"], - "no-var": ["error"], - "prefer-const": ["warn"] + "no-var": "error", + "prefer-const": "warn", + "space-infix-ops": "error", + "space-before-blocks": "error", + "padded-blocks": ["error", "never"], + "object-curly-spacing": ["error", "always"], + "no-multi-spaces": "error", + "block-spacing": ["error", "always"], + "key-spacing": ["error", { "align": "value" }], + "comma-spacing": "error", + "computed-property-spacing": "error", + "keyword-spacing": "error" } } diff --git a/package.json b/package.json index 0e3c89a..26a053f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "postinstall": "npm run build", "watch": "nodemon --exec babel-node src/index.js", "test": "NODE_ENV=test ./node_modules/mocha/bin/mocha --compilers js:babel-core/register", + "fix": "eslint --fix src", "coverage": "NODE_ENV=test istanbul cover _mocha -- -R spec" }, "repository": { diff --git a/src/backend.js b/src/backend.js index 255c08d..d923b12 100644 --- a/src/backend.js +++ b/src/backend.js @@ -37,31 +37,31 @@ export default class Backend { song.songId + '.opus'); const command = ffmpeg(stream) - .noVideo() - // .inputFormat('mp3') - // .inputOption('-ac 2') - .audioCodec('libopus') - .audioBitrate('192') - .format('opus') - .on('error', err => { - self.log.error(self.name + ': error while transcoding ' + song.songId + ': ' + err); - delete song.prepare.data; - callback(err); - }); + .noVideo() + // .inputFormat('mp3') + // .inputOption('-ac 2') + .audioCodec('libopus') + .audioBitrate('192') + .format('opus') + .on('error', err => { + self.log.error(self.name + ': error while transcoding ' + song.songId + ': ' + err); + delete song.prepare.data; + callback(err); + }); const opusStream = command.pipe(null, { end: true }); opusStream.on('data', chunk => { - // TODO: this could be optimized by using larger buffers - // song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); + // TODO: this could be optimized by using larger buffers + // song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); if (chunk.length <= song.prepare.data.length - song.prepare.dataPos) { - // If there's room in the buffer, write chunk into it + // If there's room in the buffer, write chunk into it chunk.copy(song.prepare.data, song.prepare.dataPos); song.prepare.dataPos += chunk.length; } else { - // Otherwise allocate more room, then copy chunk into buffer + // Otherwise allocate more room, then copy chunk into buffer - // Make absolutely sure that the chunk will fit inside new buffer + // Make absolutely sure that the chunk will fit inside new buffer const newSize = Math.max(song.prepare.data.length * 2, song.prepare.data.length + chunk.length); @@ -83,11 +83,11 @@ export default class Backend { self.log.verbose('transcoding ended for ' + song.songId); delete song.prepare; - // TODO: we don't know if transcoding ended successfully or not, - // and there might be a race condition between errCallback deleting - // the file and us trying to move it to the songCache - // TODO: is this still the case? - // (we no longer save incomplete files on disk) + // TODO: we don't know if transcoding ended successfully or not, + // and there might be a race condition between errCallback deleting + // the file and us trying to move it to the songCache + // TODO: is this still the case? + // (we no longer save incomplete files on disk) callback(null, null, true); }); @@ -95,7 +95,7 @@ export default class Backend { self.log.verbose('transcoding ' + song.songId + '...'); - // return a function which can be used for terminating encoding + // return a function which can be used for terminating encoding return err => { command.kill(); self.log.verbose(self.name + ': canceled preparing: ' + song.songId + ': ' + err); diff --git a/src/backends/local.js b/src/backends/local.js index d7fc61c..037c887 100644 --- a/src/backends/local.js +++ b/src/backends/local.js @@ -82,24 +82,24 @@ var probeCallback = (err, probeData, next) => { // database model const SongModel = mongoose.model('Song', { - title: String, - artist: String, - album: String, + title: String, + artist: String, + album: String, albumArt: { lq: String, hq: String, }, duration: { - type: Number, + type: Number, required: true, }, format: { - type: String, + type: String, required: true, }, filename: { - type: String, - unique: true, + type: String, + unique: true, required: true, dropDups: true, }, @@ -126,8 +126,8 @@ const guessMetadataFromPath = (filePath, fileExt) => { // TODO: compare album name against music dir, leave empty if equal return { artist: splitName[0], - title: splitName[1], - album: path.basename(path.dirname(filePath)), + title: splitName[1], + album: path.basename(path.dirname(filePath)), }; }; @@ -137,13 +137,13 @@ export default class Local extends Backend { const self = this; - // NOTE: no argument passed so we get the core's config + // NOTE: no argument passed so we get the core's config const config = require('../config').getConfig(); this.config = config; this.songCachePath = config.songCachePath; this.importFormats = config.importFormats; - // make sure all necessary directories exist + // make sure all necessary directories exist mkdirp.sync(path.join(this.songCachePath, 'local', 'incomplete')); // connect to the database @@ -165,12 +165,12 @@ export default class Local extends Backend { const guessMetadata = guessMetadataFromPath(probeData.file, probeData.fileext); let song = new SongModel({ - title: probeData.metadata.TITLE || guessMetadata.title, - artist: probeData.metadata.ARTIST || guessMetadata.artist, - album: probeData.metadata.ALBUM || guessMetadata.album, - // albumArt: {} // TODO + title: probeData.metadata.TITLE || guessMetadata.title, + artist: probeData.metadata.ARTIST || guessMetadata.artist, + album: probeData.metadata.ALBUM || guessMetadata.album, + // albumArt: {} // TODO duration: probeData.format.duration * 1000, - format: probeData.format.format_name, + format: probeData.format.format_name, filename: probeData.file, }); @@ -188,7 +188,7 @@ export default class Local extends Backend { }); }; - // create async.js queue to limit concurrent probes + // create async.js queue to limit concurrent probes const q = async.queue((task, done) => { ffprobe(task.filename, (err, probeData) => { if (!probeData) { @@ -210,9 +210,9 @@ export default class Local extends Backend { }); }, config.concurrentProbes); - // walk the filesystem and scan files - // TODO: also check through entire DB to see that all files still exist on the filesystem - // TODO: filter by allowed filename extensions + // walk the filesystem and scan files + // TODO: also check through entire DB to see that all files still exist on the filesystem + // TODO: filter by allowed filename extensions if (config.rescanAtStart) { self.log.info('Scanning directory: ' + config.importPath); const walker = walk.walk(config.importPath, options); @@ -234,27 +234,27 @@ export default class Local extends Backend { }); } - // TODO: fs watch - // set fs watcher on media directory - // TODO: add a debounce so if the file keeps changing we don't probe it multiple times - /* - watch(config.importPath, { - recursive: true, - followSymlinks: config.followSymlinks - }, (filename) => { - if (fs.existsSync(filename)) { - self.log.debug(filename + ' modified or created, queued for probing'); - q.unshift({ - filename: filename - }); - } else { - self.log.debug(filename + ' deleted'); - db.collection('songs').remove({file: filename}, (err, items) => { - self.log.debug(filename + ' deleted from db: ' + err + ', ' + items); - }); - } - }); - */ + // TODO: fs watch + // set fs watcher on media directory + // TODO: add a debounce so if the file keeps changing we don't probe it multiple times + /* + watch(config.importPath, { + recursive: true, + followSymlinks: config.followSymlinks + }, (filename) => { + if (fs.existsSync(filename)) { + self.log.debug(filename + ' modified or created, queued for probing'); + q.unshift({ + filename: filename + }); + } else { + self.log.debug(filename + ' deleted'); + db.collection('songs').remove({file: filename}, (err, items) => { + self.log.debug(filename + ' deleted from db: ' + err + ', ' + items); + }); + } + }); + */ } isPrepared(song) { @@ -277,21 +277,21 @@ export default class Local extends Backend { // TODO: move most of this into common code inside core if (self.songsPreparing[song.songId]) { - // song is preparing, caller can drop this request (previous caller will take care of - // handling once preparation is finished) + // song is preparing, caller can drop this request (previous caller will take care of + // handling once preparation is finished) callback(null, null, false); } else if (self.isPrepared(song)) { - // song has already prepared, caller can start playing song + // song has already prepared, caller can start playing song callback(null, null, true); } else { - // begin preparing song + // begin preparing song let cancelEncode = null; let canceled = false; song.prepare = { - data: new Buffer.allocUnsafe(1024 * 1024), + data: new Buffer.allocUnsafe(1024 * 1024), dataPos: 0, - cancel: () => { + cancel: () => { canceled = true; if (cancelEncode) { cancelEncode(); @@ -356,15 +356,15 @@ export default class Local extends Backend { song = song.toObject(); results.songs[song._id] = { - artist: song.artist, - title: song.title, - album: song.album, - albumArt: null, // TODO: can we add this? - duration: song.duration, - songId: song._id, - score: self.config.maxScore * (numItems - cur) / numItems, + artist: song.artist, + title: song.title, + album: song.album, + albumArt: null, // TODO: can we add this? + duration: song.duration, + songId: song._id, + score: self.config.maxScore * (numItems - cur) / numItems, backendName: 'local', - format: 'opus', + format: 'opus', }; cur++; } diff --git a/src/config.js b/src/config.js index eae97e4..f62edd2 100644 --- a/src/config.js +++ b/src/config.js @@ -4,6 +4,8 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +/* eslint no-console: ["error", { "allow": ["warn"] }] */ + function getHomeDir() { if (process.platform === 'win32') { return process.env.USERPROFILE; @@ -26,7 +28,7 @@ const defaultConfig = {}; // backends are sources of music defaultConfig.backends = [ - 'youtube', + // 'youtube', ]; // plugins are "everything else", most of the functionality is in plugins @@ -34,7 +36,7 @@ defaultConfig.backends = [ // NOTE: ordering is important here, plugins that require another plugin will // complain if order is wrong. defaultConfig.plugins = [ - 'weblistener', + // 'weblistener', ]; defaultConfig.logLevel = 'info'; @@ -82,7 +84,7 @@ exports.getDefaultConfig = () => { // path and defaults are optional, if undefined then values corresponding to core config are used exports.getConfig = (module, defaults) => { if (process.env.NODE_ENV === 'test') { - // unit tests should always use default config + // unit tests should always use default config return (defaults || defaultConfig); } @@ -97,15 +99,15 @@ exports.getConfig = (module, defaults) => { } catch (e) { if (e.code === 'MODULE_NOT_FOUND') { if (!moduleName) { - // only print welcome text for core module first run + // only print welcome text for core module first run console.warn('Welcome to nodeplayer!'); console.warn('----------------------'); } console.warn('\n====================================================================='); console.warn('We couldn\'t find the user configuration file for module "' + - (moduleName || 'core') + '",'); + (moduleName || 'core') + '",'); console.warn('so a sample configuration file containing default settings ' + - 'will be written into:'); + 'will be written into:'); console.warn(configPath); mkdirp.sync(path.join(getBaseDir(), 'config')); @@ -113,15 +115,14 @@ exports.getConfig = (module, defaults) => { console.warn('\nFile created. Go edit it NOW!'); console.warn('Note that the file only needs to contain the configuration ' + - 'variables that'); + 'variables that'); console.warn('you want to override from the defaults. Also note that it ' + - 'MUST be valid JSON!'); + 'MUST be valid JSON!'); console.warn('=====================================================================\n'); if (!moduleName) { - // only exit on missing core module config - console.warn('Exiting now. Please re-run nodeplayer when you\'re done ' + - 'configuring!'); + // only exit on missing core module config + console.warn('Exiting now. Please re-run nodeplayer when you\'re done configuring!'); process.exit(0); } diff --git a/src/logger.js b/src/logger.js index 07ecee6..80b9ad8 100644 --- a/src/logger.js +++ b/src/logger.js @@ -6,11 +6,11 @@ module.exports = label => { return new (winston.Logger)({ transports: [ new (winston.transports.Console)({ - label: label, - level: config.logLevel, - colorize: config.logColorize, + label: label, + level: config.logLevel, + colorize: config.logColorize, handleExceptions: config.logExceptions, - json: config.logJson, + json: config.logJson, }), ], }); diff --git a/src/player.js b/src/player.js index eff91d7..b8305fb 100644 --- a/src/player.js +++ b/src/player.js @@ -10,7 +10,7 @@ export default class Player { constructor(options) { options = options || {}; - // TODO: some of these should NOT be loaded from config + // TODO: some of these should NOT be loaded from config _.bindAll.apply(_, [this].concat(_.functions(this))); this.config = options.config || require('./config').getConfig(); this.logger = options.logger || labeledLogger('core'); @@ -73,9 +73,9 @@ export default class Player { // if any hooks return a truthy value, it is an error and we abort // be very careful with calling hooks from within a hook, infinite loops are possible callHooks(hook, argv) { - // _.find() used instead of _.each() because we want to break out as soon - // as a hook returns a truthy value (used to indicate an error, e.g. in form - // of a string) + // _.find() used instead of _.each() because we want to break out as soon + // as a hook returns a truthy value (used to indicate an error, e.g. in form + // of a string) let err = null; this.logger.silly('callHooks(' + hook + @@ -129,7 +129,7 @@ export default class Player { if (np) { np.playback = { startTime: 0, - startPos: pause ? pos : 0, + startPos: pause ? pos : 0, }; } } @@ -144,7 +144,7 @@ export default class Player { const player = this; if (!this.nowPlaying) { - // find first song in queue + // find first song in queue this.nowPlaying = this.queue.songs[0]; if (!this.nowPlaying) { @@ -222,68 +222,68 @@ export default class Player { Object.defineProperty(song, 'prepareTimeout', { enumerable: false, - writable: true, + writable: true, }); } clearPrepareTimeout(song) { - // clear prepare timeout + // clear prepare timeout clearTimeout(song.prepareTimeout); song.prepareTimeout = null; } prepareError(song, err) { - // TODO: mark song as failed + // TODO: mark song as failed this.callHooks('onSongPrepareError', [song, err]); } prepareProgCallback(song, bytesWritten, done) { - /* progress callback - * when this is called, new song data has been flushed to disk */ + /* progress callback + * when this is called, new song data has been flushed to disk */ - // start playback if it hasn't been started yet + // start playback if it hasn't been started yet if (this.play && this.getNowPlaying() && this.getNowPlaying().uuid === song.uuid && !this.queue.playbackStart && bytesWritten) { this.startPlayback(); } - // tell plugins that new data is available for this song, and - // whether the song is now fully written to disk or not. + // tell plugins that new data is available for this song, and + // whether the song is now fully written to disk or not. this.callHooks('onPrepareProgress', [song, bytesWritten, done]); if (done) { - // mark song as prepared + // mark song as prepared this.callHooks('onSongPrepared', [song]); - // done preparing, can't cancel anymore + // done preparing, can't cancel anymore delete (song.cancelPrepare); - // song data should now be available on disk, don't keep it in memory + // song data should now be available on disk, don't keep it in memory song.backend.songsPreparing[song.songId].songData = undefined; delete (song.backend.songsPreparing[song.songId]); - // clear prepare timeout + // clear prepare timeout this.clearPrepareTimeout(song); } else { - // reset prepare timeout + // reset prepare timeout this.setPrepareTimeout(song); } } prepareErrCallback(song, err, callback) { - /* error callback */ + /* error callback */ - // don't let anything run cancelPrepare anymore + // don't let anything run cancelPrepare anymore delete (song.cancelPrepare); this.clearPrepareTimeout(song); - // abort preparing more songs; current song will be deleted -> - // onQueueModified is called -> song preparation is triggered again + // abort preparing more songs; current song will be deleted -> + // onQueueModified is called -> song preparation is triggered again callback(true); - // TODO: investigate this, should probably be above callback + // TODO: investigate this, should probably be above callback this.prepareError(song, err); song.songData = undefined; @@ -298,17 +298,17 @@ export default class Player { } if (song.isPrepared()) { - // start playback if it hasn't been started yet + // start playback if it hasn't been started yet if (this.play && this.getNowPlaying() && this.getNowPlaying().uuid === song.uuid && !this.queue.playbackStart) { this.startPlayback(); } - // song is already prepared, ok to prepare more songs + // song is already prepared, ok to prepare more songs callback(); } else { - // song is not prepared and not currently preparing: let backend prepare it + // song is not prepared and not currently preparing: let backend prepare it this.logger.debug('DEBUG: prepareSong() ' + song.songId); song.prepare((err, chunk, done) => { @@ -339,32 +339,32 @@ export default class Player { let currentSong; async.series([ callback => { - // prepare now-playing song + // prepare now-playing song currentSong = player.getNowPlaying(); if (currentSong) { player.prepareSong(currentSong, callback); } else if (player.queue.getLength()) { - // songs exist in queue, prepare first one + // songs exist in queue, prepare first one currentSong = player.queue.songs[0]; player.prepareSong(currentSong, callback); } else { - // bail out + // bail out callback(true); } }, callback => { - // prepare next song in playlist + // prepare next song in playlist const nextSong = player.queue.songs[player.queue.findSongIndex(currentSong) + 1]; if (nextSong) { player.prepareSong(nextSong, callback); } else { - // bail out + // bail out callback(true); } }, ]); - // TODO where to put this - // player.prepareErrCallback(); + // TODO where to put this + // player.prepareErrCallback(); } getPlaylists(callback) { @@ -376,7 +376,7 @@ export default class Player { if (!backend.getPlaylists) { resultCnt++; - // got results from all services? + // got results from all services? if (resultCnt >= Object.keys(player.backends).length) { callback(allResults); } @@ -388,7 +388,7 @@ export default class Player { allResults[backend.name] = results; - // got results from all services? + // got results from all services? if (resultCnt >= Object.keys(player.backends).length) { callback(allResults); } @@ -405,8 +405,8 @@ export default class Player { backend.search(query, _.bind(results => { resultCnt++; - // make a temporary copy of songlist, clear songlist, check - // each song and add them again if they are ok + // make a temporary copy of songlist, clear songlist, check + // each song and add them again if they are ok const tempSongs = _.clone(results.songs); allResults[backend.name] = results; allResults[backend.name].songs = {}; @@ -420,7 +420,7 @@ export default class Player { } }, this); - // got results from all services? + // got results from all services? if (resultCnt >= Object.keys(this.backends).length) { callback(allResults); } @@ -428,7 +428,7 @@ export default class Player { resultCnt++; this.logger.error('error while searching ' + backend.name + ': ' + err); - // got results from all services? + // got results from all services? if (resultCnt >= Object.keys(this.backends).length) { callback(allResults); } diff --git a/src/plugins/express.js b/src/plugins/express.js index b89ff2a..b3ae6b8 100644 --- a/src/plugins/express.js +++ b/src/plugins/express.js @@ -13,7 +13,7 @@ export default class Express extends Plugin { constructor(player, callback) { super(); - // NOTE: no argument passed so we get the core's config + // NOTE: no argument passed so we get the core's config const config = require('../config').getConfig(); player.app = express(); @@ -21,14 +21,14 @@ export default class Express extends Plugin { const port = process.env.PORT || config.port; if (config.tls) { options = { - tls: config.tls, - key: config.key ? fs.readFileSync(config.key) : undefined, - cert: config.cert ? fs.readFileSync(config.cert) : undefined, - ca: config.ca ? fs.readFileSync(config.ca) : undefined, - requestCert: config.requestCert, + tls: config.tls, + key: config.key ? fs.readFileSync(config.key) : undefined, + cert: config.cert ? fs.readFileSync(config.cert) : undefined, + ca: config.ca ? fs.readFileSync(config.ca) : undefined, + requestCert: config.requestCert, rejectUnauthorized: config.rejectUnauthorized, }; - // TODO: deprecated! + // TODO: deprecated! player.app.set('tls', true); player.httpServer = https.createServer(options, player.app) .listen(port); diff --git a/src/plugins/rest.js b/src/plugins/rest.js index c590cd5..4c7c318 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -40,10 +40,10 @@ export default class Rest extends Plugin { } res.json({ - songs: player.queue.serialize(), - nowPlaying: np ? np.serialize() : null, + songs: player.queue.serialize(), + nowPlaying: np ? np.serialize() : null, nowPlayingPos: pos, - play: player.play, + play: player.play, }); }); @@ -122,10 +122,10 @@ export default class Rest extends Plugin { end = Math.min(client.wishRange[1], bytesWritten - 1); } - // console.log('end: ' + end + '\tclient.serveRange[1]: ' + client.serveRange[1]); + // console.log('end: ' + end + '\tclient.serveRange[1]: ' + client.serveRange[1]); if (client.serveRange[1] < end) { - // console.log('write'); + // console.log('write'); client.res.write(song.prepare.data.slice(client.serveRange[1] + 1, end)); } @@ -133,7 +133,7 @@ export default class Rest extends Plugin { } if (done) { - console.log('done'); + this.log.debug('done'); client.res.end(); } }); @@ -146,7 +146,7 @@ export default class Rest extends Plugin { this.registerHook('onBackendInitialized', backendName => { rest.pendingRequests[backendName] = {}; - // provide API path for music data, might block while song is preparing + // provide API path for music data, might block while song is preparing player.app.get('/song/' + backendName + '/:fileName', (req, res, next) => { const extIndex = req.params.fileName.lastIndexOf('.'); const songId = req.params.fileName.substring(0, extIndex); @@ -164,7 +164,7 @@ export default class Rest extends Plugin { async.series([ callback => { - // try finding out length of song + // try finding out length of song if (queuedSong) { res.setHeader('X-Content-Duration', queuedSong.duration / 1000); callback(); @@ -177,12 +177,12 @@ export default class Rest extends Plugin { }, callback => { if (backend.isPrepared({ songId: songId })) { - // song should be available on disk + // song should be available on disk res.sendFile(filename, { root: config.songCachePath, }); } else if (backend.songsPreparing[songId]) { - // song is preparing + // song is preparing const song = backend.songsPreparing[songId]; const haveRange = []; @@ -200,13 +200,13 @@ export default class Rest extends Plugin { res.setHeader('Transfer-Encoding', 'chunked'); if (req.headers.range) { - // partial request + // partial request wishRange = req.headers.range.substr(req.headers.range.indexOf('=') + 1).split('-'); serveRange[0] = wishRange[0]; - // a best guess for the response header + // a best guess for the response header serveRange[1] = haveRange[1]; if (wishRange[1]) { serveRange[1] = Math.min(wishRange[1], haveRange[1]); @@ -225,10 +225,10 @@ export default class Rest extends Plugin { } const client = { - res: res, + res: res, serveRange: serveRange, - wishRange: wishRange, - filepath: path.join(config.songCachePath, filename), + wishRange: wishRange, + filepath: path.join(config.songCachePath, filename), }; // TODO: If we know that we have already flushed data to disk, diff --git a/src/queue.js b/src/queue.js index eeb0971..cf8b8db 100644 --- a/src/queue.js +++ b/src/queue.js @@ -82,10 +82,10 @@ export default class Queue { insertSongs(at, songs) { let pos; if (at === null) { - // insert at start of queue + // insert at start of queue pos = 0; } else { - // insert song after song with UUID + // insert song after song with UUID pos = this.findSongIndex(at); if (pos < 0) { @@ -95,10 +95,10 @@ export default class Queue { pos++; // insert after song } - // generate Song objects of each song + // generate Song objects of each song songs = _.map(songs, song => { - // TODO: this would be best done in the song constructor, - // effectively making it a SerializedSong object deserializer + // TODO: this would be best done in the song constructor, + // effectively making it a SerializedSong object deserializer const backend = this.player.backends[song.backendName]; if (!backend) { throw new Error('Song constructor called with invalid backend: ' + song.backendName); @@ -107,7 +107,7 @@ export default class Queue { return new Song(song, backend); }, this); - // perform insertion + // perform insertion const args = [pos, 0].concat(songs); Array.prototype.splice.apply(this.songs, args); @@ -126,7 +126,7 @@ export default class Queue { return 'Song with UUID ' + at + ' not found!'; } - // cancel preparing all songs to be deleted + // cancel preparing all songs to be deleted for (let i = pos; i < pos + cnt && i < this.songs.length; i++) { const song = this.songs[i]; if (song.cancelPrepare) { @@ -134,16 +134,16 @@ export default class Queue { } } - // store index of now playing song + // store index of now playing song const np = this.player.nowPlaying; const npIndex = np ? this.findSongIndex(np.uuid) : -1; - // perform deletion + // perform deletion const removed = this.songs.splice(pos, cnt); - // was now playing removed? + // was now playing removed? if (pos <= npIndex && pos + cnt >= npIndex) { - // change to first song after splice + // change to first song after splice const newNp = this.songs[pos]; this.player.changeSong(newNp ? newNp.uuid : null); } else { @@ -158,16 +158,16 @@ export default class Queue { */ shuffle() { if (this.unshuffledSongs) { - // unshuffle + // unshuffle - // restore unshuffled list + // restore unshuffled list this.songs = this.unshuffledSongs; this.unshuffledSongs = null; } else { - // shuffle + // shuffle - // store copy of current songs array + // store copy of current songs array this.unshuffledSongs = this.songs.slice(); this.songs = _.shuffle(this.songs); diff --git a/src/song.js b/src/song.js index 20a1589..bb43d3a 100644 --- a/src/song.js +++ b/src/song.js @@ -46,7 +46,7 @@ export default class Song { this.playback = { startTime: null, - startPos: null, + startPos: null, }; // NOTE: internally to the Song we store a reference to the backend. @@ -68,7 +68,7 @@ export default class Song { playbackStarted(pos) { this.playback = { startTime: new Date(), - startPos: pos || null, + startPos: pos || null, }; } @@ -78,18 +78,18 @@ export default class Song { */ serialize() { return { - uuid: this.uuid, - title: this.title, - artist: this.artist, - album: this.album, - albumArt: this.albumArt, - duration: this.duration, - songId: this.songId, - score: this.score, - format: this.format, + uuid: this.uuid, + title: this.title, + artist: this.artist, + album: this.album, + albumArt: this.albumArt, + duration: this.duration, + songId: this.songId, + score: this.score, + format: this.format, backendName: this.backend.name, - playlist: this.playlist, - playback: this.playback, + playlist: this.playlist, + playback: this.playback, }; } From ebbb4dac950f0707e166d2eb153dcf819f22e161 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 5 Sep 2016 19:38:01 +0300 Subject: [PATCH 085/103] Cleanup Player constructor --- package.json | 1 + src/player.js | 32 +++++++++++++++----------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 26a053f..58de549 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "babel-cli": "^6.11.4", "babel-core": "^6.11.4", "babel-preset-node6": "^11.0.0", + "bluebird": "^3.4.1", "body-parser": "^1.15.1", "cookie-parser": "^1.4.3", "escape-string-regexp": "^1.0.5", diff --git a/src/player.js b/src/player.js index b8305fb..4cc8ca1 100644 --- a/src/player.js +++ b/src/player.js @@ -6,24 +6,22 @@ const labeledLogger = require('./logger'); import Queue from './queue'; const modules = require('./modules'); +import { getConfig } from './config'; + export default class Player { - constructor(options) { - options = options || {}; - - // TODO: some of these should NOT be loaded from config - _.bindAll.apply(_, [this].concat(_.functions(this))); - this.config = options.config || require('./config').getConfig(); - this.logger = options.logger || labeledLogger('core'); - this.queue = options.queue || new Queue(this); - this.nowPlaying = options.nowPlaying || null; - this.play = options.play || false; - this.repeat = options.repeat || false; - this.plugins = options.plugins || {}; - this.backends = options.backends || {}; - this.prepareTimeouts = options.prepareTimeouts || {}; - this.volume = options.volume || 1; - this.songEndTimeout = options.songEndTimeout || null; - this.pluginVars = options.pluginVars || {}; + constructor(options = {}) { + this.config = getConfig(); + this.logger = labeledLogger('core'); + this.queue = new Queue(this); + this.nowPlaying = null; + this.play = false; + this.repeat = false; + this.plugins = {}; + this.backends = {}; + this.prepareTimeouts = {}; + this.volume = 1; + this.songEndTimeout = null; + this.pluginVars = {}; this.init = this.init.bind(this); } From d626a6d99b91e473a4e230388d1e20bdf71a531d Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Mon, 5 Sep 2016 20:53:08 +0300 Subject: [PATCH 086/103] WIP: make third party modules work properly --- index.js | 17 +++++++++++------ package.json | 4 ++-- src/backend.js | 12 +++++++++--- src/backends/index.js | 2 +- src/modules.js | 27 ++++++++++++++++++++++++--- src/player.js | 10 +++++----- src/plugin.js | 9 ++++++++- src/plugins/rest.js | 1 + 8 files changed, 61 insertions(+), 21 deletions(-) diff --git a/index.js b/index.js index 9df79cd..2d7c50b 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,14 @@ 'use strict'; -var Player = require('./src/player'); -var nodeplayerConfig = require('./src/config'); -var labeledLogger = require('./src/logger'); +var config = require('./src/config'); +var logger = require('./src/logger'); -exports.Player = Player; -exports.config = nodeplayerConfig; -exports.logger = labeledLogger; +import Player from './src/player'; +import Backend from './src/backend'; + +export { + Player, + Backend, + config, + logger, +} diff --git a/package.json b/package.json index 58de549..d135cd9 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "nodeplayer", "version": "0.2.1", "description": "simple, modular music player written in node.js", - "main": "bin/nodeplayer", + "main": "index.js", "preferGlobal": true, "scripts": { "start": "node dist/index", @@ -19,7 +19,7 @@ }, "author": "FruitieX", "bin": { - "nodeplayer": "./bin/nodeplayer" + "nodeplayer": "./dist/index.js" }, "license": "MIT", "dependencies": { diff --git a/src/backend.js b/src/backend.js index d923b12..ae6cb7c 100644 --- a/src/backend.js +++ b/src/backend.js @@ -1,17 +1,23 @@ const path = require('path'); const fs = require('fs'); const ffmpeg = require('fluent-ffmpeg'); -const config = require('./config').getConfig(); +const coreConfig = require('./config').getConfig(); +const config = require('./config'); const labeledLogger = require('./logger'); /** * Super constructor for backends */ export default class Backend { - constructor() { + constructor(defaultConfig) { this.name = this.constructor.name.toLowerCase(); this.log = labeledLogger(this.name); this.songsPreparing = {}; + this.coreConfig = coreConfig; + + if (defaultConfig) { + this.config = config.getConfig(this, defaultConfig); + } } /** @@ -33,7 +39,7 @@ export default class Backend { encodeSong(stream, seek, song, callback) { const self = this; - const encodedPath = path.join(config.songCachePath, self.name, + const encodedPath = path.join(coreConfig.songCachePath, self.name, song.songId + '.opus'); const command = ffmpeg(stream) diff --git a/src/backends/index.js b/src/backends/index.js index 6e1bd2e..476d104 100644 --- a/src/backends/index.js +++ b/src/backends/index.js @@ -1,6 +1,6 @@ import Local from './local'; const Backends = []; -Backends.push(Local); +// Backends.push(Local); module.exports = Backends; diff --git a/src/modules.js b/src/modules.js index 6c839b5..6bcc768 100644 --- a/src/modules.js +++ b/src/modules.js @@ -60,9 +60,29 @@ var initModule = (moduleShortName, moduleType, callback) => { // TODO: this probably doesn't work // needs rewrite exports.loadBackends = (player, backends, forceUpdate, done) => { - // first install missing backends - installModules(backends, 'backend', forceUpdate, () => { - // then initialize all backends in parallel + async.mapSeries(backends, (moduleName, callback) => { + moduleName = 'nodeplayer-backend-' + moduleName; + + const Backend = require(moduleName); + const backend = new Backend((err, backend) => { + if (err) { + backend.log.error('while initializing: ' + err); + return callback(); + } + + player.callHooks('onBackendInitialized', [backend.name]); + backend.log.verbose('backend initialized'); + callback(null, { [backend.name]: backend }); + }); + }, (err, results) => { + done(Object.assign({}, ...results)); + }); +}; + +/* + // first install missing backends + // installModules(backends, 'backend', forceUpdate, () => { + // then initialize all backends in parallel async.map(backends, (backend, callback) => { const moduleLogger = labeledLogger(backend); const moduleName = 'nodeplayer-backend-' + backend; @@ -92,6 +112,7 @@ exports.loadBackends = (player, backends, forceUpdate, done) => { }); }); }; +*/ // TODO: this probably doesn't work // needs rewrite diff --git a/src/player.js b/src/player.js index 4cc8ca1..2e20313 100644 --- a/src/player.js +++ b/src/player.js @@ -400,7 +400,7 @@ export default class Player { const allResults = {}; _.each(this.backends, backend => { - backend.search(query, _.bind(results => { + backend.search(query, results => { resultCnt++; // make a temporary copy of songlist, clear songlist, check @@ -416,13 +416,13 @@ export default class Player { } else { allResults[backend.name].songs[song.songId] = song; } - }, this); + }); // got results from all services? if (resultCnt >= Object.keys(this.backends).length) { callback(allResults); } - }, this), _.bind(err => { + }, err => { resultCnt++; this.logger.error('error while searching ' + backend.name + ': ' + err); @@ -430,8 +430,8 @@ export default class Player { if (resultCnt >= Object.keys(this.backends).length) { callback(allResults); } - }, this)); - }, this); + }); + }); } // TODO: userID does not belong into core...? diff --git a/src/plugin.js b/src/plugin.js index 46226db..73d326c 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -1,13 +1,20 @@ +const coreConfig = require('./config').getConfig(); +const config = require('./config'); const labeledLogger = require('./logger'); /** * Super constructor for plugins */ export default class Plugin { - constructor() { + constructor(defaultConfig) { this.name = this.constructor.name.toLowerCase(); this.log = labeledLogger(this.name); this.hooks = {}; + this.coreConfig = coreConfig; + + if (defaultConfig) { + this.config = config.getConfig(this, defaultConfig); + } } registerHook(hook, callback) { diff --git a/src/plugins/rest.js b/src/plugins/rest.js index 4c7c318..5d734b2 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -104,6 +104,7 @@ export default class Rest extends Plugin { this.log.verbose('got search request: ' + JSON.stringify(req.body.query)); player.searchBackends(req.body.query, results => { + console.log(results); res.json(results); }); }); From 9911eb1441431dcc4e8e8f1edb7869c98fe91021 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sun, 9 Oct 2016 19:09:19 +0300 Subject: [PATCH 087/103] Return songs as array --- src/player.js | 45 ++++++++++++--------------------------------- src/plugins/rest.js | 6 +++--- 2 files changed, 15 insertions(+), 36 deletions(-) diff --git a/src/player.js b/src/player.js index 2e20313..fa846c4 100644 --- a/src/player.js +++ b/src/player.js @@ -395,42 +395,21 @@ export default class Player { } // make a search query to backends - searchBackends(query, callback) { - let resultCnt = 0; - const allResults = {}; - - _.each(this.backends, backend => { - backend.search(query, results => { - resultCnt++; - - // make a temporary copy of songlist, clear songlist, check - // each song and add them again if they are ok - const tempSongs = _.clone(results.songs); - allResults[backend.name] = results; - allResults[backend.name].songs = {}; - - _.each(tempSongs, song => { - const err = this.callHooks('preAddSearchResult', [song]); - if (err) { - this.logger.error('preAddSearchResult hook error: ' + err); - } else { - allResults[backend.name].songs[song.songId] = song; - } - }); - - // got results from all services? - if (resultCnt >= Object.keys(this.backends).length) { - callback(allResults); + searchBackends(query, done) { + console.log(this.backends); + async.mapValues(this.backends, (backend, backendName, callback) => { + backend.search(query, (err, results) => { + if (err) { + this.logger.error('error while searching ' + backend.name + ': ' + err); + results.error = err; } - }, err => { - resultCnt++; - this.logger.error('error while searching ' + backend.name + ': ' + err); - // got results from all services? - if (resultCnt >= Object.keys(this.backends).length) { - callback(allResults); - } + callback(null, results); }); + }, (err, allResults) => { + console.log(allResults); + + done(null, allResults); }); } diff --git a/src/plugins/rest.js b/src/plugins/rest.js index 5d734b2..9af5b2c 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -100,10 +100,10 @@ export default class Rest extends Plugin { }); // search for songs, search terms in query params - player.app.get('/search', (req, res) => { - this.log.verbose('got search request: ' + JSON.stringify(req.body.query)); + player.app.post('/search', (req, res) => { + this.log.verbose('got search request: ' + JSON.stringify(req.body)); - player.searchBackends(req.body.query, results => { + player.searchBackends(req.body, (err, results) => { console.log(results); res.json(results); }); From 731da0487a7f3ecba4bbd1657b9522660d1344d1 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sun, 9 Oct 2016 19:53:40 +0300 Subject: [PATCH 088/103] Queue.insertSongs supports either Song objects or arrays --- src/queue.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/queue.js b/src/queue.js index cf8b8db..08fe930 100644 --- a/src/queue.js +++ b/src/queue.js @@ -76,7 +76,7 @@ export default class Queue { * Insert songs into queue * @param {String | null} at - Insert songs after song with this UUID * (null = start of queue) - * @param {Object[]} songs - List of songs to insert + * @param {Song | Object[]} songs - Song or list of songs to insert * @return {Error} - in case of errors */ insertSongs(at, songs) { @@ -95,6 +95,10 @@ export default class Queue { pos++; // insert after song } + if (!_.isArray(songs)) { + songs = [songs]; + } + // generate Song objects of each song songs = _.map(songs, song => { // TODO: this would be best done in the song constructor, From 45ffe1204b331844c8a72960a0ce2b3af972d0dc Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sun, 9 Oct 2016 20:36:05 +0300 Subject: [PATCH 089/103] Implement prepare() in core --- src/backend.js | 104 +++++++++++++++++++++++++++++++++++++------------ src/player.js | 7 +--- 2 files changed, 81 insertions(+), 30 deletions(-) diff --git a/src/backend.js b/src/backend.js index ae6cb7c..da0479e 100644 --- a/src/backend.js +++ b/src/backend.js @@ -4,6 +4,7 @@ const ffmpeg = require('fluent-ffmpeg'); const coreConfig = require('./config').getConfig(); const config = require('./config'); const labeledLogger = require('./logger'); +const mkdirp = require('mkdirp'); /** * Super constructor for backends @@ -37,9 +38,7 @@ export default class Backend { * @return {Function} - Can be called to terminate encoding */ encodeSong(stream, seek, song, callback) { - const self = this; - - const encodedPath = path.join(coreConfig.songCachePath, self.name, + const encodedPath = path.join(coreConfig.songCachePath, this.name, song.songId + '.opus'); const command = ffmpeg(stream) @@ -50,7 +49,7 @@ export default class Backend { .audioBitrate('192') .format('opus') .on('error', err => { - self.log.error(self.name + ': error while transcoding ' + song.songId + ': ' + err); + this.log.error(this.name + ': error while transcoding ' + song.songId + ': ' + err); delete song.prepare.data; callback(err); }); @@ -71,7 +70,7 @@ export default class Backend { const newSize = Math.max(song.prepare.data.length * 2, song.prepare.data.length + chunk.length); - self.log.debug('Allocated new song data buffer of size: ' + newSize); + this.log.debug('Allocated new song data buffer of size: ' + newSize); const buf = new Buffer.allocUnsafe(newSize); @@ -85,31 +84,87 @@ export default class Backend { callback(null, chunk.length, false); }); opusStream.on('end', () => { - fs.writeFile(encodedPath, song.prepare.data, err => { - self.log.verbose('transcoding ended for ' + song.songId); - - delete song.prepare; - // TODO: we don't know if transcoding ended successfully or not, - // and there might be a race condition between errCallback deleting - // the file and us trying to move it to the songCache - // TODO: is this still the case? - // (we no longer save incomplete files on disk) - - callback(null, null, true); + mkdirp(path.dirname(encodedPath), err => { + if (err) { + return this.log.error(`error creating directory: ${path.dirname(encodedPath)}: ${err}`); + } + fs.writeFile(encodedPath, song.prepare.data, err => { + if (err) { + return this.log.error(`error writing file to ${encodedPath}: ${err}`); + } + + this.log.verbose('wrote file to ' + encodedPath); + this.log.verbose('transcoding ended for ' + song.songId); + + delete song.prepare; + // TODO: we don't know if transcoding ended successfully or not, + // and there might be a race condition between errCallback deleting + // the file and us trying to move it to the songCache + // TODO: is this still the case? + // (we no longer save incomplete files on disk) + + callback(null, null, true); + }); }); }); - self.log.verbose('transcoding ' + song.songId + '...'); + this.log.verbose('transcoding ' + song.songId + '...'); // return a function which can be used for terminating encoding return err => { command.kill(); - self.log.verbose(self.name + ': canceled preparing: ' + song.songId + ': ' + err); + this.log.verbose(this.name + ': canceled preparing: ' + song.songId + ': ' + err); delete song.prepare; callback(new Error('canceled preparing: ' + song.songId + ': ' + err)); }; } + /** + * Prepare song for playback + * @param {Song} song - Song to prepare + * @param {encodeCallback} callback - Called when song is ready or on error + */ + prepare(song, callback) { + if (this.songsPreparing[song.songId]) { + // song is preparing, caller can drop this request (previous caller will take care of + // handling once preparation is finished) + callback(null, null, false); + } else if (this.isPrepared(song)) { + // song has already prepared, caller can start playing song + callback(null, null, true); + } else { + // begin preparing song + let cancelEncode = null; + let canceled = false; + + song.prepare = { + data: new Buffer.allocUnsafe(1024 * 1024), + dataPos: 0, + cancel: () => { + canceled = true; + if (cancelEncode) { + cancelEncode(); + } + }, + }; + + this.songsPreparing[song.songId] = song; + + this.getSongStream(song, (err, readStream) => { + if (canceled) { + callback(new Error('song was canceled before encoding started')); + } else if (err) { + callback(new Error(`error while getting song stream: ${err}`)); + } else { + cancelEncode = this.encodeSong(readStream, 0, song, callback); + readStream.on('error', err => { + callback(err); + }); + } + }); + } + } + /** * Cancel song preparation if applicable * @param {Song} song - Song to cancel @@ -149,18 +204,19 @@ export default class Backend { * @return {Boolean} - true if song is prepared, false if not */ isPrepared(song) { - this.log.error('FATAL: backend does not implement songPrepared()!'); + this.log.error('FATAL: backend does not implement isPrepared()!'); return false; } + /** - * Prepare song for playback + * Get read stream for song * @param {Song} song - Song to prepare - * @param {encodeCallback} callback - Called when song is ready or on error + * @param {streamCallback} callback - Called when read stream is ready or on error */ - prepare(song, callback) { - this.log.error('FATAL: backend does not implement prepare()!'); - callback(new Error('FATAL: backend does not implement prepare()!')); + getSongStream(song, callback) { + this.log.error('FATAL: backend does not implement getSongStream()!'); + callback(new Error('FATAL: backend does not implement getSongStream()!')); } /** diff --git a/src/player.js b/src/player.js index fa846c4..7e9ac95 100644 --- a/src/player.js +++ b/src/player.js @@ -396,7 +396,6 @@ export default class Player { // make a search query to backends searchBackends(query, done) { - console.log(this.backends); async.mapValues(this.backends, (backend, backendName, callback) => { backend.search(query, (err, results) => { if (err) { @@ -406,11 +405,7 @@ export default class Player { callback(null, results); }); - }, (err, allResults) => { - console.log(allResults); - - done(null, allResults); - }); + }, done); } // TODO: userID does not belong into core...? From caf8af2ddbbea696710e7e89845efe4a9b999dad Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Wed, 19 Oct 2016 21:09:41 +0300 Subject: [PATCH 090/103] Create initial DB migration script (#35) * Create initial DB migration script * Fix eslint errors --- db/migrations/000_initial.js | 24 ++++++++++++++++++++ knexfile.js | 28 +++++++++++++++++++++++ package.json | 7 +++++- src/backend.js | 1 - src/backends/index.js | 2 +- src/backends/local.js | 43 ++++++++++++++++++++++++++++++++++++ src/config.js | 5 +++++ src/modules.js | 2 +- src/player.js | 22 +++++++++--------- src/plugins/rest.js | 2 +- 10 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 db/migrations/000_initial.js create mode 100644 knexfile.js diff --git a/db/migrations/000_initial.js b/db/migrations/000_initial.js new file mode 100644 index 0000000..422c285 --- /dev/null +++ b/db/migrations/000_initial.js @@ -0,0 +1,24 @@ +/*eslint-disable func-names*/ +'use strict'; + +exports.up = function(knex) { + return knex.schema + .createTable('songs', function(table) { + table.text('songId').primary(); + table.text('backendName').notNullable(); + table.integer('duration').notNullable(); + table.text('title').notNullable(); + table.text('artist'); + table.text('album'); + table.text('albumArt'); + table.timestamp('createdAt').defaultTo(knex.fn.now()); + }) + + .then(function() { + }); +}; + +exports.down = function(knex) { + return knex.schema + .dropTableIfExists('songs'); +}; diff --git a/knexfile.js b/knexfile.js new file mode 100644 index 0000000..e844f45 --- /dev/null +++ b/knexfile.js @@ -0,0 +1,28 @@ +//This file is interpreted as ES5 CommonJS module. +'use strict'; + +const getConfig = require('./src/config').getConfig; + +const ALL_ENVIRONMENTS = Object.assign(getConfig().db, { + pool: { + min: 1, + max: 1 + }, + migrations: { + tableName: 'nodeplayer_migrations', + directory: 'db/migrations' + } +}); + +// Feel free to create any number of other environments. +// The ones below are a best attempt at sensible defaults. +module.exports = { + // Developer's local machine + development: ALL_ENVIRONMENTS, + // Unit and integration test environment + test: ALL_ENVIRONMENTS, + // Shared test/qa/staging/preproduction + staging: ALL_ENVIRONMENTS, + // Production environment + production: ALL_ENVIRONMENTS +}; diff --git a/package.json b/package.json index d135cd9..1725edd 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "watch": "nodemon --exec babel-node src/index.js", "test": "NODE_ENV=test ./node_modules/mocha/bin/mocha --compilers js:babel-core/register", "fix": "eslint --fix src", - "coverage": "NODE_ENV=test istanbul cover _mocha -- -R spec" + "coverage": "NODE_ENV=test istanbul cover _mocha -- -R spec", + "db:init": "knex migrate:latest", + "db:migrate": "knex migrate:latest", + "db:rollback": "knex migrate:rollback" }, "repository": { "type": "git", @@ -33,12 +36,14 @@ "escape-string-regexp": "^1.0.5", "express": "^4.13.4", "fluent-ffmpeg": "^2.1.0", + "knex": "^0.12.5", "lodash": "^4.15.0", "mkdirp": "^0.5.1", "mongoose": "^4.4.20", "node-ffprobe": "^1.2.2", "node-uuid": "^1.4.7", "npm": "^3.9.5", + "pg": "^6.1.0", "walk": "^2.3.9", "winston": "^2.2.0", "yargs": "^4.7.1" diff --git a/src/backend.js b/src/backend.js index da0479e..1590172 100644 --- a/src/backend.js +++ b/src/backend.js @@ -208,7 +208,6 @@ export default class Backend { return false; } - /** * Get read stream for song * @param {Song} song - Song to prepare diff --git a/src/backends/index.js b/src/backends/index.js index 476d104..6e1bd2e 100644 --- a/src/backends/index.js +++ b/src/backends/index.js @@ -1,6 +1,6 @@ import Local from './local'; const Backends = []; -// Backends.push(Local); +Backends.push(Local); module.exports = Backends; diff --git a/src/backends/local.js b/src/backends/local.js index 037c887..c88dc55 100644 --- a/src/backends/local.js +++ b/src/backends/local.js @@ -1,5 +1,40 @@ 'use strict'; +const Backend = require('../../').Backend; +const path = require('path'); +const fs = require('fs'); + +import knex from '../db'; + +module.exports = class Local extends Backend { + constructor(callback) { + super(); + + callback(null, this); + } + + isPrepared(song) { + const filePath = path.join(this.coreConfig.songCachePath, 'local', song.songId + '.opus'); + return fs.existsSync(filePath); + } + + getSongStream(song, callback) { + knex + .first('songs') + .where('songId', song.songId) + .then(song => { + // song id is file path + const filePath = song.songId; + let stream = fs.createReadStream(filePath); + callback(null, stream); + }) + .catch(err => { + callback(err); + }); + } +}; + +/* const path = require('path'); const fs = require('fs'); const mkdirp = require('mkdirp'); @@ -11,6 +46,7 @@ const _ = require('lodash'); const escapeStringRegexp = require('escape-string-regexp'); import Backend from '../backend'; +*/ /* var probeCallback = (err, probeData, next) => { @@ -80,6 +116,7 @@ var probeCallback = (err, probeData, next) => { }; */ +/* // database model const SongModel = mongoose.model('Song', { title: String, @@ -104,6 +141,7 @@ const SongModel = mongoose.model('Song', { dropDups: true, }, }); +*/ /** * Try to guess metadata from file path, @@ -114,6 +152,8 @@ const SongModel = mongoose.model('Song', { * @param {String} fileExt - Filename extension * @return {Metadata} Song metadata */ + +/* const guessMetadataFromPath = (filePath, fileExt) => { const fileName = path.basename(filePath, fileExt); @@ -237,6 +277,7 @@ export default class Local extends Backend { // TODO: fs watch // set fs watcher on media directory // TODO: add a debounce so if the file keeps changing we don't probe it multiple times +// */ /* watch(config.importPath, { recursive: true, @@ -255,6 +296,7 @@ export default class Local extends Backend { } }); */ + /* } isPrepared(song) { @@ -373,3 +415,4 @@ export default class Local extends Backend { }); } } +*/ diff --git a/src/config.js b/src/config.js index f62edd2..1cbba25 100644 --- a/src/config.js +++ b/src/config.js @@ -61,6 +61,11 @@ defaultConfig.requestCert = false; defaultConfig.rejectUnauthorized = true; // built-in local file backend +defaultConfig.db = { + client: 'postgresql', + connection: 'postgres;//postgres@127.0.0.1/nodeplayer', +}; + defaultConfig.mongo = 'mongodb://localhost:27017/nodeplayer-backend-file'; defaultConfig.rescanAtStart = false; defaultConfig.importPath = path.join(getHomeDir(), 'music'); diff --git a/src/modules.js b/src/modules.js index 6bcc768..6ac26de 100644 --- a/src/modules.js +++ b/src/modules.js @@ -64,7 +64,7 @@ exports.loadBackends = (player, backends, forceUpdate, done) => { moduleName = 'nodeplayer-backend-' + moduleName; const Backend = require(moduleName); - const backend = new Backend((err, backend) => { + const backend = new Backend(err => { if (err) { backend.log.error('while initializing: ' + err); return callback(); diff --git a/src/player.js b/src/player.js index 7e9ac95..8ecc41d 100644 --- a/src/player.js +++ b/src/player.js @@ -10,18 +10,18 @@ import { getConfig } from './config'; export default class Player { constructor(options = {}) { - this.config = getConfig(); - this.logger = labeledLogger('core'); - this.queue = new Queue(this); - this.nowPlaying = null; - this.play = false; - this.repeat = false; - this.plugins = {}; - this.backends = {}; + this.config = getConfig(); + this.logger = labeledLogger('core'); + this.queue = new Queue(this); + this.nowPlaying = null; + this.play = false; + this.repeat = false; + this.plugins = {}; + this.backends = {}; this.prepareTimeouts = {}; - this.volume = 1; - this.songEndTimeout = null; - this.pluginVars = {}; + this.volume = 1; + this.songEndTimeout = null; + this.pluginVars = {}; this.init = this.init.bind(this); } diff --git a/src/plugins/rest.js b/src/plugins/rest.js index 9af5b2c..0734c81 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -104,7 +104,7 @@ export default class Rest extends Plugin { this.log.verbose('got search request: ' + JSON.stringify(req.body)); player.searchBackends(req.body, (err, results) => { - console.log(results); + // console.log(results); res.json(results); }); }); From e5c3ceb6da3673a270a6e633d96ae0fef89725d4 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Wed, 19 Oct 2016 21:44:58 +0300 Subject: [PATCH 091/103] Support backends & plugins calling callback without 'this' argument --- src/backends/local.js | 2 +- src/modules.js | 63 +++++++++++++++++++++++------------------- src/plugins/express.js | 2 +- src/plugins/rest.js | 2 +- 4 files changed, 38 insertions(+), 31 deletions(-) diff --git a/src/backends/local.js b/src/backends/local.js index c88dc55..8112561 100644 --- a/src/backends/local.js +++ b/src/backends/local.js @@ -10,7 +10,7 @@ module.exports = class Local extends Backend { constructor(callback) { super(); - callback(null, this); + callback(null); } isPrepared(song) { diff --git a/src/modules.js b/src/modules.js index 6ac26de..a91e647 100644 --- a/src/modules.js +++ b/src/modules.js @@ -57,22 +57,23 @@ var initModule = (moduleShortName, moduleType, callback) => { }; */ -// TODO: this probably doesn't work -// needs rewrite exports.loadBackends = (player, backends, forceUpdate, done) => { async.mapSeries(backends, (moduleName, callback) => { moduleName = 'nodeplayer-backend-' + moduleName; const Backend = require(moduleName); const backend = new Backend(err => { - if (err) { - backend.log.error('while initializing: ' + err); - return callback(); - } - - player.callHooks('onBackendInitialized', [backend.name]); - backend.log.verbose('backend initialized'); - callback(null, { [backend.name]: backend }); + // defer execution in case callback was called synchronously + process.nextTick(err => { + if (err) { + backend.log.error('while initializing: ' + err); + return callback(); + } + + player.callHooks('onBackendInitialized', [backend.name]); + backend.log.verbose('backend initialized'); + callback(null, { [backend.name]: backend }); + }); }); }, (err, results) => { done(Object.assign({}, ...results)); @@ -152,15 +153,18 @@ exports.loadPlugins = (player, plugins, forceUpdate, done) => { exports.loadBuiltinPlugins = (player, done) => { async.mapSeries(BuiltinPlugins, (Plugin, callback) => { - return new Plugin(player, (err, plugin) => { - if (err) { - plugin.log.error('while initializing: ' + err); - return callback(); - } - - plugin.log.verbose('plugin initialized'); - player.callHooks('onPluginInitialized', [plugin.name]); - callback(null, { [plugin.name]: plugin }); + const plugin = new Plugin(player, err => { + // defer execution in case callback was called synchronously + process.nextTick(err => { + if (err) { + plugin.log.error('while initializing: ' + err); + return callback(); + } + + plugin.log.verbose('plugin initialized'); + player.callHooks('onPluginInitialized', [plugin.name]); + callback(null, { [plugin.name]: plugin }); + }); }); }, (err, results) => { done(Object.assign({}, ...results)); @@ -169,15 +173,18 @@ exports.loadBuiltinPlugins = (player, done) => { exports.loadBuiltinBackends = (player, done) => { async.mapSeries(BuiltinBackends, (Backend, callback) => { - return new Backend((err, backend) => { - if (err) { - backend.log.error('while initializing: ' + err); - return callback(); - } - - player.callHooks('onBackendInitialized', [backend.name]); - backend.log.verbose('backend initialized'); - callback(null, { [backend.name]: backend }); + const backend = new Backend(err => { + // defer execution in case callback was called synchronously + process.nextTick(err => { + if (err) { + backend.log.error('while initializing: ' + err); + return callback(); + } + + player.callHooks('onBackendInitialized', [backend.name]); + backend.log.verbose('backend initialized'); + callback(null, { [backend.name]: backend }); + }); }); }, (err, results) => { done(Object.assign({}, ...results)); diff --git a/src/plugins/express.js b/src/plugins/express.js index b3ae6b8..0af13de 100644 --- a/src/plugins/express.js +++ b/src/plugins/express.js @@ -42,6 +42,6 @@ export default class Express extends Plugin { player.app.use(bodyParser.json({ limit: '100mb' })); player.app.use(bodyParser.urlencoded({ extended: true })); - callback(null, this); + callback(null); } } diff --git a/src/plugins/rest.js b/src/plugins/rest.js index 0734c81..4c625be 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -263,6 +263,6 @@ export default class Rest extends Plugin { }); }); - callback(null, this); + callback(null); } } From 59f0f2da7481f998a6b73707962afa92c9a466db Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Wed, 19 Oct 2016 21:45:43 +0300 Subject: [PATCH 092/103] Add db config file --- src/db.js | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/db.js diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..3797eee --- /dev/null +++ b/src/db.js @@ -0,0 +1,9 @@ +'use strict'; + +import { getConfig } from './config'; +import knex from 'knex'; + +export default knex({ + client: getConfig().db.client, + connection: getConfig().db.connection, +}); From 446ef5c3abe47f877cd30af3652cb80e8202972a Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Wed, 2 Nov 2016 18:36:24 +0200 Subject: [PATCH 093/103] Move to hapi.js instead of express --- package.json | 5 ++-- src/modules.js | 6 ++--- src/plugins/index.js | 6 ++--- src/plugins/rest.js | 21 +++++++++++++---- src/plugins/{express.js => server.js} | 34 ++++++++++++++++++--------- 5 files changed, 47 insertions(+), 25 deletions(-) rename src/plugins/{express.js => server.js} (65%) diff --git a/package.json b/package.json index 1725edd..2d46ce2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "watch": "nodemon --exec babel-node src/index.js", "test": "NODE_ENV=test ./node_modules/mocha/bin/mocha --compilers js:babel-core/register", "fix": "eslint --fix src", + "lint": "eslint src", "coverage": "NODE_ENV=test istanbul cover _mocha -- -R spec", "db:init": "knex migrate:latest", "db:migrate": "knex migrate:latest", @@ -31,11 +32,9 @@ "babel-core": "^6.11.4", "babel-preset-node6": "^11.0.0", "bluebird": "^3.4.1", - "body-parser": "^1.15.1", - "cookie-parser": "^1.4.3", "escape-string-regexp": "^1.0.5", - "express": "^4.13.4", "fluent-ffmpeg": "^2.1.0", + "hapi": "^15.2.0", "knex": "^0.12.5", "lodash": "^4.15.0", "mkdirp": "^0.5.1", diff --git a/src/modules.js b/src/modules.js index a91e647..1a67f14 100644 --- a/src/modules.js +++ b/src/modules.js @@ -64,7 +64,7 @@ exports.loadBackends = (player, backends, forceUpdate, done) => { const Backend = require(moduleName); const backend = new Backend(err => { // defer execution in case callback was called synchronously - process.nextTick(err => { + process.nextTick(() => { if (err) { backend.log.error('while initializing: ' + err); return callback(); @@ -155,7 +155,7 @@ exports.loadBuiltinPlugins = (player, done) => { async.mapSeries(BuiltinPlugins, (Plugin, callback) => { const plugin = new Plugin(player, err => { // defer execution in case callback was called synchronously - process.nextTick(err => { + process.nextTick(() => { if (err) { plugin.log.error('while initializing: ' + err); return callback(); @@ -175,7 +175,7 @@ exports.loadBuiltinBackends = (player, done) => { async.mapSeries(BuiltinBackends, (Backend, callback) => { const backend = new Backend(err => { // defer execution in case callback was called synchronously - process.nextTick(err => { + process.nextTick(() => { if (err) { backend.log.error('while initializing: ' + err); return callback(); diff --git a/src/plugins/index.js b/src/plugins/index.js index 76439f1..dbda99f 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -1,8 +1,8 @@ -import Express from './express'; +import Server from './server'; import Rest from './rest'; const Plugins = []; -Plugins.push(Express); -Plugins.push(Rest); // NOTE: must be initialized after express +Plugins.push(Server); +Plugins.push(Rest); // NOTE: must be initialized after Server module.exports = Plugins; diff --git a/src/plugins/rest.js b/src/plugins/rest.js index 4c625be..e155c2b 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -10,13 +10,21 @@ export default class Rest extends Plugin { constructor(player, callback) { super(); - // NOTE: no argument passed so we get the core's config - const config = require('../config').getConfig(); - - if (!player.app) { - return callback('module must be initialized after express module!'); + if (!player.server) { + return callback('module must be initialized after Server module!'); } + player.server.route({ + method: 'GET', + path: '/', + handler: (request, reply) => { + reply('Hello world!\n'); + } + }); + + callback(); + + /* player.app.use((req, res, next) => { res.sendRes = (err, data) => { if (err) { @@ -59,6 +67,7 @@ export default class Rest extends Plugin { res.sendRes(err); }); + */ /* player.app.post('/queue/move/:pos', (req, res) => { var err = player.moveInQueue( @@ -70,6 +79,7 @@ export default class Rest extends Plugin { }); */ + /* player.app.delete('/queue/song/:at', (req, res) => { player.removeSongs(req.params.at, Number(req.query.cnt) || 1, res.sendRes); }); @@ -264,5 +274,6 @@ export default class Rest extends Plugin { }); callback(null); + */ } } diff --git a/src/plugins/express.js b/src/plugins/server.js similarity index 65% rename from src/plugins/express.js rename to src/plugins/server.js index 0af13de..45d36fd 100644 --- a/src/plugins/express.js +++ b/src/plugins/server.js @@ -1,20 +1,34 @@ 'use strict'; -const express = require('express'); -const bodyParser = require('body-parser'); -const cookieParser = require('cookie-parser'); -const https = require('https'); -const http = require('http'); -const fs = require('fs'); +const Hapi = require('hapi'); +//const bodyParser = require('body-parser'); +//const cookieParser = require('cookie-parser'); +//const https = require('https'); +//const http = require('http'); +//const fs = require('fs'); import Plugin from '../plugin'; -export default class Express extends Plugin { +export default class Server extends Plugin { constructor(player, callback) { super(); + const server = new Hapi.Server(); + server.connection({ port: this.coreConfig.port }); + + server.start(err => { + if (err) { + return callback(err); + } else { + this.log.info(`listening on port ${this.coreConfig.port}`); + player.server = server; + + callback(); + } + }); + + /* // NOTE: no argument passed so we get the core's config - const config = require('../config').getConfig(); player.app = express(); let options = {}; @@ -36,12 +50,10 @@ export default class Express extends Plugin { player.httpServer = http.createServer(player.app) .listen(port); } - this.log.info('listening on port ' + port); player.app.use(cookieParser()); player.app.use(bodyParser.json({ limit: '100mb' })); player.app.use(bodyParser.urlencoded({ extended: true })); - - callback(null); + */ } } From 0963c01f001bb490766721c02b88ede110f7640a Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Wed, 2 Nov 2016 19:25:45 +0200 Subject: [PATCH 094/103] Refactor plugin superclass into plugins dir --- .babelrc | 2 +- package.json | 2 +- src/modules.js | 4 ++-- src/plugin.js | 23 ----------------------- src/plugins/defaults.js | 11 +++++++++++ src/plugins/index.js | 28 ++++++++++++++++++++++------ src/plugins/rest.js | 2 +- src/plugins/server.js | 3 ++- 8 files changed, 40 insertions(+), 35 deletions(-) delete mode 100644 src/plugin.js create mode 100644 src/plugins/defaults.js diff --git a/.babelrc b/.babelrc index 0b42c4e..7c19bb4 100644 --- a/.babelrc +++ b/.babelrc @@ -1,5 +1,5 @@ { "presets": [ - "node6" + "es2015-node" ] } diff --git a/package.json b/package.json index 2d46ce2..4bac80a 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "async": "^2.0.0-rc.6", "babel-cli": "^6.11.4", "babel-core": "^6.11.4", - "babel-preset-node6": "^11.0.0", + "babel-preset-es2015-node": "^6.1.1", "bluebird": "^3.4.1", "escape-string-regexp": "^1.0.5", "fluent-ffmpeg": "^2.1.0", diff --git a/src/modules.js b/src/modules.js index 1a67f14..b12fe76 100644 --- a/src/modules.js +++ b/src/modules.js @@ -1,7 +1,7 @@ const npm = require('npm'); const async = require('async'); const labeledLogger = require('./logger'); -const BuiltinPlugins = require('./plugins'); +import defaultPlugins from './plugins/defaults'; const BuiltinBackends = require('./backends'); const _ = require('lodash'); @@ -152,7 +152,7 @@ exports.loadPlugins = (player, plugins, forceUpdate, done) => { }; exports.loadBuiltinPlugins = (player, done) => { - async.mapSeries(BuiltinPlugins, (Plugin, callback) => { + async.mapSeries(defaultPlugins, (Plugin, callback) => { const plugin = new Plugin(player, err => { // defer execution in case callback was called synchronously process.nextTick(() => { diff --git a/src/plugin.js b/src/plugin.js deleted file mode 100644 index 73d326c..0000000 --- a/src/plugin.js +++ /dev/null @@ -1,23 +0,0 @@ -const coreConfig = require('./config').getConfig(); -const config = require('./config'); -const labeledLogger = require('./logger'); - -/** - * Super constructor for plugins - */ -export default class Plugin { - constructor(defaultConfig) { - this.name = this.constructor.name.toLowerCase(); - this.log = labeledLogger(this.name); - this.hooks = {}; - this.coreConfig = coreConfig; - - if (defaultConfig) { - this.config = config.getConfig(this, defaultConfig); - } - } - - registerHook(hook, callback) { - this.hooks[hook] = callback; - } -} diff --git a/src/plugins/defaults.js b/src/plugins/defaults.js new file mode 100644 index 0000000..6090067 --- /dev/null +++ b/src/plugins/defaults.js @@ -0,0 +1,11 @@ +import Server from './server'; +import Rest from './rest'; + +/** + * Export default plugins + */ +const defaultPlugins = []; +defaultPlugins.push(Server); +defaultPlugins.push(Rest); // NOTE: must be initialized after Server + +export default defaultPlugins; diff --git a/src/plugins/index.js b/src/plugins/index.js index dbda99f..5b3d021 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -1,8 +1,24 @@ -import Server from './server'; -import Rest from './rest'; +import config from '../config'; +import labeledLogger from '../logger'; -const Plugins = []; -Plugins.push(Server); -Plugins.push(Rest); // NOTE: must be initialized after Server +const coreConfig = config.getConfig(); -module.exports = Plugins; +/** + * Super constructor for plugins + */ +export default class Plugin { + constructor(defaultConfig) { + this.name = this.constructor.name.toLowerCase(); + this.log = labeledLogger(this.name); + this.hooks = {}; + this.coreConfig = coreConfig; + + if (defaultConfig) { + this.config = config.getConfig(this, defaultConfig); + } + } + + registerHook(hook, callback) { + this.hooks[hook] = callback; + } +} diff --git a/src/plugins/rest.js b/src/plugins/rest.js index e155c2b..d618cba 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -4,7 +4,7 @@ const _ = require('lodash'); const async = require('async'); const path = require('path'); -import Plugin from '../plugin'; +import Plugin from '.'; export default class Rest extends Plugin { constructor(player, callback) { diff --git a/src/plugins/server.js b/src/plugins/server.js index 45d36fd..8f0ceed 100644 --- a/src/plugins/server.js +++ b/src/plugins/server.js @@ -1,5 +1,7 @@ 'use strict'; +import Plugin from '.'; + const Hapi = require('hapi'); //const bodyParser = require('body-parser'); //const cookieParser = require('cookie-parser'); @@ -7,7 +9,6 @@ const Hapi = require('hapi'); //const http = require('http'); //const fs = require('fs'); -import Plugin from '../plugin'; export default class Server extends Plugin { constructor(player, callback) { From c30e922f8cd8fb40df90b88cdcc34beaef2a66b3 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Wed, 2 Nov 2016 19:35:06 +0200 Subject: [PATCH 095/103] Refactor backend superclass into backends dir --- index.js | 4 +- src/backend.js | 235 -------------------------------------- src/backends/defaults.js | 6 + src/backends/index.js | 237 ++++++++++++++++++++++++++++++++++++++- src/modules.js | 4 +- 5 files changed, 244 insertions(+), 242 deletions(-) delete mode 100644 src/backend.js create mode 100644 src/backends/defaults.js diff --git a/index.js b/index.js index 2d7c50b..cb01ee2 100644 --- a/index.js +++ b/index.js @@ -4,11 +4,13 @@ var config = require('./src/config'); var logger = require('./src/logger'); import Player from './src/player'; -import Backend from './src/backend'; +import Backend from './src/backends'; +import Plugin from './src/plugins'; export { Player, Backend, + Plugin, config, logger, } diff --git a/src/backend.js b/src/backend.js deleted file mode 100644 index 1590172..0000000 --- a/src/backend.js +++ /dev/null @@ -1,235 +0,0 @@ -const path = require('path'); -const fs = require('fs'); -const ffmpeg = require('fluent-ffmpeg'); -const coreConfig = require('./config').getConfig(); -const config = require('./config'); -const labeledLogger = require('./logger'); -const mkdirp = require('mkdirp'); - -/** - * Super constructor for backends - */ -export default class Backend { - constructor(defaultConfig) { - this.name = this.constructor.name.toLowerCase(); - this.log = labeledLogger(this.name); - this.songsPreparing = {}; - this.coreConfig = coreConfig; - - if (defaultConfig) { - this.config = config.getConfig(this, defaultConfig); - } - } - - /** - * Callback for reporting encoding progress - * @callback encodeCallback - * @param {Error} err - If truthy, an error occurred and preparation cannot continue - * @param {Buffer} bytesWritten - How many new bytes was written to song.data - * @param {Bool} done - True if this was the last chunk - */ - - /** - * Encode stream as opus - * @param {Stream} stream - Input stream - * @param {Number} seek - Skip to this position in song (TODO) - * @param {Song} song - Song object whose audio is being encoded - * @param {encodeCallback} callback - Called when song is ready or on error - * @return {Function} - Can be called to terminate encoding - */ - encodeSong(stream, seek, song, callback) { - const encodedPath = path.join(coreConfig.songCachePath, this.name, - song.songId + '.opus'); - - const command = ffmpeg(stream) - .noVideo() - // .inputFormat('mp3') - // .inputOption('-ac 2') - .audioCodec('libopus') - .audioBitrate('192') - .format('opus') - .on('error', err => { - this.log.error(this.name + ': error while transcoding ' + song.songId + ': ' + err); - delete song.prepare.data; - callback(err); - }); - - const opusStream = command.pipe(null, { end: true }); - opusStream.on('data', chunk => { - // TODO: this could be optimized by using larger buffers - // song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); - - if (chunk.length <= song.prepare.data.length - song.prepare.dataPos) { - // If there's room in the buffer, write chunk into it - chunk.copy(song.prepare.data, song.prepare.dataPos); - song.prepare.dataPos += chunk.length; - } else { - // Otherwise allocate more room, then copy chunk into buffer - - // Make absolutely sure that the chunk will fit inside new buffer - const newSize = Math.max(song.prepare.data.length * 2, - song.prepare.data.length + chunk.length); - - this.log.debug('Allocated new song data buffer of size: ' + newSize); - - const buf = new Buffer.allocUnsafe(newSize); - - song.prepare.data.copy(buf); - song.prepare.data = buf; - - chunk.copy(song.prepare.data, song.prepare.dataPos); - song.prepare.dataPos += chunk.length; - } - - callback(null, chunk.length, false); - }); - opusStream.on('end', () => { - mkdirp(path.dirname(encodedPath), err => { - if (err) { - return this.log.error(`error creating directory: ${path.dirname(encodedPath)}: ${err}`); - } - fs.writeFile(encodedPath, song.prepare.data, err => { - if (err) { - return this.log.error(`error writing file to ${encodedPath}: ${err}`); - } - - this.log.verbose('wrote file to ' + encodedPath); - this.log.verbose('transcoding ended for ' + song.songId); - - delete song.prepare; - // TODO: we don't know if transcoding ended successfully or not, - // and there might be a race condition between errCallback deleting - // the file and us trying to move it to the songCache - // TODO: is this still the case? - // (we no longer save incomplete files on disk) - - callback(null, null, true); - }); - }); - }); - - this.log.verbose('transcoding ' + song.songId + '...'); - - // return a function which can be used for terminating encoding - return err => { - command.kill(); - this.log.verbose(this.name + ': canceled preparing: ' + song.songId + ': ' + err); - delete song.prepare; - callback(new Error('canceled preparing: ' + song.songId + ': ' + err)); - }; - } - - /** - * Prepare song for playback - * @param {Song} song - Song to prepare - * @param {encodeCallback} callback - Called when song is ready or on error - */ - prepare(song, callback) { - if (this.songsPreparing[song.songId]) { - // song is preparing, caller can drop this request (previous caller will take care of - // handling once preparation is finished) - callback(null, null, false); - } else if (this.isPrepared(song)) { - // song has already prepared, caller can start playing song - callback(null, null, true); - } else { - // begin preparing song - let cancelEncode = null; - let canceled = false; - - song.prepare = { - data: new Buffer.allocUnsafe(1024 * 1024), - dataPos: 0, - cancel: () => { - canceled = true; - if (cancelEncode) { - cancelEncode(); - } - }, - }; - - this.songsPreparing[song.songId] = song; - - this.getSongStream(song, (err, readStream) => { - if (canceled) { - callback(new Error('song was canceled before encoding started')); - } else if (err) { - callback(new Error(`error while getting song stream: ${err}`)); - } else { - cancelEncode = this.encodeSong(readStream, 0, song, callback); - readStream.on('error', err => { - callback(err); - }); - } - }); - } - } - - /** - * Cancel song preparation if applicable - * @param {Song} song - Song to cancel - */ - cancelPrepare(song) { - if (this.songsPreparing[song.songId]) { - this.log.info('Canceling song preparing: ' + song.songId); - this.songsPreparing[song.songId].cancel(); - } else { - this.log.error('cancelPrepare() called on song not in preparation: ' + song.songId); - } - } - - // dummy functions - - /** - * Callback for reporting song duration - * @callback durationCallback - * @param {Error} err - If truthy, an error occurred - * @param {Number} duration - Duration in milliseconds - */ - - /** - * Returns length of song - * @param {Song} song - Query concerns this song - * @param {durationCallback} callback - Called with duration - */ - getDuration(song, callback) { - const err = 'FATAL: backend does not implement getDuration()!'; - this.log.error(err); - callback(err); - } - - /** - * Synchronously(!) returns whether the song with songId is prepared or not - * @param {Song} song - Query concerns this song - * @return {Boolean} - true if song is prepared, false if not - */ - isPrepared(song) { - this.log.error('FATAL: backend does not implement isPrepared()!'); - return false; - } - - /** - * Get read stream for song - * @param {Song} song - Song to prepare - * @param {streamCallback} callback - Called when read stream is ready or on error - */ - getSongStream(song, callback) { - this.log.error('FATAL: backend does not implement getSongStream()!'); - callback(new Error('FATAL: backend does not implement getSongStream()!')); - } - - /** - * Search for songs - * @param {Object} query - Search terms - * @param {String} [query.artist] - Artist - * @param {String} [query.title] - Title - * @param {String} [query.album] - Album - * @param {Boolean} [query.any] - Match any of the above, otherwise all fields have to match - * @param {Function} callback - Called with error or results - */ - search(query, callback) { - this.log.error('FATAL: backend does not implement search()!'); - callback(new Error('FATAL: backend does not implement search()!')); - } -} - diff --git a/src/backends/defaults.js b/src/backends/defaults.js new file mode 100644 index 0000000..e78553c --- /dev/null +++ b/src/backends/defaults.js @@ -0,0 +1,6 @@ +import Local from './local'; + +const defaultBackends = []; +defaultBackends.push(Local); + +export default defaultBackends; diff --git a/src/backends/index.js b/src/backends/index.js index 6e1bd2e..218feb2 100644 --- a/src/backends/index.js +++ b/src/backends/index.js @@ -1,6 +1,235 @@ -import Local from './local'; +import path from 'path'; +import fs from 'fs'; +import ffmpeg from 'fluent-ffmpeg'; +import config from '../config'; +import labeledLogger from '../logger'; +import mkdirp from 'mkdirp'; -const Backends = []; -Backends.push(Local); +const coreConfig = config.getConfig(); -module.exports = Backends; +/** + * Super constructor for backends + */ +export default class Backend { + constructor(defaultConfig) { + this.name = this.constructor.name.toLowerCase(); + this.log = labeledLogger(this.name); + this.songsPreparing = {}; + this.coreConfig = coreConfig; + + if (defaultConfig) { + this.config = config.getConfig(this, defaultConfig); + } + } + + /** + * Callback for reporting encoding progress + * @callback encodeCallback + * @param {Error} err - If truthy, an error occurred and preparation cannot continue + * @param {Buffer} bytesWritten - How many new bytes was written to song.data + * @param {Bool} done - True if this was the last chunk + */ + + /** + * Encode stream as opus + * @param {Stream} stream - Input stream + * @param {Number} seek - Skip to this position in song (TODO) + * @param {Song} song - Song object whose audio is being encoded + * @param {encodeCallback} callback - Called when song is ready or on error + * @return {Function} - Can be called to terminate encoding + */ + encodeSong(stream, seek, song, callback) { + const encodedPath = path.join(coreConfig.songCachePath, this.name, + song.songId + '.opus'); + + const command = ffmpeg(stream) + .noVideo() + // .inputFormat('mp3') + // .inputOption('-ac 2') + .audioCodec('libopus') + .audioBitrate('192') + .format('opus') + .on('error', err => { + this.log.error(this.name + ': error while transcoding ' + song.songId + ': ' + err); + delete song.prepare.data; + callback(err); + }); + + const opusStream = command.pipe(null, { end: true }); + opusStream.on('data', chunk => { + // TODO: this could be optimized by using larger buffers + // song.prepare.data = Buffer.concat([song.prepare.data, chunk], song.prepare.data.length + chunk.length); + + if (chunk.length <= song.prepare.data.length - song.prepare.dataPos) { + // If there's room in the buffer, write chunk into it + chunk.copy(song.prepare.data, song.prepare.dataPos); + song.prepare.dataPos += chunk.length; + } else { + // Otherwise allocate more room, then copy chunk into buffer + + // Make absolutely sure that the chunk will fit inside new buffer + const newSize = Math.max(song.prepare.data.length * 2, + song.prepare.data.length + chunk.length); + + this.log.debug('Allocated new song data buffer of size: ' + newSize); + + const buf = new Buffer.allocUnsafe(newSize); + + song.prepare.data.copy(buf); + song.prepare.data = buf; + + chunk.copy(song.prepare.data, song.prepare.dataPos); + song.prepare.dataPos += chunk.length; + } + + callback(null, chunk.length, false); + }); + opusStream.on('end', () => { + mkdirp(path.dirname(encodedPath), err => { + if (err) { + return this.log.error(`error creating directory: ${path.dirname(encodedPath)}: ${err}`); + } + fs.writeFile(encodedPath, song.prepare.data, err => { + if (err) { + return this.log.error(`error writing file to ${encodedPath}: ${err}`); + } + + this.log.verbose('wrote file to ' + encodedPath); + this.log.verbose('transcoding ended for ' + song.songId); + + delete song.prepare; + // TODO: we don't know if transcoding ended successfully or not, + // and there might be a race condition between errCallback deleting + // the file and us trying to move it to the songCache + // TODO: is this still the case? + // (we no longer save incomplete files on disk) + + callback(null, null, true); + }); + }); + }); + + this.log.verbose('transcoding ' + song.songId + '...'); + + // return a function which can be used for terminating encoding + return err => { + command.kill(); + this.log.verbose(this.name + ': canceled preparing: ' + song.songId + ': ' + err); + delete song.prepare; + callback(new Error('canceled preparing: ' + song.songId + ': ' + err)); + }; + } + + /** + * Prepare song for playback + * @param {Song} song - Song to prepare + * @param {encodeCallback} callback - Called when song is ready or on error + */ + prepare(song, callback) { + if (this.songsPreparing[song.songId]) { + // song is preparing, caller can drop this request (previous caller will take care of + // handling once preparation is finished) + callback(null, null, false); + } else if (this.isPrepared(song)) { + // song has already prepared, caller can start playing song + callback(null, null, true); + } else { + // begin preparing song + let cancelEncode = null; + let canceled = false; + + song.prepare = { + data: new Buffer.allocUnsafe(1024 * 1024), + dataPos: 0, + cancel: () => { + canceled = true; + if (cancelEncode) { + cancelEncode(); + } + }, + }; + + this.songsPreparing[song.songId] = song; + + this.getSongStream(song, (err, readStream) => { + if (canceled) { + callback(new Error('song was canceled before encoding started')); + } else if (err) { + callback(new Error(`error while getting song stream: ${err}`)); + } else { + cancelEncode = this.encodeSong(readStream, 0, song, callback); + readStream.on('error', err => { + callback(err); + }); + } + }); + } + } + + /** + * Cancel song preparation if applicable + * @param {Song} song - Song to cancel + */ + cancelPrepare(song) { + if (this.songsPreparing[song.songId]) { + this.log.info('Canceling song preparing: ' + song.songId); + this.songsPreparing[song.songId].cancel(); + } else { + this.log.error('cancelPrepare() called on song not in preparation: ' + song.songId); + } + } + + // dummy functions + + /** + * Callback for reporting song duration + * @callback durationCallback + * @param {Error} err - If truthy, an error occurred + * @param {Number} duration - Duration in milliseconds + */ + + /** + * Returns length of song + * @param {Song} song - Query concerns this song + * @param {durationCallback} callback - Called with duration + */ + getDuration(song, callback) { + const err = 'FATAL: backend does not implement getDuration()!'; + this.log.error(err); + callback(err); + } + + /** + * Synchronously(!) returns whether the song with songId is prepared or not + * @param {Song} song - Query concerns this song + * @return {Boolean} - true if song is prepared, false if not + */ + isPrepared(song) { + this.log.error('FATAL: backend does not implement isPrepared()!'); + return false; + } + + /** + * Get read stream for song + * @param {Song} song - Song to prepare + * @param {streamCallback} callback - Called when read stream is ready or on error + */ + getSongStream(song, callback) { + this.log.error('FATAL: backend does not implement getSongStream()!'); + callback(new Error('FATAL: backend does not implement getSongStream()!')); + } + + /** + * Search for songs + * @param {Object} query - Search terms + * @param {String} [query.artist] - Artist + * @param {String} [query.title] - Title + * @param {String} [query.album] - Album + * @param {Boolean} [query.any] - Match any of the above, otherwise all fields have to match + * @param {Function} callback - Called with error or results + */ + search(query, callback) { + this.log.error('FATAL: backend does not implement search()!'); + callback(new Error('FATAL: backend does not implement search()!')); + } +} diff --git a/src/modules.js b/src/modules.js index b12fe76..b521db3 100644 --- a/src/modules.js +++ b/src/modules.js @@ -2,7 +2,7 @@ const npm = require('npm'); const async = require('async'); const labeledLogger = require('./logger'); import defaultPlugins from './plugins/defaults'; -const BuiltinBackends = require('./backends'); +import defaultBackends from './backends/defaults'; const _ = require('lodash'); const logger = labeledLogger('modules'); @@ -172,7 +172,7 @@ exports.loadBuiltinPlugins = (player, done) => { }; exports.loadBuiltinBackends = (player, done) => { - async.mapSeries(BuiltinBackends, (Backend, callback) => { + async.mapSeries(defaultBackends, (Backend, callback) => { const backend = new Backend(err => { // defer execution in case callback was called synchronously process.nextTick(() => { From 1baeacba7bd3b6e31b0442f0dd3595cf658671b2 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sat, 12 Nov 2016 23:45:12 +0200 Subject: [PATCH 096/103] Implement search, queue and song data fetch routes with hapi.js --- package.json | 2 + src/backends/defaults.js | 2 +- src/player.js | 13 +++ src/plugins/rest.js | 230 ++++++++++++++++++++++++++++++++++++++- src/plugins/server.js | 7 +- 5 files changed, 251 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 4bac80a..29e622f 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,11 @@ "babel-core": "^6.11.4", "babel-preset-es2015-node": "^6.1.1", "bluebird": "^3.4.1", + "boom": "^4.2.0", "escape-string-regexp": "^1.0.5", "fluent-ffmpeg": "^2.1.0", "hapi": "^15.2.0", + "inert": "^4.0.2", "knex": "^0.12.5", "lodash": "^4.15.0", "mkdirp": "^0.5.1", diff --git a/src/backends/defaults.js b/src/backends/defaults.js index e78553c..3113ddc 100644 --- a/src/backends/defaults.js +++ b/src/backends/defaults.js @@ -1,6 +1,6 @@ import Local from './local'; const defaultBackends = []; -defaultBackends.push(Local); +//defaultBackends.push(Local); export default defaultBackends; diff --git a/src/player.js b/src/player.js index 8ecc41d..4e6cecf 100644 --- a/src/player.js +++ b/src/player.js @@ -26,6 +26,19 @@ export default class Player { this.init = this.init.bind(this); } + getQueue() { + return this.queue.serialize(); + } + + getState() { + return { + nowPlaying: this.nowPlaying, + play: this.play, + repeat: this.repeat, + volume: this.volume + }; + } + /** * Initializes player */ diff --git a/src/plugins/rest.js b/src/plugins/rest.js index d618cba..eae0dff 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -5,6 +5,8 @@ const _ = require('lodash'); const async = require('async'); const path = require('path'); import Plugin from '.'; +import Boom from 'boom'; +import Inert from 'inert'; export default class Rest extends Plugin { constructor(player, callback) { @@ -14,14 +16,240 @@ export default class Rest extends Plugin { return callback('module must be initialized after Server module!'); } + this.pendingRequests = {}; + + player.server.register(Inert); + player.server.route({ method: 'GET', - path: '/', + path: '/api/v1', handler: (request, reply) => { reply('Hello world!\n'); } }); + player.server.route({ + method: 'GET', + path: '/api/v1/queue', + handler: (request, reply) => { + reply(player.getQueue()); + } + }); + + player.server.route({ + method: 'GET', + path: '/api/v1/state', + handler: (request, reply) => { + reply(player.getState()); + } + }); + + player.server.route({ + method: 'POST', + path: '/api/v1/search', + handler: (request, reply) => { + this.log.verbose('got search request: ' + JSON.stringify(request.payload)); + + player.searchBackends(request.payload, (err, results) => { + // console.log(results); + reply(results); + }); + } + }); + + player.server.route({ + method: 'POST', + path: '/api/v1/queue/song', + handler: (request, reply) => { + this.log.verbose('got search request: ' + JSON.stringify(request.payload)); + + const err = player.queue.insertSongs(null, request.payload); + reply(err ? Boom.badImplementation(err) : { success: true }); + } + }); + + this.registerHook('onPrepareProgress', (song, bytesWritten, done) => { + if (!this.pendingRequests[song.backend.name]) { + return; + } + + _.each(this.pendingRequests[song.backend.name][song.songId], client => { + if (bytesWritten) { + let end = song.prepare.dataPos; + if (client.wishRange[1]) { + end = Math.min(client.wishRange[1], bytesWritten - 1); + } + + // console.log('end: ' + end + '\tclient.serveRange[1]: ' + client.serveRange[1]); + + if (client.serveRange[1] < end) { + // console.log('write'); + client.res.write(song.prepare.data.slice(client.serveRange[1] + 1, end)); + } + + client.serveRange[1] = end; + } + + if (done) { + this.log.debug('done'); + client.res.end(); + } + }); + + if (done) { + this.pendingRequests[song.backend.name][song.songId] = []; + } + }); + + this.registerHook('onBackendInitialized', backendName => { + this.pendingRequests[backendName] = {}; + + // provide API path for music data, might block while song is preparing + player.server.route({ + method: 'GET', + path: `/api/v1/song/${backendName}/{fileName}`, + handler: (request, reply) => { + const extIndex = request.params.fileName.lastIndexOf('.'); + const songId = request.params.fileName.substring(0, extIndex); + const songFormat = request.params.fileName.substring(extIndex + 1); + + const backend = player.backends[backendName]; + const filename = path.join(backendName, songId + '.' + songFormat); + + //const response = reply(); + + //res.setHeader('Content-Type', 'audio/ogg; codecs=opus'); + //res.setHeader('Accept-Ranges', 'bytes'); + //response.header('Content-Type', 'audio/ogg; codecs=opus'); + //response.header('Accept-Ranges', 'audio/ogg; codecs=opus'); + // + const setHeaders = (response) => { + return response.header('Content-Type', 'audio/ogg; codecs=opus'); + }; + + const queuedSong = _.find(player.queue.serialize(), song => { + return song.songId === songId && song.backendName === backendName; + }); + + if (backend.isPrepared({ songId: songId })) { + // song should be available on disk + const response = reply.file(filename, { + confine: this.coreConfig.songCachePath, + }); + + setHeaders(response); + } else if (backend.songsPreparing[songId]) { + reply(Boom.notImplemented('TODO: Fetching in-progress songs not yet supported')); + } else { + reply(Boom.notFound('Song not found')); + } + /* + async.series([ + callback => { + // try finding out length of song + if (queuedSong) { + //res.setHeader('X-Content-Duration', queuedSong.duration / 1000); + response.header('X-Content-Duration', queuedSong.duration / 1000); + callback(); + } else { + backend.getDuration({ songId: songId }, (err, exactDuration) => { + //res.setHeader('X-Content-Duration', exactDuration / 1000); + response.header('X-Content-Duration', exactDuration / 1000); + callback(); + }); + } + }, + callback => { + if (backend.isPrepared({ songId: songId })) { + // song should be available on disk + response.file(filename, { + confine: this.coreConfig.songCachePath, + }); + } else if (backend.songsPreparing[songId]) { + response(Boom.notImplemented('TODO: Fetching in-progress songs not yet supported')); + /* + // song is preparing + const song = backend.songsPreparing[songId]; + + const haveRange = []; + let wishRange = []; + const serveRange = []; + + haveRange[0] = 0; + haveRange[1] = song.prepare.data.length - 1; + + wishRange[0] = 0; + wishRange[1] = null; + + serveRange[0] = 0; + + res.setHeader('Transfer-Encoding', 'chunked'); + + if (request.headers.range) { + // partial request + + wishRange = request.headers.range.substr(request.headers.range.indexOf('=') + 1).split('-'); + + serveRange[0] = wishRange[0]; + + // a best guess for the response header + serveRange[1] = haveRange[1]; + if (wishRange[1]) { + serveRange[1] = Math.min(wishRange[1], haveRange[1]); + } + + res.statusCode = 206; + res.setHeader('Content-Range', 'bytes ' + serveRange[0] + '-' + serveRange[1] + '/*'); + } else { + serveRange[1] = haveRange[1]; + } + + this.log.debug('request with wishRange: ' + wishRange); + + if (!this.pendingRequests[backendName][songId]) { + this.pendingRequests[backendName][songId] = []; + } + + const client = { + res: res, + serveRange: serveRange, + wishRange: wishRange, + filepath: path.join(config.songCachePath, filename), + }; + + // TODO: If we know that we have already flushed data to disk, + // we could open up the read stream already here instead of waiting + // around for the first flush + + // If we can satisfy the start of the requested range, write as + // much as possible to res immediately + if (haveRange[1] >= wishRange[0]) { + client.res.write(song.prepare.data.slice(serveRange[0], serveRange[1] + 1)); + } + + if (serveRange[1] === wishRange[1]) { + client.res.end(); + } else { + // If we couldn't satisfy the entire request, push the client + // into pendingRequests so we can append to the stream later + this.pendingRequests[backendName][songId].push(client); + + request.on('close', () => { + this.pendingRequests[backendName][songId].splice( + this.pendingRequests[backendName][songId].indexOf(client), 1 + ); + }); + } + } else { + response(Boom.notFound('Song not found')); + } + }] + ); + */ + } + }); + }); + callback(); /* diff --git a/src/plugins/server.js b/src/plugins/server.js index 8f0ceed..830b6b3 100644 --- a/src/plugins/server.js +++ b/src/plugins/server.js @@ -15,7 +15,12 @@ export default class Server extends Plugin { super(); const server = new Hapi.Server(); - server.connection({ port: this.coreConfig.port }); + server.connection({ + port: this.coreConfig.port, + routes: { + cors: true + } + }); server.start(err => { if (err) { From 9014bb12baa7ab92652a2d55d63e234dd23192cf Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sat, 12 Nov 2016 23:45:38 +0200 Subject: [PATCH 097/103] Yarn lockfile --- yarn.lock | 4135 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 4135 insertions(+) create mode 100644 yarn.lock diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..a8898b0 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,4135 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 +abbrev@~1.0.9, abbrev@1, abbrev@1.0.x: + version "1.0.9" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135" + +accept@2.x.x: + version "2.1.3" + resolved "https://registry.yarnpkg.com/accept/-/accept-2.1.3.tgz#ab0f5bda4c449bbe926aea607b3522562f5acf86" + dependencies: + boom "4.x.x" + hoek "4.x.x" + +acorn-jsx@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b" + dependencies: + acorn "^3.0.4" + +acorn@^3.0.4: + version "3.3.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" + +acorn@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.3.tgz#1a3e850b428e73ba6b09d1cc527f5aaad4d03ef1" + +ajv-keywords@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.1.1.tgz#02550bc605a3e576041565628af972e06c549d50" + +ajv@^4.7.0: + version "4.8.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.8.2.tgz#65486936ca36fea39a1504332a78bebd5d447bdc" + dependencies: + co "^4.6.0" + json-stable-stringify "^1.0.1" + +align-text@^0.1.1, align-text@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" + dependencies: + kind-of "^3.0.2" + longest "^1.0.1" + repeat-string "^1.5.2" + +amdefine@>=0.0.4: + version "1.0.1" + resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" + +ammo@2.x.x: + version "2.0.2" + resolved "https://registry.yarnpkg.com/ammo/-/ammo-2.0.2.tgz#366c55f7bc4f2f24218ed3a4dd4b8df135c2e6ca" + dependencies: + boom "3.x.x" + hoek "4.x.x" + +ansi-escapes@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" + +ansi-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.0.0.tgz#c5061b6e0ef8a81775e50f5d66151bf6bf371107" + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + +ansi@^0.3.0, ansi@~0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/ansi/-/ansi-0.3.1.tgz#0c42d4fb17160d5a9af1e484bace1c66922c1b21" + +ansicolors@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/ansicolors/-/ansicolors-0.3.2.tgz#665597de86a9ffe3aa9bfbe6cae5c6ea426b4979" + +ansistyles@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/ansistyles/-/ansistyles-0.1.3.tgz#5de60415bda071bb37127854c864f41b23254539" + +anymatch@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.0.tgz#a3e52fa39168c825ff57b0248126ce5a8ff95507" + dependencies: + arrify "^1.0.0" + micromatch "^2.1.5" + +ap@~0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/ap/-/ap-0.2.0.tgz#ae0942600b29912f0d2b14ec60c45e8f330b6110" + +aproba@^1.0.3, aproba@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.0.4.tgz#2713680775e7614c8ba186c065d4e2e52d1072c0" + +archy@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" + +are-we-there-yet@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.2.tgz#80e470e95a084794fe1899262c5667c6e88de1b3" + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.0 || ^1.1.13" + +argparse@^1.0.7: + version "1.0.9" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.9.tgz#73d83bc263f86e97f8cc4f6bae1b0e90a7d22c86" + dependencies: + sprintf-js "~1.0.2" + +arr-diff@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" + dependencies: + arr-flatten "^1.0.1" + +arr-flatten@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.0.1.tgz#e5ffe54d45e19f32f216e91eb99c8ce892bb604b" + +array-index@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-index/-/array-index-1.0.0.tgz#ec56a749ee103e4e08c790b9c353df16055b97f9" + dependencies: + debug "^2.2.0" + es6-symbol "^3.0.2" + +array-union@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" + dependencies: + array-uniq "^1.0.1" + +array-uniq@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" + +array-unique@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" + +arrify@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" + +asap@^2.0.0, asap@~2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.5.tgz#522765b50c3510490e52d7dcfe085ef9ba96958f" + +asn1@~0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86" + +assert-plus@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234" + +assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + +assertion-error@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.0.2.tgz#13ca515d86206da0bac66e834dd397d87581094c" + +async-each@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" + +async-some@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/async-some/-/async-some-1.0.2.tgz#4d8a81620d5958791b5b98f802d3207776e95509" + dependencies: + dezalgo "^1.0.2" + +async@^0.9.0, async@0.9.x: + version "0.9.2" + resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" + +async@^1.4.0, async@1.x: + version "1.5.2" + resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" + +async@^2.0.0-rc.6, async@^2.0.1, async@>=0.2.9, async@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/async/-/async-2.1.2.tgz#612a4ab45ef42a70cde806bad86ee6db047e8385" + dependencies: + lodash "^4.14.0" + +async@~0.2.6: + version "0.2.10" + resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" + +async@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async/-/async-1.0.0.tgz#f8fc04ca3a13784ade9e1641af98578cfbd647a9" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + +aws-sign2@~0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" + +aws4@^1.2.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.5.0.tgz#0a29ffb79c31c9e712eeb087e8e7a64b4a56d755" + +b64@3.x.x: + version "3.0.2" + resolved "https://registry.yarnpkg.com/b64/-/b64-3.0.2.tgz#7a9d60466adf7b8de114cbdf651a5fdfcc90894d" + +babel-cli@^6.11.4: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-cli/-/babel-cli-6.18.0.tgz#92117f341add9dead90f6fa7d0a97c0cc08ec186" + dependencies: + babel-core "^6.18.0" + babel-polyfill "^6.16.0" + babel-register "^6.18.0" + babel-runtime "^6.9.0" + commander "^2.8.1" + convert-source-map "^1.1.0" + fs-readdir-recursive "^1.0.0" + glob "^5.0.5" + lodash "^4.2.0" + output-file-sync "^1.1.0" + path-is-absolute "^1.0.0" + slash "^1.0.0" + source-map "^0.5.0" + v8flags "^2.0.10" + optionalDependencies: + chokidar "^1.0.0" + +babel-code-frame@^6.16.0: + version "6.16.0" + resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.16.0.tgz#f90e60da0862909d3ce098733b5d3987c97cb8de" + dependencies: + chalk "^1.1.0" + esutils "^2.0.2" + js-tokens "^2.0.0" + +babel-core@^6.11.4, babel-core@^6.18.0: + version "6.18.2" + resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.18.2.tgz#d8bb14dd6986fa4f3566a26ceda3964fa0e04e5b" + dependencies: + babel-code-frame "^6.16.0" + babel-generator "^6.18.0" + babel-helpers "^6.16.0" + babel-messages "^6.8.0" + babel-register "^6.18.0" + babel-runtime "^6.9.1" + babel-template "^6.16.0" + babel-traverse "^6.18.0" + babel-types "^6.18.0" + babylon "^6.11.0" + convert-source-map "^1.1.0" + debug "^2.1.1" + json5 "^0.5.0" + lodash "^4.2.0" + minimatch "^3.0.2" + path-is-absolute "^1.0.0" + private "^0.1.6" + slash "^1.0.0" + source-map "^0.5.0" + +babel-generator@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.18.0.tgz#e4f104cb3063996d9850556a45aae4a022060a07" + dependencies: + babel-messages "^6.8.0" + babel-runtime "^6.9.0" + babel-types "^6.18.0" + detect-indent "^4.0.0" + jsesc "^1.3.0" + lodash "^4.2.0" + source-map "^0.5.0" + +babel-helper-call-delegate@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-helper-call-delegate/-/babel-helper-call-delegate-6.18.0.tgz#05b14aafa430884b034097ef29e9f067ea4133bd" + dependencies: + babel-helper-hoist-variables "^6.18.0" + babel-runtime "^6.0.0" + babel-traverse "^6.18.0" + babel-types "^6.18.0" + +babel-helper-function-name@^6.8.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-helper-function-name/-/babel-helper-function-name-6.18.0.tgz#68ec71aeba1f3e28b2a6f0730190b754a9bf30e6" + dependencies: + babel-helper-get-function-arity "^6.18.0" + babel-runtime "^6.0.0" + babel-template "^6.8.0" + babel-traverse "^6.18.0" + babel-types "^6.18.0" + +babel-helper-get-function-arity@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.18.0.tgz#a5b19695fd3f9cdfc328398b47dafcd7094f9f24" + dependencies: + babel-runtime "^6.0.0" + babel-types "^6.18.0" + +babel-helper-hoist-variables@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.18.0.tgz#a835b5ab8b46d6de9babefae4d98ea41e866b82a" + dependencies: + babel-runtime "^6.0.0" + babel-types "^6.18.0" + +babel-helper-regex@^6.8.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-helper-regex/-/babel-helper-regex-6.18.0.tgz#ae0ebfd77de86cb2f1af258e2cc20b5fe893ecc6" + dependencies: + babel-runtime "^6.9.0" + babel-types "^6.18.0" + lodash "^4.2.0" + +babel-helpers@^6.16.0: + version "6.16.0" + resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.16.0.tgz#1095ec10d99279460553e67eb3eee9973d3867e3" + dependencies: + babel-runtime "^6.0.0" + babel-template "^6.16.0" + +babel-messages@^6.8.0: + version "6.8.0" + resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.8.0.tgz#bf504736ca967e6d65ef0adb5a2a5f947c8e0eb9" + dependencies: + babel-runtime "^6.0.0" + +babel-plugin-transform-es2015-destructuring@6.x: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.18.0.tgz#a08fb89415ab82058649558bedb7bf8dafa76ba5" + dependencies: + babel-runtime "^6.9.0" + +babel-plugin-transform-es2015-function-name@6.x: + version "6.9.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.9.0.tgz#8c135b17dbd064e5bba56ec511baaee2fca82719" + dependencies: + babel-helper-function-name "^6.8.0" + babel-runtime "^6.9.0" + babel-types "^6.9.0" + +babel-plugin-transform-es2015-modules-commonjs@6.x: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.18.0.tgz#c15ae5bb11b32a0abdcc98a5837baa4ee8d67bcc" + dependencies: + babel-plugin-transform-strict-mode "^6.18.0" + babel-runtime "^6.0.0" + babel-template "^6.16.0" + babel-types "^6.18.0" + +babel-plugin-transform-es2015-parameters@6.x: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.18.0.tgz#9b2cfe238c549f1635ba27fc1daa858be70608b1" + dependencies: + babel-helper-call-delegate "^6.18.0" + babel-helper-get-function-arity "^6.18.0" + babel-runtime "^6.9.0" + babel-template "^6.16.0" + babel-traverse "^6.18.0" + babel-types "^6.18.0" + +babel-plugin-transform-es2015-shorthand-properties@6.x: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.18.0.tgz#e2ede3b7df47bf980151926534d1dd0cbea58f43" + dependencies: + babel-runtime "^6.0.0" + babel-types "^6.18.0" + +babel-plugin-transform-es2015-spread@6.x: + version "6.8.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.8.0.tgz#0217f737e3b821fa5a669f187c6ed59205f05e9c" + dependencies: + babel-runtime "^6.0.0" + +babel-plugin-transform-es2015-sticky-regex@6.x: + version "6.8.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.8.0.tgz#e73d300a440a35d5c64f5c2a344dc236e3df47be" + dependencies: + babel-helper-regex "^6.8.0" + babel-runtime "^6.0.0" + babel-types "^6.8.0" + +babel-plugin-transform-es2015-unicode-regex@6.x: + version "6.11.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.11.0.tgz#6298ceabaad88d50a3f4f392d8de997260f6ef2c" + dependencies: + babel-helper-regex "^6.8.0" + babel-runtime "^6.0.0" + regexpu-core "^2.0.0" + +babel-plugin-transform-strict-mode@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.18.0.tgz#df7cf2991fe046f44163dcd110d5ca43bc652b9d" + dependencies: + babel-runtime "^6.0.0" + babel-types "^6.18.0" + +babel-polyfill@^6.16.0: + version "6.16.0" + resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.16.0.tgz#2d45021df87e26a374b6d4d1a9c65964d17f2422" + dependencies: + babel-runtime "^6.9.1" + core-js "^2.4.0" + regenerator-runtime "^0.9.5" + +babel-preset-es2015-node@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/babel-preset-es2015-node/-/babel-preset-es2015-node-6.1.1.tgz#60b23157024b0cfebf3a63554cb05ee035b4e55f" + dependencies: + babel-plugin-transform-es2015-destructuring "6.x" + babel-plugin-transform-es2015-function-name "6.x" + babel-plugin-transform-es2015-modules-commonjs "6.x" + babel-plugin-transform-es2015-parameters "6.x" + babel-plugin-transform-es2015-shorthand-properties "6.x" + babel-plugin-transform-es2015-spread "6.x" + babel-plugin-transform-es2015-sticky-regex "6.x" + babel-plugin-transform-es2015-unicode-regex "6.x" + semver "5.x" + +babel-register@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.18.0.tgz#892e2e03865078dd90ad2c715111ec4449b32a68" + dependencies: + babel-core "^6.18.0" + babel-runtime "^6.11.6" + core-js "^2.4.0" + home-or-tmp "^2.0.0" + lodash "^4.2.0" + mkdirp "^0.5.1" + source-map-support "^0.4.2" + +babel-runtime@^6.0.0, babel-runtime@^6.11.6, babel-runtime@^6.9.0, babel-runtime@^6.9.1: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.18.0.tgz#0f4177ffd98492ef13b9f823e9994a02584c9078" + dependencies: + core-js "^2.4.0" + regenerator-runtime "^0.9.5" + +babel-template@^6.16.0, babel-template@^6.8.0: + version "6.16.0" + resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.16.0.tgz#e149dd1a9f03a35f817ddbc4d0481988e7ebc8ca" + dependencies: + babel-runtime "^6.9.0" + babel-traverse "^6.16.0" + babel-types "^6.16.0" + babylon "^6.11.0" + lodash "^4.2.0" + +babel-traverse@^6.16.0, babel-traverse@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.18.0.tgz#5aeaa980baed2a07c8c47329cd90c3b90c80f05e" + dependencies: + babel-code-frame "^6.16.0" + babel-messages "^6.8.0" + babel-runtime "^6.9.0" + babel-types "^6.18.0" + babylon "^6.11.0" + debug "^2.2.0" + globals "^9.0.0" + invariant "^2.2.0" + lodash "^4.2.0" + +babel-types@^6.16.0, babel-types@^6.18.0, babel-types@^6.8.0, babel-types@^6.9.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.18.0.tgz#1f7d5a73474c59eb9151b2417bbff4e4fce7c3f8" + dependencies: + babel-runtime "^6.9.1" + esutils "^2.0.2" + lodash "^4.2.0" + to-fast-properties "^1.0.1" + +babylon@^6.11.0: + version "6.13.1" + resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.13.1.tgz#adca350e088f0467647157652bafead6ddb8dfdb" + +balanced-match@^0.4.1: + version "0.4.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" + +bcrypt-pbkdf@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.0.tgz#3ca76b85241c7170bf7d9703e7b9aa74630040d4" + dependencies: + tweetnacl "^0.14.3" + +binary-extensions@^1.0.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.7.0.tgz#6c1610db163abfb34edfe42fa423343a1e01185d" + +bl@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/bl/-/bl-1.1.2.tgz#fdca871a99713aa00d19e3bbba41c44787a65398" + dependencies: + readable-stream "~2.0.5" + +block-stream@*, block-stream@0.0.9: + version "0.0.9" + resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" + dependencies: + inherits "~2.0.0" + +bluebird@^3.4.1, bluebird@^3.4.6: + version "3.4.6" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.6.tgz#01da8d821d87813d158967e743d5fe6c62cf8c0f" + +bluebird@2.10.2: + version "2.10.2" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.10.2.tgz#024a5517295308857f14f91f1106fc3b555f446b" + +boom, boom@4.x.x: + version "4.2.0" + resolved "https://registry.yarnpkg.com/boom/-/boom-4.2.0.tgz#c1a74174b11fbba223f6162d4fd8851a1b82a536" + dependencies: + hoek "4.x.x" + +boom@2.x.x: + version "2.10.1" + resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f" + dependencies: + hoek "2.x.x" + +boom@3.x.x: + version "3.2.2" + resolved "https://registry.yarnpkg.com/boom/-/boom-3.2.2.tgz#0f0cc5d04adc5003b8c7d71f42cca7271fef0e78" + dependencies: + hoek "4.x.x" + +brace-expansion@^1.0.0: + version "1.1.6" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.6.tgz#7197d7eaa9b87e648390ea61fc66c84427420df9" + dependencies: + balanced-match "^0.4.1" + concat-map "0.0.1" + +braces@^1.8.2: + version "1.8.5" + resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" + dependencies: + expand-range "^1.8.1" + preserve "^0.2.0" + repeat-element "^1.1.2" + +browser-stdout@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.0.tgz#f351d32969d32fa5d7a5567154263d928ae3bd1f" + +bson@~0.5.4, bson@~0.5.6: + version "0.5.6" + resolved "https://registry.yarnpkg.com/bson/-/bson-0.5.6.tgz#e03de80a692c28fca4396f0d14c97069bd2b73a6" + +buffer-shims@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-shims/-/buffer-shims-1.0.0.tgz#9978ce317388c649ad8793028c3477ef044a8b51" + +buffer-writer@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-1.0.1.tgz#22a936901e3029afcd7547eb4487ceb697a3bf08" + +builtin-modules@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" + +builtins@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/builtins/-/builtins-0.0.7.tgz#355219cd6cf18dbe7c01cc7fd2dce765cfdc549a" + +call@3.x.x: + version "3.0.3" + resolved "https://registry.yarnpkg.com/call/-/call-3.0.3.tgz#e4748ddbbb7f41ae40cee055f8b270b733bf7c8d" + dependencies: + boom "3.x.x" + hoek "4.x.x" + +caller-path@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" + dependencies: + callsites "^0.2.0" + +callsites@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca" + +camelcase@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" + +camelcase@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" + +camelcase@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" + +caseless@~0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.11.0.tgz#715b96ea9841593cc33067923f5ec60ebda4f7d7" + +catbox-memory@2.x.x: + version "2.0.4" + resolved "https://registry.yarnpkg.com/catbox-memory/-/catbox-memory-2.0.4.tgz#433e255902caf54233d1286429c8f4df14e822d5" + dependencies: + hoek "4.x.x" + +catbox@7.x.x: + version "7.1.2" + resolved "https://registry.yarnpkg.com/catbox/-/catbox-7.1.2.tgz#46721b1c99967513fd7b7e9451706a05edfed5ad" + dependencies: + boom "3.x.x" + hoek "4.x.x" + joi "9.x.x" + +center-align@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" + dependencies: + align-text "^0.1.3" + lazy-cache "^1.0.3" + +chai@*: + version "3.5.0" + resolved "https://registry.yarnpkg.com/chai/-/chai-3.5.0.tgz#4d02637b067fe958bdbfdd3a40ec56fef7373247" + dependencies: + assertion-error "^1.0.1" + deep-eql "^0.1.3" + type-detect "^1.0.0" + +chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +char-spinner@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/char-spinner/-/char-spinner-1.0.1.tgz#e6ea67bd247e107112983b7ab0479ed362800081" + +chmodr@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/chmodr/-/chmodr-1.0.2.tgz#04662b932d0f02ec66deaa2b0ea42811968e3eb9" + +chokidar@^1.0.0, chokidar@^1.4.3: + version "1.6.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.6.1.tgz#2f4447ab5e96e50fb3d789fd90d4c72e0e4c70c2" + dependencies: + anymatch "^1.3.0" + async-each "^1.0.0" + glob-parent "^2.0.0" + inherits "^2.0.1" + is-binary-path "^1.0.0" + is-glob "^2.0.0" + path-is-absolute "^1.0.0" + readdirp "^2.0.0" + optionalDependencies: + fsevents "^1.0.0" + +chownr@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181" + +circular-json@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.1.tgz#be8b36aefccde8b3ca7aa2d6afc07a37242c0d2d" + +cli-cursor@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" + dependencies: + restore-cursor "^1.0.1" + +cli-width@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a" + +cliui@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" + dependencies: + center-align "^0.1.1" + right-align "^0.1.1" + wordwrap "0.0.2" + +cliui@^3.0.3, cliui@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + wrap-ansi "^2.0.0" + +clone@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.2.tgz#260b7a99ebb1edfe247538175f783243cb19d149" + +cmd-shim@~2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-2.0.2.tgz#6fcbda99483a8fd15d7d30a196ca69d688a2efdb" + dependencies: + graceful-fs "^4.1.2" + mkdirp "~0.5.0" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + +colors@1.0.x: + version "1.0.3" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" + +columnify@~1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/columnify/-/columnify-1.5.4.tgz#4737ddf1c7b69a8a7c340570782e947eec8e78bb" + dependencies: + strip-ansi "^3.0.0" + wcwidth "^1.0.0" + +combined-stream@^1.0.5, combined-stream@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" + dependencies: + delayed-stream "~1.0.0" + +commander@^2.2.0, commander@^2.8.1, commander@^2.9.0, commander@2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4" + dependencies: + graceful-readlink ">= 1.0.0" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + +concat-stream@^1.4.6, concat-stream@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.5.2.tgz#708978624d856af41a5a741defdd261da752c266" + dependencies: + inherits "~2.0.1" + readable-stream "~2.0.0" + typedarray "~0.0.5" + +config-chain@~1.1.10, config-chain@~1.1.11: + version "1.1.11" + resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.11.tgz#aba09747dfbe4c3e70e766a6e41586e1859fc6f2" + dependencies: + ini "^1.3.4" + proto-list "~1.2.1" + +configstore@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/configstore/-/configstore-1.4.0.tgz#c35781d0501d268c25c54b8b17f6240e8a4fb021" + dependencies: + graceful-fs "^4.1.2" + mkdirp "^0.5.0" + object-assign "^4.0.1" + os-tmpdir "^1.0.0" + osenv "^0.1.0" + uuid "^2.0.1" + write-file-atomic "^1.1.2" + xdg-basedir "^2.0.0" + +console-control-strings@^1.0.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + +content@3.x.x: + version "3.0.3" + resolved "https://registry.yarnpkg.com/content/-/content-3.0.3.tgz#000f8a01371b95c66afe99be9390fa6cb91aa87a" + dependencies: + boom "4.x.x" + +convert-source-map@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.3.0.tgz#e9f3e9c6e2728efc2676696a70eb382f73106a67" + +core-js@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" + +core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + +coveralls@^2.11.9: + version "2.11.15" + resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-2.11.15.tgz#37d3474369d66c14f33fa73a9d25cee6e099fca0" + dependencies: + js-yaml "3.6.1" + lcov-parse "0.0.10" + log-driver "1.2.5" + minimist "1.2.0" + request "2.75.0" + +cryptiles@2.x.x: + version "2.0.5" + resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8" + dependencies: + boom "2.x.x" + +cryptiles@3.x.x: + version "3.1.1" + resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.1.tgz#86a9203f7367a0e9324bc7555ff0fcf5f81979ee" + dependencies: + boom "4.x.x" + +cycle@1.0.x: + version "1.0.3" + resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2" + +d@^0.1.1, d@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/d/-/d-0.1.1.tgz#da184c535d18d8ee7ba2aa229b914009fae11309" + dependencies: + es5-ext "~0.10.2" + +dashdash@^1.12.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.0.tgz#29e486c5418bf0f356034a993d51686a33e84141" + dependencies: + assert-plus "^1.0.0" + +debug@^2.1.1, debug@^2.1.3, debug@^2.2.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.3.2.tgz#94cb466ef7d6d2c7e5245cdd6e4104f2d0d70d30" + dependencies: + ms "0.7.2" + +debug@~2.2.0, debug@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da" + dependencies: + ms "0.7.1" + +debuglog@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" + +decamelize@^1.0.0, decamelize@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + +deep-eql@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-0.1.3.tgz#ef558acab8de25206cd713906d74e56930eb69f2" + dependencies: + type-detect "0.1.1" + +deep-extend@~0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.1.tgz#efe4113d08085f4e6f9687759810f807469e2253" + +deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + +defaults@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" + dependencies: + clone "^1.0.2" + +del@^2.0.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/del/-/del-2.2.2.tgz#c12c981d067846c84bcaf862cff930d907ffd1a8" + dependencies: + globby "^5.0.0" + is-path-cwd "^1.0.0" + is-path-in-cwd "^1.0.0" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + rimraf "^2.2.8" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + +detect-file@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-0.1.0.tgz#4935dedfd9488648e006b0129566e9386711ea63" + dependencies: + fs-exists-sync "^0.1.0" + +detect-indent@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208" + dependencies: + repeating "^2.0.0" + +dezalgo@^1.0.0, dezalgo@^1.0.1, dezalgo@^1.0.2, dezalgo@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.3.tgz#7f742de066fc748bc8db820569dddce49bf0d456" + dependencies: + asap "^2.0.0" + wrappy "1" + +diff@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-1.4.0.tgz#7f28d2eb9ee7b15a97efd89ce63dcfdaa3ccbabf" + +doctrine@^1.2.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" + dependencies: + esutils "^2.0.2" + isarray "^1.0.0" + +duplexer@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" + +duplexify@^3.2.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.5.0.tgz#1aa773002e1578457e9d9d4a50b0ccaaebcbd604" + dependencies: + end-of-stream "1.0.0" + inherits "^2.0.1" + readable-stream "^2.0.0" + stream-shift "^1.0.0" + +ecc-jsbn@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505" + dependencies: + jsbn "~0.1.0" + +editor@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/editor/-/editor-1.0.0.tgz#60c7f87bd62bcc6a894fa8ccd6afb7823a24f742" + +end-of-stream@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.0.0.tgz#d4596e702734a93e40e9af864319eabd99ff2f0e" + dependencies: + once "~1.3.0" + +error-ex@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.0.tgz#e67b43f3e82c96ea3a584ffee0b9fc3325d802d9" + dependencies: + is-arrayish "^0.2.1" + +es5-ext@^0.10.7, es5-ext@^0.10.8, es5-ext@~0.10.11, es5-ext@~0.10.2, es5-ext@~0.10.7: + version "0.10.12" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.12.tgz#aa84641d4db76b62abba5e45fd805ecbab140047" + dependencies: + es6-iterator "2" + es6-symbol "~3.1" + +es6-iterator@2: + version "2.0.0" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.0.tgz#bd968567d61635e33c0b80727613c9cb4b096bac" + dependencies: + d "^0.1.1" + es5-ext "^0.10.7" + es6-symbol "3" + +es6-map@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.4.tgz#a34b147be224773a4d7da8072794cefa3632b897" + dependencies: + d "~0.1.1" + es5-ext "~0.10.11" + es6-iterator "2" + es6-set "~0.1.3" + es6-symbol "~3.1.0" + event-emitter "~0.3.4" + +es6-promise@^3.0.2: + version "3.3.1" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" + +es6-promise@3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.2.1.tgz#ec56233868032909207170c39448e24449dd1fc4" + +es6-set@~0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.4.tgz#9516b6761c2964b92ff479456233a247dc707ce8" + dependencies: + d "~0.1.1" + es5-ext "~0.10.11" + es6-iterator "2" + es6-symbol "3" + event-emitter "~0.3.4" + +es6-symbol@^3.0.2, es6-symbol@~3.1, es6-symbol@~3.1.0, es6-symbol@3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.0.tgz#94481c655e7a7cad82eba832d97d5433496d7ffa" + dependencies: + d "~0.1.1" + es5-ext "~0.10.11" + +es6-weak-map@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.1.tgz#0d2bbd8827eb5fb4ba8f97fbfea50d43db21ea81" + dependencies: + d "^0.1.1" + es5-ext "^0.10.8" + es6-iterator "2" + es6-symbol "3" + +escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5, escape-string-regexp@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + +escodegen@1.8.x: + version "1.8.1" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.8.1.tgz#5a5b53af4693110bebb0867aa3430dd3b70a1018" + dependencies: + esprima "^2.7.1" + estraverse "^1.9.1" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.2.0" + +escope@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/escope/-/escope-3.6.0.tgz#e01975e812781a163a6dadfdd80398dc64c889c3" + dependencies: + es6-map "^0.1.3" + es6-weak-map "^2.0.1" + esrecurse "^4.1.0" + estraverse "^4.1.1" + +eslint-config-google@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.6.0.tgz#c542ec18fb3247983ac16bba31662d01625b763f" + dependencies: + eslint-config-xo "^0.13.0" + +eslint-config-xo@^0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/eslint-config-xo/-/eslint-config-xo-0.13.0.tgz#f916765432ba67d2fc7a7177b8bcfef3f6eb0564" + +eslint@^3.0.0, eslint@^3.4.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-3.10.0.tgz#4a90079046b3a89099eaa47787eafeb081e78209" + dependencies: + babel-code-frame "^6.16.0" + chalk "^1.1.3" + concat-stream "^1.4.6" + debug "^2.1.1" + doctrine "^1.2.2" + escope "^3.6.0" + espree "^3.3.1" + estraverse "^4.2.0" + esutils "^2.0.2" + file-entry-cache "^2.0.0" + glob "^7.0.3" + globals "^9.2.0" + ignore "^3.2.0" + imurmurhash "^0.1.4" + inquirer "^0.12.0" + is-my-json-valid "^2.10.0" + is-resolvable "^1.0.0" + js-yaml "^3.5.1" + json-stable-stringify "^1.0.0" + levn "^0.3.0" + lodash "^4.0.0" + mkdirp "^0.5.0" + natural-compare "^1.4.0" + optionator "^0.8.2" + path-is-inside "^1.0.1" + pluralize "^1.2.1" + progress "^1.1.8" + require-uncached "^1.0.2" + shelljs "^0.7.5" + strip-bom "^3.0.0" + strip-json-comments "~1.0.1" + table "^3.7.8" + text-table "~0.2.0" + user-home "^2.0.0" + +espree@^3.3.1: + version "3.3.2" + resolved "https://registry.yarnpkg.com/espree/-/espree-3.3.2.tgz#dbf3fadeb4ecb4d4778303e50103b3d36c88b89c" + dependencies: + acorn "^4.0.1" + acorn-jsx "^3.0.0" + +esprima@^2.6.0, esprima@^2.7.1, esprima@2.7.x: + version "2.7.3" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" + +esrecurse@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.1.0.tgz#4713b6536adf7f2ac4f327d559e7756bff648220" + dependencies: + estraverse "~4.1.0" + object-assign "^4.0.1" + +estraverse@^1.9.1: + version "1.9.3" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-1.9.3.tgz#af67f2dc922582415950926091a4005d29c9bb44" + +estraverse@^4.1.1, estraverse@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" + +estraverse@~4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.1.1.tgz#f6caca728933a850ef90661d0e17982ba47111a2" + +esutils@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" + +event-emitter@~0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.4.tgz#8d63ddfb4cfe1fae3b32ca265c4c720222080bb5" + dependencies: + d "~0.1.1" + es5-ext "~0.10.7" + +event-stream@~3.3.0: + version "3.3.4" + resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571" + dependencies: + duplexer "~0.1.1" + from "~0" + map-stream "~0.1.0" + pause-stream "0.0.11" + split "0.3" + stream-combiner "~0.0.4" + through "~2.3.1" + +exit-hook@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" + +expand-brackets@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" + dependencies: + is-posix-bracket "^0.1.0" + +expand-range@^1.8.1: + version "1.8.2" + resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" + dependencies: + fill-range "^2.1.0" + +expand-tilde@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-1.2.2.tgz#0b81eba897e5a3d31d1c3d102f8f01441e559449" + dependencies: + os-homedir "^1.0.1" + +extend@^3.0.0, extend@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.0.tgz#5a474353b9f3353ddd8176dfd37b91c83a46f1d4" + +extglob@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" + dependencies: + is-extglob "^1.0.0" + +extsprintf@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.0.2.tgz#e1080e0658e300b06294990cc70e1502235fd550" + +eyes@0.1.x: + version "0.1.8" + resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0" + +fast-levenshtein@~2.0.4: + version "2.0.5" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.5.tgz#bd33145744519ab1c36c3ee9f31f08e9079b67f2" + +figures@^1.3.5: + version "1.7.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" + dependencies: + escape-string-regexp "^1.0.5" + object-assign "^4.1.0" + +file-entry-cache@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-2.0.0.tgz#c392990c3e684783d838b8c84a45d8a048458361" + dependencies: + flat-cache "^1.2.1" + object-assign "^4.0.1" + +filename-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.0.tgz#996e3e80479b98b9897f15a8a58b3d084e926775" + +fill-range@^2.1.0: + version "2.2.3" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.3.tgz#50b77dfd7e469bc7492470963699fe7a8485a723" + dependencies: + is-number "^2.1.0" + isobject "^2.0.0" + randomatic "^1.1.3" + repeat-element "^1.1.2" + repeat-string "^1.5.2" + +find-up@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" + dependencies: + path-exists "^2.0.0" + pinkie-promise "^2.0.0" + +findup-sync@^0.4.2: + version "0.4.3" + resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.4.3.tgz#40043929e7bc60adf0b7f4827c4c6e75a0deca12" + dependencies: + detect-file "^0.1.0" + is-glob "^2.0.1" + micromatch "^2.3.7" + resolve-dir "^0.1.0" + +flagged-respawn@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/flagged-respawn/-/flagged-respawn-0.3.2.tgz#ff191eddcd7088a675b2610fffc976be9b8074b5" + +flat-cache@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.2.1.tgz#6c837d6225a7de5659323740b36d5361f71691ff" + dependencies: + circular-json "^0.3.0" + del "^2.0.2" + graceful-fs "^4.1.2" + write "^0.2.1" + +fluent-ffmpeg@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fluent-ffmpeg/-/fluent-ffmpeg-2.1.0.tgz#e6ab85e75ba8e49119a3900cd9df10d39831d392" + dependencies: + async ">=0.2.9" + which "^1.1.1" + +for-in@^0.1.5: + version "0.1.6" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.6.tgz#c9f96e89bfad18a545af5ec3ed352a1d9e5b4dc8" + +for-own@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.4.tgz#0149b41a39088c7515f51ebe1c1386d45f935072" + dependencies: + for-in "^0.1.5" + +foreachasync@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/foreachasync/-/foreachasync-3.0.0.tgz#5502987dc8714be3392097f32e0071c9dee07cf6" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + +form-data@~1.0.0-rc4: + version "1.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-1.0.1.tgz#ae315db9a4907fa065502304a66d7733475ee37c" + dependencies: + async "^2.0.1" + combined-stream "^1.0.5" + mime-types "^2.1.11" + +form-data@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.0.0.tgz#6f0aebadcc5da16c13e1ecc11137d85f9b883b25" + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.5" + mime-types "^2.1.11" + +form-data@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.2.tgz#89c3534008b97eada4cbb157d58f6f5df025eae4" + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.5" + mime-types "^2.1.12" + +from@~0: + version "0.1.3" + resolved "https://registry.yarnpkg.com/from/-/from-0.1.3.tgz#ef63ac2062ac32acf7862e0d40b44b896f22f3bc" + +fs-exists-sync@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add" + +fs-readdir-recursive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-readdir-recursive/-/fs-readdir-recursive-1.0.0.tgz#8cd1745c8b4f8a29c8caec392476921ba195f560" + +fs-vacuum@~1.2.9: + version "1.2.9" + resolved "https://registry.yarnpkg.com/fs-vacuum/-/fs-vacuum-1.2.9.tgz#4f90193ab8ea02890995bcd4e804659a5d366b2d" + dependencies: + graceful-fs "^4.1.2" + path-is-inside "^1.0.1" + rimraf "^2.5.2" + +fs-write-stream-atomic@~1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.8.tgz#e49aaddf288f87d46ff9e882f216a13abc40778b" + dependencies: + graceful-fs "^4.1.2" + iferr "^0.1.5" + imurmurhash "^0.1.4" + readable-stream "1 || 2" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + +fsevents@^1.0.0: + version "1.0.15" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.0.15.tgz#fa63f590f3c2ad91275e4972a6cea545fb0aae44" + dependencies: + nan "^2.3.0" + node-pre-gyp "^0.6.29" + +fstream-ignore@^1.0.0, fstream-ignore@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/fstream-ignore/-/fstream-ignore-1.0.5.tgz#9c31dae34767018fe1d249b24dada67d092da105" + dependencies: + fstream "^1.0.0" + inherits "2" + minimatch "^3.0.0" + +fstream-npm@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/fstream-npm/-/fstream-npm-1.1.1.tgz#6b9175db6239a83d8209e232426c494dbb29690c" + dependencies: + fstream-ignore "^1.0.0" + inherits "2" + +fstream-npm@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/fstream-npm/-/fstream-npm-1.2.0.tgz#d2c3c89101346982d64e57091c38487bda916fce" + dependencies: + fstream-ignore "^1.0.0" + inherits "2" + +fstream@^1.0.0, fstream@^1.0.2, fstream@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.10.tgz#604e8a92fe26ffd9f6fae30399d4984e1ab22822" + dependencies: + graceful-fs "^4.1.2" + inherits "~2.0.0" + mkdirp ">=0.5 0" + rimraf "2" + +gauge@~1.2.5: + version "1.2.7" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-1.2.7.tgz#e9cec5483d3d4ee0ef44b60a7d99e4935e136d93" + dependencies: + ansi "^0.3.0" + has-unicode "^2.0.0" + lodash.pad "^4.1.0" + lodash.padend "^4.1.0" + lodash.padstart "^4.1.0" + +gauge@~2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.6.0.tgz#d35301ad18e96902b4751dcbbe40f4218b942a46" + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-color "^0.1.7" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + +generate-function@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.0.0.tgz#6858fe7c0969b7d4e9093337647ac79f60dfbe74" + +generate-object-property@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/generate-object-property/-/generate-object-property-1.2.0.tgz#9c0e1c40308ce804f4783618b937fa88f99d50d0" + dependencies: + is-property "^1.0.0" + +generic-pool@^2.4.2: + version "2.4.6" + resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-2.4.6.tgz#f1b55e572167dba2fe75d5aa91ebb1e9f72642d7" + +generic-pool@2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-2.4.2.tgz#886bc5bf0beb7db96e81bcbba078818de5a62683" + +get-caller-file@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5" + +getpass@^0.1.1: + version "0.1.6" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.6.tgz#283ffd9fc1256840875311c1b60e8c40187110e6" + dependencies: + assert-plus "^1.0.0" + +github-url-from-git@~1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/github-url-from-git/-/github-url-from-git-1.4.0.tgz#285e6b520819001bde128674704379e4ff03e0de" + +github-url-from-username-repo@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/github-url-from-username-repo/-/github-url-from-username-repo-1.0.2.tgz#7dd79330d2abe69c10c2cef79714c97215791dfa" + +glob-all@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/glob-all/-/glob-all-3.1.0.tgz#8913ddfb5ee1ac7812656241b03d5217c64b02ab" + dependencies: + glob "^7.0.5" + yargs "~1.2.6" + +glob-base@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" + dependencies: + glob-parent "^2.0.0" + is-glob "^2.0.0" + +glob-parent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" + dependencies: + is-glob "^2.0.0" + +glob@^5.0.15, glob@^5.0.5: + version "5.0.15" + resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^6.0.0: + version "6.0.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22" + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@~7.1.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.2" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@~7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.0.6.tgz#211bafaf49e525b8cd93260d14ab136152b3f57a" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.2" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.0.5.tgz#b4202a69099bbb4d292a7c1b95b6682b67ebdc95" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.2" + once "^1.3.0" + path-is-absolute "^1.0.0" + +global-modules@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d" + dependencies: + global-prefix "^0.1.4" + is-windows "^0.2.0" + +global-prefix@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-0.1.4.tgz#05158db1cde2dd491b455e290eb3ab8bfc45c6e1" + dependencies: + ini "^1.3.4" + is-windows "^0.2.0" + osenv "^0.1.3" + which "^1.2.10" + +globals@^9.0.0, globals@^9.2.0: + version "9.13.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-9.13.0.tgz#d97706b61600d8dbe94708c367d3fdcf48470b8f" + +globby@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-5.0.0.tgz#ebd84667ca0dbb330b99bcfc68eac2bc54370e0d" + dependencies: + array-union "^1.0.1" + arrify "^1.0.0" + glob "^7.0.3" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +got@^3.2.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/got/-/got-3.3.1.tgz#e5d0ed4af55fc3eef4d56007769d98192bcb2eca" + dependencies: + duplexify "^3.2.0" + infinity-agent "^2.0.0" + is-redirect "^1.0.0" + is-stream "^1.0.0" + lowercase-keys "^1.0.0" + nested-error-stacks "^1.0.0" + object-assign "^3.0.0" + prepend-http "^1.0.0" + read-all-stream "^3.0.0" + timed-out "^2.0.0" + +graceful-fs@^4.1.2, graceful-fs@^4.1.4, graceful-fs@^4.1.6, graceful-fs@~4.1.6, graceful-fs@~4.1.9: + version "4.1.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.10.tgz#f2d720c22092f743228775c75e3612632501f131" + +"graceful-readlink@>= 1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" + +growl@1.9.2: + version "1.9.2" + resolved "https://registry.yarnpkg.com/growl/-/growl-1.9.2.tgz#0ea7743715db8d8de2c5ede1775e1b45ac85c02f" + +handlebars@^4.0.1: + version "4.0.5" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.5.tgz#92c6ed6bb164110c50d4d8d0fbddc70806c6f8e7" + dependencies: + async "^1.4.0" + optimist "^0.6.1" + source-map "^0.4.4" + optionalDependencies: + uglify-js "^2.6" + +hapi@^15.2.0: + version "15.2.0" + resolved "https://registry.yarnpkg.com/hapi/-/hapi-15.2.0.tgz#5704ca2c04b6386c03caf9ee901f1de080316d23" + dependencies: + accept "2.x.x" + ammo "2.x.x" + boom "4.x.x" + call "3.x.x" + catbox "7.x.x" + catbox-memory "2.x.x" + cryptiles "3.x.x" + heavy "4.x.x" + hoek "4.x.x" + iron "4.x.x" + items "2.x.x" + joi "9.x.x" + mimos "3.x.x" + podium "^1.2.x" + shot "3.x.x" + statehood "5.x.x" + subtext "^4.3.x" + topo "2.x.x" + +har-validator@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-2.0.6.tgz#cdcbc08188265ad119b6a5a7c8ab70eecfb5d27d" + dependencies: + chalk "^1.1.1" + commander "^2.9.0" + is-my-json-valid "^2.12.4" + pinkie-promise "^2.0.0" + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + dependencies: + ansi-regex "^2.0.0" + +has-color@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/has-color/-/has-color-0.1.7.tgz#67144a5260c34fc3cca677d041daf52fe7b78b2f" + +has-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" + +has-unicode@^2.0.0, has-unicode@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + +hawk@~3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4" + dependencies: + boom "2.x.x" + cryptiles "2.x.x" + hoek "2.x.x" + sntp "1.x.x" + +heavy@4.x.x: + version "4.0.2" + resolved "https://registry.yarnpkg.com/heavy/-/heavy-4.0.2.tgz#dbb66cda5f017a594fc6c8301df6999ea8d533f0" + dependencies: + boom "3.x.x" + hoek "4.x.x" + joi "9.x.x" + +hoek@2.x.x: + version "2.16.3" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" + +hoek@4.x.x: + version "4.1.0" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.1.0.tgz#4a4557460f69842ed463aa00628cc26d2683afa7" + +home-or-tmp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.1" + +hooks-fixed@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/hooks-fixed/-/hooks-fixed-1.2.0.tgz#0d2772d4d7d685ff9244724a9f0b5b2559aac96b" + +hosted-git-info@^2.1.4, hosted-git-info@^2.1.5, hosted-git-info@~2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.1.5.tgz#0ba81d90da2e25ab34a332e6ec77936e1598118b" + +http-signature@~1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf" + dependencies: + assert-plus "^0.2.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +iferr@^0.1.5, iferr@~0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" + +ignore-by-default@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + +ignore@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.2.0.tgz#8d88f03c3002a0ac52114db25d2c673b0bf1e435" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + +inert: + version "4.0.2" + resolved "https://registry.yarnpkg.com/inert/-/inert-4.0.2.tgz#f26094988e653f81c84a690664781546f8d75928" + dependencies: + ammo "2.x.x" + boom "3.x.x" + hoek "4.x.x" + items "2.x.x" + joi "9.x.x" + lru-cache "4.0.x" + +infinity-agent@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/infinity-agent/-/infinity-agent-2.0.3.tgz#45e0e2ff7a9eb030b27d62b74b3744b7a7ac4216" + +inflight@^1.0.4, inflight@~1.0.4, inflight@~1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@^2.0.1, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3, inherits@2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + +ini@^1.3.4, ini@~1.3.0, ini@~1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e" + +init-package-json@~1.9.4: + version "1.9.4" + resolved "https://registry.yarnpkg.com/init-package-json/-/init-package-json-1.9.4.tgz#b4053d0b40f0cf842a41966937cb3dc0f534e856" + dependencies: + glob "^6.0.0" + npm-package-arg "^4.0.0" + promzard "^0.3.0" + read "~1.0.1" + read-package-json "1 || 2" + semver "2.x || 3.x || 4 || 5" + validate-npm-package-license "^3.0.1" + validate-npm-package-name "^2.0.1" + +inquirer@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-0.12.0.tgz#1ef2bfd63504df0bc75785fff8c2c41df12f077e" + dependencies: + ansi-escapes "^1.1.0" + ansi-regex "^2.0.0" + chalk "^1.0.0" + cli-cursor "^1.0.1" + cli-width "^2.0.0" + figures "^1.3.5" + lodash "^4.3.0" + readline2 "^1.0.1" + run-async "^0.1.0" + rx-lite "^3.1.2" + string-width "^1.0.1" + strip-ansi "^3.0.0" + through "^2.3.6" + +interpret@^0.6.5: + version "0.6.6" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-0.6.6.tgz#fecd7a18e7ce5ca6abfb953e1f86213a49f1625b" + +interpret@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.1.tgz#d579fb7f693b858004947af39fa0db49f795602c" + +invariant@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.1.tgz#b097010547668c7e337028ebe816ebe36c8a8d54" + dependencies: + loose-envify "^1.0.0" + +invert-kv@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" + +iron@4.x.x: + version "4.0.4" + resolved "https://registry.yarnpkg.com/iron/-/iron-4.0.4.tgz#c1f8cc4c91454194ab8920d9247ba882e528061a" + dependencies: + boom "4.x.x" + cryptiles "3.x.x" + hoek "4.x.x" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + +is-binary-path@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" + dependencies: + binary-extensions "^1.0.0" + +is-buffer@^1.0.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.4.tgz#cfc86ccd5dc5a52fa80489111c6920c457e2d98b" + +is-builtin-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-1.0.0.tgz#540572d34f7ac3119f8f76c30cbc1b1e037affbe" + dependencies: + builtin-modules "^1.0.0" + +is-dotfile@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.2.tgz#2c132383f39199f8edc268ca01b9b007d205cc4d" + +is-equal-shallow@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" + dependencies: + is-primitive "^2.0.0" + +is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + +is-extglob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" + +is-finite@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa" + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + +is-glob@^2.0.0, is-glob@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" + dependencies: + is-extglob "^1.0.0" + +is-my-json-valid@^2.10.0, is-my-json-valid@^2.12.4: + version "2.15.0" + resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.15.0.tgz#936edda3ca3c211fd98f3b2d3e08da43f7b2915b" + dependencies: + generate-function "^2.0.0" + generate-object-property "^1.1.0" + jsonpointer "^4.0.0" + xtend "^4.0.0" + +is-npm@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4" + +is-number@^2.0.2, is-number@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" + dependencies: + kind-of "^3.0.2" + +is-path-cwd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d" + +is-path-in-cwd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.0.tgz#6477582b8214d602346094567003be8a9eac04dc" + dependencies: + is-path-inside "^1.0.0" + +is-path-inside@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.0.tgz#fc06e5a1683fbda13de667aff717bbc10a48f37f" + dependencies: + path-is-inside "^1.0.1" + +is-posix-bracket@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" + +is-primitive@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" + +is-property@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" + +is-redirect@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" + +is-resolvable@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.0.0.tgz#8df57c61ea2e3c501408d100fb013cf8d6e0cc62" + dependencies: + tryit "^1.0.1" + +is-stream@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + +is-utf8@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" + +is-windows@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c" + +isarray@^1.0.0, isarray@~1.0.0, isarray@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + +isemail@2.x.x: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isemail/-/isemail-2.2.1.tgz#0353d3d9a62951080c262c2aa0a42b8ea8e9e2a6" + +isexe@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-1.1.2.tgz#36f3e22e60750920f5e7241a476a8c6a42275ad0" + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + dependencies: + isarray "1.0.0" + +isstream@~0.1.2, isstream@0.1.x: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + +istanbul@*: + version "0.4.5" + resolved "https://registry.yarnpkg.com/istanbul/-/istanbul-0.4.5.tgz#65c7d73d4c4da84d4f3ac310b918fb0b8033733b" + dependencies: + abbrev "1.0.x" + async "1.x" + escodegen "1.8.x" + esprima "2.7.x" + glob "^5.0.15" + handlebars "^4.0.1" + js-yaml "3.x" + mkdirp "0.5.x" + nopt "3.x" + once "1.x" + resolve "1.1.x" + supports-color "^3.1.0" + which "^1.1.1" + wordwrap "^1.0.0" + +items@2.x.x: + version "2.1.1" + resolved "https://registry.yarnpkg.com/items/-/items-2.1.1.tgz#8bd16d9c83b19529de5aea321acaada78364a198" + +jju@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/jju/-/jju-1.3.0.tgz#dadd9ef01924bc728b03f2f7979bdbd62f7a2aaa" + +jodid25519@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/jodid25519/-/jodid25519-1.0.2.tgz#06d4912255093419477d425633606e0e90782967" + dependencies: + jsbn "~0.1.0" + +joi@9.x.x: + version "9.2.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-9.2.0.tgz#3385ac790192130cbe230e802ec02c9215bbfeda" + dependencies: + hoek "4.x.x" + isemail "2.x.x" + items "2.x.x" + moment "2.x.x" + topo "2.x.x" + +js-tokens@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-2.0.0.tgz#79903f5563ee778cc1162e6dcf1a0027c97f9cb5" + +js-yaml@^3.5.1, js-yaml@3.x: + version "3.7.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.7.0.tgz#5c967ddd837a9bfdca5f2de84253abe8a1c03b80" + dependencies: + argparse "^1.0.7" + esprima "^2.6.0" + +js-yaml@3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.6.1.tgz#6e5fe67d8b205ce4d22fad05b7781e8dadcc4b30" + dependencies: + argparse "^1.0.7" + esprima "^2.6.0" + +jsbn@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.0.tgz#650987da0dd74f4ebf5a11377a2aa2d273e97dfd" + +jsesc@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b" + +jsesc@~0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" + +json-parse-helpfulerror@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/json-parse-helpfulerror/-/json-parse-helpfulerror-1.0.3.tgz#13f14ce02eed4e981297b64eb9e3b932e2dd13dc" + dependencies: + jju "^1.1.0" + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + +json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" + dependencies: + jsonify "~0.0.0" + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + +json3@3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" + +json5@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.0.tgz#9b20715b026cbe3778fd769edccd822d8332a5b2" + +jsonify@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" + +jsonpointer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.0.tgz#6661e161d2fc445f19f98430231343722e1fcbd5" + +jsprim@^1.2.2: + version "1.3.1" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.3.1.tgz#2a7256f70412a29ee3670aaca625994c4dcff252" + dependencies: + extsprintf "1.0.2" + json-schema "0.2.3" + verror "1.3.6" + +kareem@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/kareem/-/kareem-1.1.3.tgz#0877610d8879c38da62d1dbafde4e17f2692f041" + +kind-of@^3.0.2: + version "3.0.4" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.0.4.tgz#7b8ecf18a4e17f8269d73b501c9f232c96887a74" + dependencies: + is-buffer "^1.0.2" + +knex@^0.12.5: + version "0.12.6" + resolved "https://registry.yarnpkg.com/knex/-/knex-0.12.6.tgz#a255f0ea03af2c2c94687a622c08acc1a9463c0e" + dependencies: + babel-runtime "^6.11.6" + bluebird "^3.4.6" + chalk "^1.0.0" + commander "^2.2.0" + debug "^2.1.3" + generic-pool "^2.4.2" + inherits "~2.0.1" + interpret "^0.6.5" + liftoff "~2.2.0" + lodash "^4.6.0" + minimist "~1.1.0" + mkdirp "^0.5.0" + node-uuid "^1.4.7" + pg-connection-string "^0.1.3" + readable-stream "^1.1.12" + tildify "~1.0.0" + v8flags "^2.0.2" + +latest-version@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-1.0.1.tgz#72cfc46e3e8d1be651e1ebb54ea9f6ea96f374bb" + dependencies: + package-json "^1.0.0" + +lazy-cache@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" + +lcid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" + dependencies: + invert-kv "^1.0.0" + +lcov-parse@0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/lcov-parse/-/lcov-parse-0.0.10.tgz#1b0b8ff9ac9c7889250582b70b71315d9da6d9a3" + +levn@^0.3.0, levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +liftoff@~2.2.0: + version "2.2.5" + resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-2.2.5.tgz#998c2876cff484b103e4423b93d356da44734c91" + dependencies: + extend "^3.0.0" + findup-sync "^0.4.2" + flagged-respawn "^0.3.2" + rechoir "^0.6.2" + resolve "^1.1.7" + +load-json-file@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" + dependencies: + graceful-fs "^4.1.2" + parse-json "^2.2.0" + pify "^2.0.0" + pinkie-promise "^2.0.0" + strip-bom "^2.0.0" + +lockfile@~1.0.1, lockfile@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/lockfile/-/lockfile-1.0.2.tgz#97e1990174f696cbe0a3acd58a43b84aa30c7c83" + +lodash._baseassign@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz#8c38a099500f215ad09e59f1722fd0c52bfe0a4e" + dependencies: + lodash._basecopy "^3.0.0" + lodash.keys "^3.0.0" + +lodash._basecopy@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36" + +lodash._basecreate@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz#1bc661614daa7fc311b7d03bf16806a0213cf821" + +lodash._baseuniq@~4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8" + dependencies: + lodash._createset "~4.0.0" + lodash._root "~3.0.0" + +lodash._bindcallback@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e" + +lodash._createassigner@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz#838a5bae2fdaca63ac22dee8e19fa4e6d6970b11" + dependencies: + lodash._bindcallback "^3.0.0" + lodash._isiterateecall "^3.0.0" + lodash.restparam "^3.0.0" + +lodash._createset@~4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/lodash._createset/-/lodash._createset-4.0.3.tgz#0f4659fbb09d75194fa9e2b88a6644d363c9fe26" + +lodash._getnative@^3.0.0: + version "3.9.1" + resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" + +lodash._isiterateecall@^3.0.0: + version "3.0.9" + resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c" + +lodash._root@~3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692" + +lodash.assign@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-3.2.0.tgz#3ce9f0234b4b2223e296b8fa0ac1fee8ebca64fa" + dependencies: + lodash._baseassign "^3.0.0" + lodash._createassigner "^3.0.0" + lodash.keys "^3.0.0" + +lodash.assign@^4.0.3, lodash.assign@^4.0.6: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" + +lodash.clonedeep@~4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + +lodash.create@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lodash.create/-/lodash.create-3.1.1.tgz#d7f2849f0dbda7e04682bb8cd72ab022461debe7" + dependencies: + lodash._baseassign "^3.0.0" + lodash._basecreate "^3.0.0" + lodash._isiterateecall "^3.0.0" + +lodash.defaults@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-3.1.2.tgz#c7308b18dbf8bc9372d701a73493c61192bd2e2c" + dependencies: + lodash.assign "^3.0.0" + lodash.restparam "^3.0.0" + +lodash.isarguments@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + +lodash.isarray@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" + +lodash.keys@^3.0.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" + dependencies: + lodash._getnative "^3.0.0" + lodash.isarguments "^3.0.0" + lodash.isarray "^3.0.0" + +lodash.pad@^4.1.0: + version "4.5.1" + resolved "https://registry.yarnpkg.com/lodash.pad/-/lodash.pad-4.5.1.tgz#4330949a833a7c8da22cc20f6a26c4d59debba70" + +lodash.padend@^4.1.0: + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.padend/-/lodash.padend-4.6.1.tgz#53ccba047d06e158d311f45da625f4e49e6f166e" + +lodash.padstart@^4.1.0: + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.padstart/-/lodash.padstart-4.6.1.tgz#d2e3eebff0d9d39ad50f5cbd1b52a7bce6bb611b" + +lodash.restparam@^3.0.0: + version "3.6.1" + resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" + +lodash.union@~4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" + +lodash.uniq@~4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" + +lodash.without@~4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.without/-/lodash.without-4.4.0.tgz#3cd4574a00b67bae373a94b748772640507b7aac" + +lodash@^4.0.0, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.2.0, lodash@^4.3.0, lodash@^4.6.0: + version "4.16.6" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.16.6.tgz#d22c9ac660288f3843e16ba7d2b5d06cca27d777" + +log-driver@1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.5.tgz#7ae4ec257302fd790d557cb10c97100d857b0056" + +longest@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" + +loose-envify@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.0.tgz#6b26248c42f6d4fa4b0d8542f78edfcde35642a8" + dependencies: + js-tokens "^2.0.0" + +lowercase-keys@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" + +lru-cache@~4.0.1, lru-cache@4.0.x: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.0.1.tgz#1343955edaf2e37d9b9e7ee7241e27c4b9fb72be" + dependencies: + pseudomap "^1.0.1" + yallist "^2.0.0" + +map-stream@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" + +micromatch@^2.1.5, micromatch@^2.3.7: + version "2.3.11" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" + dependencies: + arr-diff "^2.0.0" + array-unique "^0.2.1" + braces "^1.8.2" + expand-brackets "^0.1.4" + extglob "^0.3.1" + filename-regex "^2.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.1" + kind-of "^3.0.2" + normalize-path "^2.0.1" + object.omit "^2.0.0" + parse-glob "^3.0.4" + regex-cache "^0.4.2" + +mime-db@~1.24.0: + version "1.24.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.24.0.tgz#e2d13f939f0016c6e4e9ad25a8652f126c467f0c" + +mime-db@1.x.x: + version "1.25.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.25.0.tgz#c18dbd7c73a5dbf6f44a024dc0d165a1e7b1c392" + +mime-types@^2.1.11, mime-types@^2.1.12, mime-types@~2.1.7: + version "2.1.12" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.12.tgz#152ba256777020dd4663f54c2e7bc26381e71729" + dependencies: + mime-db "~1.24.0" + +mimos@3.x.x: + version "3.0.3" + resolved "https://registry.yarnpkg.com/mimos/-/mimos-3.0.3.tgz#b9109072ad378c2b72f6a0101c43ddfb2b36641f" + dependencies: + hoek "4.x.x" + mime-db "1.x.x" + +minimatch@^3.0.0, minimatch@^3.0.2, minimatch@~3.0.3, "minimatch@2 || 3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.3.tgz#2a4e4090b96b2db06a9d7df01055a62a77c9b774" + dependencies: + brace-expansion "^1.0.0" + +minimist@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.1.0.tgz#99df657a52574c21c9057497df742790b2b4c0de" + +minimist@^1.2.0, minimist@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" + +minimist@~0.0.1: + version "0.0.10" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" + +minimist@~1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.1.3.tgz#3bedfd91a92d39016fcfaa1c681e8faa1a1efda8" + +minimist@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + +mkdirp@^0.5.0, mkdirp@^0.5.1, "mkdirp@>=0.5 0", mkdirp@~0.5.0, mkdirp@~0.5.1, mkdirp@0.5.1, mkdirp@0.5.x: + version "0.5.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + dependencies: + minimist "0.0.8" + +mocha-eslint@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mocha-eslint/-/mocha-eslint-3.0.1.tgz#ae72abc561cb289d2e6c143788ee182aca1a04db" + dependencies: + chalk "^1.1.0" + eslint "^3.0.0" + glob-all "^3.0.1" + replaceall "^0.1.6" + +mocha@*: + version "3.1.2" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-3.1.2.tgz#51f93b432bf7e1b175ffc22883ccd0be32dba6b5" + dependencies: + browser-stdout "1.3.0" + commander "2.9.0" + debug "2.2.0" + diff "1.4.0" + escape-string-regexp "1.0.5" + glob "7.0.5" + growl "1.9.2" + json3 "3.3.2" + lodash.create "3.1.1" + mkdirp "0.5.1" + supports-color "3.1.2" + +moment@2.x.x: + version "2.16.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.16.0.tgz#f38f2c97c9889b0ee18fc6cc392e1e443ad2da8e" + +mongodb-core@2.0.13: + version "2.0.13" + resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-2.0.13.tgz#f9394b588dce0e579482e53d74dbc7d7a9d4519c" + dependencies: + bson "~0.5.6" + require_optional "~1.0.0" + +mongodb@2.2.11: + version "2.2.11" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-2.2.11.tgz#a828b036fe6a437a35e723af5f81781c4976306c" + dependencies: + es6-promise "3.2.1" + mongodb-core "2.0.13" + readable-stream "2.1.5" + +mongoose@^4.4.20: + version "4.6.7" + resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-4.6.7.tgz#fc82b4851620e8d994b883fb3fd03b401566d693" + dependencies: + async "2.1.2" + bson "~0.5.4" + hooks-fixed "1.2.0" + kareem "1.1.3" + mongodb "2.2.11" + mpath "0.2.1" + mpromise "0.5.5" + mquery "2.0.0" + ms "0.7.1" + muri "1.1.1" + regexp-clone "0.0.1" + sliced "1.0.1" + +mpath@0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.2.1.tgz#3a4e829359801de96309c27a6b2e102e89f9e96e" + +mpromise@0.5.5: + version "0.5.5" + resolved "https://registry.yarnpkg.com/mpromise/-/mpromise-0.5.5.tgz#f5b24259d763acc2257b0a0c8c6d866fd51732e6" + +mquery@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mquery/-/mquery-2.0.0.tgz#b5abc850b90dffc3e10ae49b4b6e7a479752df22" + dependencies: + bluebird "2.10.2" + debug "2.2.0" + regexp-clone "0.0.1" + sliced "0.0.5" + +ms@0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098" + +ms@0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765" + +muri@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/muri/-/muri-1.1.1.tgz#64bd904eaf8ff89600c994441fad3c5195905ac2" + +mute-stream@~0.0.4: + version "0.0.6" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.6.tgz#48962b19e169fd1dfc240b3f1e7317627bbc47db" + +mute-stream@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" + +nan@^2.3.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.4.0.tgz#fb3c59d45fe4effe215f0b890f8adf6eb32d2232" + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + +nested-error-stacks@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-1.0.2.tgz#19f619591519f096769a5ba9a86e6eeec823c3cf" + dependencies: + inherits "~2.0.1" + +nigel@2.x.x: + version "2.0.2" + resolved "https://registry.yarnpkg.com/nigel/-/nigel-2.0.2.tgz#93a1866fb0c52d87390aa75e2b161f4b5c75e5b1" + dependencies: + hoek "4.x.x" + vise "2.x.x" + +node-ffprobe@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/node-ffprobe/-/node-ffprobe-1.2.2.tgz#ab2e7152c9d2b7e296fce2f77a97ef249baa6d33" + +node-gyp@~3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.4.0.tgz#dda558393b3ecbbe24c9e6b8703c71194c63fa36" + dependencies: + fstream "^1.0.0" + glob "^7.0.3" + graceful-fs "^4.1.2" + minimatch "^3.0.2" + mkdirp "^0.5.0" + nopt "2 || 3" + npmlog "0 || 1 || 2 || 3" + osenv "0" + path-array "^1.0.0" + request "2" + rimraf "2" + semver "2.x || 3.x || 4 || 5" + tar "^2.0.0" + which "1" + +node-pre-gyp@^0.6.29: + version "0.6.31" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.31.tgz#d8a00ddaa301a940615dbcc8caad4024d58f6017" + dependencies: + mkdirp "~0.5.1" + nopt "~3.0.6" + npmlog "^4.0.0" + rc "~1.1.6" + request "^2.75.0" + rimraf "~2.5.4" + semver "~5.3.0" + tar "~2.2.1" + tar-pack "~3.3.0" + +node-uuid@^1.4.7, node-uuid@~1.4.7: + version "1.4.7" + resolved "https://registry.yarnpkg.com/node-uuid/-/node-uuid-1.4.7.tgz#6da5a17668c4b3dd59623bda11cf7fa4c1f60a6f" + +nodemon@^1.10.2: + version "1.11.0" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.11.0.tgz#226c562bd2a7b13d3d7518b49ad4828a3623d06c" + dependencies: + chokidar "^1.4.3" + debug "^2.2.0" + es6-promise "^3.0.2" + ignore-by-default "^1.0.0" + lodash.defaults "^3.1.2" + minimatch "^3.0.0" + ps-tree "^1.0.1" + touch "1.0.0" + undefsafe "0.0.3" + update-notifier "0.5.0" + +nodeplayer-backend-dummy@^0.1.999: + version "0.1.999" + resolved "https://registry.yarnpkg.com/nodeplayer-backend-dummy/-/nodeplayer-backend-dummy-0.1.999.tgz#9a15c522a6cf06770e7a6ee6d610319bb75e5665" + dependencies: + nodeplayer "*" + underscore "^1.8.2" + +nodeplayer@*: + version "0.2.0" + resolved "https://registry.yarnpkg.com/nodeplayer/-/nodeplayer-0.2.0.tgz#a2cddfe4002197dbcd37e1b32d93600331b3fbdd" + dependencies: + async "^0.9.0" + mkdirp "^0.5.0" + npm "^2.7.1" + underscore "^1.7.0" + winston "^0.9.0" + yargs "^3.6.0" + +nopt@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" + dependencies: + abbrev "1" + +nopt@~3.0.6, "nopt@2 || 3", nopt@3.x: + version "3.0.6" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" + dependencies: + abbrev "1" + +normalize-git-url@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/normalize-git-url/-/normalize-git-url-3.0.2.tgz#8e5f14be0bdaedb73e07200310aa416c27350fc4" + +normalize-package-data@^2.0.0, normalize-package-data@^2.3.2, "normalize-package-data@~1.0.1 || ^2.0.0", normalize-package-data@~2.3.5: + version "2.3.5" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.3.5.tgz#8d924f142960e1777e7ffe170543631cc7cb02df" + dependencies: + hosted-git-info "^2.1.4" + is-builtin-module "^1.0.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + +normalize-path@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.0.1.tgz#47886ac1662760d4261b7d979d241709d3ce3f7a" + +npm-cache-filename@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/npm-cache-filename/-/npm-cache-filename-1.0.2.tgz#ded306c5b0bfc870a9e9faf823bc5f283e05ae11" + +npm-install-checks@~1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/npm-install-checks/-/npm-install-checks-1.0.7.tgz#6d91aeda0ac96801f1ed7aadee116a6c0a086a57" + dependencies: + npmlog "0.1 || 1 || 2" + semver "^2.3.0 || 3.x || 4 || 5" + +npm-install-checks@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/npm-install-checks/-/npm-install-checks-3.0.0.tgz#d4aecdfd51a53e3723b7b2f93b2ee28e307bc0d7" + dependencies: + semver "^2.3.0 || 3.x || 4 || 5" + +"npm-package-arg@^3.0.0 || ^4.0.0", npm-package-arg@^4.0.0, npm-package-arg@^4.1.1, npm-package-arg@~4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-4.2.0.tgz#809bc61cabf54bd5ff94f6165c89ba8ee88c115c" + dependencies: + hosted-git-info "^2.1.5" + semver "^5.1.0" + +npm-package-arg@~4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-4.1.1.tgz#86d9dca985b4c5e5d59772dfd5de6919998a495a" + dependencies: + hosted-git-info "^2.1.4" + semver "4 || 5" + +npm-registry-client@~7.2.1: + version "7.2.1" + resolved "https://registry.yarnpkg.com/npm-registry-client/-/npm-registry-client-7.2.1.tgz#c792266b088cc313f8525e7e35248626c723db75" + dependencies: + concat-stream "^1.5.2" + graceful-fs "^4.1.6" + normalize-package-data "~1.0.1 || ^2.0.0" + npm-package-arg "^3.0.0 || ^4.0.0" + once "^1.3.3" + request "^2.74.0" + retry "^0.10.0" + semver "2 >=2.2.1 || 3.x || 4 || 5" + slide "^1.1.3" + optionalDependencies: + npmlog "~2.0.0 || ~3.1.0" + +npm-user-validate@~0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/npm-user-validate/-/npm-user-validate-0.1.5.tgz#52465d50c2d20294a57125b996baedbf56c5004b" + +npm@^2.7.1: + version "2.15.11" + resolved "https://registry.yarnpkg.com/npm/-/npm-2.15.11.tgz#350588fba9cd8d384cf9a6e8dc0fef0f94992b7c" + dependencies: + abbrev "~1.0.9" + ansi "~0.3.1" + ansicolors "~0.3.2" + ansistyles "~0.1.3" + archy "~1.0.0" + async-some "~1.0.2" + block-stream "0.0.9" + char-spinner "~1.0.1" + chmodr "~1.0.2" + chownr "~1.0.1" + cmd-shim "~2.0.2" + columnify "~1.5.4" + config-chain "~1.1.10" + dezalgo "~1.0.3" + editor "~1.0.0" + fs-vacuum "~1.2.9" + fs-write-stream-atomic "~1.0.8" + fstream "~1.0.10" + fstream-npm "~1.1.1" + github-url-from-git "~1.4.0" + github-url-from-username-repo "~1.0.2" + glob "~7.0.6" + graceful-fs "~4.1.6" + hosted-git-info "~2.1.5" + inflight "~1.0.4" + inherits "~2.0.3" + ini "~1.3.4" + init-package-json "~1.9.4" + lockfile "~1.0.1" + lru-cache "~4.0.1" + minimatch "~3.0.3" + mkdirp "~0.5.1" + node-gyp "~3.4.0" + nopt "~3.0.6" + normalize-git-url "~3.0.2" + normalize-package-data "~2.3.5" + npm-cache-filename "~1.0.2" + npm-install-checks "~1.0.7" + npm-package-arg "~4.1.0" + npm-registry-client "~7.2.1" + npm-user-validate "~0.1.5" + npmlog "~2.0.4" + once "~1.4.0" + opener "~1.4.1" + osenv "~0.1.3" + path-is-inside "~1.0.0" + read "~1.0.7" + read-installed "~4.0.3" + read-package-json "~2.0.4" + readable-stream "~2.1.5" + realize-package-specifier "~3.0.1" + request "~2.74.0" + retry "~0.10.0" + rimraf "~2.5.4" + semver "~5.1.0" + sha "~2.0.1" + slide "~1.1.6" + sorted-object "~2.0.0" + spdx-license-ids "~1.2.2" + strip-ansi "~3.0.1" + tar "~2.2.1" + text-table "~0.2.0" + uid-number "0.0.6" + umask "~1.1.0" + validate-npm-package-license "~3.0.1" + validate-npm-package-name "~2.2.2" + which "~1.2.11" + wrappy "~1.0.2" + write-file-atomic "~1.1.4" + +npm@^3.9.5: + version "3.10.10" + resolved "https://registry.yarnpkg.com/npm/-/npm-3.10.10.tgz#5b1d577e4c8869d6c8603bc89e9cd1637303e46e" + dependencies: + abbrev "~1.0.9" + ansicolors "~0.3.2" + ansistyles "~0.1.3" + aproba "~1.0.4" + archy "~1.0.0" + asap "~2.0.5" + chownr "~1.0.1" + cmd-shim "~2.0.2" + columnify "~1.5.4" + config-chain "~1.1.11" + dezalgo "~1.0.3" + editor "~1.0.0" + fs-vacuum "~1.2.9" + fs-write-stream-atomic "~1.0.8" + fstream "~1.0.10" + fstream-npm "~1.2.0" + glob "~7.1.0" + graceful-fs "~4.1.9" + has-unicode "~2.0.1" + hosted-git-info "~2.1.5" + iferr "~0.1.5" + inflight "~1.0.5" + inherits "~2.0.3" + ini "~1.3.4" + init-package-json "~1.9.4" + lockfile "~1.0.2" + lodash._baseuniq "~4.6.0" + lodash.clonedeep "~4.5.0" + lodash.union "~4.6.0" + lodash.uniq "~4.5.0" + lodash.without "~4.4.0" + mkdirp "~0.5.1" + node-gyp "~3.4.0" + nopt "~3.0.6" + normalize-git-url "~3.0.2" + normalize-package-data "~2.3.5" + npm-cache-filename "~1.0.2" + npm-install-checks "~3.0.0" + npm-package-arg "~4.2.0" + npm-registry-client "~7.2.1" + npm-user-validate "~0.1.5" + npmlog "~4.0.0" + once "~1.4.0" + opener "~1.4.2" + osenv "~0.1.3" + path-is-inside "~1.0.2" + read "~1.0.7" + read-cmd-shim "~1.0.1" + read-installed "~4.0.3" + read-package-json "~2.0.4" + read-package-tree "~5.1.5" + readable-stream "~2.1.5" + realize-package-specifier "~3.0.3" + request "~2.75.0" + retry "~0.10.0" + rimraf "~2.5.4" + semver "~5.3.0" + sha "~2.0.1" + slide "~1.1.6" + sorted-object "~2.0.1" + strip-ansi "~3.0.1" + tar "~2.2.1" + text-table "~0.2.0" + uid-number "0.0.6" + umask "~1.1.0" + unique-filename "~1.1.0" + unpipe "~1.0.0" + validate-npm-package-name "~2.2.2" + which "~1.2.11" + wrappy "~1.0.2" + write-file-atomic "~1.2.0" + +npmlog@^4.0.0, npmlog@~4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.0.tgz#e094503961c70c1774eb76692080e8d578a9f88f" + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.6.0" + set-blocking "~2.0.0" + +"npmlog@~2.0.0 || ~3.1.0", "npmlog@0 || 1 || 2 || 3": + version "3.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-3.1.2.tgz#2d46fa874337af9498a2f12bb43d8d0be4a36873" + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.6.0" + set-blocking "~2.0.0" + +npmlog@~2.0.4, "npmlog@0.1 || 1 || 2": + version "2.0.4" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-2.0.4.tgz#98b52530f2514ca90d09ec5b22c8846722375692" + dependencies: + ansi "~0.3.1" + are-we-there-yet "~1.1.2" + gauge "~1.2.5" + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + +oauth-sign@~0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" + +object-assign@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" + +object-assign@^4.0.1, object-assign@^4.1.0, object-assign@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0" + +object.omit@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" + dependencies: + for-own "^0.1.4" + is-extendable "^0.1.1" + +once@^1.3.0, once@^1.3.3, once@~1.4.0, once@1.x: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + dependencies: + wrappy "1" + +once@~1.3.0, once@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/once/-/once-1.3.3.tgz#b2e261557ce4c314ec8304f3fa82663e4297ca20" + dependencies: + wrappy "1" + +onetime@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" + +opener@~1.4.1, opener@~1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/opener/-/opener-1.4.2.tgz#b32582080042af8680c389a499175b4c54fff523" + +optimist@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" + dependencies: + minimist "~0.0.1" + wordwrap "~0.0.2" + +optionator@^0.8.1, optionator@^0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.4" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + wordwrap "~1.0.0" + +os-homedir@^1.0.0, os-homedir@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + +os-locale@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9" + dependencies: + lcid "^1.0.0" + +os-tmpdir@^1.0.0, os-tmpdir@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + +osenv@^0.1.0, osenv@^0.1.3, osenv@~0.1.3, osenv@0: + version "0.1.3" + resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.3.tgz#83cf05c6d6458fc4d5ac6362ea325d92f2754217" + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.0" + +output-file-sync@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/output-file-sync/-/output-file-sync-1.1.2.tgz#d0a33eefe61a205facb90092e826598d5245ce76" + dependencies: + graceful-fs "^4.1.4" + mkdirp "^0.5.1" + object-assign "^4.1.0" + +package-json@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/package-json/-/package-json-1.2.0.tgz#c8ecac094227cdf76a316874ed05e27cc939a0e0" + dependencies: + got "^3.2.0" + registry-url "^3.0.0" + +packet-reader@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-0.2.0.tgz#819df4d010b82d5ea5671f8a1a3acf039bcd7700" + +parse-glob@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" + dependencies: + glob-base "^0.3.0" + is-dotfile "^1.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.0" + +parse-json@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" + dependencies: + error-ex "^1.2.0" + +path-array@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-array/-/path-array-1.0.1.tgz#7e2f0f35f07a2015122b868b7eac0eb2c4fec271" + dependencies: + array-index "^1.0.0" + +path-exists@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b" + dependencies: + pinkie-promise "^2.0.0" + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + +path-is-inside@^1.0.1, path-is-inside@~1.0.0, path-is-inside@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" + +path-type@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" + dependencies: + graceful-fs "^4.1.2" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +pause-stream@0.0.11: + version "0.0.11" + resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" + dependencies: + through "~2.3" + +pez@2.x.x: + version "2.1.3" + resolved "https://registry.yarnpkg.com/pez/-/pez-2.1.3.tgz#e53ebcbf48961b4aa1bb2b68cfd1eb20c9898039" + dependencies: + b64 "3.x.x" + boom "4.x.x" + content "3.x.x" + hoek "4.x.x" + nigel "2.x.x" + +pg-connection-string@^0.1.3, pg-connection-string@0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7" + +pg-pool@1.*: + version "1.5.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-1.5.0.tgz#d789756ccb90cd389fc5a395e0ca9f2d2c558d48" + dependencies: + generic-pool "2.4.2" + object-assign "4.1.0" + +pg-types@1.*: + version "1.11.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-1.11.0.tgz#aae91a82d952b633bb88d006350a166daaf6ea90" + dependencies: + ap "~0.2.0" + postgres-array "~1.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.0" + postgres-interval "~1.0.0" + +pg@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-6.1.0.tgz#4ebc58100a79187b6b98fa5caf1675d669926b41" + dependencies: + buffer-writer "1.0.1" + packet-reader "0.2.0" + pg-connection-string "0.1.3" + pg-pool "1.*" + pg-types "1.*" + pgpass "1.x" + semver "4.3.2" + +pgpass@1.x: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.1.tgz#0de8b5bef993295d90a7e17d976f568dcd25d49f" + dependencies: + split "^1.0.0" + +pify@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + +pinkie-promise@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" + dependencies: + pinkie "^2.0.0" + +pinkie@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" + +pkginfo@0.3.x: + version "0.3.1" + resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.3.1.tgz#5b29f6a81f70717142e09e765bbeab97b4f81e21" + +pluralize@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45" + +podium@^1.2.x: + version "1.2.3" + resolved "https://registry.yarnpkg.com/podium/-/podium-1.2.3.tgz#5c95b7cc2f5c87dd324e0ad4a9363ac62d66b371" + dependencies: + hoek "4.x.x" + items "2.x.x" + joi "9.x.x" + +postgres-array@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-1.0.0.tgz#48c2e82935b178bf805e0dff689d137eec2bfe6b" + +postgres-bytea@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" + +postgres-date@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.3.tgz#e2d89702efdb258ff9d9cee0fe91bd06975257a8" + +postgres-interval@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.0.2.tgz#7261438d862b412921c6fdb7617668424b73a6ed" + dependencies: + xtend "^4.0.0" + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + +prepend-http@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" + +preserve@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" + +private@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/private/-/private-0.1.6.tgz#55c6a976d0f9bafb9924851350fe47b9b5fbb7c1" + +process-nextick-args@~1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" + +progress@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" + +promzard@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/promzard/-/promzard-0.3.0.tgz#26a5d6ee8c7dee4cb12208305acfb93ba382a9ee" + dependencies: + read "1" + +proto-list@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" + +ps-tree@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ps-tree/-/ps-tree-1.1.0.tgz#b421b24140d6203f1ed3c76996b4427b08e8c014" + dependencies: + event-stream "~3.3.0" + +pseudomap@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + +qs@~6.2.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.1.tgz#ce03c5ff0935bc1d9d69a9f14cbd18e568d67625" + +qs@~6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.0.tgz#f403b264f23bc01228c74131b407f18d5ea5d442" + +randomatic@^1.1.3: + version "1.1.5" + resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.5.tgz#5e9ef5f2d573c67bd2b8124ae90b5156e457840b" + dependencies: + is-number "^2.0.2" + kind-of "^3.0.2" + +rc@^1.0.1, rc@~1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.1.6.tgz#43651b76b6ae53b5c802f1151fa3fc3b059969c9" + dependencies: + deep-extend "~0.4.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~1.0.4" + +read-all-stream@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/read-all-stream/-/read-all-stream-3.1.0.tgz#35c3e177f2078ef789ee4bfafa4373074eaef4fa" + dependencies: + pinkie-promise "^2.0.0" + readable-stream "^2.0.0" + +read-cmd-shim@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-1.0.1.tgz#2d5d157786a37c055d22077c32c53f8329e91c7b" + dependencies: + graceful-fs "^4.1.2" + +read-installed@~4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/read-installed/-/read-installed-4.0.3.tgz#ff9b8b67f187d1e4c29b9feb31f6b223acd19067" + dependencies: + debuglog "^1.0.1" + read-package-json "^2.0.0" + readdir-scoped-modules "^1.0.0" + semver "2 || 3 || 4 || 5" + slide "~1.1.3" + util-extend "^1.0.1" + optionalDependencies: + graceful-fs "^4.1.2" + +read-package-json@^2.0.0, read-package-json@~2.0.4, "read-package-json@1 || 2": + version "2.0.4" + resolved "https://registry.yarnpkg.com/read-package-json/-/read-package-json-2.0.4.tgz#61ed1b2256ea438d8008895090be84b8e799c853" + dependencies: + glob "^6.0.0" + json-parse-helpfulerror "^1.0.2" + normalize-package-data "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.2" + +read-package-tree@~5.1.5: + version "5.1.5" + resolved "https://registry.yarnpkg.com/read-package-tree/-/read-package-tree-5.1.5.tgz#ace7e6381c7684f970aaa98fc7c5d2b666addab6" + dependencies: + debuglog "^1.0.1" + dezalgo "^1.0.0" + once "^1.3.0" + read-package-json "^2.0.0" + readdir-scoped-modules "^1.0.0" + +read-pkg-up@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" + dependencies: + find-up "^1.0.0" + read-pkg "^1.0.0" + +read-pkg@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28" + dependencies: + load-json-file "^1.0.0" + normalize-package-data "^2.3.2" + path-type "^1.0.0" + +read@~1.0.1, read@~1.0.7, read@1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/read/-/read-1.0.7.tgz#b3da19bd052431a97671d44a42634adf710b40c4" + dependencies: + mute-stream "~0.0.4" + +readable-stream@^1.1.12: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^2.0.0, "readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.2, "readable-stream@1 || 2": + version "2.2.1" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.1.tgz#c459a6687ad6195f936b959870776edef27a7655" + dependencies: + buffer-shims "^1.0.0" + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "~1.0.0" + process-nextick-args "~1.0.6" + string_decoder "~0.10.x" + util-deprecate "~1.0.1" + +readable-stream@~2.0.0, readable-stream@~2.0.5: + version "2.0.6" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "~1.0.0" + process-nextick-args "~1.0.6" + string_decoder "~0.10.x" + util-deprecate "~1.0.1" + +readable-stream@~2.1.4, readable-stream@~2.1.5, readable-stream@2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.1.5.tgz#66fa8b720e1438b364681f2ad1a63c618448c9d0" + dependencies: + buffer-shims "^1.0.0" + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "~1.0.0" + process-nextick-args "~1.0.6" + string_decoder "~0.10.x" + util-deprecate "~1.0.1" + +readdir-scoped-modules@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.0.2.tgz#9fafa37d286be5d92cbaebdee030dc9b5f406747" + dependencies: + debuglog "^1.0.1" + dezalgo "^1.0.0" + graceful-fs "^4.1.2" + once "^1.3.0" + +readdirp@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78" + dependencies: + graceful-fs "^4.1.2" + minimatch "^3.0.2" + readable-stream "^2.0.2" + set-immediate-shim "^1.0.1" + +readline2@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + mute-stream "0.0.5" + +realize-package-specifier@~3.0.1, realize-package-specifier@~3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/realize-package-specifier/-/realize-package-specifier-3.0.3.tgz#d0def882952b8de3f67eba5e91199661271f41f4" + dependencies: + dezalgo "^1.0.1" + npm-package-arg "^4.1.1" + +rechoir@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" + dependencies: + resolve "^1.1.6" + +regenerate@^1.2.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.2.tgz#d1941c67bad437e1be76433add5b385f95b19260" + +regenerator-runtime@^0.9.5: + version "0.9.6" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.9.6.tgz#d33eb95d0d2001a4be39659707c51b0cb71ce029" + +regex-cache@^0.4.2: + version "0.4.3" + resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.3.tgz#9b1a6c35d4d0dfcef5711ae651e8e9d3d7114145" + dependencies: + is-equal-shallow "^0.1.3" + is-primitive "^2.0.0" + +regexp-clone@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/regexp-clone/-/regexp-clone-0.0.1.tgz#a7c2e09891fdbf38fbb10d376fb73003e68ac589" + +regexpu-core@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-2.0.0.tgz#49d038837b8dcf8bfa5b9a42139938e6ea2ae240" + dependencies: + regenerate "^1.2.1" + regjsgen "^0.2.0" + regjsparser "^0.1.4" + +registry-url@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942" + dependencies: + rc "^1.0.1" + +regjsgen@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7" + +regjsparser@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c" + dependencies: + jsesc "~0.5.0" + +repeat-element@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.2.tgz#ef089a178d1483baae4d93eb98b4f9e4e11d990a" + +repeat-string@^1.5.2: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + +repeating@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/repeating/-/repeating-1.1.3.tgz#3d4114218877537494f97f77f9785fab810fa4ac" + dependencies: + is-finite "^1.0.0" + +repeating@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" + dependencies: + is-finite "^1.0.0" + +replaceall@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/replaceall/-/replaceall-0.1.6.tgz#81d81ac7aeb72d7f5c4942adf2697a3220688d8e" + +request@^2.74.0, request@^2.75.0, request@2: + version "2.78.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.78.0.tgz#e1c8dec346e1c81923b24acdb337f11decabe9cc" + dependencies: + aws-sign2 "~0.6.0" + aws4 "^1.2.1" + caseless "~0.11.0" + combined-stream "~1.0.5" + extend "~3.0.0" + forever-agent "~0.6.1" + form-data "~2.1.1" + har-validator "~2.0.6" + hawk "~3.1.3" + http-signature "~1.1.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.7" + node-uuid "~1.4.7" + oauth-sign "~0.8.1" + qs "~6.3.0" + stringstream "~0.0.4" + tough-cookie "~2.3.0" + tunnel-agent "~0.4.1" + +request@~2.74.0: + version "2.74.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.74.0.tgz#7693ca768bbb0ea5c8ce08c084a45efa05b892ab" + dependencies: + aws-sign2 "~0.6.0" + aws4 "^1.2.1" + bl "~1.1.2" + caseless "~0.11.0" + combined-stream "~1.0.5" + extend "~3.0.0" + forever-agent "~0.6.1" + form-data "~1.0.0-rc4" + har-validator "~2.0.6" + hawk "~3.1.3" + http-signature "~1.1.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.7" + node-uuid "~1.4.7" + oauth-sign "~0.8.1" + qs "~6.2.0" + stringstream "~0.0.4" + tough-cookie "~2.3.0" + tunnel-agent "~0.4.1" + +request@~2.75.0, request@2.75.0: + version "2.75.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.75.0.tgz#d2b8268a286da13eaa5d01adf5d18cc90f657d93" + dependencies: + aws-sign2 "~0.6.0" + aws4 "^1.2.1" + bl "~1.1.2" + caseless "~0.11.0" + combined-stream "~1.0.5" + extend "~3.0.0" + forever-agent "~0.6.1" + form-data "~2.0.0" + har-validator "~2.0.6" + hawk "~3.1.3" + http-signature "~1.1.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.7" + node-uuid "~1.4.7" + oauth-sign "~0.8.1" + qs "~6.2.0" + stringstream "~0.0.4" + tough-cookie "~2.3.0" + tunnel-agent "~0.4.1" + +require_optional@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/require_optional/-/require_optional-1.0.0.tgz#52a86137a849728eb60a55533617f8f914f59abf" + dependencies: + resolve-from "^2.0.0" + semver "^5.1.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + +require-main-filename@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" + +require-uncached@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3" + dependencies: + caller-path "^0.1.0" + resolve-from "^1.0.0" + +resolve-dir@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e" + dependencies: + expand-tilde "^1.2.2" + global-modules "^0.2.3" + +resolve-from@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226" + +resolve-from@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-2.0.0.tgz#9480ab20e94ffa1d9e80a804c7ea147611966b57" + +resolve@^1.1.6, resolve@^1.1.7, resolve@1.1.x: + version "1.1.7" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" + +restore-cursor@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" + dependencies: + exit-hook "^1.0.0" + onetime "^1.0.0" + +retry@^0.10.0, retry@~0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.10.0.tgz#649e15ca408422d98318161935e7f7d652d435dd" + +right-align@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" + dependencies: + align-text "^0.1.1" + +rimraf@^2.2.8, rimraf@^2.5.2, rimraf@~2.5.1, rimraf@~2.5.4, rimraf@2: + version "2.5.4" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.5.4.tgz#96800093cbf1a0c86bd95b4625467535c29dfa04" + dependencies: + glob "^7.0.5" + +run-async@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389" + dependencies: + once "^1.3.0" + +rx-lite@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102" + +semver-diff@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36" + dependencies: + semver "^5.0.3" + +"semver@^2.3.0 || 3.x || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@~5.3.0, "semver@2 >=2.2.1 || 3.x || 4 || 5", "semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", "semver@4 || 5", semver@5.x: + version "5.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" + +semver@~5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.1.1.tgz#a3292a373e6f3e0798da0b20641b9a9c5bc47e19" + +semver@4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.2.tgz#c7a07158a80bedd052355b770d82d6640f803be7" + +set-blocking@^2.0.0, set-blocking@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + +set-immediate-shim@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" + +sha@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/sha/-/sha-2.0.1.tgz#6030822fbd2c9823949f8f72ed6411ee5cf25aae" + dependencies: + graceful-fs "^4.1.2" + readable-stream "^2.0.2" + +shelljs@^0.7.5: + version "0.7.5" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.5.tgz#2eef7a50a21e1ccf37da00df767ec69e30ad0675" + dependencies: + glob "^7.0.0" + interpret "^1.0.0" + rechoir "^0.6.2" + +shot@3.x.x: + version "3.3.2" + resolved "https://registry.yarnpkg.com/shot/-/shot-3.3.2.tgz#691c2611759decc20487b20d25cc299f39e5f9b7" + dependencies: + hoek "4.x.x" + joi "9.x.x" + +signal-exit@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.1.tgz#5a4c884992b63a7acd9badb7894c3ee9cfccad81" + +slash@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" + +slice-ansi@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" + +sliced@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/sliced/-/sliced-0.0.5.tgz#5edc044ca4eb6f7816d50ba2fc63e25d8fe4707f" + +sliced@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41" + +slide@^1.1.3, slide@^1.1.5, slide@~1.1.3, slide@~1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" + +sntp@1.x.x: + version "1.0.9" + resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198" + dependencies: + hoek "2.x.x" + +sorted-object@~2.0.0, sorted-object@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/sorted-object/-/sorted-object-2.0.1.tgz#7d631f4bd3a798a24af1dffcfbfe83337a5df5fc" + +source-map-support@^0.4.2: + version "0.4.6" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.6.tgz#32552aa64b458392a85eab3b0b5ee61527167aeb" + dependencies: + source-map "^0.5.3" + +source-map@^0.4.4: + version "0.4.4" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" + dependencies: + amdefine ">=0.0.4" + +source-map@^0.5.0, source-map@^0.5.3, source-map@~0.5.1: + version "0.5.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" + +source-map@~0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.2.0.tgz#dab73fbcfc2ba819b4de03bd6f6eaa48164b3f9d" + dependencies: + amdefine ">=0.0.4" + +spdx-correct@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-1.0.2.tgz#4b3073d933ff51f3912f03ac5519498a4150db40" + dependencies: + spdx-license-ids "^1.0.2" + +spdx-expression-parse@~1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-1.0.4.tgz#9bdf2f20e1f40ed447fbe273266191fced51626c" + +spdx-license-ids@^1.0.2, spdx-license-ids@~1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz#c9df7a3424594ade6bd11900d596696dc06bac57" + +split@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/split/-/split-1.0.0.tgz#c4395ce683abcd254bc28fe1dabb6e5c27dcffae" + dependencies: + through "2" + +split@0.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/split/-/split-0.3.3.tgz#cd0eea5e63a211dfff7eb0f091c4133e2d0dd28f" + dependencies: + through "2" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + +sshpk@^1.7.0: + version "1.10.1" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.10.1.tgz#30e1a5d329244974a1af61511339d595af6638b0" + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + dashdash "^1.12.0" + getpass "^0.1.1" + optionalDependencies: + bcrypt-pbkdf "^1.0.0" + ecc-jsbn "~0.1.1" + jodid25519 "^1.0.0" + jsbn "~0.1.0" + tweetnacl "~0.14.0" + +stack-trace@0.0.x: + version "0.0.9" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.9.tgz#a8f6eaeca90674c333e7c43953f275b451510695" + +statehood@5.x.x: + version "5.0.0" + resolved "https://registry.yarnpkg.com/statehood/-/statehood-5.0.0.tgz#ce2285aabeae398ae87cbba746184b7599b8fa31" + dependencies: + boom "3.x.x" + cryptiles "3.x.x" + hoek "4.x.x" + iron "4.x.x" + items "2.x.x" + joi "9.x.x" + +stream-combiner@~0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/stream-combiner/-/stream-combiner-0.0.4.tgz#4d5e433c185261dde623ca3f44c586bcf5c4ad14" + dependencies: + duplexer "~0.1.1" + +stream-shift@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" + +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + +string-length@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-1.0.1.tgz#56970fb1c38558e9e70b728bf3de269ac45adfac" + dependencies: + strip-ansi "^3.0.0" + +string-width@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +string-width@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.0.0.tgz#635c5436cc72a6e0c387ceca278d4e2eec52687e" + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^3.0.0" + +stringstream@~0.0.4: + version "0.0.5" + resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1, strip-ansi@~3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + dependencies: + ansi-regex "^2.0.0" + +strip-bom@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" + dependencies: + is-utf8 "^0.2.0" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + +strip-json-comments@~1.0.1, strip-json-comments@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-1.0.4.tgz#1e15fbcac97d3ee99bf2d73b4c656b082bbafb91" + +subtext@^4.3.x: + version "4.3.0" + resolved "https://registry.yarnpkg.com/subtext/-/subtext-4.3.0.tgz#dfac90492ec35669fd6e00c6e5d938b06d7ccfbb" + dependencies: + boom "4.x.x" + content "3.x.x" + hoek "4.x.x" + pez "2.x.x" + wreck "10.x.x" + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + +supports-color@^3.1.0, supports-color@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.1.2.tgz#72a262894d9d408b956ca05ff37b2ed8a6e2a2d5" + dependencies: + has-flag "^1.0.0" + +table@^3.7.8: + version "3.8.3" + resolved "https://registry.yarnpkg.com/table/-/table-3.8.3.tgz#2bbc542f0fda9861a755d3947fefd8b3f513855f" + dependencies: + ajv "^4.7.0" + ajv-keywords "^1.0.0" + chalk "^1.1.1" + lodash "^4.0.0" + slice-ansi "0.0.4" + string-width "^2.0.0" + +tar-pack@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.3.0.tgz#30931816418f55afc4d21775afdd6720cee45dae" + dependencies: + debug "~2.2.0" + fstream "~1.0.10" + fstream-ignore "~1.0.5" + once "~1.3.3" + readable-stream "~2.1.4" + rimraf "~2.5.1" + tar "~2.2.1" + uid-number "~0.0.6" + +tar@^2.0.0, tar@~2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1" + dependencies: + block-stream "*" + fstream "^1.0.2" + inherits "2" + +text-table@~0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + +through@^2.3.6, through@~2.3, through@~2.3.1, through@2: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + +tildify@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/tildify/-/tildify-1.0.0.tgz#2a021db5e8fbde0a8f8b4df37adaa8fb1d39d7dd" + dependencies: + user-home "^1.0.0" + +timed-out@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-2.0.0.tgz#f38b0ae81d3747d628001f41dafc652ace671c0a" + +to-fast-properties@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.2.tgz#f3f5c0c3ba7299a7ef99427e44633257ade43320" + +topo@2.x.x: + version "2.0.2" + resolved "https://registry.yarnpkg.com/topo/-/topo-2.0.2.tgz#cd5615752539057c0dc0491a621c3bc6fbe1d182" + dependencies: + hoek "4.x.x" + +touch@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/touch/-/touch-1.0.0.tgz#449cbe2dbae5a8c8038e30d71fa0ff464947c4de" + dependencies: + nopt "~1.0.10" + +tough-cookie@~2.3.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.2.tgz#f081f76e4c85720e6c37a5faced737150d84072a" + dependencies: + punycode "^1.4.1" + +tryit@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb" + +tunnel-agent@~0.4.1: + version "0.4.3" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.4.3.tgz#6373db76909fe570e08d73583365ed828a74eeeb" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.3" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.3.tgz#3da382f670f25ded78d7b3d1792119bca0b7132d" + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + dependencies: + prelude-ls "~1.1.2" + +type-detect@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-1.0.0.tgz#762217cc06db258ec48908a1298e8b95121e8ea2" + +type-detect@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-0.1.1.tgz#0ba5ec2a885640e470ea4e8505971900dac58822" + +typedarray@~0.0.5: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + +uglify-js@^2.6: + version "2.7.4" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.7.4.tgz#a295a0de12b6a650c031c40deb0dc40b14568bd2" + dependencies: + async "~0.2.6" + source-map "~0.5.1" + uglify-to-browserify "~1.0.0" + yargs "~3.10.0" + +uglify-to-browserify@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" + +uid-number@~0.0.6, uid-number@0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" + +umask@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/umask/-/umask-1.1.0.tgz#f29cebf01df517912bb58ff9c4e50fde8e33320d" + +undefsafe@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-0.0.3.tgz#ecca3a03e56b9af17385baac812ac83b994a962f" + +underscore@^1.7.0, underscore@^1.8.2: + version "1.8.3" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022" + +unique-filename@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.0.tgz#d05f2fe4032560871f30e93cbe735eea201514f3" + dependencies: + unique-slug "^2.0.0" + +unique-slug@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.0.tgz#db6676e7c7cc0629878ff196097c78855ae9f4ab" + dependencies: + imurmurhash "^0.1.4" + +unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + +update-notifier@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-0.5.0.tgz#07b5dc2066b3627ab3b4f530130f7eddda07a4cc" + dependencies: + chalk "^1.0.0" + configstore "^1.0.0" + is-npm "^1.0.0" + latest-version "^1.0.0" + repeating "^1.1.2" + semver-diff "^2.0.0" + string-length "^1.0.0" + +user-home@^1.0.0, user-home@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190" + +user-home@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/user-home/-/user-home-2.0.0.tgz#9c70bfd8169bc1dcbf48604e0f04b8b49cde9e9f" + dependencies: + os-homedir "^1.0.0" + +util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + +util-extend@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/util-extend/-/util-extend-1.0.3.tgz#a7c216d267545169637b3b6edc6ca9119e2ff93f" + +uuid@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" + +v8flags@^2.0.10, v8flags@^2.0.2: + version "2.0.11" + resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.0.11.tgz#bca8f30f0d6d60612cc2c00641e6962d42ae6881" + dependencies: + user-home "^1.1.1" + +validate-npm-package-license@^3.0.1, validate-npm-package-license@~3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz#2804babe712ad3379459acfbe24746ab2c303fbc" + dependencies: + spdx-correct "~1.0.0" + spdx-expression-parse "~1.0.0" + +validate-npm-package-name@^2.0.1, validate-npm-package-name@~2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-2.2.2.tgz#f65695b22f7324442019a3c7fa39a6e7fd299085" + dependencies: + builtins "0.0.7" + +verror@1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.3.6.tgz#cff5df12946d297d2baaefaa2689e25be01c005c" + dependencies: + extsprintf "1.0.2" + +vise@2.x.x: + version "2.0.2" + resolved "https://registry.yarnpkg.com/vise/-/vise-2.0.2.tgz#6b08e8fb4cb76e3a50cd6dd0ec37338e811a0d39" + dependencies: + hoek "4.x.x" + +walk@^2.3.9: + version "2.3.9" + resolved "https://registry.yarnpkg.com/walk/-/walk-2.3.9.tgz#31b4db6678f2ae01c39ea9fb8725a9031e558a7b" + dependencies: + foreachasync "^3.0.0" + +wcwidth@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + dependencies: + defaults "^1.0.3" + +which-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" + +which@^1.1.1, which@^1.2.10, which@~1.2.11, which@1: + version "1.2.12" + resolved "https://registry.yarnpkg.com/which/-/which-1.2.12.tgz#de67b5e450269f194909ef23ece4ebe416fa1192" + dependencies: + isexe "^1.1.1" + +wide-align@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.0.tgz#40edde802a71fea1f070da3e62dcda2e7add96ad" + dependencies: + string-width "^1.0.1" + +window-size@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.4.tgz#f8e1aa1ee5a53ec5bf151ffa09742a6ad7697876" + +window-size@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075" + +window-size@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" + +winston@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-0.9.0.tgz#b5726e6c42291e305e36286ce7ae9f3b74a527a8" + dependencies: + async "0.9.x" + colors "1.0.x" + cycle "1.0.x" + eyes "0.1.x" + isstream "0.1.x" + pkginfo "0.3.x" + stack-trace "0.0.x" + +winston@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-2.3.0.tgz#207faaab6fccf3fe493743dd2b03dbafc7ceb78c" + dependencies: + async "~1.0.0" + colors "1.0.x" + cycle "1.0.x" + eyes "0.1.x" + isstream "0.1.x" + stack-trace "0.0.x" + +wordwrap@^1.0.0, wordwrap@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + +wordwrap@~0.0.2: + version "0.0.3" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" + +wordwrap@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" + +wrap-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.0.0.tgz#7d30f8f873f9a5bbc3a64dabc8d177e071ae426f" + dependencies: + string-width "^1.0.1" + +wrappy@~1.0.2, wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + +wreck@10.x.x: + version "10.0.0" + resolved "https://registry.yarnpkg.com/wreck/-/wreck-10.0.0.tgz#98ab882f85e16a526332507f101f5a7841162278" + dependencies: + boom "4.x.x" + hoek "4.x.x" + +write-file-atomic@^1.1.2, write-file-atomic@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.2.0.tgz#14c66d4e4cb3ca0565c28cf3b7a6f3e4d5938fab" + dependencies: + graceful-fs "^4.1.2" + imurmurhash "^0.1.4" + slide "^1.1.5" + +write-file-atomic@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.1.4.tgz#b1f52dc2e8dc0e3cb04d187a25f758a38a90ca3b" + dependencies: + graceful-fs "^4.1.2" + imurmurhash "^0.1.4" + slide "^1.1.5" + +write@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757" + dependencies: + mkdirp "^0.5.1" + +xdg-basedir@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2" + dependencies: + os-homedir "^1.0.0" + +xtend@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" + +y18n@^3.2.0, y18n@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" + +yallist@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.0.0.tgz#306c543835f09ee1a4cb23b7bce9ab341c91cdd4" + +yargs-parser@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-2.4.1.tgz#85568de3cf150ff49fa51825f03a8c880ddcc5c4" + dependencies: + camelcase "^3.0.0" + lodash.assign "^4.0.6" + +yargs@^3.6.0: + version "3.32.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.32.0.tgz#03088e9ebf9e756b69751611d2a5ef591482c995" + dependencies: + camelcase "^2.0.1" + cliui "^3.0.3" + decamelize "^1.1.1" + os-locale "^1.4.0" + string-width "^1.0.1" + window-size "^0.1.4" + y18n "^3.2.0" + +yargs@^4.7.1: + version "4.8.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-4.8.1.tgz#c0c42924ca4aaa6b0e6da1739dfb216439f9ddc0" + dependencies: + cliui "^3.2.0" + decamelize "^1.1.1" + get-caller-file "^1.0.1" + lodash.assign "^4.0.3" + os-locale "^1.4.0" + read-pkg-up "^1.0.1" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^1.0.1" + which-module "^1.0.0" + window-size "^0.2.0" + y18n "^3.2.1" + yargs-parser "^2.4.1" + +yargs@~1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-1.2.6.tgz#9c7b4a82fd5d595b2bf17ab6dcc43135432fe34b" + dependencies: + minimist "^0.1.0" + +yargs@~3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" + dependencies: + camelcase "^1.0.2" + cliui "^2.1.0" + decamelize "^1.0.0" + window-size "0.1.0" + From 0caaa4813aaa9bfc31aceed18c76aba167837ec5 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 18 Nov 2016 16:52:03 +0200 Subject: [PATCH 098/103] @#$%ing EVERYTHING WORKS --- package.json | 2 + src/config.js | 2 + src/player.js | 67 +++++++++++++++++++++----------- src/plugins/defaults.js | 4 ++ src/plugins/rest.js | 79 +++++++++++++++++++++++++++++++++++--- src/plugins/server.js | 3 +- src/plugins/storeQueue.js | 81 +++++++++++++++++++++++++++++++++++++++ src/plugins/ws.js | 76 ++++++++++++++++++++++++++++++++++++ src/queue.js | 14 ++++++- src/song.js | 12 +++++- yarn.lock | 78 ++++++++++++++++++++++++++++++++++++- 11 files changed, 383 insertions(+), 35 deletions(-) create mode 100644 src/plugins/storeQueue.js create mode 100644 src/plugins/ws.js diff --git a/package.json b/package.json index 29e622f..7c1a15b 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "node-uuid": "^1.4.7", "npm": "^3.9.5", "pg": "^6.1.0", + "sockjs": "^0.3.18", + "sockjs-client": "^1.1.1", "walk": "^2.3.9", "winston": "^2.2.0", "yargs": "^4.7.1" diff --git a/src/config.js b/src/config.js index 1cbba25..66794d7 100644 --- a/src/config.js +++ b/src/config.js @@ -82,6 +82,8 @@ defaultConfig.maxScore = 10; // FIXME: ATM the search algo can return VERY irrel // hostname of the server, may be used as a default value by other plugins defaultConfig.hostname = os.hostname(); +defaultConfig.queueStorePath = path.join(getBaseDir(), 'stored-queue.json'); + exports.getDefaultConfig = () => { return defaultConfig; }; diff --git a/src/player.js b/src/player.js index 4e6cecf..36f8c37 100644 --- a/src/player.js +++ b/src/player.js @@ -14,7 +14,7 @@ export default class Player { this.logger = labeledLogger('core'); this.queue = new Queue(this); this.nowPlaying = null; - this.play = false; + this.play = false; // TODO: integrate with Song? this.repeat = false; this.plugins = {}; this.backends = {}; @@ -70,7 +70,7 @@ export default class Player { }, callback => { modules.loadBackends(player, config.backends, forceUpdate, results => { player.backends = _.extend(player.backends, results); - player.callHooks('onBackendsInitialized'); + player.callHooks('onBackendsInitialized', [player.backends]); callback(); }); }, @@ -121,7 +121,7 @@ export default class Player { * @return {Song|null} - Song object, null if no now playing song */ getNowPlaying() { - return this.nowPlaying; + return this.nowPlaying ? this.nowPlaying.serialize() : null; } // TODO: handling of pause in a good way? @@ -136,13 +136,21 @@ export default class Player { this.play = false; const np = this.nowPlaying; - const pos = np.playback.startPos + (new Date().getTime() - np.playback.startTime); if (np) { + const pos = np.playback.startPos + (new Date().getTime() - np.playback.startTime); + np.playback = { startTime: 0, startPos: pause ? pos : 0, }; + console.log(np.playback); + } + + if (!pause) { + this.nowPlaying = null; } + + this.callHooks('onStopPlayback', [np ? np.serialize() : null]); } /** @@ -151,8 +159,8 @@ export default class Player { * @throws {Error} if an error occurred */ startPlayback(position) { - position = position || 0; - const player = this; + console.log('pos', position); + clearTimeout(this.songEndTimeout); if (!this.nowPlaying) { // find first song in queue @@ -163,15 +171,22 @@ export default class Player { } } + position = _.isNumber(position) ? position : this.nowPlaying.playback.startPos; + this.nowPlaying.prepare(err => { if (err) { throw new Error('error while preparing now playing: ' + err); } - player.nowPlaying.playbackStarted(position || player.nowPlaying.playback.startPos); + console.log(position); + console.log(this.nowPlaying.playback.startPos); + this.nowPlaying.playbackStarted(position); + + this.callHooks('onStartPlayback', [this.nowPlaying.serialize()]); - player.logger.info('playback started.'); - player.play = true; + this.logger.info('playback started.'); + this.play = true; + this.songEndTimeout = setTimeout(this.songEnd.bind(this), this.nowPlaying.duration - position); }); } @@ -181,18 +196,26 @@ export default class Player { * playing is removed, playback stopped */ changeSong(uuid) { - this.logger.verbose('changing song to: ' + uuid); - clearTimeout(this.songEndTimeout); - this.nowPlaying = this.queue.findSong(uuid); if (!this.nowPlaying) { this.logger.info('song not found: ' + uuid); - this.stopPlayback(); + throw new Error('song not found', uuid); } - this.startPlayback(); - this.logger.info('changed song to: ' + uuid); + this.logger.info('changing song to: ' + uuid); + this.startPlayback(0); + } + + endOfQueue() { + this.logger.info('hit end of queue.'); + + if (this.repeat) { + this.logger.info('repeat is on, restarting playback from start of queue.'); + this.changeSong(this.queue.uuidAtIndex(0)); + } else { + this.stopPlayback(); + } } songEnd() { @@ -200,18 +223,13 @@ export default class Player { const npIndex = np ? this.queue.findSongIndex(np.uuid) : -1; this.logger.info('end of song ' + np.uuid); - this.callHooks('onSongEnd', [np]); + this.callHooks('onSongEnd', [this.queue.findSong(np.uuid)]); const nextSong = this.queue.songs[npIndex + 1]; if (nextSong) { this.changeSong(nextSong.uuid); } else { - this.logger.info('hit end of queue.'); - - if (this.repeat) { - this.logger.info('repeat is on, restarting playback from start of queue.'); - this.changeSong(this.queue.uuidAtIndex(0)); - } + this.endOfQueue(); } this.prepareSongs(); @@ -351,7 +369,10 @@ export default class Player { async.series([ callback => { // prepare now-playing song - currentSong = player.getNowPlaying(); + if (player.getNowPlaying()) { + currentSong = player.queue.findSong(player.getNowPlaying().uuid); + } + if (currentSong) { player.prepareSong(currentSong, callback); } else if (player.queue.getLength()) { diff --git a/src/plugins/defaults.js b/src/plugins/defaults.js index 6090067..cbdff32 100644 --- a/src/plugins/defaults.js +++ b/src/plugins/defaults.js @@ -1,5 +1,7 @@ import Server from './server'; import Rest from './rest'; +import WebSockets from './ws'; +import StoreQueue from './storeQueue'; /** * Export default plugins @@ -7,5 +9,7 @@ import Rest from './rest'; const defaultPlugins = []; defaultPlugins.push(Server); defaultPlugins.push(Rest); // NOTE: must be initialized after Server +defaultPlugins.push(WebSockets); // NOTE: must be initialized after Server +defaultPlugins.push(StoreQueue); export default defaultPlugins; diff --git a/src/plugins/rest.js b/src/plugins/rest.js index eae0dff..adb2a57 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -48,11 +48,8 @@ export default class Rest extends Plugin { method: 'POST', path: '/api/v1/search', handler: (request, reply) => { - this.log.verbose('got search request: ' + JSON.stringify(request.payload)); - player.searchBackends(request.payload, (err, results) => { - // console.log(results); - reply(results); + reply(err ? Boom.badImplementation(err) : results); }); } }); @@ -61,13 +58,82 @@ export default class Rest extends Plugin { method: 'POST', path: '/api/v1/queue/song', handler: (request, reply) => { - this.log.verbose('got search request: ' + JSON.stringify(request.payload)); + const at = player.queue.uuidAtIndex(player.queue.getLength() - 1) || null; + const err = player.queue.insertSongs(at, request.payload); + reply(err ? Boom.badImplementation(err) : { success: true }); + } + }); + + player.server.route({ + method: 'POST', + path: '/api/v1/play', + handler: (request, reply) => { + const err = player.startPlayback(); + reply(err ? Boom.badImplementation(err) : { success: true }); + } + }); + + player.server.route({ + method: 'POST', + path: '/api/v1/stop', + handler: (request, reply) => { + const err = player.stopPlayback(); + reply(err ? Boom.badImplementation(err) : { success: true }); + } + }); + + player.server.route({ + method: 'POST', + path: '/api/v1/pause', + handler: (request, reply) => { + const err = player.stopPlayback(true); + reply(err ? Boom.badImplementation(err) : { success: true }); + } + }); - const err = player.queue.insertSongs(null, request.payload); + player.server.route({ + method: 'POST', + path: '/api/v1/seek', + handler: (request, reply) => { + const err = player.startPlayback(request.payload.position); reply(err ? Boom.badImplementation(err) : { success: true }); } }); + player.server.route({ + method: 'POST', + path: '/api/v1/changeSong', + handler: (request, reply) => { + player.changeSong(request.payload.uuid); + } + }) + player.server.route({ + method: 'POST', + path: '/api/v1/skip', + handler: (request, reply) => { + let nowPlayingIndex = -1; + let cnt = 1; + + if (request.payload && _.isNumber(request.payload.cnt)) { + cnt = request.payload.cnt; + } + + const nowPlaying = player.getNowPlaying(); + if (nowPlaying) { + nowPlayingIndex = player.queue.findSongIndex(nowPlaying.uuid); + } + + const nextSongUuid = player.queue.uuidAtIndex(nowPlayingIndex + cnt); + if (nextSongUuid) { + player.changeSong(nextSongUuid); + } else { + player.endOfQueue(); + } + + reply({ success: true }); + } + }); + this.registerHook('onPrepareProgress', (song, bytesWritten, done) => { if (!this.pendingRequests[song.backend.name]) { return; @@ -104,6 +170,7 @@ export default class Rest extends Plugin { this.registerHook('onBackendInitialized', backendName => { this.pendingRequests[backendName] = {}; + console.log('installing backend route'); // provide API path for music data, might block while song is preparing player.server.route({ method: 'GET', diff --git a/src/plugins/server.js b/src/plugins/server.js index 830b6b3..d3b4dc9 100644 --- a/src/plugins/server.js +++ b/src/plugins/server.js @@ -2,14 +2,13 @@ import Plugin from '.'; -const Hapi = require('hapi'); +import Hapi from 'hapi'; //const bodyParser = require('body-parser'); //const cookieParser = require('cookie-parser'); //const https = require('https'); //const http = require('http'); //const fs = require('fs'); - export default class Server extends Plugin { constructor(player, callback) { super(); diff --git a/src/plugins/storeQueue.js b/src/plugins/storeQueue.js new file mode 100644 index 0000000..d8c2ff1 --- /dev/null +++ b/src/plugins/storeQueue.js @@ -0,0 +1,81 @@ +'use strict'; + +import Plugin from '.'; +import Song from '../song'; +import fs from 'fs'; + +export default class StoreQueue extends Plugin { + constructor(player, callback) { + super(); + + this.path = this.coreConfig.queueStorePath; + this.initialized = false; + this.player = player; + + this.registerHook('onQueueModify', () => { + if (!this.path || !this.initialized) { + return; + } + + this.storeQueue(); + }); + + this.registerHook('onStartPlayback', () => { + if (!this.path || !this.initialized) { + return; + } + + this.storeQueue(); + }); + + this.registerHook('onBackendsInitialized', (backends) => { + if (this.path && fs.existsSync(this.path)) { + fs.readFile(this.path, (err, data) => { + if (!err) { + data = JSON.parse(data); + + const queue = data.queue; + player.queue.insertSongs(null, queue); + + let np = data.nowPlaying; + if (np) { + np = new Song(np, backends[np.backendName]); + player.nowPlaying = np; + player.startPlayback(data.nowPlaying.playback.curPos); + } + + process.once('SIGINT', () => { + console.log('SIGINT received, saving queue'); + this.storeQueue(true); + }); + process.once('SIGUSR2', () => { + console.log('SIGUSR2 received, saving queue'); + this.storeQueue(true); + }); + + this.initialized = true; + } + }); + } + }); + + callback(); + } + + storeQueue(quit) { + let np = this.player.nowPlaying ? this.player.nowPlaying.serialize() : null; + + if (np) { + np.playback.startPos = np.playback.curPos; + } + + fs.writeFile(this.path, JSON.stringify({ + queue: this.player.queue.serialize(), + nowPlaying: np + }, '', 4), () => { + if (quit) { + process.exit(0); + } + }); + } +} diff --git a/src/plugins/ws.js b/src/plugins/ws.js new file mode 100644 index 0000000..367d821 --- /dev/null +++ b/src/plugins/ws.js @@ -0,0 +1,76 @@ +'use strict'; + +import Plugin from '.'; +import Sockjs from 'sockjs'; +import _ from 'lodash'; + +const sockjsOpts = { + sockjs_url: "http://cdn.jsdelivr.net/sockjs/1.0.1/sockjs.min.js" +}; + +export default class WebSockets extends Plugin { + constructor(player, callback) { + super(); + + this.clients = {}; + + const sockjs = Sockjs.createServer(sockjsOpts); + + sockjs.installHandlers(player.server.listener, { + prefix: '/ws' + }); + + sockjs.on('connection', (conn) => { + this.clients[conn.id] = conn; + + conn.write(JSON.stringify({ + jsonrpc: '2.0', + method: 'sync', + params: { + nowPlaying: player.getNowPlaying(), + queue: player.queue.serialize() + } + })); + + conn.on('close', () => { + delete this.clients[conn.id]; + }); + }); + + player.ws = sockjs; + + this.registerHook('onStartPlayback', (song) => { + this.broadcast({ + jsonrpc: '2.0', + method: 'play', + params: song + }); + }); + + this.registerHook('onStopPlayback', (song) => { + this.broadcast({ + jsonrpc: '2.0', + method: 'stop', + params: song + }); + }); + + this.registerHook('onQueueModify', (queue) => { + this.broadcast({ + jsonrpc: '2.0', + method: 'queue', + params: queue + }); + }); + + callback(); + } + + broadcast(message) { + // iterate through each client in clients object + _.forOwn(this.clients, (client) => { + // send the message to that client + client.write(JSON.stringify(message)); + }); + } +} diff --git a/src/queue.js b/src/queue.js index 08fe930..7ee368c 100644 --- a/src/queue.js +++ b/src/queue.js @@ -1,4 +1,4 @@ -const _ = require('lodash'); +import _ from 'lodash'; import Song from './song'; /** @@ -115,6 +115,16 @@ export default class Queue { const args = [pos, 0].concat(songs); Array.prototype.splice.apply(this.songs, args); + this.player.logger.verbose('Inserted songs:', songs.map((song) => { + return _.pick(song.serialize(), [ + 'backendName', + 'title', + 'artist', + 'album', + 'uuid' + ]); + })); + this.player.callHooks('onQueueModify', [this.serialize()]); this.player.prepareSongs(); } @@ -154,6 +164,7 @@ export default class Queue { this.player.prepareSongs(); } + this.player.callHooks('onQueueModify', [this.serialize()]); return removed; } @@ -177,6 +188,7 @@ export default class Queue { this.songs = _.shuffle(this.songs); } + this.player.callHooks('onQueueModify', [this.serialize()]); this.player.prepareSongs(); } } diff --git a/src/song.js b/src/song.js index bb43d3a..3c76807 100644 --- a/src/song.js +++ b/src/song.js @@ -66,12 +66,17 @@ export default class Song { * @param {Number} [pos] - position to start playing at */ playbackStarted(pos) { + console.log(pos); this.playback = { startTime: new Date(), startPos: pos || null, }; } + isPlaying() { + return !!this.playback.startTime; + } + /** * Return serialized details of the song * @return {SerializedSong} - serialized Song object @@ -89,7 +94,12 @@ export default class Song { format: this.format, backendName: this.backend.name, playlist: this.playlist, - playback: this.playback, + playback: { + startTime: this.playback.startTime, + startPos: this.playback.startPos, + curPos: this.playback.startPos + (this.playback.startTime ? + new Date().getTime() - this.playback.startTime.getTime() : null) + } }; } diff --git a/yarn.lock b/yarn.lock index a8898b0..7424a25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1146,6 +1146,12 @@ event-stream@~3.3.0: stream-combiner "~0.0.4" through "~2.3.1" +eventsource@~0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-0.1.6.tgz#0acede849ed7dd1ccc32c811bb11b944d4f29232" + dependencies: + original ">=0.0.5" + exit-hook@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" @@ -1190,6 +1196,18 @@ fast-levenshtein@~2.0.4: version "2.0.5" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.5.tgz#bd33145744519ab1c36c3ee9f31f08e9079b67f2" +faye-websocket@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" + dependencies: + websocket-driver ">=0.5.1" + +faye-websocket@~0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.0.tgz#d9ccf0e789e7db725d74bc4877d23aa42972ac50" + dependencies: + websocket-driver ">=0.5.1" + figures@^1.3.5: version "1.7.0" resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" @@ -2021,7 +2039,7 @@ json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" -json3@3.3.2: +json3@^3.3.2, json3@3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" @@ -2867,6 +2885,12 @@ optionator@^0.8.1, optionator@^0.8.2: type-check "~0.3.2" wordwrap "~1.0.0" +original@>=0.0.5: + version "1.0.0" + resolved "https://registry.yarnpkg.com/original/-/original-1.0.0.tgz#9147f93fa1696d04be61e01bd50baeaca656bd3b" + dependencies: + url-parse "1.0.x" + os-homedir@^1.0.0, os-homedir@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" @@ -3109,6 +3133,10 @@ qs@~6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.0.tgz#f403b264f23bc01228c74131b407f18d5ea5d442" +querystringify@0.0.x: + version "0.0.4" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-0.0.4.tgz#0cf7f84f9463ff0ae51c4c4b142d95be37724d9c" + randomatic@^1.1.3: version "1.1.5" resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.5.tgz#5e9ef5f2d573c67bd2b8124ae90b5156e457840b" @@ -3441,6 +3469,10 @@ require-uncached@^1.0.2: caller-path "^0.1.0" resolve-from "^1.0.0" +requires-port@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + resolve-dir@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e" @@ -3571,6 +3603,24 @@ sntp@1.x.x: dependencies: hoek "2.x.x" +sockjs: + version "0.3.18" + resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.18.tgz#d9b289316ca7df77595ef299e075f0f937eb4207" + dependencies: + faye-websocket "^0.10.0" + uuid "^2.0.2" + +sockjs-client: + version "1.1.1" + resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.1.1.tgz#284843e9a9784d7c474b1571b3240fca9dda4bb0" + dependencies: + debug "^2.2.0" + eventsource "~0.1.6" + faye-websocket "~0.11.0" + inherits "^2.0.1" + json3 "^3.3.2" + url-parse "^1.1.1" + sorted-object@~2.0.0, sorted-object@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/sorted-object/-/sorted-object-2.0.1.tgz#7d631f4bd3a798a24af1dffcfbfe83337a5df5fc" @@ -3895,6 +3945,20 @@ update-notifier@0.5.0: semver-diff "^2.0.0" string-length "^1.0.0" +url-parse@^1.1.1: + version "1.1.7" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.1.7.tgz#025cff999653a459ab34232147d89514cc87d74a" + dependencies: + querystringify "0.0.x" + requires-port "1.0.x" + +url-parse@1.0.x: + version "1.0.5" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.0.5.tgz#0854860422afdcfefeb6c965c662d4800169927b" + dependencies: + querystringify "0.0.x" + requires-port "1.0.x" + user-home@^1.0.0, user-home@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190" @@ -3913,7 +3977,7 @@ util-extend@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/util-extend/-/util-extend-1.0.3.tgz#a7c216d267545169637b3b6edc6ca9119e2ff93f" -uuid@^2.0.1: +uuid@^2.0.1, uuid@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" @@ -3960,6 +4024,16 @@ wcwidth@^1.0.0: dependencies: defaults "^1.0.3" +websocket-driver@>=0.5.1: + version "0.6.5" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.6.5.tgz#5cb2556ceb85f4373c6d8238aa691c8454e13a36" + dependencies: + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.1.tgz#76899499c184b6ef754377c2dbb0cd6cb55d29e7" + which-module@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" From 85af1fe9d6d81f9c2809e6ef40f501e9efa91191 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 18 Nov 2016 18:15:47 +0200 Subject: [PATCH 099/103] add some terrible hax to allow storeQueue to restore uuids, comment out broken shit --- src/player.js | 15 ++++++++++---- src/plugins/storeQueue.js | 4 ++-- src/plugins/ws.js | 42 ++++++++++++++++++++++++++++++++++++--- src/queue.js | 4 ++-- src/song.js | 8 ++++++-- 5 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/player.js b/src/player.js index 36f8c37..ea73a36 100644 --- a/src/player.js +++ b/src/player.js @@ -270,12 +270,17 @@ export default class Player { /* progress callback * when this is called, new song data has been flushed to disk */ + const np = this.getNowPlaying(); + + /* + * TODO! // start playback if it hasn't been started yet if (this.play && this.getNowPlaying() && - this.getNowPlaying().uuid === song.uuid && - !this.queue.playbackStart && bytesWritten) { + np.uuid === song.uuid && + !np.playback.startTime && bytesWritten) { this.startPlayback(); } + */ // tell plugins that new data is available for this song, and // whether the song is now fully written to disk or not. @@ -327,10 +332,12 @@ export default class Player { } if (song.isPrepared()) { + const np = this.getNowPlaying(); + // start playback if it hasn't been started yet if (this.play && this.getNowPlaying() && - this.getNowPlaying().uuid === song.uuid && - !this.queue.playbackStart) { + np.uuid === song.uuid && + !np.playback.startTime) { this.startPlayback(); } diff --git a/src/plugins/storeQueue.js b/src/plugins/storeQueue.js index d8c2ff1..afe8b6a 100644 --- a/src/plugins/storeQueue.js +++ b/src/plugins/storeQueue.js @@ -35,11 +35,11 @@ export default class StoreQueue extends Plugin { data = JSON.parse(data); const queue = data.queue; - player.queue.insertSongs(null, queue); + player.queue.insertSongs(null, queue, true); let np = data.nowPlaying; if (np) { - np = new Song(np, backends[np.backendName]); + np = new Song(np, backends[np.backendName], true); player.nowPlaying = np; player.startPlayback(data.nowPlaying.playback.curPos); } diff --git a/src/plugins/ws.js b/src/plugins/ws.js index 367d821..77e2cf5 100644 --- a/src/plugins/ws.js +++ b/src/plugins/ws.js @@ -23,12 +23,22 @@ export default class WebSockets extends Plugin { sockjs.on('connection', (conn) => { this.clients[conn.id] = conn; + const np = player.getNowPlaying(); + let queue = player.queue.serialize(); + + if (np) { + const npPos = player.queue.findSongIndex(np.uuid); + if (npPos > 0) { + queue = queue.slice(npPos - 1); + } + } + conn.write(JSON.stringify({ jsonrpc: '2.0', method: 'sync', params: { - nowPlaying: player.getNowPlaying(), - queue: player.queue.serialize() + nowPlaying: np, + queue: queue } })); @@ -40,6 +50,22 @@ export default class WebSockets extends Plugin { player.ws = sockjs; this.registerHook('onStartPlayback', (song) => { + const np = player.getNowPlaying(); + let queue = player.queue.serialize(); + + if (np) { + const npPos = player.queue.findSongIndex(np.uuid); + if (npPos > 0) { + queue = queue.slice(npPos - 1); + } + } + + this.broadcast({ + jsonrpc: '2.0', + method: 'queue', + params: queue + }); + this.broadcast({ jsonrpc: '2.0', method: 'play', @@ -55,7 +81,17 @@ export default class WebSockets extends Plugin { }); }); - this.registerHook('onQueueModify', (queue) => { + this.registerHook('onQueueModify', () => { + const np = player.getNowPlaying(); + let queue = player.queue.serialize(); + + if (np) { + const npPos = player.queue.findSongIndex(np.uuid); + if (npPos > 0) { + queue = queue.slice(npPos - 1); + } + } + this.broadcast({ jsonrpc: '2.0', method: 'queue', diff --git a/src/queue.js b/src/queue.js index 7ee368c..015f79c 100644 --- a/src/queue.js +++ b/src/queue.js @@ -79,7 +79,7 @@ export default class Queue { * @param {Song | Object[]} songs - Song or list of songs to insert * @return {Error} - in case of errors */ - insertSongs(at, songs) { + insertSongs(at, songs, forceUuid) { let pos; if (at === null) { // insert at start of queue @@ -108,7 +108,7 @@ export default class Queue { throw new Error('Song constructor called with invalid backend: ' + song.backendName); } - return new Song(song, backend); + return new Song(song, backend, forceUuid); }, this); // perform insertion diff --git a/src/song.js b/src/song.js index 3c76807..fabc770 100644 --- a/src/song.js +++ b/src/song.js @@ -8,7 +8,7 @@ const uuid = require('node-uuid'); * @throws {Error} in case of errors */ export default class Song { - constructor(song, backend) { + constructor(song, backend, forceUuid) { // make sure we have a reference to backend if (!backend || !_.isObject(backend)) { throw new Error('Song constructor called with invalid backend: ' + backend); @@ -30,7 +30,11 @@ export default class Song { throw new Error('Song constructor called without format!'); } - this.uuid = uuid.v4(); + if (forceUuid) { + this.uuid = song.uuid; + } else { + this.uuid = uuid.v4(); + } this.title = song.title; this.artist = song.artist; From fa020e4f148f458fcdc65679ed2c2df873853f6a Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 18 Nov 2016 18:53:22 +0200 Subject: [PATCH 100/103] store queue even if it didnt exist at startup --- src/plugins/storeQueue.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/plugins/storeQueue.js b/src/plugins/storeQueue.js index afe8b6a..676dc5e 100644 --- a/src/plugins/storeQueue.js +++ b/src/plugins/storeQueue.js @@ -43,20 +43,19 @@ export default class StoreQueue extends Plugin { player.nowPlaying = np; player.startPlayback(data.nowPlaying.playback.curPos); } - - process.once('SIGINT', () => { - console.log('SIGINT received, saving queue'); - this.storeQueue(true); - }); - process.once('SIGUSR2', () => { - console.log('SIGUSR2 received, saving queue'); - this.storeQueue(true); - }); - - this.initialized = true; } + + this.initialized = true; }); } + process.once('SIGINT', () => { + console.log('SIGINT received, saving queue'); + this.storeQueue(true); + }); + process.once('SIGUSR2', () => { + console.log('SIGUSR2 received, saving queue'); + this.storeQueue(true); + }); }); callback(); From e7be62548eab3fd5f60ba8bfe6a6400b297046a8 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Fri, 18 Nov 2016 19:03:21 +0200 Subject: [PATCH 101/103] fix more stupid typos --- src/player.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/player.js b/src/player.js index ea73a36..ce24063 100644 --- a/src/player.js +++ b/src/player.js @@ -173,7 +173,7 @@ export default class Player { position = _.isNumber(position) ? position : this.nowPlaying.playback.startPos; - this.nowPlaying.prepare(err => { + this.prepareSong(this.nowPlaying, (err) => { if (err) { throw new Error('error while preparing now playing: ' + err); } From 3cec10f2fd5c4c2e5e7960e9c54a624b7df833a1 Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sat, 19 Nov 2016 10:24:08 +0200 Subject: [PATCH 102/103] Add endpoint for deleting songs --- src/plugins/rest.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/plugins/rest.js b/src/plugins/rest.js index adb2a57..9c9c484 100644 --- a/src/plugins/rest.js +++ b/src/plugins/rest.js @@ -54,6 +54,15 @@ export default class Rest extends Plugin { } }); + player.server.route({ + method: 'DELETE', + path: '/api/v1/queue/delete', + handler: (request, reply) => { + const err = player.queue.removeSongs(request.payload.at, Number(request.payload.cnt) || 1); + reply(err ? Boom.badImplementation(err) : { success: true }); + } + }); + player.server.route({ method: 'POST', path: '/api/v1/queue/song', From 11fac87d98b2964e5fa1980023b53d5ac7b46c9d Mon Sep 17 00:00:00 2001 From: Rasmus Eskola Date: Sat, 19 Nov 2016 10:27:04 +0200 Subject: [PATCH 103/103] For now don't auto start playback after song prepared, causes weirdness --- src/player.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/player.js b/src/player.js index ce24063..5d71e7a 100644 --- a/src/player.js +++ b/src/player.js @@ -335,11 +335,13 @@ export default class Player { const np = this.getNowPlaying(); // start playback if it hasn't been started yet + /* if (this.play && this.getNowPlaying() && np.uuid === song.uuid && !np.playback.startTime) { this.startPlayback(); } + */ // song is already prepared, ok to prepare more songs callback();