├── .gitignore ├── .jscsrc ├── .jshintignore ├── .jshintrc ├── .travis.yml ├── LICENSE ├── README.md ├── TODO.md ├── bin └── nodeplayer ├── index.js ├── lib ├── config.js ├── logger.js └── player.js ├── media ├── logo.png └── logo_text.png ├── package.json └── test ├── exampleQueue.json ├── jscs.spec.js ├── jshint.spec.js └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | # partyplay song cache 31 | songCache 32 | cache 33 | settings 34 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "google", 3 | 4 | "requireParenthesesAroundIIFE": true, 5 | "maximumLineLength": 100, 6 | "validateLineBreaks": "LF", 7 | "validateIndentation": 4, 8 | 9 | "excludeFiles": [ 10 | "cache/**", 11 | "coverage/**", 12 | "settings/**", 13 | "node_modules/**" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | cache 2 | coverage 3 | settings 4 | node_modules 5 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "mocha": true 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | notifications: 2 | webhooks: 3 | urls: 4 | - https://webhooks.gitter.im/e/90c139c7c84b7aab80d9 5 | on_success: change # options: [always|never|change] default: always 6 | on_failure: always # options: [always|never|change] default: always 7 | on_start: false # default: false 8 | language: node_js 9 | node_js: 10 | - "node" 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Rasmus Eskola 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](/media/logo_text.png) 2 | 3 | **NOTE: this project is not really in a usable state right now. I'm working on a giant refactor which will hopefully be finished around early summer 2016. This refactor is taking place in the `playlists` branch, but it is unfinished as of yet. Pull requests are welcome there, but please keep this refactor in mind for now!** 4 | 5 | Simple, modular music player written in node.js 6 | 7 | [![Build Status](https://travis-ci.org/FruitieX/nodeplayer.svg?branch=develop)](https://travis-ci.org/FruitieX/nodeplayer) 8 | [![Coverage Status](https://coveralls.io/repos/FruitieX/nodeplayer/badge.svg?branch=master)](https://coveralls.io/r/FruitieX/nodeplayer?branch=master) 9 | [![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) 10 | [![Gratipay](https://img.shields.io/gratipay/FruitieX.svg)](https://gratipay.com/FruitieX/) 11 | 12 | Disclaimer: for personal use only - make sure you configure nodeplayer 13 | appropriately so that others can't access your music. I take no responsibility 14 | for example if your streaming services find you are violating their ToS. You're running 15 | this software entirely at your own risk! 16 | 17 | ### Screenshot of the [weblistener](https://github.com/FruitieX/nodeplayer-plugin-weblistener) 18 | 19 | ![Screenshot of the weblistener] (https://raw.githubusercontent.com/FruitieX/nodeplayer-plugin-weblistener/master/screenshot.png) 20 | 21 | Quickstart 22 | ---------- 23 | 24 | Make sure you have [Node.js](https://nodejs.org/) installed, then run: 25 | 26 | - `npm install -g nodeplayer` ([here's](https://github.com/sindresorhus/guides/blob/master/npm-global-without-sudo.md) how you can do this without sudo, *highly recommended*) 27 | - `nodeplayer` 28 | 29 | nodeplayer will now ask you to edit its configuration file. For a basic setup 30 | the defaults should be good. You may want to add a few more backends and/or 31 | plugins later, see below for some examples! 32 | 33 | When you're done configuring, run `nodeplayer` again. nodeplayer now 34 | automatically installs missing plugins and backends. Note that if you installed 35 | nodeplayer as root (you *probably shouldn't*), this step also requires root 36 | since modules are installed to the same path as nodeplayer. 37 | 38 | Note that backends and plugins you load may ask you to perform additional 39 | configuration steps. Read through the setup instructions for each of the 40 | plugins/backends you enable, and read through the output they print to console 41 | on first run. If you're using the default nodeplayer configuration, you should 42 | at least consider changing the configuration of the: 43 | 44 | - [YouTube backend](https://github.com/FruitieX/nodeplayer-backend-youtube), 45 | which uses a throwaway API key by default (might or might not work!) 46 | - [Passport plugin](https://github.com/FruitieX/nodeplayer-plugin-passport) 47 | for password protecting nodeplayer. By default passport uses username `changeMe` and password `keyboard cat`. 48 | 49 | All modules can be updated by running `nodeplayer -u` 50 | 51 | ### The nodeplayer project 52 | * [nodeplayer](https://github.com/FruitieX/nodeplayer) The core music player component 53 | * [nodeplayer-client](https://github.com/FruitieX/nodeplayer-client) CLI client for controlling nodeplayer 54 | * [nodeplayer-player](https://github.com/FruitieX/nodeplayer-player) CLI audio playback client 55 | 56 | #### Plugin modules 57 | * [nodeplayer-plugin-express](https://github.com/FruitieX/nodeplayer-plugin-express) expressjs server 58 | * [nodeplayer-plugin-passport](https://github.com/FruitieX/nodeplayer-plugin-passport) Password protection for nodeplayer 59 | * [nodeplayer-plugin-ipfilter](https://github.com/FruitieX/nodeplayer-plugin-ipfilter) IP filtering 60 | * [nodeplayer-plugin-partyplay](https://github.com/FruitieX/nodeplayer-plugin-partyplay) Party playlist 61 | * [nodeplayer-plugin-rest](https://github.com/FruitieX/nodeplayer-plugin-rest) REST API 62 | * [nodeplayer-plugin-socketio](https://github.com/FruitieX/nodeplayer-plugin-socketio) socket.io API 63 | * [nodeplayer-plugin-storequeue](https://github.com/FruitieX/nodeplayer-plugin-storequeue) Save the queue 64 | * [nodeplayer-plugin-verifymac](https://github.com/FruitieX/nodeplayer-plugin-verifymac) Verify queue add operations 65 | * [nodeplayer-plugin-weblistener](https://github.com/FruitieX/nodeplayer-plugin-weblistener) Web-based audio player 66 | 67 | #### Backend modules 68 | * [nodeplayer-backend-gmusic](https://github.com/FruitieX/nodeplayer-backend-gmusic) 69 | * [nodeplayer-backend-youtube](https://github.com/FruitieX/nodeplayer-backend-youtube) 70 | * [nodeplayer-backend-spotify](https://github.com/FruitieX/nodeplayer-backend-spotify) 71 | * [nodeplayer-backend-file](https://github.com/FruitieX/nodeplayer-backend-file) 72 | 73 | Introduction 74 | ------------ 75 | 76 | This repository contains the core nodeplayer module. As a standalone 77 | component it is rather useless, as it is meant to be extended by other modules. 78 | The core module manages a playback queue and initializes any external 79 | modules that you have configured it to load. External modules are given various ways to 80 | manipulate the queue, and without them you can't really interact with nodeplayer in any way! 81 | 82 | External modules are categorized as follows: 83 | 84 | * Backend modules: Sources of music 85 | * Plugin modules: Extend the functionality of the core in various ways 86 | 87 | By keeping nodeplayer modular it is possible to use it in a wide variety of 88 | scenarios, ranging from being a basic personal music player to a party playlist 89 | manager where partygoers can vote on songs. Or perhaps configure it as a 90 | streaming music player to your mobile devices and when you come home, you can 91 | simply switch music sources over to your PC since the music plays back in sync. 92 | More cool functionality can easily be implemented by writing new modules! 93 | 94 | For developers 95 | -------------- 96 | 97 | NOTE: The API is NOT stable yet! Things may break at any time without warning, 98 | I'm trying to stabilize things for [0.2.0](https://github.com/FruitieX/nodeplayer/milestones/0.2.0). 99 | But [some issues](https://github.com/FruitieX/nodeplayer/labels/API%20change) still exist that will definitely change the plugin and backend API a bit (likewise for the [client API](https://github.com/FruitieX/nodeplayer/labels/Client%20API)). 100 | 101 | ### Pull requests 102 | 103 | Code style adheres mostly to the [Google JavaScript Style Guide](https://google-styleguide.googlecode.com/svn/trunk/javascriptguide.xml), 104 | with the following exceptions: 105 | 106 | - One indent equals 4 spaces, not 2 107 | - Maximum line length is 100, not 80 108 | - UNIX endlines (LF) are enforced 109 | 110 | Apart from unit tests, code is ran through `jshint` and `jscs` with above options. 111 | Before submitting a pull request, make sure that your code passes the test suite. 112 | This can be checked with: 113 | 114 | npm test 115 | 116 | ### Collaborators 117 | 118 | This repository follows the principles of [git-flow](http://nvie.com/posts/a-successful-git-branching-model/) 119 | 120 | ### Modules 121 | 122 | Modules are best developed by using `npm link`. This allows nodeplayer to load the module directly from your module repository. This is only possible when running nodeplayer directly from the [nodeplayer repository](https://github.com/FruitieX/nodeplayer), 123 | so getting a clone is highly recommended. `npm link` can be used like so: 124 | 125 | - First make sure your module has a `package.json` file 126 | - In your module repository, run `npm link` ([here's](https://github.com/sindresorhus/guides/blob/master/npm-global-without-sudo.md) how you can do this without sudo, *highly recommended*) 127 | - In the nodeplayer repository, link the module with `npm link nodeplayer-plugin-myplugin` 128 | - Now you can develop your module inside its own repository, any changes will take 129 | effect immediately when you run nodeplayer. 130 | 131 | #### Plugin modules 132 | 133 | The core provides several functions for managing the queue to plugins, and 134 | through the use of hooks the core will call a plugin's hook functions (if 135 | defined) at well defined times. 136 | 137 | TODO: template plugin 138 | 139 | ##### Initialization 140 | 141 | A plugin module must export at least an init function: 142 | 143 | exports.init = function(player, logger, callback) {...}; 144 | 145 | The init functions: 146 | 147 | * Are called once for each configured plugin when nodeplayer is started. 148 | * Are called in sequence (unlike backends), and can thus depend on another plugin 149 | being loaded, possibly expanding the functionalities of that plugin. 150 | * Are passed the following arguments: 151 | * player: reference to the player object in nodeplayer core, store this if you 152 | need it later. 153 | * logger: [winston](https://github.com/winstonjs/winston) logger with per-plugin tag, 154 | use this for logging! (log levels are: logger.silly, logger.debug, logger.info, logger.warn, logger.error) 155 | * callback: callback must be called with no arguments when you are done 156 | initializing the plugin. If there was an error initializing, call it with a 157 | string stating the reason for the error. 158 | 159 | And there you have it, the simplest possible plugin. For more details, take a look at example 160 | plugins linked at the top! Now let's make it actually do something by taking a look at *hook functions*! 161 | 162 | ##### Hook functions 163 | 164 | Plugin hook functions are called by the core (usually) before or after completing 165 | some specific task. For instance `onSongEnd` whenever a song ends, with the song as the first 166 | and only argument. Anything with a reference to the player object can call hook functions like so: 167 | 168 | player.callHooks('hookName', [arg1, arg2, ...]); 169 | 170 | This will call the hook function `hookName` in every plugin that has defined a 171 | function with that name, in the order the plugins were loaded, with `arg1, 172 | arg2, ...` as arguments. Simply define a hook function, eg. `hookName` in the plugin as such: 173 | 174 | exports.hookName = function(arg1, arg2, ...) {...}; 175 | 176 | If any hook returns a truthy value it is an error that will also be returned by 177 | `callHooks()`, and `callHooks()` will stop iterating through other hooks with the same name. 178 | 179 | ###### List of hook functions with explanations (FIXME: might be out of date, grep the code for `callHooks` to be sure) 180 | 181 | * `onSongChange(np)` - song has changed to `np` 182 | * `onSongEnd(np)` - song `np` ended 183 | * `onSongPause(np)` - song `np` was paused 184 | * `onSongPrepareError(song, err)` - preparing `song` failed with `err` 185 | * `onSongPrepared(song)` - preparing `song` succeeded 186 | * `onPrepareProgress(song, s, done)` - data (`s` bytes) related to `song` written to disk. If `done` true then we're done preparing the song. 187 | * `onEndOfQueue()` - queue ended 188 | * `onQueueModify(queue)` - queue was potentially modified 189 | * `preAddSearchResult(song)` - about to add search result `song`, returning a truthy value rejects search result 190 | * `preSongsRemoved(pos, cnt)` - about to remove `cnt` amount of songs starting at `pos`. TODO: these should probably be possible to reject 191 | * `postSongsRemoved(pos, cnt)` - removed `cnt` amount of songs starting at `pos` 192 | * `preSongsQueued(songs, pos)` - about to queue `songs` to `pos` 193 | * `postSongsQueued(songs, pos)` - queued `songs` to `pos` 194 | * `preSongQueued(song)` - about to queue `song` to `pos` 195 | * `postSongQueued(song)` - queued `song` to `pos` 196 | * `sortQueue()` - queue sort hook 197 | * `onPluginInitialized(plugin)` - `plugin` was initialized 198 | * `onPluginInitError(plugin, err)` - `err` while initializing `plugin` 199 | * `onPluginsInitialized()` - all plugins were initialized 200 | * `onBackendInitialized(backend)` - `backend` was initialized 201 | * `onBackendInitError(backend, err)` - `err` while initializing `backend` 202 | * `onBackendsInitialized()` - all backends were initialized 203 | 204 | #### Backend modules 205 | 206 | Backend modules are sources of music and need to export the following functions: 207 | 208 | ``` 209 | exports.init = function(player, logger, callback) {...}; 210 | ``` 211 | 212 | * Very similar to the plugin init function 213 | * Perform necessary initialization here 214 | * Run callback with descriptive string argument on error, and no argument on success. 215 | 216 | ``` 217 | exports.search = function(query, callback, errCallback) {...}; 218 | ``` 219 | 220 | * Used for searching songs in your backend. `query` contains a `terms` property 221 | which represents the search terms 222 | * Callback should be called with results on success 223 | * errCallback should be called with descriptive error string on error 224 | * Results are a JavaScript object like so: 225 | 226 | ``` 227 | { 228 | songs: { 229 | dummySongID1: { // dummySongID1 should equal to the value of songID inside the song object 230 | ... 231 | }, 232 | dummySongID1: { 233 | ... 234 | }, 235 | } 236 | } 237 | ``` 238 | * You can also choose to include some custom metadata as keys in the object, these will 239 | be passed along with the results. (eg. pagination) 240 | * Song objects look like this: 241 | ``` 242 | { 243 | artist: 'dummyArtist', 244 | title: 'dummyTitle', 245 | album: 'dummyAlbum', 246 | albumArt: { // URLs to album art, must contain 'lq' (low quality) and 'hq' (high quality) links 247 | lq: 'http://dummy.com/albumArt.png', 248 | hq: 'http://dummy.com/albumArt_HighQuality.png' 249 | }, 250 | duration: 123456, // in milliseconds 251 | songID: 'dummySongID1', // a string uniquely identifying the song in your backend 252 | score: i, // how relevant is this result, ideally from 0 (least relevant) to 100 (most relevant) 253 | backendName: 'dummy', // name of this backend 254 | format: 'opus' // file format/extension of encoded song 255 | }; 256 | ``` 257 | 258 | And finally, get ready for the insane one doing all the heavy lifting: 259 | ``` 260 | exports.prepareSong = function(song, progCallback, errCallback) {...}; 261 | ``` 262 | 263 | * Called by the core when it wants backend to prepare audio data to disk. 264 | * Audio data should be encoded and stored in for example: 265 | * `/home/user/.nodeplayer/song-cache/backendName/songID.opus` 266 | * Use the following functions/variables to build up the path: 267 | * config.getConfigDir() 268 | * path.sep 269 | * When more audio data has been written to disk, call progCallback with arguments: 270 | * song object (song) 271 | * Number of bytes written to disk 272 | * true/false: is the whole song now written to disk? 273 | * Call errCallback with descriptive error string if something goes wrong, nodeplayer 274 | will then remove all instances of that song from the queue and skip to the next song. 275 | * `prepareSong` should return a function which if called, will cancel the preparation 276 | process and clean up any song data written so far. Nodeplayer may call this function 277 | if for example the song is skipped. 278 | 279 | ``` 280 | exports.isPrepared = function(song) {...}; 281 | ``` 282 | 283 | * Called by nodeplayer to check if preparation is needed or not 284 | * Returns `true` if the song is prepared, `false` otherwise 285 | * Is allowed to return `true` while the song is being prepared 286 | * Often just a `return fs.existsSync(filePath)` 287 | 288 | TODO: template backend 289 | 290 | For more details, take a look at example backends linked at the top! 291 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | nodeplayer TODO 2 | =============== 3 | 4 | Note: most of this was/should be moved to issues in relevant modules 5 | 6 | ### web frontends 7 | - rewrite web frontends using eg. angular.js 8 | - get dependencies with bower 9 | - qr code for partyplay 10 | 11 | ### CLI client 12 | - use verifymac plugin instead of duplicating code 13 | - clean unnecessary properties from song before storing a playlist 14 | - less hacky music playback than now? 15 | 16 | ### android client 17 | - supports streaming, controlling playback 18 | - android wear support 19 | - eventually local caching? 20 | -------------------------------------------------------------------------------- /bin/nodeplayer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var argv = require('yargs').argv; 4 | var nodeplayer = require('../'); 5 | var logger = nodeplayer.labeledLogger('core'); 6 | 7 | var core = new nodeplayer.Core(); 8 | core.initModules(argv.u, function() { 9 | logger.info('ready'); 10 | }); 11 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('underscore'); 4 | var npm = require('npm'); 5 | var async = require('async'); 6 | var labeledLogger = require('./lib/logger'); 7 | var Player = require('./lib/player'); 8 | var nodeplayerConfig = require('./lib/config'); 9 | var config = nodeplayerConfig.getConfig(); 10 | 11 | var logger = labeledLogger('core'); 12 | 13 | function Core() { 14 | this.player = new Player(); 15 | } 16 | 17 | Core.prototype.checkModule = function(module) { 18 | try { 19 | require.resolve(module); 20 | return true; 21 | } catch (e) { 22 | return false; 23 | } 24 | }; 25 | 26 | Core.prototype.installModule = function(moduleName, callback) { 27 | logger.info('installing module: ' + moduleName); 28 | npm.load({}, function(err) { 29 | npm.commands.install(__dirname, [moduleName], function(err) { 30 | if (err) { 31 | logger.error(moduleName + ' installation failed:', err); 32 | callback(); 33 | } else { 34 | logger.info(moduleName + ' successfully installed'); 35 | callback(); 36 | } 37 | }); 38 | }); 39 | }; 40 | 41 | // make sure all modules are installed, installs missing ones, then calls loadCallback 42 | Core.prototype.installModules = function(modules, moduleType, update, loadCallback) { 43 | async.eachSeries(modules, _.bind(function(moduleShortName, callback) { 44 | var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; 45 | if (!this.checkModule(moduleName) || update) { 46 | // perform install / update 47 | this.installModule(moduleName, callback); 48 | } else { 49 | // skip already installed 50 | callback(); 51 | } 52 | }, this), loadCallback); 53 | }; 54 | 55 | Core.prototype.initModule = function(moduleShortName, moduleType, callback) { 56 | var moduleTypeCapital = moduleType.charAt(0).toUpperCase() + moduleType.slice(1); 57 | var moduleName = 'nodeplayer-' + moduleType + '-' + moduleShortName; 58 | var module = require(moduleName); 59 | 60 | var moduleLogger = labeledLogger(moduleShortName); 61 | module.init(this.player, moduleLogger, _.bind(function(err) { 62 | if (!err) { 63 | this[moduleType + 's'][moduleShortName] = module; 64 | if (moduleType === 'backend') { 65 | this.songsPreparing[moduleShortName] = {}; 66 | } 67 | 68 | moduleLogger.info(moduleType + ' module initialized'); 69 | this.callHooks('on' + moduleTypeCapital + 'Initialized', [moduleShortName]); 70 | } else { 71 | moduleLogger.error('while initializing: ' + err); 72 | this.callHooks('on' + moduleTypeCapital + 'InitError', [moduleShortName]); 73 | } 74 | callback(err); 75 | }, this.player)); 76 | }; 77 | 78 | Core.prototype.initModules = function(update, callback) { 79 | async.eachSeries(['plugin', 'backend'], _.bind(function(moduleType, installCallback) { 80 | // first install missing modules 81 | this.installModules(config[moduleType + 's'], moduleType, update, installCallback); 82 | }, this), _.bind(function() { 83 | // then initialize modules, first all plugins in series, then all backends in parallel 84 | async.eachSeries(['plugin', 'backend'], _.bind(function(moduleType, typeCallback) { 85 | var moduleTypeCapital = moduleType.charAt(0).toUpperCase() + moduleType.slice(1); 86 | 87 | (moduleType === 'plugin' ? async.eachSeries : async.each) 88 | (config[moduleType + 's'], _.bind(function(moduleName, moduleCallback) { 89 | if (this.checkModule('nodeplayer-' + moduleType + '-' + moduleName)) { 90 | this.initModule(moduleName, moduleType, moduleCallback); 91 | } 92 | }, this), _.bind(function(err) { 93 | logger.info('all ' + moduleType + ' modules initialized'); 94 | this.callHooks('on' + moduleTypeCapital + 'sInitialized'); 95 | typeCallback(); 96 | }, this.player)); 97 | }, this), function() { 98 | callback(); 99 | }); 100 | }, this)); 101 | }; 102 | 103 | exports.Player = Player; 104 | exports.labeledLogger = labeledLogger; 105 | exports.config = nodeplayerConfig; 106 | 107 | exports.Core = Core; 108 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore'); 2 | var mkdirp = require('mkdirp'); 3 | var fs = require('fs'); 4 | var os = require('os'); 5 | var path = require('path'); 6 | 7 | function getHomeDir() { 8 | if (process.platform === 'win32') { 9 | return process.env.USERPROFILE; 10 | } else { 11 | return process.env.HOME; 12 | } 13 | } 14 | exports.getHomeDir = getHomeDir; 15 | 16 | function getConfigDir() { 17 | if (process.platform === 'win32') { 18 | return process.env.USERPROFILE + '\\nodeplayer\\config'; 19 | } else { 20 | return process.env.HOME + '/.nodeplayer/config'; 21 | } 22 | } 23 | exports.getConfigDir = getConfigDir; 24 | 25 | function getBaseDir() { 26 | if (process.platform === 'win32') { 27 | return process.env.USERPROFILE + '\\nodeplayer'; 28 | } else { 29 | return process.env.HOME + '/.nodeplayer'; 30 | } 31 | } 32 | exports.getBaseDir = getBaseDir; 33 | 34 | var defaultConfig = {}; 35 | 36 | // backends are sources of music, default backends don't require API keys 37 | defaultConfig.backends = [ 38 | 'youtube' 39 | ]; 40 | 41 | // plugins are "everything else", most of the functionality is in plugins 42 | // 43 | // NOTE: ordering is important here, plugins that require another plugin will 44 | // complain if order is wrong. 45 | defaultConfig.plugins = [ 46 | 'storequeue', 47 | 'express', 48 | 'socketio', 49 | 'passport', 50 | 'rest', 51 | 'weblistener' 52 | ]; 53 | 54 | defaultConfig.logLevel = 'info'; 55 | defaultConfig.logColorize = true; 56 | defaultConfig.logExceptions = false; // disabled for now because it looks terrible 57 | defaultConfig.logJson = false; 58 | 59 | defaultConfig.songCachePath = getBaseDir() + path.sep + 'song-cache'; 60 | defaultConfig.searchResultCnt = 10; 61 | defaultConfig.playedQueueSize = 100; 62 | defaultConfig.songDelayMs = 1000; // add delay between songs to prevent skips 63 | 64 | defaultConfig.songPrepareTimeout = 10000; // cancel preparation if no progress 65 | 66 | // hostname of the server, may be used as a default value by other plugins 67 | defaultConfig.hostname = os.hostname(); 68 | 69 | exports.getDefaultConfig = function() { 70 | return defaultConfig; 71 | }; 72 | 73 | // path and defaults are optional, if undefined then values corresponding to core config are used 74 | exports.getConfig = function(moduleName, defaults) { 75 | if (process.env.NODE_ENV === 'test') { 76 | // unit tests should always use default config 77 | return (defaults || defaultConfig); 78 | } 79 | 80 | var configPath = getConfigDir() + path.sep + (moduleName || 'core') + '.json'; 81 | 82 | try { 83 | var userConfig = require(configPath); 84 | var config = _.defaults(userConfig, defaults || defaultConfig); 85 | return config; 86 | } catch (e) { 87 | if (e.code === 'MODULE_NOT_FOUND') { 88 | if (!moduleName) { 89 | // only print welcome text for core module first run 90 | console.warn('Welcome to nodeplayer!'); 91 | console.warn('----------------------'); 92 | } 93 | console.warn('\n====================================================================='); 94 | console.warn('We couldn\'t find the user configuration file for module "' + 95 | (moduleName || 'core') + '",'); 96 | console.warn('so a sample configuration file containing default settings ' + 97 | 'will be written into:'); 98 | console.warn(configPath); 99 | 100 | mkdirp.sync(getConfigDir()); 101 | fs.writeFileSync(configPath, JSON.stringify(defaults || defaultConfig, undefined, 4)); 102 | 103 | console.warn('\nFile created. Go edit it NOW!'); 104 | console.warn('Note that the file only needs to contain the configuration ' + 105 | 'variables that'); 106 | console.warn('you want to override from the defaults. Also note that it ' + 107 | 'MUST be valid JSON!'); 108 | console.warn('=====================================================================\n'); 109 | 110 | if (!moduleName) { 111 | // only exit on missing core module config 112 | console.warn('Exiting now. Please re-run nodeplayer when you\'re done ' + 113 | 'configuring!'); 114 | process.exit(0); 115 | } 116 | 117 | return (defaults || defaultConfig); 118 | } else { 119 | console.warn('Unexpected error while loading configuration for module "' + 120 | (moduleName || 'core') + '":'); 121 | console.warn(e); 122 | } 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var config = require('./config').getConfig(); 3 | var winston = require('winston'); 4 | 5 | module.exports = function(label) { 6 | return new (winston.Logger)({ 7 | transports: [ 8 | new (winston.transports.Console)({ 9 | label: label, 10 | level: config.logLevel, 11 | colorize: config.logColorize, 12 | handleExceptions: config.logExceptions, 13 | json: config.logJson 14 | }) 15 | ] 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /lib/player.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var _ = require('underscore'); 3 | var async = require('async'); 4 | var labeledLogger = require('./logger'); 5 | 6 | function Player(options) { 7 | options = options || {}; 8 | 9 | // TODO: some of these should NOT be loaded from config 10 | _.bindAll.apply(_, [this].concat(_.functions(this))); 11 | this.config = options.config || require('./config').getConfig(); 12 | this.logger = options.logger || labeledLogger('core'); 13 | this.playedQueue = options.playedQueue || []; 14 | this.queue = options.queue || []; 15 | this.plugins = options.plugins || {}; 16 | this.backends = options.backends || {}; 17 | this.songsPreparing = options.songsPreparing || {}; 18 | this.volume = options.volume || 1; 19 | this.songEndTimeout = options.songEndTimeout || null; 20 | this.playbackState = { 21 | // TODO: move playbackStart, playbackPosition etc here 22 | }; 23 | } 24 | 25 | // call hook function in all modules 26 | // if any hooks return a truthy value, it is an error and we abort 27 | // be very careful with calling hooks from within a hook, infinite loops are possible 28 | Player.prototype.callHooks = function(hook, argv) { 29 | // _.find() used instead of _.each() because we want to break out as soon 30 | // as a hook returns a truthy value (used to indicate an error, e.g. in form 31 | // of a string) 32 | var err = null; 33 | 34 | this.logger.silly('callHooks(' + hook + 35 | (argv ? ', ' + JSON.stringify(argv) + ')' : ')')); 36 | 37 | _.find(this.plugins, function(plugin) { 38 | if (plugin[hook]) { 39 | err = plugin[hook].apply(null, argv); 40 | return err; 41 | } 42 | }); 43 | 44 | return err; 45 | }; 46 | 47 | // returns number of hook functions attached to given hook 48 | Player.prototype.numHooks = function(hook) { 49 | var cnt = 0; 50 | 51 | _.find(this.plugins, function(plugin) { 52 | if (plugin[hook]) { 53 | cnt++; 54 | } 55 | }); 56 | 57 | return cnt; 58 | }; 59 | 60 | Player.prototype.endOfSong = function() { 61 | var np = this.queue[0]; 62 | 63 | this.logger.info('end of song ' + np.songID); 64 | this.callHooks('onSongEnd', [np]); 65 | 66 | this.playedQueue.push(this.queue[0]); 67 | this.playedQueue = _.last(this.playedQueue, this.config.playedQueueSize); 68 | 69 | this.playbackPosition = null; 70 | this.playbackStart = null; 71 | this.queue[0] = null; 72 | this.songEndTimeout = null; 73 | this.onQueueModify(); 74 | }; 75 | 76 | // start or resume playback of now playing song. 77 | // if pos is undefined, playback continues (or starts from 0 if !playbackPosition) 78 | Player.prototype.startPlayback = function(pos) { 79 | var np = this.queue[0]; 80 | if (!np) { 81 | this.logger.verbose('startPlayback called, but hit end of queue'); 82 | return; 83 | } 84 | 85 | if (!_.isUndefined(pos) && !_.isNull(pos)) { 86 | this.logger.info('playing song: ' + np.songID + ', from pos: ' + pos); 87 | } else { 88 | this.logger.info('playing song: ' + np.songID); 89 | } 90 | 91 | var oldPlaybackStart = this.playbackStart; 92 | this.playbackStart = new Date().getTime(); // song is playing while this is truthy 93 | 94 | // where did the song start playing from at playbackStart? 95 | if (!_.isUndefined(pos) && !_.isNull(pos)) { 96 | this.playbackPosition = pos; 97 | } else if (!this.playbackPosition) { 98 | this.playbackPosition = 0; 99 | } 100 | 101 | if (oldPlaybackStart) { 102 | this.callHooks('onSongSeek', [np]); 103 | } else { 104 | this.callHooks('onSongChange', [np]); 105 | } 106 | 107 | var durationLeft = parseInt(np.duration) - this.playbackPosition + this.config.songDelayMs; 108 | if (this.songEndTimeout) { 109 | this.logger.debug('songEndTimeout was cleared'); 110 | clearTimeout(this.songEndTimeout); 111 | this.songEndTimeout = null; 112 | } 113 | this.songEndTimeout = setTimeout(this.endOfSong, durationLeft); 114 | }; 115 | 116 | Player.prototype.pausePlayback = function() { 117 | // update position 118 | this.playbackPosition += new Date().getTime() - this.playbackStart; 119 | this.playbackStart = null; 120 | 121 | clearTimeout(this.songEndTimeout); 122 | this.songEndTimeout = null; 123 | this.callHooks('onSongPause', [this.nowPlaying]); 124 | }; 125 | 126 | // TODO: proper song object with constructor? 127 | Player.prototype.setPrepareTimeout = function(song) { 128 | if (song.prepareTimeout) { 129 | clearTimeout(song.prepareTimeout); 130 | } 131 | 132 | song.prepareTimeout = setTimeout(_.bind(function() { 133 | this.logger.info('prepare timeout for song: ' + song.songID + ', removing'); 134 | song.cancelPrepare('prepare timeout'); 135 | song.prepareTimeout = null; 136 | }, this), this.config.songPrepareTimeout); 137 | 138 | Object.defineProperty(song, 'prepareTimeout', { 139 | enumerable: false, 140 | writable: true 141 | }); 142 | }; 143 | 144 | Player.prototype.prepareError = function(song, err) { 145 | // remove all instances of this song 146 | for (var i = this.queue.length - 1; i >= 0; i--) { 147 | if (this.queue[i].songID === song.songID && 148 | this.queue[i].backendName === song.backendName) { 149 | if (!song.beingDeleted) { 150 | this.logger.error('preparing song failed! (' + err + '), removing from queue: ' + 151 | song.songID); 152 | this.removeFromQueue(i); 153 | } 154 | } 155 | } 156 | 157 | this.callHooks('onSongPrepareError', [song, err]); 158 | }; 159 | 160 | Player.prototype.prepareProgCallback = function(song, newData, done, asyncCallback) { 161 | /* progress callback 162 | * when this is called, new song data has been flushed to disk */ 163 | 164 | // append new song data to buffer 165 | Object.defineProperty(song, 'songData', { 166 | enumerable: false, 167 | writable: true 168 | }); 169 | if (newData) { 170 | song.songData = song.songData ? Buffer.concat([song.songData, newData]) : newData; 171 | } else if (!song.songData) { 172 | song.songData = new Buffer(0); 173 | } 174 | 175 | // start playback if it hasn't been started yet 176 | if (this.queue[0] && 177 | this.queue[0].backendName === song.backendName && 178 | this.queue[0].songID === song.songID && 179 | !this.playbackStart && newData) { 180 | this.startPlayback(); 181 | } 182 | 183 | // tell plugins that new data is available for this song, and 184 | // whether the song is now fully written to disk or not. 185 | this.callHooks('onPrepareProgress', [song, newData, done]); 186 | 187 | if (done) { 188 | // mark song as prepared 189 | this.callHooks('onSongPrepared', [song]); 190 | 191 | // done preparing, can't cancel anymore 192 | delete(song.cancelPrepare); 193 | 194 | // song data should now be available on disk, don't keep it in memory 195 | this.songsPreparing[song.backendName][song.songID].songData = undefined; 196 | delete(this.songsPreparing[song.backendName][song.songID]); 197 | 198 | // clear prepare timeout 199 | clearTimeout(song.prepareTimeout); 200 | song.prepareTimeout = null; 201 | 202 | asyncCallback(); 203 | } else { 204 | // reset prepare timeout 205 | this.setPrepareTimeout(song); 206 | } 207 | }; 208 | 209 | Player.prototype.prepareErrCallback = function(song, err, asyncCallback) { 210 | /* error callback */ 211 | 212 | // don't let anything run cancelPrepare anymore 213 | delete(song.cancelPrepare); 214 | 215 | // clear prepare timeout 216 | clearTimeout(song.prepareTimeout); 217 | song.prepareTimeout = null; 218 | 219 | // abort preparing more songs; current song will be deleted -> 220 | // onQueueModified is called -> song preparation is triggered again 221 | asyncCallback(true); 222 | 223 | // TODO: investigate this, should probably be above asyncCallback 224 | this.prepareError(song, err); 225 | 226 | song.songData = undefined; 227 | delete(this.songsPreparing[song.backendName][song.songID]); 228 | }; 229 | 230 | // TODO: get rid of the callback hell, use promises? 231 | Player.prototype.prepareSong = function(song, asyncCallback) { 232 | if (!song) { 233 | this.logger.warn('prepareSong() without song'); 234 | asyncCallback(true); 235 | return; 236 | } 237 | if (!this.backends[song.backendName]) { 238 | this.prepareError(song, 'prepareSong() with unknown backend ' + song.backendName); 239 | asyncCallback(true); 240 | return; 241 | } 242 | 243 | if (this.backends[song.backendName].isPrepared(song)) { 244 | // start playback if it hasn't been started yet 245 | if (this.queue[0] && 246 | this.queue[0].backendName === song.backendName && 247 | this.queue[0].songID === song.songID && 248 | !this.playbackStart) { 249 | this.startPlayback(); 250 | } 251 | 252 | // song is already prepared, ok to prepare more songs 253 | asyncCallback(); 254 | } else if (this.songsPreparing[song.backendName][song.songID]) { 255 | // this song is already preparing, so don't yet prepare next song 256 | asyncCallback(true); 257 | } else { 258 | // song is not prepared and not currently preparing: let backend prepare it 259 | this.logger.debug('DEBUG: prepareSong() ' + song.songID); 260 | this.songsPreparing[song.backendName][song.songID] = song; 261 | 262 | song.cancelPrepare = this.backends[song.backendName].prepareSong( 263 | song, 264 | _.partial(this.prepareProgCallback, _, _, _, asyncCallback), 265 | _.partial(this.prepareErrCallback, _, _, asyncCallback) 266 | ); 267 | 268 | this.setPrepareTimeout(song); 269 | } 270 | }; 271 | 272 | // prepare now playing and queued songs for playback 273 | Player.prototype.prepareSongs = function() { 274 | async.series([ 275 | _.bind(function(callback) { 276 | // prepare now-playing song if it exists and if not prepared 277 | if (this.queue[0]) { 278 | this.prepareSong(this.queue[0], callback); 279 | } else { 280 | callback(true); 281 | } 282 | }, this), 283 | _.bind(function(callback) { 284 | // prepare next song in queue if it exists and if not prepared 285 | if (this.queue[1]) { 286 | this.prepareSong(this.queue[1], callback); 287 | } else { 288 | callback(true); 289 | } 290 | }, this) 291 | ]); 292 | }; 293 | 294 | // to be called whenever the queue has been modified 295 | // this function will: 296 | // - play back the first song in the queue if no song is playing 297 | // - call prepareSongs() 298 | Player.prototype.onQueueModify = function() { 299 | this.callHooks('preQueueModify', [this.queue]); 300 | 301 | // set next song as now playing 302 | if (!this.queue[0]) { 303 | this.queue.shift(); 304 | } 305 | 306 | if (!this.queue.length) { 307 | // if the queue is now empty, do nothing 308 | this.callHooks('onEndOfQueue'); 309 | this.logger.info('end of queue, waiting for more songs'); 310 | } else { 311 | // else prepare songs 312 | this.prepareSongs(); 313 | } 314 | this.callHooks('postQueueModify', [this.queue]); 315 | }; 316 | 317 | // find song from queue 318 | Player.prototype.searchQueue = function(backendName, songID) { 319 | for (var i = 0; i < this.queue.length; i++) { 320 | if (this.queue[i].songID === songID && 321 | this.queue[i].backendName === backendName) { 322 | return this.queue[i]; 323 | } 324 | } 325 | 326 | return null; 327 | }; 328 | 329 | // make a search query to backends 330 | Player.prototype.searchBackends = function(query, callback) { 331 | var resultCnt = 0; 332 | var allResults = {}; 333 | 334 | _.each(this.backends, function(backend) { 335 | backend.search(query, _.bind(function(results) { 336 | resultCnt++; 337 | 338 | // make a temporary copy of songlist, clear songlist, check 339 | // each song and add them again if they are ok 340 | var tempSongs = _.clone(results.songs); 341 | allResults[backend.name] = results; 342 | allResults[backend.name].songs = {}; 343 | 344 | _.each(tempSongs, function(song) { 345 | var err = this.callHooks('preAddSearchResult', [song]); 346 | if (!err) { 347 | allResults[backend.name].songs[song.songID] = song; 348 | } else { 349 | this.logger.error('preAddSearchResult hook error: ' + err); 350 | } 351 | }, this); 352 | 353 | // got results from all services? 354 | if (resultCnt >= Object.keys(this.backends).length) { 355 | callback(allResults); 356 | } 357 | }, this), _.bind(function(err) { 358 | resultCnt++; 359 | this.logger.error('error while searching ' + backend.name + ': ' + err); 360 | 361 | // got results from all services? 362 | if (resultCnt >= Object.keys(this.backends).length) { 363 | callback(allResults); 364 | } 365 | }, this)); 366 | }, this); 367 | }; 368 | 369 | // get rid of song in queue 370 | // cnt can be left out for deleting only one song 371 | Player.prototype.removeFromQueue = function(pos, cnt, onlyRemove) { 372 | var retval = []; 373 | if (!cnt) { 374 | cnt = 1; 375 | } 376 | pos = Math.max(0, parseInt(pos)); 377 | 378 | if (!onlyRemove) { 379 | this.callHooks('preSongsRemoved', [pos, cnt]); 380 | } 381 | 382 | // remove songs from queue 383 | if (pos + cnt > 0) { 384 | if (this.queue.length) { 385 | // stop preparing songs we are about to remove 386 | // we want to limit this to this.queue.length if cnt is very large 387 | for (var i = 0; i < Math.min(this.queue.length, pos + cnt); i++) { 388 | var song = this.queue[i]; 389 | 390 | // signal prepareError function not to run removeFromQueue again 391 | // TODO: try getting rid of this ugly hack (beingDeleted)... 392 | // TODO: more non enumerable properties, especially plugins? 393 | Object.defineProperty(song, 'beingDeleted', { 394 | enumerable: false, 395 | writable: true 396 | }); 397 | 398 | song.beingDeleted = true; 399 | if (song.cancelPrepare) { 400 | song.cancelPrepare('song deleted'); 401 | delete(song.cancelPrepare); 402 | } 403 | } 404 | 405 | retval = this.queue.splice(pos, cnt); 406 | 407 | if (pos === 0) { 408 | // now playing was deleted 409 | this.playbackPosition = null; 410 | this.playbackStart = null; 411 | clearTimeout(this.songEndTimeout); 412 | this.songEndTimeout = null; 413 | } 414 | } 415 | } 416 | 417 | if (!onlyRemove) { 418 | this.onQueueModify(); 419 | this.callHooks('postSongsRemoved', [pos, cnt]); 420 | } 421 | 422 | return retval; 423 | }; 424 | 425 | Player.prototype.moveInQueue = function(from, to, cnt) { 426 | if (!cnt || cnt < 1) { 427 | cnt = 1; 428 | } 429 | if (from < 0 || from + cnt > this.queue.length || to + cnt > this.queue.length) { 430 | return null; 431 | } 432 | 433 | this.callHooks('preSongsMoved', [from, to, cnt]); 434 | 435 | var songs = this.removeFromQueue(from, cnt, true); 436 | Array.prototype.splice.apply(this.queue, [to, 0].concat(songs)); 437 | 438 | this.callHooks('sortQueue'); 439 | this.onQueueModify(); 440 | this.callHooks('postSongsMoved', [songs, from, to, cnt]); 441 | 442 | return songs; 443 | }; 444 | 445 | // add songs to the queue, at optional position 446 | Player.prototype.addToQueue = function(songs, pos) { 447 | if (!pos) { 448 | pos = this.queue.length; 449 | } 450 | if (pos < 0) { 451 | pos = 1; 452 | } 453 | pos = Math.min(pos, this.queue.length); 454 | 455 | this.callHooks('preSongsQueued', [songs, pos]); 456 | _.each(songs, function(song) { 457 | // check that required fields are provided 458 | if (!song.title || !song.songID || !song.backendName || !song.duration) { 459 | this.logger.info('required song fields not provided: ' + song.songID); 460 | return; 461 | //return 'required song fields not provided'; // TODO: this ain't gonna work 462 | } 463 | 464 | var err = this.callHooks('preSongQueued', [song]); 465 | if (err) { 466 | this.logger.error('not adding song to queue: ' + err); 467 | } else { 468 | song.timeAdded = new Date().getTime(); 469 | 470 | this.queue.splice(pos++, 0, song); 471 | this.logger.info('added song to queue: ' + song.songID); 472 | this.callHooks('postSongQueued', [song]); 473 | } 474 | }, this); 475 | 476 | this.callHooks('sortQueue'); 477 | this.onQueueModify(); 478 | this.callHooks('postSongsQueued', [songs, pos]); 479 | }; 480 | 481 | Player.prototype.shuffleQueue = function() { 482 | // don't change now playing 483 | var temp = this.queue.shift(); 484 | this.queue = _.shuffle(this.queue); 485 | this.queue.unshift(temp); 486 | 487 | this.callHooks('onQueueShuffled', [this.queue]); 488 | this.onQueueModify(); 489 | }; 490 | 491 | // cnt can be negative to go back or zero to restart current song 492 | Player.prototype.skipSongs = function(cnt) { 493 | this.npIsPlaying = false; 494 | 495 | // TODO: this could be replaced with a splice? 496 | for (var i = 0; i < Math.abs(cnt); i++) { 497 | if (cnt > 0) { 498 | if (this.queue[0]) { 499 | this.playedQueue.push(this.queue[0]); 500 | } 501 | 502 | this.queue.shift(); 503 | } else if (cnt < 0) { 504 | if (this.playedQueue.length) { 505 | this.queue.unshift(this.playedQueue.pop()); 506 | } 507 | } 508 | 509 | // ran out of songs while skipping, stop 510 | if (!this.queue[0]) { 511 | break; 512 | } 513 | } 514 | 515 | this.playedQueue = _.last(this.playedQueue, this.config.playedQueueSize); 516 | 517 | this.playbackPosition = null; 518 | this.playbackStart = null; 519 | clearTimeout(this.songEndTimeout); 520 | this.songEndTimeout = null; 521 | this.onQueueModify(); 522 | }; 523 | 524 | // TODO: userID does not belong into core...? 525 | Player.prototype.setVolume = function(newVol, userID) { 526 | newVol = Math.min(1, Math.max(0, newVol)); 527 | this.volume = newVol; 528 | this.callHooks('onVolumeChange', [newVol, userID]); 529 | }; 530 | 531 | module.exports = Player; 532 | -------------------------------------------------------------------------------- /media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FruitieX/nodeplayer/3e1e609467fabbc35ad4848d8f5f64ae06baa202/media/logo.png -------------------------------------------------------------------------------- /media/logo_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FruitieX/nodeplayer/3e1e609467fabbc35ad4848d8f5f64ae06baa202/media/logo_text.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodeplayer", 3 | "version": "0.2.0", 4 | "description": "simple, modular music player written in node.js", 5 | "main": "index.js", 6 | "preferGlobal": true, 7 | "scripts": { 8 | "start": "./bin/nodeplayer", 9 | "test": "NODE_ENV=test ./node_modules/mocha/bin/mocha", 10 | "coverage": "NODE_ENV=test istanbul cover _mocha -- -R spec" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/FruitieX/nodeplayer" 15 | }, 16 | "author": "FruitieX", 17 | "bin": { 18 | "nodeplayer": "./bin/nodeplayer" 19 | }, 20 | "license": "MIT", 21 | "dependencies": { 22 | "async": "^0.9.0", 23 | "mkdirp": "^0.5.0", 24 | "npm": "^2.7.1", 25 | "underscore": "^1.7.0", 26 | "winston": "^0.9.0", 27 | "yargs": "^3.6.0" 28 | }, 29 | "devDependencies": { 30 | "chai": "*", 31 | "istanbul": "*", 32 | "jscs": "^1.11.3", 33 | "mocha": "*", 34 | "mocha-jscs": "^1.0.2", 35 | "mocha-jshint": "^1.0.0", 36 | "nodeplayer-backend-dummy": "^0.1.9" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/exampleQueue.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "album": "dummyAlbum1", 4 | "albumArt": null, 5 | "artist": "dummyArtist1", 6 | "backendName": "dummyBackend", 7 | "duration": "317000", 8 | "format": "opus", 9 | "hmac": "dummyHmac1", 10 | "prepared": true, 11 | "score": 137.55496215820312, 12 | "songID": "dummySong1", 13 | "timeAdded": 1426544145629, 14 | "title": "dummyTitle1", 15 | "userID": "dummyClient" 16 | }, 17 | { 18 | "album": "dummyAlbum2", 19 | "albumArt": null, 20 | "artist": "dummyArtist2", 21 | "backendName": "dummyBackend", 22 | "downVotes": {}, 23 | "duration": "302000", 24 | "format": "opus", 25 | "hmac": "dummyHmac2", 26 | "oldness": 5, 27 | "score": 177.30935668945312, 28 | "songID": "dummySong2", 29 | "timeAdded": 1426544145629, 30 | "title": "dummyTitle2", 31 | "upVotes": { 32 | "dummyClient": true 33 | }, 34 | "userID": "dummyClient" 35 | }, 36 | { 37 | "album": "dummyAlbum3", 38 | "albumArt": null, 39 | "artist": "dummyArtist3", 40 | "backendName": "dummyBackend", 41 | "duration": "191000", 42 | "format": "opus", 43 | "hmac": "dummyHmac3", 44 | "score": 343.02227783203125, 45 | "songID": "dummySong3", 46 | "timeAdded": 1426544145630, 47 | "title": "dummyTitle3", 48 | "userID": "dummyClient" 49 | }, 50 | { 51 | "album": "dummyAlbum4", 52 | "albumArt": null, 53 | "artist": "dummyArtist4", 54 | "backendName": "dummyBackend", 55 | "duration": "454000", 56 | "format": "opus", 57 | "hmac": "dummyHmac4", 58 | "score": 55.62825393676758, 59 | "songID": "dummySong4", 60 | "timeAdded": 1426544145630, 61 | "title": "dummyTitle4", 62 | "userID": "dummyClient" 63 | }, 64 | { 65 | "album": "dummyAlbum5", 66 | "albumArt": null, 67 | "artist": "dummyArtist5", 68 | "backendName": "dummyBackend", 69 | "duration": "285000", 70 | "format": "opus", 71 | "hmac": "dummyHmac5", 72 | "score": 53.24725341796875, 73 | "songID": "dummySong5", 74 | "timeAdded": 1426544145631, 75 | "title": "dummyTitle5", 76 | "userID": "dummyClient" 77 | }, 78 | { 79 | "album": "dummyAlbum6", 80 | "albumArt": null, 81 | "artist": "dummyArtist6", 82 | "backendName": "dummyBackend", 83 | "duration": "488000", 84 | "format": "opus", 85 | "hmac": "dummyHmac6", 86 | "score": 175.5480194091797, 87 | "songID": "dummySong6", 88 | "timeAdded": 1426544145631, 89 | "title": "dummyTitle6", 90 | "userID": "dummyClient" 91 | } 92 | ] 93 | -------------------------------------------------------------------------------- /test/jscs.spec.js: -------------------------------------------------------------------------------- 1 | require('mocha-jscs')(); 2 | -------------------------------------------------------------------------------- /test/jshint.spec.js: -------------------------------------------------------------------------------- 1 | require('mocha-jshint')(); 2 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /*jshint expr: true*/ 4 | var should = require('chai').should(); 5 | var _ = require('underscore'); 6 | var Player = require('../lib/player'); 7 | var dummyBackend = require('nodeplayer-backend-dummy'); 8 | var exampleQueue = require('./exampleQueue.json'); 9 | 10 | process.env.NODE_ENV = 'test'; 11 | 12 | var dummyClone = function(obj) { 13 | return JSON.parse(JSON.stringify(obj)); 14 | }; 15 | 16 | var dummyLogger = { 17 | silly: _.noop, 18 | debug: _.noop, 19 | verbose: _.noop, 20 | info: _.noop, 21 | warn: _.noop, 22 | error: _.noop, 23 | }; 24 | 25 | describe('exampleQueue', function() { 26 | it('should contain at least 5 items', function() { 27 | exampleQueue.length.should.be.above(5); 28 | }); 29 | }); 30 | 31 | // TODO: test error cases also 32 | describe('Player', function() { 33 | describe('#setVolume()', function() { 34 | var player; 35 | 36 | beforeEach(function() { 37 | player = new Player({logger: dummyLogger}); 38 | }); 39 | it('should set volume to 1 by default', function() { 40 | player.volume.should.equal(1).and.be.a('number'); 41 | }); 42 | it('should set volume to 0 for negative values', function() { 43 | player.setVolume(-1); 44 | player.volume.should.equal(0).and.be.a('number'); 45 | }); 46 | it('should set volume to 1 for values greater than 1', function() { 47 | player.setVolume(42); 48 | player.volume.should.equal(1).and.be.a('number'); 49 | }); 50 | it('should set volume to 0.5', function() { 51 | player.setVolume(0.5); 52 | player.volume.should.equal(0.5).and.be.a('number'); 53 | }); 54 | }); 55 | 56 | describe('#skipSongs()', function() { 57 | var player; 58 | var playedQueueSize = 3; // TODO: better handling of config variables here 59 | 60 | beforeEach(function() { 61 | player = new Player({logger: dummyLogger}); 62 | player.queue = dummyClone(exampleQueue); 63 | player.config.playedQueueSize = playedQueueSize; 64 | player.prepareSongs = _.noop; 65 | }); 66 | it('should put a skipped song into playedQueue', function() { 67 | player.skipSongs(1); 68 | _.last(player.playedQueue).should.deep.equal(_.first(exampleQueue)); 69 | }); 70 | it('should put multiple skipped songs into playedQueue', function() { 71 | player.skipSongs(2); 72 | _.last(player.playedQueue, 2).should.deep.equal(_.first(exampleQueue, 2)); 73 | }); 74 | it('should put up to playedQueueSize songs into playedQueue ' + 75 | 'if skipping by a large amount', function() { 76 | player.skipSongs(exampleQueue.length + 100); 77 | player.playedQueue.should.deep.equal(_.last(exampleQueue, playedQueueSize)); 78 | }); 79 | it('should put last song from playedQueue into queue ' + 80 | 'when skipping to prev song', function() { 81 | player.skipSongs(exampleQueue.length + 100); 82 | player.skipSongs(-1); 83 | _.first(player.queue).should.deep.equal(_.last(exampleQueue)); 84 | }); 85 | it('should put up to playedQueueSize songs from playedQueue into queue ' + 86 | 'when skipping to prev songs', function() { 87 | player.skipSongs(exampleQueue.length + 100); 88 | player.skipSongs((playedQueueSize + 100) * -1); 89 | player.queue.should.deep.equal(_.last(exampleQueue, playedQueueSize)); 90 | }); 91 | }); 92 | 93 | describe('#shuffleQueue()', function() { 94 | var player; 95 | 96 | beforeEach(function() { 97 | player = new Player({logger: dummyLogger}); 98 | player.queue = dummyClone(exampleQueue); 99 | player.prepareSongs = _.noop; 100 | }); 101 | it('should not change the now playing song', function() { 102 | for (var i = 0; i < 10; i++) { 103 | // no matter how many times we shuffle :-) 104 | player.shuffleQueue(); 105 | _.first(player.queue).should.deep.equal(_.first(exampleQueue)); 106 | } 107 | }); 108 | }); 109 | describe('#addToQueue()', function() { 110 | var player; 111 | 112 | beforeEach(function() { 113 | player = new Player({logger: dummyLogger}); 114 | player.prepareSongs = _.noop; 115 | }); 116 | it('should not add song if required fields are not provided', function() { 117 | player.addToQueue([{title: 'foo', songID: 'bar', backendName: 'baz'}]); 118 | player.addToQueue([{title: 'foo', songID: 'bar', duration: 42}]); 119 | player.addToQueue([{title: 'foo', backendName: 'bar', duration: 42}]); 120 | player.addToQueue([{songID: 'foo', backendName: 'bar', duration: 42}]); 121 | player.queue.length.should.equal(0); 122 | }); 123 | it('should add song correctly', function() { 124 | player.addToQueue([_.first(exampleQueue)]); 125 | _.first(player.queue).should.deep.equal(_.first(exampleQueue)); 126 | }); 127 | it('should add multiple songs correctly', function() { 128 | player.addToQueue(_.first(exampleQueue, 3)); 129 | player.queue.should.deep.equal(_.first(exampleQueue, 3)); 130 | }); 131 | it('should add song to provided position', function() { 132 | player.addToQueue(_.first(exampleQueue, 3)); 133 | player.addToQueue([exampleQueue[3]], 1); 134 | player.queue.should.deep.equal([ 135 | exampleQueue[0], 136 | exampleQueue[3], 137 | exampleQueue[1], 138 | exampleQueue[2] 139 | ]); 140 | }); 141 | it('should add multiple songs to provided position', function() { 142 | player.addToQueue(_.first(exampleQueue, 3)); 143 | player.addToQueue(_.last(exampleQueue, 2), 1); 144 | player.queue.should.deep.equal([ 145 | exampleQueue[0], 146 | exampleQueue[exampleQueue.length - 2], 147 | exampleQueue[exampleQueue.length - 1], 148 | exampleQueue[1], 149 | exampleQueue[2] 150 | ]); 151 | }); 152 | it('should add song to end of queue if provided position is huge', function() { 153 | player.addToQueue(_.first(exampleQueue, 3)); 154 | player.addToQueue([_.last(exampleQueue)], 100000); 155 | player.queue.should.deep.equal([ 156 | exampleQueue[0], 157 | exampleQueue[1], 158 | exampleQueue[2], 159 | exampleQueue[exampleQueue.length - 1] 160 | ]); 161 | }); 162 | it('should add song to beginning of queue (not replacing now playing!) ' + 163 | 'if provided position is negative', function() { 164 | player.addToQueue(_.first(exampleQueue, 3)); 165 | player.addToQueue([_.last(exampleQueue)], -100000); 166 | player.queue.should.deep.equal([ 167 | exampleQueue[0], 168 | exampleQueue[exampleQueue.length - 1], 169 | exampleQueue[1], 170 | exampleQueue[2] 171 | ]); 172 | }); 173 | }); 174 | describe('#removeFromQueue()', function() { 175 | var player; 176 | 177 | beforeEach(function() { 178 | player = new Player({logger: dummyLogger}); 179 | player.queue = dummyClone(exampleQueue); 180 | player.prepareSongs = _.noop; 181 | }); 182 | it('should remove song from provided pos', function() { 183 | player.removeFromQueue(1); 184 | player.queue.should.deep.equal(_.without(exampleQueue, exampleQueue[1])); 185 | }); 186 | it('should remove now playing if pos is 0', function() { 187 | player.playbackPosition = true; 188 | player.playbackStart = true; 189 | player.songEndTimeout = true; 190 | player.removeFromQueue(0); 191 | player.queue.should.deep.equal(_.without(exampleQueue, exampleQueue[0])); 192 | should.equal(player.playbackPosition, null); 193 | should.equal(player.playbackStart, null); 194 | should.equal(player.songEndTimeout, null); 195 | }); 196 | it('should remove multiple songs from provided pos', function() { 197 | player.removeFromQueue(1, 2); 198 | player.queue.should.deep.equal(_.without( 199 | exampleQueue, 200 | exampleQueue[1], 201 | exampleQueue[2] 202 | )); 203 | }); 204 | }); 205 | describe('#searchQueue()', function() { 206 | var player; 207 | 208 | beforeEach(function() { 209 | player = new Player({logger: dummyLogger}); 210 | player.queue = dummyClone(exampleQueue); 211 | }); 212 | it('should return correct song from queue', function() { 213 | player.searchQueue(exampleQueue[2].backendName, exampleQueue[2].songID) 214 | .should.deep.equal(exampleQueue[2]); 215 | }); 216 | it('should return null for bogus terms', function() { 217 | (player.searchQueue('thisBackendShouldNotExist', 'thisSongIdShouldNotExist') === null) 218 | .should.equal(true); 219 | }); 220 | }); 221 | describe('#onQueueModify()', function() { 222 | var player; 223 | 224 | beforeEach(function() { 225 | player = new Player({logger: dummyLogger}); 226 | player.queue = dummyClone(exampleQueue); 227 | player.prepareSongs = _.noop; 228 | }); 229 | it('should move next song to now playing if there is no now playing song', function() { 230 | player.queue[0] = null; 231 | player.onQueueModify(); 232 | _.first(player.queue).should.deep.equal(exampleQueue[1]); 233 | }); 234 | }); 235 | describe('#searchBackends()', function() { 236 | var player; 237 | var dummyResults; 238 | 239 | beforeEach(function(done) { 240 | player = new Player({logger: dummyLogger}); 241 | dummyBackend.init(player, dummyLogger, _.noop); 242 | player.backends.dummyBackend = dummyBackend; 243 | player.songsPreparing.dummyBackend = {}; 244 | 245 | dummyBackend.search({terms: 'dummySearch'}, function(results) { 246 | dummyResults = {dummy: results}; 247 | done(); 248 | }); 249 | }); 250 | it('should return same results as dummy backend', function(done) { 251 | player.searchBackends({terms: 'dummySearch'}, function(results) { 252 | results.should.deep.equal(dummyResults); 253 | done(); 254 | }); 255 | }); 256 | it('should return empty object if backend errors', function(done) { 257 | player.searchBackends({terms: 'shouldCauseError'}, function(results) { 258 | results.should.deep.equal({}); 259 | done(); 260 | }); 261 | }); 262 | }); 263 | describe('#prepareSong()', function() { 264 | var player; 265 | 266 | beforeEach(function() { 267 | player = new Player({logger: dummyLogger}); 268 | dummyBackend.init(player, dummyLogger, _.noop); 269 | player.backends.dummyBackend = dummyBackend; 270 | player.songsPreparing.dummyBackend = {}; 271 | 272 | player.startPlayback = _.noop; 273 | player.setPrepareTimeout = _.noop; 274 | }); 275 | it('should return truthy value (error) if called without song', function(done) { 276 | player.prepareSong(undefined, function(err) { 277 | err.should.be.ok; 278 | done(); 279 | }); 280 | }); 281 | it('should call startPlayback and return falsy value ' + 282 | 'on first queue item if prepared', function(done) { 283 | player.queue = dummyClone(exampleQueue); 284 | player.queue[0].songID = 'shouldBePrepared'; 285 | 286 | var startPlaybackWasCalled = false; 287 | player.startPlayback = function() { 288 | startPlaybackWasCalled = true; 289 | }; 290 | 291 | player.prepareSong(player.queue[0], function(err) { 292 | startPlaybackWasCalled.should.equal(true); 293 | (!err).should.be.ok; 294 | done(); 295 | }); 296 | }); 297 | it('should return truthy value if song already preparing', function(done) { 298 | player.queue = dummyClone(exampleQueue); 299 | 300 | player.queue[0].songID = 'shouldPrepareForever'; 301 | 302 | player.prepareSong(player.queue[0], _.noop); 303 | player.prepareSong(player.queue[0], function(err) { 304 | err.should.be.ok; 305 | done(); 306 | }); 307 | }); 308 | }); 309 | describe('#endOfSong()', function() { 310 | var player; 311 | 312 | beforeEach(function() { 313 | player = new Player({logger: dummyLogger}); 314 | player.queue = dummyClone(exampleQueue); 315 | 316 | player.onQueueModify = _.noop; 317 | }); 318 | it('should push now playing song onto playedQueue', function() { 319 | player.endOfSong(); 320 | _.last(player.playedQueue).should.deep.equal(_.first(exampleQueue)); 321 | }); 322 | it('should clear playback state of now playing song', function() { 323 | player.endOfSong(); 324 | (player.playbackPosition === null).should.be.ok; 325 | (player.playbackStart === null).should.be.ok; 326 | (player.queue[0] === null).should.be.ok; 327 | (player.songEndTimeout === null).should.be.ok; 328 | }); 329 | }); 330 | describe('#startPlayback()', function() { 331 | var player; 332 | 333 | beforeEach(function() { 334 | player = new Player({logger: dummyLogger}); 335 | player.queue = dummyClone(exampleQueue); 336 | 337 | player.onQueueModify = _.noop; 338 | }); 339 | afterEach(function() { 340 | if (player.songEndTimeout) { 341 | clearTimeout(player.songEndTimeout); 342 | } 343 | }); 344 | it('should do nothing if the queue is empty', function() { 345 | player.queue = []; 346 | player.startPlayback(); 347 | 348 | // something startPlayback() would do after the queue check 349 | (player.playbackStart === undefined).should.be.ok; 350 | }); 351 | it('should start playback from the start when first called', function() { 352 | player.startPlayback(); 353 | 354 | player.playbackPosition.should.equal(0); 355 | }); 356 | it('should start playback from given pos', function() { 357 | player.startPlayback(42); 358 | 359 | player.playbackPosition.should.equal(42); 360 | }); 361 | it('should set a song end timeout', function() { 362 | player.startPlayback(0); 363 | 364 | player.songEndTimeout.should.be.ok; 365 | }); 366 | it('should resume playback if no pos given', function() { 367 | player.playbackStart = 42; 368 | player.playbackPosition = 42; 369 | player.startPlayback(undefined); 370 | player.startPlayback(null); 371 | 372 | player.playbackPosition.should.equal(42); 373 | }); 374 | it('should restart playback if pos is 0', function() { 375 | player.playbackStart = 42; 376 | player.playbackPosition = 42; 377 | player.startPlayback(0); 378 | 379 | player.playbackPosition.should.equal(0); 380 | }); 381 | it('should call song end timeout immediately for insane start pos', function(done) { 382 | player.endOfSong = function() { 383 | done(); 384 | }; 385 | player.startPlayback(100000000000); 386 | }); 387 | it('should clear old song timeout', function(done) { 388 | player.songEndTimeout = setTimeout(function() { 389 | throw new Error('this should never be executed'); 390 | }, 0); 391 | 392 | player.endOfSong = function() { 393 | done(); 394 | }; 395 | 396 | // call endOfSong immediately 397 | player.config.songDelayMs = 0; 398 | player.queue[0].duration = 0; 399 | 400 | player.startPlayback(); 401 | }); 402 | }); 403 | describe('#pausePlayback()', function() { 404 | var player; 405 | 406 | beforeEach(function() { 407 | player = new Player({logger: dummyLogger}); 408 | }); 409 | it('should clear songEndTimeout', function(done) { 410 | player.songEndTimeout = setTimeout(function() { 411 | throw new Error('this should never be executed'); 412 | }, 0); 413 | 414 | player.pausePlayback(); 415 | 416 | setTimeout(function() { 417 | done(); 418 | }, 0); 419 | }); 420 | it('should move playbackPosition forward', function() { 421 | player.playbackPosition = 42; 422 | player.playbackStart = 42; 423 | player.pausePlayback(); 424 | player.playbackPosition.should.be.greaterThan(42); 425 | }); 426 | it('should clear playbackStart', function() { 427 | player.playbackStart = 42; 428 | player.pausePlayback(); 429 | (player.playbackStart === null).should.be.ok; 430 | }); 431 | }); 432 | describe('#prepareError()', function() { 433 | var player; 434 | 435 | beforeEach(function() { 436 | player = new Player({logger: dummyLogger}); 437 | player.queue = dummyClone(exampleQueue); 438 | }); 439 | it('should call removeFromQueue on song', function(done) { 440 | player.removeFromQueue = function(i) { 441 | i.should.equal(2); 442 | done(); 443 | }; 444 | player.prepareError(exampleQueue[2], 'dummyError'); 445 | }); 446 | it('should call removeFromQueue on all instances song', function(done) { 447 | var numCalled = 0; 448 | player.removeFromQueue = function(i) { 449 | if (player.queue[i].songID === exampleQueue[2].songID) { 450 | numCalled++; 451 | } 452 | 453 | // song exists 4 times in queue 454 | if (numCalled === 4) { 455 | done(); 456 | } 457 | }; 458 | 459 | player.queue.push(_.clone(exampleQueue[2])); 460 | player.queue.push(_.clone(exampleQueue[2])); 461 | player.queue.push(_.clone(exampleQueue[2])); 462 | 463 | player.prepareError(exampleQueue[2], 'dummyError'); 464 | }); 465 | }); 466 | describe('#setPrepareTimeout()', function() { 467 | var player; 468 | var song; 469 | 470 | beforeEach(function() { 471 | player = new Player({logger: dummyLogger}); 472 | player.config.songPrepareTimeout = 0; 473 | song = _.clone(exampleQueue[0]); 474 | song.cancelPrepare = _.noop; 475 | }); 476 | afterEach(function() { 477 | if (song.prepareTimeout) { 478 | clearTimeout(song.prepareTimeout); 479 | } 480 | }); 481 | it('should call cancelPrepare', function(done) { 482 | song.cancelPrepare = function() { 483 | done(); 484 | }; 485 | player.setPrepareTimeout(song); 486 | }); 487 | it('should clear old song timeout', function(done) { 488 | song.prepareTimeout = setTimeout(function() { 489 | throw new Error('this should never be executed'); 490 | }, 0); 491 | 492 | song.cancelPrepare = function() { 493 | done(); 494 | }; 495 | player.setPrepareTimeout(song); 496 | }); 497 | }); 498 | describe('#moveInQueue()', function() { 499 | var player; 500 | 501 | beforeEach(function() { 502 | player = new Player({logger: dummyLogger}); 503 | player.queue = dummyClone(exampleQueue); 504 | player.prepareSongs = _.noop; 505 | }); 506 | it('should correctly move a single song backward', function() { 507 | player.moveInQueue(2, 1); 508 | player.queue[0].should.deep.equal(exampleQueue[0]); 509 | player.queue[1].should.deep.equal(exampleQueue[2]); 510 | player.queue[2].should.deep.equal(exampleQueue[1]); 511 | player.queue[3].should.deep.equal(exampleQueue[3]); 512 | player.queue[4].should.deep.equal(exampleQueue[4]); 513 | }); 514 | it('should correctly move several songs backward', function() { 515 | player.moveInQueue(2, 0, 2); 516 | player.queue[0].should.deep.equal(exampleQueue[2]); 517 | player.queue[1].should.deep.equal(exampleQueue[3]); 518 | player.queue[2].should.deep.equal(exampleQueue[0]); 519 | player.queue[3].should.deep.equal(exampleQueue[1]); 520 | player.queue[4].should.deep.equal(exampleQueue[4]); 521 | }); 522 | it('should correctly move a single song forward', function() { 523 | player.moveInQueue(1, 2); 524 | player.queue[0].should.deep.equal(exampleQueue[0]); 525 | player.queue[1].should.deep.equal(exampleQueue[2]); 526 | player.queue[2].should.deep.equal(exampleQueue[1]); 527 | player.queue[3].should.deep.equal(exampleQueue[3]); 528 | player.queue[4].should.deep.equal(exampleQueue[4]); 529 | }); 530 | it('should correctly move several songs forward', function() { 531 | player.moveInQueue(1, 2, 2); 532 | player.queue[0].should.deep.equal(exampleQueue[0]); 533 | player.queue[1].should.deep.equal(exampleQueue[3]); 534 | player.queue[2].should.deep.equal(exampleQueue[1]); 535 | player.queue[3].should.deep.equal(exampleQueue[2]); 536 | player.queue[4].should.deep.equal(exampleQueue[4]); 537 | }); 538 | it('should correctly move from beginning of queue', function() { 539 | player.moveInQueue(0, 1); 540 | player.queue[0].should.deep.equal(exampleQueue[1]); 541 | player.queue[1].should.deep.equal(exampleQueue[0]); 542 | player.queue[2].should.deep.equal(exampleQueue[2]); 543 | }); 544 | it('should correctly move to end of queue', function() { 545 | var l = exampleQueue.length; 546 | player.moveInQueue(l - 2, l - 1); 547 | player.queue[l - 3].should.deep.equal(exampleQueue[l - 3]); 548 | player.queue[l - 2].should.deep.equal(exampleQueue[l - 1]); 549 | player.queue[l - 1].should.deep.equal(exampleQueue[l - 2]); 550 | }); 551 | it('should return error and not do anything for invalid ranges', function() { 552 | var l = exampleQueue.length; 553 | should.equal(null, player.moveInQueue(-1, 2, 2)); 554 | player.queue.should.deep.equal(exampleQueue); 555 | should.equal(null, player.moveInQueue(1, 2, 2 + l)); 556 | player.queue.should.deep.equal(exampleQueue); 557 | should.equal(null, player.moveInQueue(l - 1, l)); 558 | player.queue.should.deep.equal(exampleQueue); 559 | }); 560 | }); 561 | }); 562 | --------------------------------------------------------------------------------