[SCM] groovebasin/master: Imported Upstream version 1.3.0
andrewrk-guest at users.alioth.debian.org
andrewrk-guest at users.alioth.debian.org
Sat Oct 4 00:38:53 UTC 2014
The following commit has been merged in the master branch:
commit 5a5791b39a7982746db371f33f3bb43d3bebe564
Author: Andrew Kelley <superjoe30 at gmail.com>
Date: Fri Oct 3 20:18:40 2014 +0000
Imported Upstream version 1.3.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9047e0b..e531131 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,21 +1,76 @@
+### Version 1.3.0 (2014-10-03)
+
+ * Andrew Kelley:
+ - if songs have no track numbers then never use album loudness
+ - fix YouTube import
+ - fix streaming not pausing and playing reliably
+ - fix glitch in streaming when resuming after a long pause
+ - add client side volume slider
+ - use SSL by default with a public self signed cert
+ - import URL allows downloading from https with invalid certs
+ - replace uuid dependency with a simpler, faster, and more robust
+ random string
+ - rewrite user login and permissions support. MPD users can log in with
+ (username) + '/' + (password)
+ - user accounts and permissions are managed via the browser interface
+ instead of with the configuration file
+ - add events tab which tells what actions have happened recently, supports
+ chat, and displays which users are streaming
+ - fix permissions checking for downloading anonymous requests
+ - remove 'l' hotkey for library and add 'e' hotkey for settings
+ - rename legacy protocol message names
+ - quieter log by default; ability to run with --verbose
+ - fix bug where all files on play queue would be preloaded; now only the
+ next and previous few files are preloaded
+ - client: shift+delete only attempts to delete tracks when you have the
+ necessary permissions
+ - stream endpoint obeys permission settings
+ - stream count is number of logged in users with an activated stream button
+ plus number of anonymous users connected to the http endpoint
+ - auto-pause is now instant instead of half second timer
+ - client: fix cutting/pasting text filter box behavior
+ - fix crash when removing a nested directory in the music directory
+ - client: fix player preduction when currently playing track is removed
+ - fix handling of slashes when importing from YouTube
+ - client: disable hardware playback toggle button when not admin
+ - cut the client javascript bundle size in half
+ - build: /bin/sh instead of /bin/bash
+
+ * Josh Wolfe:
+ - implement and switch to more robust zip generating module. Fixes unicode
+ file names in zips and enables the download progress bar.
+ - implement and switch to simpler object diffing module. Reduces client-side
+ JavaScript bundle size as well as bandwidth needed to stay connected to
+ Groove Basin when other users are making edits.
+ - Multi-file downloads use a GET request. This lets you copy a download URL
+ which downloads multiple files to the clipboard.
+
+ * David Renshaw:
+ - Fix crash when import URL fails to download.
+
+### Version 1.2.1 (2014-07-04)
+
+ * Andrew Kelley:
+ - fix ytdl-core version locking. Fixes YouTube import.
+
### Version 1.2.0 (2014-07-04)
-* Andrew Kelley:
- - client uses relative stream URL so reverse proxies can work.
- - client uses wss if protocol is https.
- - client UI indicates how many people are streaming
- - automatically pause when last streamer disconnects
- - client: remove dotted outline of links.
- - uploading is permission add, not control
- - rename the Upload tab to the Import tab
- - fix not being able to see client with anonymous read-only permissions set
- - fix library scan errors deleting songs from database.
- - streaming: less chance of glitches
- - streaming: no hiccup sound on skip
+ * Andrew Kelley:
+ - client uses relative stream URL so reverse proxies can work.
+ - client uses wss if protocol is https.
+ - client UI indicates how many people are streaming
+ - automatically pause when last streamer disconnects
+ - client: remove dotted outline of links.
+ - uploading is permission add, not control
+ - rename the Upload tab to the Import tab
+ - fix not being able to see client with anonymous read-only permissions set
+ - fix library scan errors deleting songs from database.
+ - streaming: less chance of glitches
+ - streaming: no hiccup sound on skip
* Josh Wolfe:
- - fix unable to download songs with hashtags in the URL
- (but first, let me take a #selfie)
+ - fix unable to download songs with hashtags in the URL
+ (but first, let me take a #selfie)
### Version 1.1.0 (2014-06-20)
diff --git a/README.md b/README.md
index f6313fb..d96508e 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# 
+# 
Music player server with a web-based user interface inspired by Amarok 1.4.
@@ -43,11 +43,24 @@ Try out the [live demo](http://demo.groovebasin.com/).
## Install
-1. Install [Node.js](http://nodejs.org) v0.10.x. Note that on Debian and
- Ubuntu, you also need the nodejs-dev and nodejs-legacy packages. You may
- also choose to use [Chris Lea's PPA](https://launchpad.net/~chris-lea/+archive/node.js/)
- or compile from source.
+### Pre-Built Packages
+
+#### Ubuntu
+
+```
+sudo apt-add-repository ppa:andrewrk/libgroove
+sudo apt-get update
+sudo apt-get install groovebasin
+groovebasin
+```
+
+### From Source
+
+1. Install [Node.js](http://nodejs.org) v0.10.x. On Debian and
+ Ubuntu, `sudo apt-get install nodejs-dev nodejs-legacy npm`.
2. Install [libgroove](https://github.com/andrewrk/libgroove).
+ libgroove is available in Debian Testing and an Ubuntu PPA. See the
+ libgroove README for more details.
3. Clone the source and cd to it.
4. `npm run build`
5. `npm start`
@@ -57,7 +70,10 @@ Try out the [live demo](http://demo.groovebasin.com/).
When Groove Basin starts it will look for `config.js` in the current directory.
If not found it creates one for you with default values.
-Use this to set your music library location, permissions, and other settings.
+Use this to set your music library location and other settings.
+
+It is recommended that you generate a self-signed certificate and use that
+instead of using the public one bundled with this source code.
## Screenshots
@@ -87,14 +103,7 @@ in #libgroove on Freenode.
### Roadmap
- 1. Tag Editing
- 2. Music library organization
- 3. Accoustid Integration
- 4. Playlists
- 5. User accounts / permissions rehaul
- 6. Event history / chat
- 7. Finalize GrooveBasin protocol spec
-
-## Release Notes
-
-See CHANGELOG.md
+ 0. Music library organization
+ 0. Playlists
+ 0. Accoustid Integration
+ 0. Finalize GrooveBasin protocol spec
diff --git a/build b/build
index fd508d0..a420605 100755
--- a/build
+++ b/build
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/bin/sh
mkdir -p public
./node_modules/.bin/stylus -o public/ -c --include-css src/client/styles
-./node_modules/.bin/browserify src/client/app.js --outfile public/app.js
+./node_modules/.bin/browserify-lite ./src/client/app.js --outfile public/app.js
diff --git a/certs/self-signed-cert.pem b/certs/self-signed-cert.pem
new file mode 100644
index 0000000..665f30b
--- /dev/null
+++ b/certs/self-signed-cert.pem
@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIICBzCCAXACCQDVkWABMBBTKDANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJV
+UzEQMA4GA1UECAwHQXJpem9uYTEQMA4GA1UEBwwHUGhvZW5peDEVMBMGA1UECgwM
+R3Jvb3ZlIEJhc2luMB4XDTE0MDgxODAxMDczM1oXDTM0MDgxMzAxMDczM1owSDEL
+MAkGA1UEBhMCVVMxEDAOBgNVBAgMB0FyaXpvbmExEDAOBgNVBAcMB1Bob2VuaXgx
+FTATBgNVBAoMDEdyb292ZSBCYXNpbjCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkC
+gYEA3T0DvkbQrSJaNVXLmZSxVzVZBrPs/1Ht4ZWPcSt2aJifZKBYytrHUkhqpN0r
+K6xH9QCGYPAp4MDO9BlNp8s4KqAhuc4cUyc/fcfWOrBIb3/nrBtZKDlt2jkV1JUo
+dD0mjFqdjU/mVkZQSbMRMCzV6hQXul7kdcVnUxqtlh86FBUCAwEAATANBgkqhkiG
+9w0BAQsFAAOBgQCBHR3zJtB0N693WRgX8YmlGIisZHzXjtWDhk8bJiPHxWV5nx1u
+UDOdwA5Vqv2gRp4BvS2+o2fSB9m3Gi80A9+jxrtIYDXBNvbFSH/LTjxTD372pPIf
+ZjGgWuwUJUVdeh3GLFhnzdGE/b4PoqyhaT3yVUAc0ap0XgHTrzwz/vm5yw==
+-----END CERTIFICATE-----
diff --git a/certs/self-signed-key.pem b/certs/self-signed-key.pem
new file mode 100644
index 0000000..2fbfd54
--- /dev/null
+++ b/certs/self-signed-key.pem
@@ -0,0 +1,15 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIICXgIBAAKBgQDdPQO+RtCtIlo1VcuZlLFXNVkGs+z/Ue3hlY9xK3ZomJ9koFjK
+2sdSSGqk3SsrrEf1AIZg8CngwM70GU2nyzgqoCG5zhxTJz99x9Y6sEhvf+esG1ko
+OW3aORXUlSh0PSaMWp2NT+ZWRlBJsxEwLNXqFBe6XuR1xWdTGq2WHzoUFQIDAQAB
+AoGATfhn7lJUzv/RXQSsqabOzVZe1s7okp8UQDGOiSrxIzHO0w7z3CI4pxYgh5Pu
+2Ahyn7UcpuNdTvmEtmCIjr8/PpZs1I1YaacHDfmbxOW8qnqh5sIj0GfAZB/dSdXZ
+1e4UvMHUrj07qY6/vMrmirwpZyrO9DEezrOf6sUoX7KuwO0CQQD+VC8BjzjmezYC
+9qC0+kRKJElqP/zOhcjlA64dwm5YnVRCiXN/bNcwGAc0qRMzrU3Lysh37iWNtK8W
+l2pdMwBXAkEA3rErDmCrBfW8Wdz9wF37mi1f4EZ2bP4UzkwZYAEnHIjVPk/5Ymol
+2PIjaHUQHpYzziKBZuPfP3A+uKY/2h5bcwJBAK0rBsKKEVUliZYk9TGkwgC1imNU
+5D5+a1Y71j8fFuExZqDTVBfsNOzjP2zEvnVOSA09qpe2SE2fPCQmvt5sjosCQQCd
+HWaBSFahZ9Sxmic1t5kyF91TAKPBFipbunkUsPuFOE0rH4WVl8qIG547rovm6JY4
+U0P08cSqn2jBIhpeq5hdAkEA6UD86Rd7Yq1YiUUrZ/E8ABeZ7SYTeDerB44oRFwV
+vY59FCnq1+YOy+QL2oh0bfgvQ06nPCZbA3bCRblI0bdvqg==
+-----END RSA PRIVATE KEY-----
diff --git a/lib/db_iterate.js b/lib/db_iterate.js
new file mode 100644
index 0000000..2a56d9b
--- /dev/null
+++ b/lib/db_iterate.js
@@ -0,0 +1,34 @@
+var log = require('./log');
+
+module.exports = dbIterate;
+
+function dbIterate(db, keyPrefix, processOne, cb) {
+ var it = db.iterator({
+ gte: keyPrefix,
+ keyAsBuffer: false,
+ valueAsBuffer: false,
+ });
+ itOne();
+ function itOne() {
+ it.next(function(err, key, value) {
+ if (err) {
+ it.end(onItEndErr);
+ cb(err);
+ return;
+ }
+ if (!key || key.indexOf(keyPrefix) !== 0) {
+ it.end(cb);
+ } else {
+ processOne(key, value);
+ itOne();
+ }
+ });
+ }
+}
+
+function onItEndErr(err) {
+ if (err) {
+ log.error("Unable to close iterator:", err.stack);
+ }
+}
+
diff --git a/lib/download.js b/lib/download.js
new file mode 100644
index 0000000..4bb55e9
--- /dev/null
+++ b/lib/download.js
@@ -0,0 +1,24 @@
+var http = require('http');
+var https = require('https');
+var url = require('url');
+
+exports.download = download;
+
+var whichHttpLib = {
+ 'http:': http,
+ 'https:': https,
+};
+
+function download(urlString, cb) {
+ var parsedUrl = url.parse(urlString);
+ var httpLib = whichHttpLib[parsedUrl.protocol];
+ if (!httpLib) return cb(new Error("Invalid URL"));
+
+ parsedUrl.agent = false;
+ parsedUrl.rejectUnauthorized = false;
+ httpLib.get(parsedUrl, function(res) {
+ cb(null, res);
+ }).on('error', function(err) {
+ cb(err, null);
+ });
+}
diff --git a/lib/groovebasin.js b/lib/groovebasin.js
index 7be4441..574591e 100644
--- a/lib/groovebasin.js
+++ b/lib/groovebasin.js
@@ -1,31 +1,33 @@
var EventEmitter = require('events').EventEmitter;
var http = require('http');
+var https = require('https');
var assert = require('assert');
var WebSocketServer = require('ws').Server;
var fs = require('fs');
-var archiver = require('archiver');
+var yazl = require('yazl');
var util = require('util');
var path = require('path');
var Pend = require('pend');
var express = require('express');
var osenv = require('osenv');
var spawn = require('child_process').spawn;
-var requireIndex = require('requireindex');
-var plugins = requireIndex(path.join(__dirname, 'plugins'));
+var plugins = [
+ require('./plugins/ytdl'),
+ require('./plugins/lastfm'),
+];
var Player = require('./player');
var PlayerServer = require('./player_server');
var MpdProtocol = require('./mpd_protocol');
var MpdApiServer = require('./mpd_api_server');
var WebSocketApiClient = require('./web_socket_api_client');
-var levelup = require('level');
-var crypto = require('crypto');
+var leveldown = require('leveldown');
var net = require('net');
var safePath = require('./safe_path');
var MultipartForm = require('multiparty').Form;
var createGzipStatic = require('connect-static');
var serveStatic = require('serve-static');
-var bodyParser = require('body-parser');
var Cookies = require('cookies');
+var log = require('./log');
module.exports = GrooveBasin;
@@ -34,25 +36,14 @@ var defaultConfig = {
port: 16242,
dbPath: "groovebasin.db",
musicDirectory: path.join(osenv.home(), "music"),
- permissions: {},
- defaultPermissions: {
- read: true,
- add: true,
- control: true,
- },
lastFmApiKey: "bb9b81026cd44fd086fa5533420ac9b4",
lastFmApiSecret: "2309a40ae3e271de966bf320498a8f09",
mpdHost: '0.0.0.0',
mpdPort: 6600,
acoustidAppKey: 'bgFvC4vW',
encodeQueueDuration: 8,
-};
-
-defaultConfig.permissions[genPassword()] = {
- admin: true,
- read: true,
- add: true,
- control: true,
+ sslKey: 'certs/self-signed-key.pem',
+ sslCert: 'certs/self-signed-cert.pem',
};
util.inherits(GrooveBasin, EventEmitter);
@@ -77,7 +68,7 @@ GrooveBasin.prototype.loadConfig = function(cb) {
if (err.code === 'ENOENT') {
anythingAdded = true;
self.config = defaultConfig;
- console.warn("No config.js found; writing default.");
+ log.warn("No config.js found; writing default.");
} else {
return cb(err);
}
@@ -106,6 +97,12 @@ GrooveBasin.prototype.loadConfig = function(cb) {
GrooveBasin.prototype.start = function() {
var self = this;
+ if (process.argv.indexOf('--verbose') > 0) {
+ log.level = log.levels.Debug;
+ } else {
+ log.level = log.levels.Warn;
+ }
+
var pend = new Pend();
pend.go(function(cb) {
self.loadConfig(cb);
@@ -130,39 +127,52 @@ GrooveBasin.prototype.start = function() {
});
pend.wait(function(err) {
if (err) {
- console.error(err.stack);
+ log.error(err.stack);
+ return;
+ }
+ pend.go(function(cb) {
+ self.db = leveldown(self.config.dbPath);
+ self.db.open(cb);
+ });
+ pend.wait(makePlayer);
+ });
+ function makePlayer(err) {
+ if (err) {
+ log.error(err.stack);
return;
}
-
self.httpHost = self.config.host;
self.httpPort = self.config.port;
- self.db = levelup(self.config.dbPath);
+ if (process.argv.indexOf('--delete-all-users') > 0) {
+ // this will call process.exit when done
+ PlayerServer.deleteAllUsers(self.db);
+ return;
+ }
+
self.player = new Player(self.db, self.config.musicDirectory, self.config.encodeQueueDuration);
self.player.initialize(function(err) {
if (err) {
- console.error("unable to initialize player:", err.stack);
+ log.error("unable to initialize player:", err.stack);
return;
}
- console.info("Player initialization complete.");
-
- self.app.use(self.player.streamMiddleware.bind(self.player));
+ log.debug("Player initialization complete.");
var pend = new Pend();
- for (var pluginName in plugins) {
- var PluginClass = plugins[pluginName];
+ plugins.forEach(function(PluginClass) {
var plugin = new PluginClass(self);
if (plugin.initialize) pend.go(plugin.initialize.bind(plugin));
- }
+ });
pend.wait(function(err) {
if (err) {
- console.error("Error initializing plugin:", err.stack);
+ log.error("Error initializing plugin:", err.stack);
return;
}
self.startServer();
});
});
- });
+ }
+
};
GrooveBasin.prototype.initializeDownload = function() {
@@ -178,19 +188,20 @@ GrooveBasin.prototype.initializeDownload = function() {
var zipName = safePath(reqDir.replace(/\//g, " - ")) + ".zip";
downloadPath(reqDir, zipName, req, resp);
});
- var urlencodedMw = bodyParser.urlencoded({extended: false});
- self.app.post('/download/custom', self.hasPermRead, urlencodedMw, function(req, resp) {
- var reqKeys = req.body.key;
- if (!Array.isArray(reqKeys)) {
- reqKeys = [reqKeys];
- }
+ self.app.get('/download/keys', self.hasPermRead, function(req, resp) {
+ var reqKeys = Object.keys(req.query);
var files = [];
- for (var i = 0; i < reqKeys.length; i += 1) {
- var key = reqKeys[i];
+ var commonArtistName, commonAlbumName;
+ reqKeys.forEach(function(key) {
var dbFile = self.player.libraryIndex.trackTable[key];
- if (dbFile) files.push(path.join(musicDir, dbFile.file));
- }
- var reqZipName = (req.body.zipName || "music").toString();
+ if (!dbFile) return;
+ files.push(path.join(musicDir, dbFile.file));
+ if (commonAlbumName === undefined) commonAlbumName = dbFile.albumName || "";
+ else if (commonAlbumName !== dbFile.albumName) commonAlbumName = null;
+ if (commonArtistName === undefined) commonArtistName = dbFile.artistName || "";
+ else if (commonArtistName !== dbFile.artistName) commonArtistName = null;
+ });
+ var reqZipName = commonAlbumName || commonArtistName || "songs";
var zipName = safePath(reqZipName) + ".zip";
sendZipOfFiles(zipName, files, req, resp);
});
@@ -221,49 +232,28 @@ GrooveBasin.prototype.initializeDownload = function() {
}
function sendZipOfFiles(zipName, files, req, resp) {
- var cleanup = [];
req.on('close', cleanupEverything);
resp.setHeader("Content-Type", "application/zip");
resp.setHeader("Content-Disposition", "attachment; filename=" + zipName);
- var archive = archiver('zip');
- archive.on('error', function(err) {
- console.log("Error while sending zip of files:", err.stack);
+ var zipfile = new yazl.ZipFile();
+ zipfile.on('error', function(err) {
+ log.error("Error while sending zip of files:", err.stack);
cleanupEverything();
});
- cleanup.push(function(){
- archive.destroy();
- });
- archive.pipe(resp);
-
files.forEach(function(file) {
- var options = {
- name: path.relative(self.config.musicDirectory, file),
- };
- var readStream = fs.createReadStream(file);
- readStream.on('error', function(err) {
- console.error("zip read stream error:", err.stack);
- });
- cleanup.push(function() {
- readStream.destroy();
- });
- archive.append(readStream, options);
+ zipfile.addFile(file, path.relative(self.config.musicDirectory, file), {compress: false});
});
- archive.finalize(function(err) {
- if (err) {
- console.error("Error finalizing zip:", err.stack);
- cleanupEverything();
- }
+ zipfile.end(function(finalSize) {
+ resp.setHeader("Content-Length", finalSize.toString());
+ zipfile.outputStream.pipe(resp);
});
function cleanupEverything() {
- cleanup.forEach(function(fn) {
- try {
- fn();
- } catch(err) {}
- });
+ // TODO: clean shutdown?
+ // zipfile.destroy();
resp.end();
}
}
@@ -301,13 +291,16 @@ GrooveBasin.prototype.initializeUpload = function() {
function makeImportFn(file) {
return function(cb) {
- self.player.importFile(file.path, file.originalFilename, function(err, dbFile) {
+ var filenameHintWithoutPath = path.basename(file.originalFilename);
+ self.player.importFile(file.path, filenameHintWithoutPath, function(err, dbFile) {
if (err) {
- console.error("Unable to import file:", file.path, "error:", err.stack);
+ log.error("Unable to import file:", file.path, "error:", err.stack);
} else if (!dbFile) {
- console.error("Unable to locate new file due to race condition");
+ log.error("Unable to locate new file due to race condition");
} else {
keys.push(dbFile.key);
+ var user = request.client && request.client.user;
+ self.playerServer.addEvent(user, 'import', null, dbFile.key);
}
cb();
});
@@ -317,16 +310,34 @@ GrooveBasin.prototype.initializeUpload = function() {
});
};
+GrooveBasin.prototype.initializeStream = function() {
+ var self = this;
+ self.app.get('/stream.mp3', self.hasPermRead, function(req, resp, next) {
+ resp.setHeader('Content-Type', 'audio/mpeg');
+ resp.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
+ resp.setHeader('Pragma', 'no-cache');
+ resp.setHeader('Expires', '0');
+ resp.statusCode = 200;
+
+ self.player.startStreaming(resp);
+
+ req.on('close', function() {
+ self.player.stopStreaming(resp);
+ resp.end();
+ });
+ });
+};
+
GrooveBasin.prototype.createHasPermMiddleware = function(permName) {
var self = this;
return function(req, resp, next) {
var cookies = new Cookies(req, resp);
var token = cookies.get('token');
- var client = self.playerServer.clientTokens[token];
- var permissions = client ? client.permissions : self.playerServer.defaultPermissions;
- var hasPermission = permName != null && permissions[permName];
+ var client = self.playerServer.clients[token];
+ var hasPermission = self.playerServer.userHasPerm(client && client.user, permName);
if (hasPermission) {
req.client = client;
+ resp.client = client;
next();
} else {
resp.statusCode = 403;
@@ -342,8 +353,7 @@ GrooveBasin.prototype.startServer = function() {
self.playerServer = new PlayerServer({
player: self.player,
- authenticate: authenticate,
- defaultPermissions: self.config.defaultPermissions,
+ db: self.db,
});
self.hasPermRead = self.createHasPermMiddleware('read');
@@ -352,71 +362,117 @@ GrooveBasin.prototype.startServer = function() {
self.initializeDownload();
self.initializeUpload();
+ self.initializeStream();
- self.httpServer = http.createServer(self.app);
- self.wss = new WebSocketServer({
- server: self.httpServer,
- clientTracking: false,
- });
- self.wss.on('connection', function(ws) {
- self.playerServer.handleNewClient(new WebSocketApiClient(ws));
- });
- self.httpServer.listen(self.httpPort, self.httpHost, function() {
- self.emit('listening');
- console.info("Listening at http://" + self.httpHost + ":" + self.httpPort + "/");
- });
- self.httpServer.on('close', function() {
- console.info("server closed");
+
+ self.playerServer.init(function(err) {
+ if (err) throw err;
+ createHttpServer(attachWebSocketServer);
});
- var mpdPort = self.config.mpdPort;
- var mpdHost = self.config.mpdHost;
- if (mpdPort == null || mpdHost == null) {
- console.info("MPD Protocol disabled");
- } else {
- self.protocolServer = net.createServer(function(socket) {
- socket.setEncoding('utf8');
- var protocol = new MpdProtocol({
- player: self.player,
- playerServer: self.playerServer,
- apiServer: self.mpdApiServer,
- authenticate: authenticate,
- permissions: self.config.defaultPermissions,
- });
- protocol.on('error', handleError);
- socket.on('error', handleError);
- socket.pipe(protocol).pipe(socket);
- socket.on('close', cleanup);
- self.mpdApiServer.handleNewClient(protocol);
-
- function handleError(err) {
- console.error("socket error:", err.stack);
- socket.destroy();
- cleanup();
- }
- function cleanup() {
- self.mpdApiServer.handleClientEnd(protocol);
+
+ function createHttpServer(cb) {
+ if (!self.config.sslKey || !self.config.sslCert) {
+ log.warn("WARNING: SSL disabled, using HTTP.");
+ self.httpServer = http.createServer(self.app);
+ self.httpProtocol = 'http';
+ cb();
+ } else {
+ if (self.config.sslKey === defaultConfig.sslKey ||
+ self.config.sslCert === defaultConfig.sslCert)
+ {
+ log.warn("WARNING: Using public self-signed certificate.\n" +
+ "For better security, provide your own self-signed certificate, \n" +
+ "or better yet, one signed by a certificate authority.\n");
}
+ self.httpProtocol = 'https';
+ readKeyAndCert(cb);
+ }
+ function readKeyAndCert(cb) {
+ var pend = new Pend();
+ var options = {};
+ pend.go(function(cb) {
+ fs.readFile(self.config.sslKey, function(err, data) {
+ if (err) {
+ log.fatal("Unable to read SSL key file: " + err.message);
+ process.exit(1);
+ return;
+ }
+ options.key = data;
+ cb();
+ });
+ });
+ pend.go(function(cb) {
+ fs.readFile(self.config.sslCert, function(err, data) {
+ if (err) {
+ log.fatal("Unable to read SSL cert file: " + err.message);
+ process.exit(1);
+ return;
+ }
+ options.cert = data;
+ cb();
+ });
+ });
+ pend.wait(function() {
+ self.httpServer = https.createServer(options, self.app);
+ cb();
+ });
+ }
+ }
+
+ function attachWebSocketServer() {
+ self.wss = new WebSocketServer({
+ server: self.httpServer,
+ clientTracking: false,
});
- self.protocolServer.on('error', function(err) {
- if (err.code === 'EADDRINUSE') {
- console.error("Failed to bind MPD protocol to port " + mpdPort +
- ": Address in use.");
- } else {
- throw err;
- }
+ self.wss.on('connection', function(ws) {
+ self.playerServer.handleNewClient(new WebSocketApiClient(ws));
});
- self.protocolServer.listen(mpdPort, mpdHost, function() {
- console.info("MPD/GrooveBasin Protocol listening at " +
- mpdHost + ":" + mpdPort);
+ self.httpServer.listen(self.httpPort, self.httpHost, function() {
+ self.emit('listening');
+ log.info("Web interface listening at " + self.httpProtocol + "://" + self.httpHost + ":" + self.httpPort + "/");
});
- }
-
- function authenticate(password) {
- return self.config.permissions[password];
+ self.httpServer.on('close', function() {
+ log.debug("server closed");
+ });
+ var mpdPort = self.config.mpdPort;
+ var mpdHost = self.config.mpdHost;
+ if (mpdPort == null || mpdHost == null) {
+ log.info("MPD Protocol disabled");
+ } else {
+ self.protocolServer = net.createServer(function(socket) {
+ socket.setEncoding('utf8');
+ var protocol = new MpdProtocol({
+ player: self.player,
+ playerServer: self.playerServer,
+ apiServer: self.mpdApiServer,
+ });
+ protocol.on('error', handleError);
+ socket.on('error', handleError);
+ socket.pipe(protocol).pipe(socket);
+ socket.on('close', cleanup);
+ self.mpdApiServer.handleNewClient(protocol);
+
+ function handleError(err) {
+ log.error("socket error:", err.stack);
+ socket.destroy();
+ cleanup();
+ }
+ function cleanup() {
+ self.mpdApiServer.handleClientEnd(protocol);
+ }
+ });
+ self.protocolServer.on('error', function(err) {
+ if (err.code === 'EADDRINUSE') {
+ log.error("Failed to bind MPD protocol to port " + mpdPort +
+ ": Address in use.");
+ } else {
+ throw err;
+ }
+ });
+ self.protocolServer.listen(mpdPort, mpdHost, function() {
+ log.info("MPD/GrooveBasin Protocol listening at " +
+ mpdHost + ":" + mpdPort);
+ });
+ }
}
};
-
-function genPassword() {
- return crypto.pseudoRandomBytes(9).toString('base64');
-}
-
diff --git a/lib/log.js b/lib/log.js
new file mode 100644
index 0000000..6d45122
--- /dev/null
+++ b/lib/log.js
@@ -0,0 +1,28 @@
+var levels = {
+ Fatal: 0,
+ Error: 1,
+ Info: 2,
+ Warn: 3,
+ Debug: 4,
+};
+
+exports.levels = levels;
+exports.level = levels.Info;
+exports.log = log;
+exports.fatal = makeLogFn(levels.Fatal);
+exports.error = makeLogFn(levels.Error);
+exports.info = makeLogFn(levels.Info);
+exports.warn = makeLogFn(levels.Warn);
+exports.debug = makeLogFn(levels.Debug);
+
+
+function makeLogFn(level) {
+ return function() {
+ log.apply(this, [level].concat(Array.prototype.slice.call(arguments, 0)));
+ };
+}
+
+function log(level) {
+ if (level > exports.level) return;
+ console.error.apply(console, Array.prototype.slice.call(arguments, 1));
+}
diff --git a/lib/mpd_protocol.js b/lib/mpd_protocol.js
index e410606..e412df2 100644
--- a/lib/mpd_protocol.js
+++ b/lib/mpd_protocol.js
@@ -3,6 +3,7 @@ var util = require('util');
var Player = require('./player');
var path = require('path');
var GrooveBasinProtocol = require('./protocol_parser');
+var log = require('./log');
module.exports = MpdProtocol;
@@ -410,7 +411,7 @@ var commands = {
fn: function (self, args, cb) {
for (var commandName in commands) {
var cmd = commands[commandName];
- if (cmd.permission != null && !self.permissions[cmd.permission]) {
+ if (cmd.permission != null && !self.havePermission(cmd.permission)) {
self.push("command: " + commandName + "\n");
}
}
@@ -437,10 +438,10 @@ var commands = {
},
],
fn: function (self, args, cb) {
- var perms = self.authenticate(args.password);
- var success = perms != null;
+ var user = self.playerServer.getOneLineAuth(args.password);
+ var success = user != null;
if (!success) return cb(ERR_CODE_PASSWORD, "incorrect password");
- self.permissions = perms;
+ self.user = user;
cb();
},
},
@@ -856,10 +857,9 @@ function MpdProtocol(options) {
var streamOptions = extend(extend({}, options.streamOptions || {}), {decodeStrings: false});
Duplex.call(this, streamOptions);
this.player = options.player;
- this.authenticate = options.authenticate;
- this.permissions = options.permissions;
this.apiServer = options.apiServer;
this.playerServer = options.playerServer;
+ this.user = null;
this.buffer = "";
this.bufferIndex = 0;
@@ -888,6 +888,10 @@ function MpdProtocol(options) {
this.initialize();
}
+MpdProtocol.prototype.havePermission = function(permName) {
+ return this.playerServer.userHasPerm(this.user, permName);
+};
+
MpdProtocol.prototype.initialize = function() {
this.push("OK MPD 0.19.0\n");
};
@@ -1085,7 +1089,7 @@ function mpdWrite(chunk, encoding, callback) {
return;
}
if (cmdName === 'idle') {
- if (!self.permissions.read) {
+ if (!self.havePermission('read')) {
cmdDone(ERR_CODE_PERMISSION, "you don't have permission for \"" + cmdName + "\"");
} else {
self.handleIdle(args);
@@ -1105,7 +1109,7 @@ function mpdWrite(chunk, encoding, callback) {
function cmdDone(code, msg) {
if (code) {
- console.warn("cmd err:", cmdName, JSON.stringify(args), msg);
+ log.warn("cmd err:", cmdName, JSON.stringify(args), msg);
if (code === ERR_CODE_UNKNOWN) cmdName = "";
self.push("ACK [" + code + "@" + index + "] {" + cmdName + "} " + msg + "\n");
cb(false);
@@ -1121,7 +1125,7 @@ function mpdWrite(chunk, encoding, callback) {
if (!cmd) return cb(ERR_CODE_UNKNOWN, "unknown command \"" + cmdName + "\"");
var perm = cmd.permission;
- if (perm != null && !self.permissions[perm]) {
+ if (perm != null && !self.havePermission(perm)) {
cb(ERR_CODE_PERMISSION, "you don't have permission for \"" + cmdName + "\"");
return;
}
@@ -1162,7 +1166,7 @@ function mpdWrite(chunk, encoding, callback) {
}
argsParam = namedArgs;
}
- console.info("ok mpd command", cmdName, JSON.stringify(argsParam));
+ log.debug("ok mpd command", cmdName, JSON.stringify(argsParam));
cmd.fn(self, argsParam, cb);
}
}
diff --git a/lib/player.js b/lib/player.js
index a05053a..8a523f1 100644
--- a/lib/player.js
+++ b/lib/player.js
@@ -4,26 +4,25 @@ var EventEmitter = require('events').EventEmitter;
var util = require('util');
var mkdirp = require('mkdirp');
var fs = require('fs');
-var uuid = require('uuid');
+var uuid = require('./uuid');
var path = require('path');
var Pend = require('pend');
var DedupedQueue = require('./deduped_queue');
var findit = require('findit');
var shuffle = require('mess');
var mv = require('mv');
-var zfill = require('zfill');
var MusicLibraryIndex = require('music-library-index');
var keese = require('keese');
var safePath = require('./safe_path');
var PassThrough = require('stream').PassThrough;
var url = require('url');
-var superagent = require('superagent');
-var Cookies = require('cookies');
+var download = require('./download').download;
+var dbIterate = require('./db_iterate');
+var log = require('./log');
module.exports = Player;
ensureGrooveVersionIsOk();
-groove.setLogging(groove.LOG_WARNING);
var cpuCount = require('os').cpus().length;
@@ -218,6 +217,8 @@ function Player(db, musicDirectory, encodeQueueDuration) {
EventEmitter.call(this);
this.setMaxListeners(0);
+ setGrooveLoggingLevel();
+
this.db = db;
this.musicDirectory = musicDirectory;
this.dbFilesByPath = {};
@@ -238,7 +239,7 @@ function Player(db, musicDirectory, encodeQueueDuration) {
maxAsync: 1,
});
this.dirScanQueue.on('error', function(err) {
- console.error("library scanning error:", err.stack);
+ log.error("library scanning error:", err.stack);
});
this.playlist = {};
@@ -283,7 +284,6 @@ function Player(db, musicDirectory, encodeQueueDuration) {
this.grooveEncoder.encodedBufferSize = 128 * 1024;
this.detachEncoderTimeout = null;
- this.autoPauseTimeout = null;
this.pendingEncoderAttachDetach = null;
this.desiredEncoderAttachState = false;
this.flushEncodedInterval = null;
@@ -294,8 +294,6 @@ function Player(db, musicDirectory, encodeQueueDuration) {
this.grooveEncoder.bitRate = 256 * 1000;
this.importUrlFilters = [];
-
- this.pendingFilePreloads = 0;
}
Player.prototype.initialize = function(cb) {
@@ -335,80 +333,33 @@ Player.prototype.initialize = function(cb) {
});
function cacheAllPlaylistMeta(cb) {
- var stream = self.db.createReadStream({
- start: PLAYLIST_META_KEY_PREFIX,
- });
- stream.on('data', function(data) {
- if (data.key.indexOf(PLAYLIST_META_KEY_PREFIX) !== 0) {
- stream.removeAllListeners();
- stream.destroy();
- cb();
- return;
- }
- var playlist = deserializePlaylist(data.value);
+ dbIterate(self.db, PLAYLIST_META_KEY_PREFIX, processOne, cb);
+ function processOne(key, value) {
+ log.debug("key:", key, "value:", value);
+ var playlist = deserializePlaylist(value);
self.playlists[playlist.id] = playlist;
- });
- stream.on('error', function(err) {
- stream.removeAllListeners();
- stream.destroy();
- cb(err);
- });
- stream.on('close', function() {
- cb();
- });
+ }
}
function cacheAllPlaylistItems(cb) {
- var stream = self.db.createReadStream({
- start: PLAYLIST_KEY_PREFIX,
- });
- stream.on('data', function(data) {
- if (data.key.indexOf(PLAYLIST_KEY_PREFIX) !== 0) {
- stream.removeAllListeners();
- stream.destroy();
- cb();
- return;
- }
- var playlistIdEnd = data.key.indexOf('.', PLAYLIST_KEY_PREFIX.length);
- var playlistId = data.key.substring(PLAYLIST_KEY_PREFIX.length, playlistIdEnd);
+ dbIterate(self.db, PLAYLIST_KEY_PREFIX, processOne, cb);
+ function processOne(key, value) {
+ var playlistIdEnd = key.indexOf('.', PLAYLIST_KEY_PREFIX.length);
+ var playlistId = key.substring(PLAYLIST_KEY_PREFIX.length, playlistIdEnd);
// TODO remove this once verified that it's working
- console.log("playlistId", playlistId);
- var playlistItem = JSON.parse(data.value);
+ log.debug("playlistId", playlistId);
+ var playlistItem = JSON.parse(value);
self.playlists[playlistId].items[playlistItem.id] = playlistItem;
- });
- stream.on('error', function(err) {
- stream.removeAllListeners();
- stream.destroy();
- cb(err);
- });
- stream.on('close', function() {
- cb();
- });
+ }
}
}
function cacheAllQueue(cb) {
- var stream = self.db.createReadStream({
- start: QUEUE_KEY_PREFIX,
- });
- stream.on('data', function(data) {
- if (data.key.indexOf(QUEUE_KEY_PREFIX) !== 0) {
- stream.removeAllListeners();
- stream.destroy();
- cb();
- return;
- }
- var plEntry = JSON.parse(data.value);
+ dbIterate(self.db, QUEUE_KEY_PREFIX, processOne, cb);
+ function processOne(key, value) {
+ var plEntry = JSON.parse(value);
self.playlist[plEntry.id] = plEntry;
- });
- stream.on('error', function(err) {
- stream.removeAllListeners();
- stream.destroy();
- cb(err);
- });
- stream.on('close', function() {
- cb();
- });
+ }
}
function cacheAllOptions(cb) {
@@ -448,7 +399,7 @@ Player.prototype.initialize = function(cb) {
// fall back to dummy
self.setHardwarePlayback(hardwarePlaybackValue, function(err) {
if (err) {
- console.error("Unable to attach hardware player, falling back to dummy.", err.stack);
+ log.error("Unable to attach hardware player, falling back to dummy.", err.stack);
self.setHardwarePlayback(false);
}
cb();
@@ -473,63 +424,31 @@ Player.prototype.initialize = function(cb) {
}
function cacheAllDirs(cb) {
- var stream = self.db.createReadStream({
- start: LIBRARY_DIR_PREFIX,
- });
- stream.on('data', function(data) {
- if (data.key.indexOf(LIBRARY_DIR_PREFIX) !== 0) {
- stream.removeAllListeners();
- stream.destroy();
- cb();
- return;
- }
- var dirEntry = JSON.parse(data.value);
+ dbIterate(self.db, LIBRARY_DIR_PREFIX, processOne, cb);
+ function processOne(key, value) {
+ var dirEntry = JSON.parse(value);
self.dirs[dirEntry.dirName] = dirEntry;
- });
- stream.on('error', function(err) {
- stream.removeAllListeners();
- stream.destroy();
- cb(err);
- });
- stream.on('close', function() {
- cb();
- });
+ }
}
function cacheAllDb(cb) {
var scrubCmds = [];
- var stream = self.db.createReadStream({
- start: LIBRARY_KEY_PREFIX,
- });
- stream.on('data', function(data) {
- if (data.key.indexOf(LIBRARY_KEY_PREFIX) !== 0) {
- stream.removeAllListeners();
- stream.destroy();
- scrubAndCb();
- return;
- }
- var dbFile = deserializeFileData(data.value);
+ dbIterate(self.db, LIBRARY_KEY_PREFIX, processOne, scrubAndCb);
+ function processOne(key, value) {
+ var dbFile = deserializeFileData(value);
// scrub duplicates
if (self.dbFilesByPath[dbFile.file]) {
- scrubCmds.push({type: 'del', key: data.key});
+ scrubCmds.push({type: 'del', key: key});
} else {
self.libraryIndex.addTrack(dbFile);
self.dbFilesByPath[dbFile.file] = dbFile;
}
- });
- stream.on('error', function(err) {
- stream.removeAllListeners();
- stream.destroy();
- cb(err);
- });
- stream.on('close', function() {
- scrubAndCb();
- });
+ }
function scrubAndCb() {
if (scrubCmds.length === 0) return cb();
- console.info("Scrubbing " + scrubCmds.length + " duplicate db entries");
+ log.warn("Scrubbing " + scrubCmds.length + " duplicate db entries");
self.db.batch(scrubCmds, function(err) {
- if (err) console.error("Unable to scrub duplicate tracks from db:", err.stack);
+ if (err) log.error("Unable to scrub duplicate tracks from db:", err.stack);
cb();
});
}
@@ -543,6 +462,7 @@ Player.prototype.initialize = function(cb) {
};
function startEncoderAttach(self, cb) {
+ if (self.desiredEncoderAttachState) return;
self.desiredEncoderAttachState = true;
if (!self.pendingEncoderAttachDetach) {
self.pendingEncoderAttachDetach = true;
@@ -555,6 +475,7 @@ function startEncoderAttach(self, cb) {
}
function startEncoderDetach(self, cb) {
+ if (!self.desiredEncoderAttachState) return;
self.desiredEncoderAttachState = false;
if (!self.pendingEncoderAttachDetach) {
self.pendingEncoderAttachDetach = true;
@@ -582,7 +503,7 @@ Player.prototype.attachEncoder = function(cb) {
if (self.flushEncodedInterval) return cb();
- console.info("first streamer connected - attaching encoder");
+ log.debug("first streamer connected - attaching encoder");
self.flushEncodedInterval = setInterval(flushEncoded, 100);
startEncoderAttach(self, cb);
@@ -599,10 +520,10 @@ Player.prototype.attachEncoder = function(cb) {
var buf;
while (buf = self.recentBuffers[0]) {
/*
- console.log(" buf.item " + buf.item.file.filename + "\n" +
- "playHead.item " + playHead.item.file.filename + "\n" +
- " playHead.pos " + playHead.pos + "\n" +
- " buf.pos " + buf.pos);
+ log.debug(" buf.item " + buf.item.file.filename + "\n" +
+ "playHead.item " + playHead.item.file.filename + "\n" +
+ " playHead.pos " + playHead.pos + "\n" +
+ " buf.pos " + buf.pos);
*/
if (isBufOld(buf)) {
self.recentBuffers.shift();
@@ -619,7 +540,7 @@ Player.prototype.attachEncoder = function(cb) {
if (buf.buffer) {
if (buf.item) {
if (self.expectHeaders) {
- console.log("encoder: got first non-header");
+ log.debug("encoder: got first non-header");
self.headerBuffers = self.newHeaderBuffers;
self.newHeaderBuffers = [];
self.expectHeaders = false;
@@ -630,15 +551,15 @@ Player.prototype.attachEncoder = function(cb) {
}
} else if (self.expectHeaders) {
// this is a header
- console.log("encoder: got header");
+ log.debug("encoder: got header");
self.newHeaderBuffers.push(buf.buffer);
} else {
// it's a footer, ignore the fuck out of it
- console.info("ignoring encoded audio footer");
+ log.debug("ignoring encoded audio footer");
}
} else {
// end of playlist sentinel
- console.log("encoder: end of playlist sentinel");
+ log.debug("encoder: end of playlist sentinel");
if (self.queueClearEncodedBuffers) {
self.queueClearEncodedBuffers = false;
self.clearEncodedBuffer();
@@ -670,7 +591,7 @@ Player.prototype.attachEncoder = function(cb) {
function logIfError(err) {
if (err) {
- console.error("Unable to attach encoder:", err.stack);
+ log.error("Unable to attach encoder:", err.stack);
}
}
};
@@ -687,7 +608,7 @@ Player.prototype.detachEncoder = function(cb) {
function logIfError(err) {
if (err) {
- console.error("Unable to attach encoder:", err.stack);
+ log.error("Unable to attach encoder:", err.stack);
}
}
};
@@ -707,6 +628,7 @@ Player.prototype.refreshFilesIndex = function(args, cb) {
var dirWithSlash = ensureSep(dir);
var walker = findit(dirWithSlash, {followSymlinks: true});
var thisScanId = uuid();
+ var errorOccurred = false;
walker.on('directory', function(fullDirPath, stat, stop) {
var dirName = path.relative(self.musicDirectory, fullDirPath);
var baseName = path.basename(dirName);
@@ -732,10 +654,16 @@ Player.prototype.refreshFilesIndex = function(args, cb) {
onAddOrChange(relPath, stat);
});
walker.on('error', function(err) {
+ if (errorOccurred) return;
+ errorOccurred = true;
walker.stop();
cb(err);
});
walker.on('end', function() {
+ // findit bug, end still fires. I'd link to the issue here but I'm on an
+ // airplane.
+ if (errorOccurred) return;
+
var dirName = path.relative(self.musicDirectory, dir);
checkDirEntry(self.dirs[dirName]);
cb();
@@ -776,7 +704,7 @@ Player.prototype.refreshFilesIndex = function(args, cb) {
function onDirMissing(parentDirEntry, baseName) {
var dirName = path.join(parentDirEntry.dirName, baseName);
- console.log("directory deleted:", dirName);
+ log.debug("directory deleted:", dirName);
var dirEntry = self.dirs[dirName];
var watcher = dirEntry.watcher;
if (watcher) watcher.close();
@@ -786,7 +714,7 @@ Player.prototype.refreshFilesIndex = function(args, cb) {
function onFileMissing(parentDirEntry, baseName) {
var relPath = path.join(parentDirEntry.dirName, baseName);
- console.log("file deleted:", relPath);
+ log.debug("file deleted:", relPath);
delete parentDirEntry.entries[baseName];
var dbFile = self.dbFilesByPath[relPath];
if (dbFile) self.delDbEntry(dbFile);
@@ -821,7 +749,7 @@ Player.prototype.watchDirEntry = function(dirEntry) {
watcher = fs.watch(fullDirPath, onChange);
watcher.on('error', onWatchError);
} catch (err) {
- console.error("Unable to fs.watch:", err.stack);
+ log.error("Unable to fs.watch:", err.stack);
watcher = null;
}
dirEntry.watcher = watcher;
@@ -830,13 +758,13 @@ Player.prototype.watchDirEntry = function(dirEntry) {
if (changeTriggered) clearTimeout(changeTriggered);
changeTriggered = setTimeout(function() {
changeTriggered = null;
- console.log("dir updated:", dirEntry.dirName);
+ log.debug("dir updated:", dirEntry.dirName);
self.dirScanQueue.add(fullDirPath, { dir: fullDirPath });
}, 100);
}
function onWatchError(err) {
- console.error("watch error:", err.stack);
+ log.error("watch error:", err.stack);
}
};
@@ -914,7 +842,6 @@ Player.prototype.setHardwarePlayback = function(value, cb) {
function onNowPlaying() {
var playHead = self.groovePlayer.position();
var decodeHead = self.groovePlaylist.position();
- console.log("onNowPlaying", playHead.item && playHead.item.file.filename);
if (playHead.item) {
var nowMs = (new Date()).getTime();
var posMs = playHead.pos * 1000;
@@ -925,91 +852,70 @@ Player.prototype.setHardwarePlayback = function(value, cb) {
} else if (!decodeHead.item) {
if (!self.dontBelieveTheEndOfPlaylistSentinelItsATrap) {
// both play head and decode head are null. end of playlist.
- console.log("end of playlist");
+ log.debug("end of playlist");
self.currentTrack = null;
playlistChanged(self);
self.currentTrackChanged();
- } else if (self.pendingFilePreloads === 0) {
- // all the items in the groove playlist had an error, so we need to
- // set the current track to the next one after the last one in the
- // groove playlist and continue playing
- var groovePlaylist = self.groovePlaylist.items();
- var lastGrooveItem = groovePlaylist[groovePlaylist.length - 1];
- if (lastGrooveItem) {
- var track = self.grooveItems[lastGrooveItem.id];
- self.currentTrack = self.tracksInOrder[track.index + 1];
- playlistChanged(self);
- self.currentTrackChanged();
- }
}
}
}
function logIfError(err) {
if (err) {
- console.error("Unable to set hardware playback mode:", err.stack);
+ log.error("Unable to set hardware playback mode:", err.stack);
}
}
};
-Player.prototype.streamMiddleware = function(req, resp, next) {
- var self = this;
- if (req.path !== '/stream.mp3') return next();
-
- var cookies = new Cookies(req, resp);
- resp.token = cookies.get('token');
-
- resp.setHeader('Content-Type', 'audio/mpeg');
- resp.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
- resp.setHeader('Pragma', 'no-cache');
- resp.setHeader('Expires', '0');
- resp.statusCode = 200;
-
- self.headerBuffers.forEach(function(headerBuffer) {
+Player.prototype.startStreaming = function(resp) {
+ this.headerBuffers.forEach(function(headerBuffer) {
resp.write(headerBuffer);
});
- self.recentBuffers.forEach(function(recentBuffer) {
+ this.recentBuffers.forEach(function(recentBuffer) {
resp.write(recentBuffer.buffer);
});
- if (self.detachEncoderTimeout) {
- clearTimeout(self.detachEncoderTimeout);
- self.detachEncoderTimeout = null;
- }
- self.attachEncoder();
- self.openStreamers.push(resp);
- self.emit('streamerConnect');
- req.on('close', function() {
- for (var i = 0; i < self.openStreamers.length; i += 1) {
- if (self.openStreamers[i] === resp) {
- self.openStreamers.splice(i, 1);
- if (self.openStreamers.length === 0) {
- self.detachEncoderTimeout = setTimeout(checkDetachEncoder, 5000);
- if (self.autoPauseTimeout) {
- clearTimeout(self.autoPauseTimeout);
- }
- self.autoPauseTimeout = setTimeout(checkAutoPause, 500);
- } else {
- console.info("streamer count:", self.openStreamers.length);
- }
- self.emit('streamerDisconnect');
- break;
- }
- }
- resp.end();
- });
+ this.cancelDetachEncoderTimeout();
+ this.attachEncoder();
+ this.openStreamers.push(resp);
+ this.emit('streamerConnect', resp.client);
+};
- function checkAutoPause() {
- self.autoPauseTimeout = null;
- // when last streamer disconnects, if hardware playback is off,
- // press pause
- if (!self.desiredPlayerHardwareState && self.openStreamers.length === 0) {
- self.pause();
+Player.prototype.stopStreaming = function(resp) {
+ for (var i = 0; i < this.openStreamers.length; i += 1) {
+ if (this.openStreamers[i] === resp) {
+ this.openStreamers.splice(i, 1);
+ this.emit('streamerDisconnect', resp.client);
+ break;
}
}
+};
+
+Player.prototype.lastStreamerDisconnected = function() {
+ log.debug("last streamer disconnected");
+ this.startDetachEncoderTimeout();
+ if (!this.desiredPlayerHardwareState) {
+ this.pause();
+ }
+};
+
+Player.prototype.cancelDetachEncoderTimeout = function() {
+ if (this.detachEncoderTimeout) {
+ clearTimeout(this.detachEncoderTimeout);
+ this.detachEncoderTimeout = null;
+ }
+};
- function checkDetachEncoder() {
- if (self.openStreamers.length === 0) {
- console.info("last streamer disconnected. detaching encoder");
+Player.prototype.startDetachEncoderTimeout = function() {
+ var self = this;
+ self.cancelDetachEncoderTimeout();
+ // we use encodeQueueDuration for the encoder timeout so that we are
+ // guaranteed to have audio available for the encoder in the case of
+ // detaching and reattaching the encoder.
+ self.detachEncoderTimeout = setTimeout(timeout, self.encodeQueueDuration * 1000);
+
+ function timeout() {
+ if (self.openStreamers.length === 0 && self.isPlaying) {
+ log.debug("detaching encoder");
self.detachEncoder();
}
}
@@ -1019,13 +925,13 @@ Player.prototype.deleteFile = function(key) {
var self = this;
var dbFile = self.libraryIndex.trackTable[key];
if (!dbFile) {
- console.error("Error deleting file - no entry:", key);
+ log.error("Error deleting file - no entry:", key);
return;
}
var fullPath = path.join(self.musicDirectory, dbFile.file);
fs.unlink(fullPath, function(err) {
if (err) {
- console.error("Error deleting", dbFile.file, err.stack);
+ log.error("Error deleting", dbFile.file, err.stack);
}
});
self.delDbEntry(dbFile);
@@ -1052,13 +958,13 @@ Player.prototype.delDbEntry = function(dbFile) {
this.emit('deleteDbTrack', dbFile);
this.db.del(LIBRARY_KEY_PREFIX + dbFile.key, function(err) {
if (err) {
- console.error("Error deleting db entry", dbFile.key, err.stack);
+ log.error("Error deleting db entry", dbFile.key, err.stack);
}
});
};
Player.prototype.setVolume = function(value) {
- value = Math.min(1.0, value);
+ value = Math.min(2.0, value);
value = Math.max(0.0, value);
this.volume = value;
this.groovePlaylist.setGain(value);
@@ -1086,14 +992,14 @@ Player.prototype.importUrl = function(urlString, cb) {
} else {
downloadRaw();
}
- function callNextFilter(err, dlStream, filename) {
+ function callNextFilter(err, dlStream, filenameHintWithoutPath) {
if (err || !dlStream) {
- if (err) console.warn("import filter error, skipping:", err.stack);
+ if (err) log.error("import filter error, skipping:", err.stack);
filterIndex += 1;
tryImportFilter();
return;
}
- handleDownload(dlStream, filename);
+ importStream(dlStream, filenameHintWithoutPath);
}
}
@@ -1106,20 +1012,22 @@ Player.prototype.importUrl = function(urlString, cb) {
} catch (err) {
decodedFilename = remoteFilename;
}
- var req = superagent.get(urlString);
- handleDownload(req, decodedFilename);
+ download(urlString, function(err, resp) {
+ if (err) return cb(err);
+ importStream(resp, decodedFilename);
+ });
}
- function handleDownload(req, remoteFilename) {
- var ext = path.extname(remoteFilename);
+ function importStream(readStream, filenameHintWithoutPath) {
+ var ext = path.extname(filenameHintWithoutPath);
var destPath = path.join(tmpDir, uuid() + ext);
- var ws = fs.createWriteStream(destPath);
+ var writeStream = fs.createWriteStream(destPath);
var calledCallback = false;
- req.pipe(ws);
- ws.on('close', function(){
+ readStream.pipe(writeStream);
+ writeStream.on('close', function(){
if (calledCallback) return;
- self.importFile(ws.path, remoteFilename, function(err, dbFile) {
+ self.importFile(writeStream.path, filenameHintWithoutPath, function(err, dbFile) {
if (err) {
cleanAndCb(err);
} else {
@@ -1128,13 +1036,13 @@ Player.prototype.importUrl = function(urlString, cb) {
}
});
});
- ws.on('error', cleanAndCb);
- req.on('error', cleanAndCb);
+ writeStream.on('error', cleanAndCb);
+ readStream.on('error', cleanAndCb);
function cleanAndCb(err) {
fs.unlink(destPath, function(err) {
if (err) {
- console.warn("Unable to clean up temp file:", err.stack);
+ log.warn("Unable to clean up temp file:", err.stack);
}
});
if (calledCallback) return;
@@ -1145,22 +1053,24 @@ Player.prototype.importUrl = function(urlString, cb) {
function logIfError(err) {
if (err) {
- console.error("Unable to import by URL.", err.stack, "URL:", urlString);
+ log.error("Unable to import by URL.", err.stack, "URL:", urlString);
}
}
};
// moves the file at srcFullPath to the music library
-Player.prototype.importFile = function(srcFullPath, filenameHint, cb) {
+Player.prototype.importFile = function(srcFullPath, filenameHintWithoutPath, cb) {
var self = this;
cb = cb || logIfError;
+ log.debug("importFile open file:", srcFullPath);
groove.open(srcFullPath, function(err, file) {
if (err) return cb(err);
- var newDbFile = grooveFileToDbFile(file, filenameHint);
- var suggestedPath = self.getSuggestedPath(newDbFile, filenameHint);
+ var newDbFile = grooveFileToDbFile(file, filenameHintWithoutPath);
+ var suggestedPath = self.getSuggestedPath(newDbFile, filenameHintWithoutPath);
var pend = new Pend();
pend.go(function(cb) {
+ log.debug("importFile close file:", file.filename);
file.close(cb);
});
pend.go(function(cb) {
@@ -1188,7 +1098,7 @@ Player.prototype.importFile = function(srcFullPath, filenameHint, cb) {
self.addQueue.waitForId(destRelPath, function(err) {
if (err) return cb(err);
newDbFile = self.dbFilesByPath[destRelPath];
- cb();
+ cb(null, newDbFile);
});
});
});
@@ -1197,7 +1107,7 @@ Player.prototype.importFile = function(srcFullPath, filenameHint, cb) {
function logIfError(err) {
if (err) {
- console.error("unable to import file:", err.stack);
+ log.error("unable to import file:", err.stack);
}
}
};
@@ -1208,7 +1118,7 @@ Player.prototype.persistDirEntry = function(dirEntry, cb) {
function logIfError(err) {
if (err) {
- console.error("unable to persist db entry:", dirEntry, err.stack);
+ log.error("unable to persist db entry:", dirEntry, err.stack);
}
}
};
@@ -1223,7 +1133,7 @@ Player.prototype.persist = function(dbFile, cb) {
function logIfError(err) {
if (err) {
- console.error("unable to persist db entry:", dbFile, err.stack);
+ log.error("unable to persist db entry:", dbFile, err.stack);
}
}
};
@@ -1234,7 +1144,7 @@ Player.prototype.persistPlaylistItem = function(playlist, item, cb) {
function logIfError(err) {
if (err) {
- console.error("unable to persist playlist item:", item, err.stack);
+ log.error("unable to persist playlist item:", item, err.stack);
}
}
};
@@ -1244,7 +1154,7 @@ Player.prototype.persistQueueItem = function(item, cb) {
function logIfError(err) {
if (err) {
- console.error("unable to persist queue item:", item, err.stack);
+ log.error("unable to persist queue item:", item, err.stack);
}
}
};
@@ -1253,7 +1163,7 @@ Player.prototype.persistOption = function(name, value, cb) {
this.db.put(PLAYER_KEY_PREFIX + name, JSON.stringify(value), cb || logIfError);
function logIfError(err) {
if (err) {
- console.error("unable to persist player option:", err.stack);
+ log.error("unable to persist player option:", err.stack);
}
}
};
@@ -1263,6 +1173,7 @@ Player.prototype.addToLibrary = function(args, cb) {
var relPath = args.relPath;
var mtime = args.mtime;
var fullPath = path.join(self.musicDirectory, relPath);
+ log.debug("addToLibrary open file:", fullPath);
groove.open(fullPath, function(err, file) {
if (err) {
self.invalidPaths[relPath] = err.message;
@@ -1271,16 +1182,18 @@ Player.prototype.addToLibrary = function(args, cb) {
}
var dbFile = self.dbFilesByPath[relPath];
var eventType = dbFile ? 'updateDbTrack' : 'addDbTrack';
- var newDbFile = grooveFileToDbFile(file, relPath, dbFile);
+ var filenameHintWithoutPath = path.basename(relPath);
+ var newDbFile = grooveFileToDbFile(file, filenameHintWithoutPath, dbFile);
newDbFile.file = relPath;
newDbFile.mtime = mtime;
var pend = new Pend();
pend.go(function(cb) {
+ log.debug("addToLibrary close file:", file.filename);
file.close(cb);
});
pend.go(function(cb) {
self.persist(newDbFile, function(err) {
- if (err) console.error("Error saving", relPath, "to db:", err.stack);
+ if (err) log.error("Error saving", relPath, "to db:", err.stack);
cb();
});
});
@@ -1409,7 +1322,7 @@ Player.prototype.playlistDelete = function(playlistIds) {
function logIfError(err) {
if (err) {
- console.error("Error deleting playlist entries from db:", err.stack);
+ log.error("Error deleting playlist entries from db:", err.stack);
}
}
};
@@ -1456,7 +1369,7 @@ Player.prototype.playlistRemoveItems = function(playlistId, ids) {
function logIfError(err) {
if (err) {
- console.error("Error deleting playlist entries from db:", err.stack);
+ log.error("Error deleting playlist entries from db:", err.stack);
}
}
};
@@ -1483,7 +1396,7 @@ Player.prototype.persistPlaylist = function(playlist, cb) {
function logIfError(err) {
if (err) {
- console.error("unable to persist playlist:", err.stack);
+ log.error("unable to persist playlist:", err.stack);
}
}
};
@@ -1547,7 +1460,7 @@ Player.prototype.removeQueueItems = function(ids) {
function logIfError(err) {
if (err) {
- console.error("Error deleting playlist entries from db:", err.stack);
+ log.error("Error deleting playlist entries from db:", err.stack);
}
}
};
@@ -1599,6 +1512,7 @@ Player.prototype.pause = function() {
this.isPlaying = false;
this.pausedTime = (new Date() - this.trackStartDate) / 1000;
this.groovePlaylist.pause();
+ this.cancelDetachEncoderTimeout();
playlistChanged(this);
this.currentTrackChanged();
};
@@ -1610,6 +1524,7 @@ Player.prototype.play = function() {
this.trackStartDate = new Date(new Date() - this.pausedTime * 1000);
}
this.groovePlaylist.play();
+ this.startDetachEncoderTimeout();
this.isPlaying = true;
playlistChanged(this);
this.currentTrackChanged();
@@ -1690,6 +1605,7 @@ Player.prototype.setDynamicModeFutureSize = function(value) {
Player.prototype.stop = function() {
this.isPlaying = false;
+ this.cancelDetachEncoderTimeout();
this.groovePlaylist.pause();
this.seekRequestPos = 0;
this.pausedTime = 0;
@@ -1755,16 +1671,16 @@ Player.prototype.performScan = function(args, cb) {
self.libraryIndex.rebuild();
var album = self.libraryIndex.albumTable[albumKey];
if (!album) {
- console.warn("wanted to scan album with key", JSON.stringify(albumKey), "but no longer exists.");
+ log.warn("wanted to scan album with key", JSON.stringify(albumKey), "but no longer exists.");
cb();
return;
}
- console.info("Scanning album for loudness:", JSON.stringify(albumKey));
+ log.debug("Scanning album for loudness:", JSON.stringify(albumKey));
dbFilesToOpen = album.trackList;
} else if (scanType === 'track') {
var trackKey = scanKey;
var dbFile = self.libraryIndex.trackTable[trackKey];
- console.info("Scanning track for loudness:", JSON.stringify(trackKey));
+ log.debug("Scanning track for loudness:", JSON.stringify(trackKey));
dbFilesToOpen = [dbFile];
} else {
throw new Error("unexpected scan type: " + scanType);
@@ -1781,9 +1697,10 @@ Player.prototype.performScan = function(args, cb) {
dbFilesToOpen.forEach(function(dbFile) {
pend.go(function(cb) {
var fullPath = path.join(self.musicDirectory, dbFile.file);
+ log.debug("performScan open file:", fullPath);
groove.open(fullPath, function(err, file) {
if (err) {
- console.error("Error opening", fullPath, "in order to scan:", err.stack);
+ log.error("Error opening", fullPath, "in order to scan:", err.stack);
} else {
var fileInfo;
files[file.id] = fileInfo = {
@@ -1830,7 +1747,7 @@ Player.prototype.performScan = function(args, cb) {
function onEverythingAttached(err) {
if (err) {
- console.error("Error attaching:", err.stack);
+ log.error("Error attaching:", err.stack);
cleanupAndCb();
return;
}
@@ -1882,7 +1799,7 @@ Player.prototype.performScan = function(args, cb) {
fileInfo = files[info.item.file.id];
fileInfo.loudnessDone = true;
dbFile = fileInfo.dbFile;
- console.info("loudness scan file complete:", dbFile.name,
+ log.info("loudness scan file complete:", dbFile.name,
"gain", gain, "duration", info.duration);
dbFile.replayGainTrackGain = gain;
dbFile.replayGainTrackPeak = info.peak;
@@ -1890,7 +1807,7 @@ Player.prototype.performScan = function(args, cb) {
checkUpdateGroovePlaylist(self);
self.emit('scanProgress');
} else {
- console.info("loudness scan complete:", JSON.stringify(scanKey), "gain", gain);
+ log.debug("loudness scan complete:", JSON.stringify(scanKey), "gain", gain);
for (var fileId in files) {
fileInfo = files[fileId];
dbFile = fileInfo.dbFile;
@@ -1914,11 +1831,11 @@ Player.prototype.performScan = function(args, cb) {
var fileInfo = files[info.item.file.id];
fileInfo.fingerprintDone = true;
var dbFile = fileInfo.dbFile;
- console.info("fingerprint scan file complete:", dbFile.name);
+ log.info("fingerprint scan file complete:", dbFile.name);
dbFile.fingerprint = info.fingerprint;
self.emit('scanProgress');
} else {
- console.info("fingerprint scan complete:", JSON.stringify(scanKey));
+ log.debug("fingerprint scan complete:", JSON.stringify(scanKey));
if (endOfFingerprinterCb) {
endOfFingerprinterCb();
endOfFingerprinterCb = null;
@@ -1934,6 +1851,7 @@ Player.prototype.performScan = function(args, cb) {
var fileInfo = files[file.id];
var dbFile = fileInfo.dbFile;
delete self.ongoingScans[dbFile.key];
+ log.debug("performScan close file:", file.filename);
file.close(cb);
});
});
@@ -2105,18 +2023,21 @@ function playlistChanged(self) {
if (self.currentTrack) {
self.tracksInOrder.forEach(function(track, index) {
- var withinPrev = (self.currentTrack.index - index) <= PREV_FILE_COUNT;
- var withinNext = (index - self.currentTrack.index) <= NEXT_FILE_COUNT;
+ var prevDiff = self.currentTrack.index - index;
+ var nextDiff = index - self.currentTrack.index;
+ var withinPrev = prevDiff <= PREV_FILE_COUNT && prevDiff >= 0;
+ var withinNext = nextDiff <= NEXT_FILE_COUNT && nextDiff >= 0;
var shouldHaveGrooveFile = withinPrev || withinNext;
var hasGrooveFile = track.grooveFile != null || track.pendingGrooveFile;
if (hasGrooveFile && !shouldHaveGrooveFile) {
- removePreloadFromTrack(self, track);
+ self.playlistItemDeleteQueue.push(track);
} else if (!hasGrooveFile && shouldHaveGrooveFile) {
preloadFile(self, track);
}
});
} else {
self.isPlaying = false;
+ self.cancelDetachEncoderTimeout();
self.trackStartDate = null;
self.pausedTime = 0;
}
@@ -2131,10 +2052,17 @@ function playlistChanged(self) {
function performGrooveFileDeletes(self) {
while (self.playlistItemDeleteQueue.length) {
var item = self.playlistItemDeleteQueue.shift();
+
// we set this so that any callbacks that return which were trying to
// set the grooveItem can check if the item got deleted
item.deleted = true;
- closeFile(item.grooveFile);
+
+ if (!item.grooveFile) continue;
+
+ log.debug("performGrooveFileDeletes close file:", item.grooveFile.filename);
+ var grooveFile = item.grooveFile;
+ item.grooveFile = null;
+ closeFile(grooveFile);
}
}
@@ -2142,15 +2070,20 @@ function preloadFile(self, track) {
var relPath = self.libraryIndex.trackTable[track.key].file;
var fullPath = path.join(self.musicDirectory, relPath);
track.pendingGrooveFile = true;
- self.pendingFilePreloads += 1;
+
+ log.debug("preloadFile open file:", fullPath);
+
+ // set this so that we know we want the file preloaded
+ track.deleted = false;
+
groove.open(fullPath, function(err, file) {
track.pendingGrooveFile = false;
- self.pendingFilePreloads -= 1;
if (err) {
- console.error("Error opening", relPath, err.stack);
+ log.error("Error opening", relPath, err.stack);
return;
}
if (track.deleted) {
+ log.debug("preloadFile close file (already deleted):", file.filename);
closeFile(file);
return;
}
@@ -2286,7 +2219,12 @@ function checkUpdateGroovePlaylist(self) {
var albumMatch = self.libraryIndex.getAlbumKey(dbFile) === self.libraryIndex.getAlbumKey(otherDbFile);
if (!albumMatch) return false;
- var trackMatch = (dbFile.track == null && otherDbFile.track == null) || dbFile.track + dir === otherDbFile.track;
+ // if there are no track numbers then it's hardly an album, is it?
+ if (dbFile.track == null || otherDbFile.track == null) {
+ return false;
+ }
+
+ var trackMatch = dbFile.track + dir === otherDbFile.track;
if (!trackMatch) return false;
return true;
@@ -2304,13 +2242,6 @@ function checkUpdateGroovePlaylist(self) {
}
}
-function removePreloadFromTrack(self, track) {
- if (!track.grooveFile) return;
- var file = track.grooveFile;
- track.grooveFile = null;
- closeFile(file);
-}
-
function isFileIgnored(basename) {
return (/^\./).test(basename) || (/~$/).test(basename);
}
@@ -2369,16 +2300,15 @@ function serializeDirEntry(dirEntry) {
});
}
-function trackNameFromFile(filename) {
- var basename = path.basename(filename);
- var ext = path.extname(basename);
- return basename.substring(0, basename.length - ext.length);
+function filenameWithoutExt(filename) {
+ var ext = path.extname(filename);
+ return filename.substring(0, filename.length - ext.length);
}
function closeFile(file) {
file.close(function(err) {
if (err) {
- console.error("Error closing", file, err.stack);
+ log.error("Error closing", file, err.stack);
}
});
}
@@ -2409,11 +2339,11 @@ function parseFloatOrNull(n) {
return n;
}
-function grooveFileToDbFile(file, filenameHint, object) {
+function grooveFileToDbFile(file, filenameHintWithoutPath, object) {
object = object || {key: uuid()};
var parsedTrack = parseTrackString(file.getMetadata("track"));
var parsedDisc = parseTrackString(file.getMetadata("disc") || file.getMetadata("TPA"));
- object.name = (file.getMetadata("title") || trackNameFromFile(filenameHint) || "").trim();
+ object.name = (file.getMetadata("title") || filenameWithoutExt(filenameHintWithoutPath) || "").trim();
object.artistName = (file.getMetadata("artist") || "").trim();
object.composerName = (file.getMetadata("composer") ||
file.getMetadata("TCM") || "").trim();
@@ -2478,7 +2408,7 @@ function ensureGrooveVersionIsOk() {
if (semver.satisfies(verStr, reqVer)) return;
- console.log("Found libgroove", verStr, "need", reqVer);
+ log.fatal("Found libgroove", verStr, "need", reqVer);
process.exit(1);
}
@@ -2502,3 +2432,29 @@ function deserializePlaylist(str) {
playlist.items = {};
return playlist;
}
+
+function zfill(number, size) {
+ number = String(number);
+ while (number.length < size) number = "0" + number;
+ return number;
+}
+
+function setGrooveLoggingLevel() {
+ switch (log.level) {
+ case log.levels.Fatal:
+ groove.setLogging(groove.LOG_QUIET);
+ break;
+ case log.levels.Error:
+ groove.setLogging(groove.LOG_QUIET);
+ break;
+ case log.levels.Info:
+ groove.setLogging(groove.LOG_QUIET);
+ break;
+ case log.levels.Warn:
+ groove.setLogging(groove.LOG_WARNING);
+ break;
+ case log.levels.Debug:
+ groove.setLogging(groove.LOG_INFO);
+ break;
+ }
+}
diff --git a/lib/player_server.js b/lib/player_server.js
index f4b1c07..52e94de 100644
--- a/lib/player_server.js
+++ b/lib/player_server.js
@@ -1,18 +1,32 @@
-var uuid = require('uuid');
-var jsondiffpatch = require('jsondiffpatch');
+var uuid = require('./uuid');
+var curlydiff = require('curlydiff');
var Player = require('./player');
-var crypto = require('crypto');
+var Pend = require('pend');
+var util = require('util');
+var EventEmitter = require('events').EventEmitter;
+var keese = require('keese');
+var dbIterate = require('./db_iterate');
+var log = require('./log');
+
+var USERS_KEY_PREFIX = "Users.";
+var EVENTS_KEY_PREFIX = "Events.";
+var GUEST_USER_ID = "(guest)"; // uses characters not in the uuid() character set
+
+var MAX_EVENT_COUNT = 400;
+var MAX_NAME_LEN = 64;
+var MAX_PASSWORD_LEN = 1024;
+var UUID_LEN = uuid().length;
module.exports = PlayerServer;
PlayerServer.plugins = [];
PlayerServer.actions = {
- 'addid': {
- permission: 'add',
- args: 'object',
- fn: function(self, client, items) {
- self.player.addItems(items);
+ 'approve': {
+ permission: 'admin',
+ args: 'array',
+ fn: function(self, client, approvals) {
+ self.processApprovals(approvals);
},
},
'clear': {
@@ -21,6 +35,13 @@ PlayerServer.actions = {
self.player.clearQueue();
},
},
+ 'chat': {
+ permission: 'control',
+ args: 'string',
+ fn: function(self, client, text) {
+ self.addEvent(client.user, 'chat', text);
+ },
+ },
'deleteTracks': {
permission: 'admin',
args: 'array',
@@ -31,11 +52,11 @@ PlayerServer.actions = {
}
},
},
- 'deleteid': {
- permission: 'control',
+ 'deleteUsers': {
+ permission: 'admin',
args: 'array',
fn: function(self, client, ids) {
- self.player.removeQueueItems(ids);
+ self.deleteUsers(ids);
},
},
'dynamicModeOn': {
@@ -59,6 +80,12 @@ PlayerServer.actions = {
self.player.setDynamicModeFutureSize(size);
},
},
+ 'ensureAdminUser': {
+ permission: null,
+ fn: function(self) {
+ self.ensureAdminUser();
+ },
+ },
'hardwarePlayback': {
permission: 'admin',
args: 'boolean',
@@ -70,41 +97,70 @@ PlayerServer.actions = {
permission: 'control',
args: 'object',
fn: function(self, client, args) {
- var urlString = String(args.url);
+ var urlString = args.url;
var id = args.id;
+ if (!self.validateString(client, urlString)) return;
+ if (!self.validateString(client, id, UUID_LEN)) return;
self.player.importUrl(urlString, function(err, dbFile) {
var key = null;
if (err) {
- console.error("Unable to import url:", urlString, "error:", err.stack);
+ log.error("Unable to import url:", urlString, "error:", err.stack);
} else if (!dbFile) {
- console.error("Unable to import file due to race condition.");
+ log.error("Unable to import file due to race condition.");
} else {
key = dbFile.key;
}
client.sendMessage('importUrl', {id: id, key: key});
+ self.addEvent(client.user, 'import', null, key);
});
},
},
+ 'login': {
+ permission: null,
+ args: 'object',
+ fn: function(self, client, args) {
+ if (!self.validateString(client, args.username, MAX_NAME_LEN)) return;
+ if (!self.validateString(client, args.password, MAX_PASSWORD_LEN)) return;
+ self.login(client, args.username, args.password);
+ self.sendUserMessage(client);
+ },
+ },
+ 'logout': {
+ permission: null,
+ fn: function(self, client) {
+ self.logout(client);
+ },
+ },
'subscribe': {
permission: 'read',
args: 'object',
fn: function(self, client, args) {
+ var errText;
var name = args.name;
var subscription = self.subscriptions[name];
if (!subscription) {
- console.warn("Invalid subscription item:", name);
+ errText = "Invalid subscription item: " + JSON.stringify(name);
+ log.warn(errText);
+ client.sendMessage("error", errText);
+ return;
+ }
+ if (!self.userHasPerm(client.user, subscription.perm)) {
+ errText = "subscribing to " + JSON.stringify(name) +
+ " requires permission " + JSON.stringify(subscription.perm);
+ log.warn(errText);
+ client.sendMessage("error", errText);
return;
}
- if (args.delta) {
+ if (args.delta && client.subscriptions[name] !== 'delta') {
client.subscriptions[name] = 'delta';
if (args.version !== subscription.version) {
client.sendMessage(name, {
version: subscription.version,
reset: true,
- delta: jsondiffpatch.diff(undefined, subscription.value),
+ delta: curlydiff.diff(undefined, subscription.value),
});
}
- } else {
+ } else if (client.subscriptions[name] !== 'simple') {
client.subscriptions[name] = 'simple';
client.sendMessage(name, subscription.value);
}
@@ -117,11 +173,19 @@ PlayerServer.actions = {
self.player.updateTags(obj);
},
},
+ 'updateUser': {
+ permission: 'admin',
+ args: 'object',
+ fn: function(self, client, args) {
+ if (!self.validateString(client, args.userId, UUID_LEN)) return;
+ self.updateUser(client, args.userId, args.perms);
+ },
+ },
'unsubscribe': {
permission: 'read',
args: 'string',
fn: function(self, client, name) {
- delete client.subscriptions[name];
+ self.unsubscribe(client, name);
},
},
'move': {
@@ -129,35 +193,82 @@ PlayerServer.actions = {
args: 'object',
fn: function(self, client, items) {
self.player.moveQueueItems(items);
- },
- },
- 'password': {
- permission: null,
- args: 'string',
- fn: function(self, client, password) {
- var perms = self.authenticate(password);
- var success = perms != null;
- if (success) client.permissions = perms;
- client.sendMessage('permissions', client.permissions);
+ self.addEvent(client.user, 'move', null, null, null, true);
},
},
'pause': {
permission: 'control',
fn: function(self, client) {
+ self.addEvent(client.user, 'pause');
self.player.pause();
},
},
'play': {
permission: 'control',
fn: function(self, client) {
+ self.addEvent(client.user, 'play');
self.player.play();
},
},
+ 'queue': {
+ permission: 'add',
+ args: 'object',
+ fn: function(self, client, items) {
+ var id, item;
+ var trackCount = 0;
+ var trackKey = null;
+ for (id in items) {
+ item = items[id];
+ if (!self.validateObject(client, item)) return;
+ trackCount += 1;
+ trackKey = trackKey || item.key;
+ }
+
+ if (trackCount !== 1) {
+ trackKey = null;
+ }
+ self.addEvent(client.user, 'queue', null, trackKey, trackCount);
+ self.player.addItems(items);
+ },
+ },
'seek': {
permission: 'control',
args: 'object',
fn: function(self, client, args) {
- self.player.seek(args.id, args.pos);
+ var id = args.id;
+ var pos = parseFloat(args.pos);
+
+ if (!self.validateString(client, id, UUID_LEN)) return;
+ if (!self.validateFloat(client, pos)) return;
+
+ var track = self.player.playlist[id];
+ if (track) {
+ self.addEvent(client.user, 'seek', null, track.key, pos);
+ }
+
+ self.player.seek(id, pos);
+ },
+ },
+ 'setStreaming': {
+ args: 'boolean',
+ fn: function(self, client, streamOn) {
+ if (client.streaming === streamOn) return;
+ client.streaming = streamOn;
+ if (streamOn) {
+ self.emit('streamStart', client);
+ } else {
+ self.emit('streamStop', client);
+ }
+ },
+ },
+ 'remove': {
+ permission: 'control',
+ args: 'array',
+ fn: function(self, client, ids) {
+ var item = (ids.length === 1) && self.player.playlist[ids[0]];
+ var key = item && item.key;
+ self.addEvent(client.user, 'remove', null, key, ids.length);
+ self.player.removeQueueItems(ids);
},
},
'repeat': {
@@ -167,6 +278,13 @@ PlayerServer.actions = {
self.player.setRepeat(mode);
},
},
+ 'requestApproval': {
+ permission: null,
+ fn: function(self, client) {
+ self.requestApproval(client);
+ self.sendUserMessage(client);
+ },
+ },
'setvol': {
permission: 'control',
args: 'number',
@@ -184,6 +302,7 @@ PlayerServer.actions = {
permission: 'control',
fn: function(self, client) {
self.player.stop();
+ self.addEvent(client.user, 'stop');
},
},
'playlistCreate': {
@@ -230,26 +349,103 @@ PlayerServer.actions = {
},
};
+util.inherits(PlayerServer, EventEmitter);
function PlayerServer(options) {
+ EventEmitter.call(this);
+
this.player = options.player;
- this.authenticate = options.authenticate;
- this.defaultPermissions = options.defaultPermissions;
+ this.db = options.db;
this.subscriptions = {};
- this.clients = [];
+ this.users = {};
+ this.addGuestUser();
+ this.usernameIndex = null; // username -> user
+ this.oneLineAuth = null; // username/password -> perms
+ this.computeUsersIndex();
+
+ this.clients = {};
+
+ this.events = {};
+ this.eventsInOrder = [];
- // for each connected client, we generate a random token which can be used to
- // represent their session if the client wishes to perform a command based on
- // another connection such as an HTTP upload
- this.clientTokens = {};
- // for each connected client, we generate a random id which can be used to
- // identify them
- this.clientIds = {};
this.playlistId = uuid();
this.libraryId = uuid();
this.initialize();
}
+PlayerServer.prototype.ensureGuestUser = function() {
+ this.guestUser = this.users[GUEST_USER_ID];
+ if (!this.guestUser) {
+ this.addGuestUser();
+ }
+};
+
+PlayerServer.prototype.addGuestUser = function() {
+ // default guest user. overridden by db if present
+ this.guestUser = {
+ id: GUEST_USER_ID,
+ name: 'Guest',
+ password: "",
+ registered: true,
+ requested: true,
+ approved: true,
+ perms: {
+ read: true,
+ add: true,
+ control: true,
+ admin: false,
+ },
+ };
+ this.users[this.guestUser.id] = this.guestUser;
+};
+
+PlayerServer.prototype.haveAdminUser = function() {
+ for (var id in this.users) {
+ var user = this.users[id];
+ if (user.perms.admin) {
+ return true;
+ }
+ }
+ return false;
+};
+
+PlayerServer.prototype.ensureAdminUser = function() {
+ if (this.haveAdminUser()) {
+ return;
+ }
+
+ var user = true;
+ var name;
+ while (user) {
+ name = "Admin-" + uuid.len(6);
+ user = this.usernameIndex[name];
+ }
+
+ var adminUser = {
+ id: uuid(),
+ name: name,
+ password: uuid(),
+ registered: true,
+ requested: true,
+ approved: true,
+ perms: {
+ read: true,
+ add: true,
+ control: true,
+ admin: true,
+ },
+ };
+ this.users[adminUser.id] = adminUser;
+ this.saveUser(adminUser);
+
+ log.info("No admin account found. Created one:");
+ log.info("Username: " + adminUser.name);
+ log.info("Password: " + adminUser.password);
+
+ this.emit("haveAdminUser");
+ this.emit("users");
+};
+
PlayerServer.prototype.initialize = function() {
var self = this;
self.player.on('currentTrack', addSubscription('currentTrack', getCurrentTrack));
@@ -276,33 +472,86 @@ PlayerServer.prototype.initialize = function() {
self.player.on('playlistDelete', onPlaylistUpdate);
self.player.on('seek', function() {
- self.clients.forEach(function(client) {
+ self.forEachClient(function(client) {
client.sendMessage('seek');
});
});
+ // this is only anonymous streamers
var onStreamersUpdate = addSubscription('streamers', serializeStreamers);
self.player.on('streamerConnect', onStreamersUpdate);
self.player.on('streamerDisconnect', onStreamersUpdate);
setInterval(function() {
- self.clients.forEach(function(client) {
+ self.forEachClient(function(client) {
client.sendMessage('time', new Date());
});
}, 30000);
+ self.on('haveAdminUser', addSubscription('haveAdminUser', getHaveAdminUser));
+ self.on('events', addSubscription('events', getEvents));
+
+ var onUsersUpdate = addSubscription('users', getUsers);
+ self.on('users', onUsersUpdate);
+ self.on('streamStart', onUsersUpdate);
+ self.on('streamStop', onUsersUpdate);
+
+ // events
+ self.player.on('currentTrack', addCurrentTrackEvent);
+ self.on('streamStart', addStreamerConnectEvent);
+ self.on('streamStop', addStreamerDisconnectEvent);
+ self.player.on('streamerConnect', maybeAddAnonStreamerConnectEvent);
+ self.player.on('streamerDisconnect', maybeAddAnonStreamerDisconnectEvent);
+
+
+ self.player.on('streamerDisconnect', self.checkLastStreamerDisconnected.bind(self));
+
+ var prevCurrentTrackKey = null;
+ function addCurrentTrackEvent() {
+ var currentTrackKey = self.player.currentTrack ? self.player.currentTrack.key : null;
+ if (currentTrackKey !== prevCurrentTrackKey) {
+ prevCurrentTrackKey = currentTrackKey;
+ self.addEvent(null, 'currentTrack', null, currentTrackKey);
+ }
+ }
+
+ function addStreamerConnectEvent(client) {
+ self.addEvent(client.user, 'streamStart');
+ }
+
+ function addStreamerDisconnectEvent(client) {
+ self.addEvent(client.user, 'streamStop');
+ }
+
+ function maybeAddAnonStreamerConnectEvent(client) {
+ if (!client) {
+ self.addEvent(null, 'streamStart');
+ }
+ }
+
+ function maybeAddAnonStreamerDisconnectEvent(client) {
+ if (!client) {
+ self.addEvent(null, 'streamStop');
+ }
+ }
+
function addSubscription(name, serializeFn) {
+ return addPermSubscription(name, null, serializeFn);
+ }
+
+ function addPermSubscription(name, perm, serializeFn) {
var subscription = self.subscriptions[name] = {
version: uuid(),
value: serializeFn(),
+ perm: perm,
};
return function() {
var newValue = serializeFn();
- var delta = jsondiffpatch.diff(subscription.value, newValue);
- if (!delta) return; // no delta, nothing to send!
+ var delta = curlydiff.diff(subscription.value, newValue);
+ if (delta === undefined) return; // no delta, nothing to send!
subscription.value = newValue;
subscription.version = uuid();
- self.clients.forEach(function(client) {
+ self.forEachClient(function(client) {
var clientSubscription = client.subscriptions[name];
if (clientSubscription === 'simple') {
client.sendMessage(name, newValue);
@@ -394,74 +643,659 @@ PlayerServer.prototype.initialize = function() {
}
function serializeStreamers() {
- var clientIds = [];
var anonCount = 0;
self.player.openStreamers.forEach(function(openStreamer) {
- var client = self.clientTokens[openStreamer.token];
- if (client) {
- clientIds.push(client.id);
- } else {
+ if (!openStreamer.client) {
anonCount += 1;
}
});
- return {
- clientIds: clientIds,
- anonCount: anonCount,
- };
+ return anonCount;
+ }
+
+ function getUsers() {
+ var users = {};
+ var outUser;
+ for (var id in self.users) {
+ var user = self.users[id];
+ outUser = {
+ name: user.name,
+ perms: extend({}, user.perms),
+ };
+ if (user.requested) outUser.requested = true;
+ if (user.approved) outUser.approved = true;
+ users[id] = outUser;
+ }
+ for (var clientId in self.clients) {
+ var client = self.clients[clientId];
+ outUser = users[client.user.id];
+ outUser.connected = true;
+ if (client.streaming) outUser.streaming = true;
+ }
+ return users;
+ }
+
+ function getEvents() {
+ var events = {};
+ for (var id in self.events) {
+ var ev = self.events[id];
+ var outEvent = {
+ date: ev.date,
+ type: ev.type,
+ sortKey: ev.sortKey,
+ };
+ events[ev.id] = outEvent;
+ if (ev.userId) {
+ outEvent.userId = ev.userId;
+ }
+ if (ev.text) {
+ outEvent.text = ev.text;
+ }
+ if (ev.trackId) {
+ outEvent.trackId = ev.trackId;
+ }
+ if (ev.pos) {
+ outEvent.pos = ev.pos;
+ }
+ }
+ return events;
+ }
+
+ function getHaveAdminUser() {
+ return self.haveAdminUser();
+ }
+};
+
+PlayerServer.prototype.checkLastStreamerDisconnected = function() {
+ var streamerCount = 0;
+ this.forEachClient(function(client) {
+ streamerCount += client.streaming;
+ });
+ if (this.player.openStreamers.length === 0 && streamerCount === 0) {
+ this.player.lastStreamerDisconnected();
+ }
+};
+
+PlayerServer.prototype.init = function(cb) {
+ var self = this;
+
+ var pend = new Pend();
+ pend.go(loadAllUsers);
+ pend.go(loadAllEvents);
+ pend.wait(cb);
+
+ function loadAllUsers(cb) {
+ dbIterate(self.db, USERS_KEY_PREFIX, processOne, function(err) {
+ if (err) return cb(err);
+ self.ensureGuestUser();
+ self.computeUsersIndex();
+ self.emit('users');
+ self.emit('haveAdminUser');
+ cb();
+ });
+ function processOne(key, value) {
+ var user = deserializeUser(value);
+ self.users[user.id] = user;
+ }
}
+
+ function loadAllEvents(cb) {
+ dbIterate(self.db, EVENTS_KEY_PREFIX, processOne, function(err) {
+ if (err) return cb(err);
+ self.cacheEventsArray();
+ self.emit('events');
+ cb();
+ });
+ function processOne(key, value) {
+ var ev = deserializeEvent(value);
+ self.events[ev.id] = ev;
+ }
+ }
+};
+
+PlayerServer.prototype.forEachClient = function(fn) {
+ for (var id in this.clients) {
+ var client = this.clients[id];
+ fn(client);
+ }
+};
+
+PlayerServer.prototype.createGuestUser = function() {
+ var user = true;
+ var name;
+ while (user) {
+ name = this.guestUser.name + "-" + uuid.len(6);
+ user = this.usernameIndex[name];
+ }
+ user = {
+ id: uuid(),
+ name: name,
+ password: "",
+ registered: false,
+ requested: false,
+ approved: false,
+ perms: extend({}, this.guestUser.perms),
+ };
+ this.users[user.id] = user;
+ this.computeUsersIndex();
+ this.saveUser(user);
+ return user;
+};
+
+PlayerServer.prototype.unsubscribe = function(client, name) {
+ delete client.subscriptions[name];
+};
+
+PlayerServer.prototype.logout = function(client) {
+ client.user = this.createGuestUser();
+ // unsubscribe from subscriptions that the client no longer has permissions for
+ for (var name in client.subscriptions) {
+ var subscription = this.subscriptions[name];
+ if (!this.userHasPerm(client.user, subscription.perm)) {
+ this.unsubscribe(client, name);
+ }
+ }
+ this.sendUserMessage(client);
};
PlayerServer.prototype.handleNewClient = function(client) {
var self = this;
client.subscriptions = {};
- client.token = generateClientToken();
+
+ // this is a secret; if a user finds out the client.id they can execute
+ // commands on behalf of that user.
client.id = uuid();
- client.permissions = self.defaultPermissions;
+
+ client.user = self.createGuestUser();
+ client.streaming = false;
+ self.clients[client.id] = client;
client.on('message', onMessage);
- client.sendMessage('permissions', client.permissions);
+ self.sendUserMessage(client);
client.sendMessage('time', new Date());
- client.sendMessage('token', client.token);
+ client.sendMessage('token', client.id);
client.on('close', onClose);
- self.clients.push(client);
- self.clientIds[client.id] = client;
- self.clientTokens[client.token] = client;
PlayerServer.plugins.forEach(function(plugin) {
plugin.handleNewClient(client);
});
function onClose() {
- var index = self.clients.indexOf(client);
- if (index >= 0) self.clients.splice(index, 1);
- delete self.clientTokens[client.token];
- delete self.clientIds[client.id];
+ self.addEvent(client.user, 'part');
+ delete self.clients[client.id];
+ self.emit('users');
+ self.checkLastStreamerDisconnected();
}
function onMessage(name, args) {
var action = PlayerServer.actions[name];
if (!action) {
- console.warn("Invalid command:", name);
+ log.warn("Invalid command:", name);
client.sendMessage("error", "invalid command: " + JSON.stringify(name));
return;
}
var perm = action.permission;
- if (perm != null && !client.permissions[perm]) {
+ if (perm != null && !self.userHasPerm(client.user, perm)) {
var errText = "command " + JSON.stringify(name) +
" requires permission " + JSON.stringify(perm);
- console.warn("permissions error:", errText);
+ log.warn("permissions error:", errText);
client.sendMessage("error", errText);
return;
}
var argsType = Array.isArray(args) ? 'array' : typeof args;
if (action.args && argsType !== action.args) {
- console.warn("expected arg type", action.args, args);
+ log.warn("expected arg type", action.args, args);
client.sendMessage("error", "expected " + action.args + ": " + JSON.stringify(args));
return;
}
- console.info("ok command", name, args);
+ log.debug("ok command", name, args);
action.fn(self, client, args);
}
};
-function generateClientToken() {
- return crypto.pseudoRandomBytes(32).toString('base64');
+PlayerServer.prototype.userHasPerm = function(user, perm) {
+ if (!perm) {
+ return true;
+ }
+ user = user ? this.users[user.id] : null;
+ var perms = this.getUserPerms(user);
+ return perms[perm];
+};
+
+PlayerServer.prototype.getUserPerms = function(user) {
+ return (!user || !user.approved) ? this.guestUser.perms : user.perms;
+};
+
+PlayerServer.prototype.requestApproval = function(client) {
+ client.user.requested = true;
+ client.user.registered = true;
+ this.saveUser(client.user);
+ this.emit('users');
+};
+
+PlayerServer.prototype.login = function(client, username, password) {
+ var errText;
+ if (!password) {
+ errText = "empty password";
+ log.warn("Refusing to login:", errText);
+ client.sendMessage('error', errText);
+ return;
+ }
+ var user = this.usernameIndex[username];
+ if (!user) {
+ client.user.name = username;
+ client.user.password = password;
+ client.user.registered = true;
+
+ this.computeUsersIndex();
+ this.saveUser(client.user);
+
+ this.emit('users');
+
+ this.addEvent(client.user, 'register');
+ return;
+ }
+
+ if (user === client.user) {
+ user.name = username;
+ user.password = password;
+ this.computeUsersIndex();
+ this.saveUser(user);
+ this.emit('users');
+ return;
+ }
+
+ if (!user.password || user.password !== password) {
+ errText = "invalid login";
+ log.warn(errText);
+ client.sendMessage('error', errText);
+ return;
+ }
+
+ var oldUser = client.user;
+ client.user = user;
+
+ if (!oldUser.registered) {
+ var cmds = [];
+ this.mergeUsers(cmds, oldUser, user);
+ if (cmds.length > 0) {
+ this.db.batch(cmds, logIfError);
+ }
+ }
+
+ this.emit('users');
+
+ this.addEvent(client.user, 'login');
+
+ function logIfError(err) {
+ if (err) {
+ log.error("Unable to modify users:", err.stack);
+ }
+ }
+};
+
+PlayerServer.prototype.mergeUsers = function(cmds, dupeUser, canonicalUser) {
+ for (var eventId in this.events) {
+ var ev = this.events[eventId];
+ if (ev.userId === dupeUser.id) {
+ ev.userId = canonicalUser.id;
+ cmds.push({type: 'put', key: eventKey(ev), value: serializeEvent(ev)});
+ }
+ }
+ this.forEachClient(function(client) {
+ if (client.user === dupeUser) {
+ client.user = canonicalUser;
+ }
+ });
+ cmds.push({type: 'del', key: userKey(dupeUser)});
+ cmds.push({type: 'put', key: userKey(canonicalUser), value: serializeUser(canonicalUser)});
+ delete this.users[dupeUser.id];
+};
+
+PlayerServer.prototype.computeUsersIndex = function() {
+ this.usernameIndex = {};
+ this.oneLineAuth = {};
+ for (var id in this.users) {
+ var user = this.users[id];
+ this.usernameIndex[user.name] = user;
+ this.oneLineAuth[user.name + '/' + user.password] = user;
+ }
+};
+
+PlayerServer.prototype.sendUserMessage = function(client) {
+ client.sendMessage('user', {
+ id: client.user.id,
+ name: client.user.name,
+ perms: this.getUserPerms(client.user),
+ registered: client.user.registered,
+ requested: client.user.requested,
+ approved: client.user.approved,
+ });
+};
+
+PlayerServer.prototype.saveUser = function(user) {
+ this.db.put(userKey(user), serializeUser(user), function(err) {
+ if (err) {
+ log.error("Unable to save user:", err.stack);
+ }
+ });
+};
+
+PlayerServer.prototype.processApprovals = function(approvals) {
+ var cmds = [];
+ var eventsModified = false;
+
+ var connectedUserIds = {};
+ for (var id in this.clients) {
+ var client = this.clients[id];
+ connectedUserIds[client.user.id] = true;
+ }
+
+ for (var i = 0; i < approvals.length; i += 1) {
+ var approval = approvals[i];
+ var user = this.users[approval.id];
+ var replaceUser = this.users[approval.replaceId];
+ if (!user) continue;
+ if (!approval.approved) {
+ user.requested = false;
+ cmds.push({type: 'put', key: userKey(user), value: serializeUser(user)});
+ } else if (replaceUser && user !== replaceUser) {
+ replaceUser.name = approval.name;
+
+ eventsModified = true;
+ this.mergeUsers(cmds, user, replaceUser);
+ } else {
+ user.name = approval.name;
+ user.approved = true;
+ cmds.push({type: 'put', key: userKey(user), value: serializeUser(user)});
+ }
+ }
+
+ if (cmds.length > 0) {
+ this.computeUsersIndex();
+ this.db.batch(cmds, logIfError);
+ if (eventsModified) {
+ this.emit('events');
+ }
+ this.emit('users');
+ }
+
+ function logIfError(err) {
+ if (err) {
+ log.error("Unable to modify users:", err.stack);
+ }
+ }
+};
+
+PlayerServer.prototype.cacheEventsArray = function() {
+ var self = this;
+ self.eventsInOrder = Object.keys(self.events).map(eventById);
+ self.eventsInOrder.sort(asc);
+ self.eventsInOrder.forEach(function(ev, index) {
+ ev.index = index;
+ });
+
+ function asc(a, b) {
+ return operatorCompare(a.sortKey, b.sortKey);
+ }
+ function eventById(id) {
+ return self.events[id];
+ }
+};
+
+PlayerServer.prototype.addEvent = function(user, type, text, trackKey, pos, dedupe) {
+ var lastEvent = this.eventsInOrder[this.eventsInOrder.length - 1];
+ if (dedupe && lastEvent.type === type && lastEvent.userId === user.id) {
+ return;
+ }
+ var ev = {
+ id: uuid(),
+ date: new Date(),
+ userId: user && user.id,
+ type: type,
+ sortKey: keese(lastEvent && lastEvent.sortKey, null),
+ text: text,
+ trackId: trackKey,
+ pos: pos,
+ };
+ this.events[ev.id] = ev;
+ this.eventsInOrder.push(ev);
+ var extraEvents = this.eventsInOrder.length - MAX_EVENT_COUNT;
+ var cmds = [];
+ var usersChanged = 0;
+ var haveAdminUserChange = false;
+ if (extraEvents > 0) {
+ var scrubUserIds = {};
+ var i;
+ for (i = 0; i < extraEvents; i += 1) {
+ var thisEvent = this.eventsInOrder[i];
+ if (thisEvent.user && !thisEvent.user.approved) {
+ scrubUserIds[thisEvent.user.id] = true;
+ }
+ deleteEventCmd(cmds, thisEvent);
+ delete this.events[thisEvent.id];
+ }
+ this.eventsInOrder.splice(0, extraEvents);
+ // scrub users associated with these deleted events if they are not
+ // referenced anywhere else
+ for (i = 0; i < this.eventsInOrder.length; i += 1) {
+ delete scrubUserIds[this.eventsInOrder[i].userId];
+ }
+ for (var clientId in this.clients) {
+ delete scrubUserIds[this.clients[clientId].user.id];
+ }
+ for (var userId in scrubUserIds) {
+ usersChanged += 1;
+ var deletedUser = this.users[userId];
+ delete this.users[userId];
+ cmds.push({type: 'del', key: userKey(deletedUser)});
+ haveAdminUserChange = haveAdminUserChange || deletedUser.perms.admin;
+ }
+ }
+ cmds.push({type: 'put', key: eventKey(ev), value: serializeEvent(ev)});
+ this.db.batch(cmds, logIfError);
+ this.emit('events');
+ if (usersChanged > 0) {
+ this.emit('users');
+ }
+ if (haveAdminUserChange) {
+ this.emit('haveAdminUser');
+ }
+
+ function logIfError(err) {
+ if (err) {
+ log.error("Unable to modify events:", err.stack);
+ }
+ }
+};
+
+PlayerServer.prototype.updateUser = function(client, userId, perms) {
+ var user = this.users[userId];
+ if (!user) {
+ var errText = "invalid user id";
+ log.warn("unable to update user: " + errText);
+ client.sendMessage('error', errText);
+ return;
+ }
+
+ var guestUserChanged = (user === this.guestUser);
+
+ extend(user.perms, perms);
+ this.saveUser(user);
+
+
+ for (var id in this.clients) {
+ client = this.clients[id];
+ if (client.user === user || (guestUserChanged && !client.user.approved)) {
+ this.sendUserMessage(client);
+ }
+ }
+ this.emit('haveAdminUser');
+ this.emit('users');
+};
+
+PlayerServer.prototype.validateObject = function(client, val) {
+ if (typeof val !== 'object' || Array.isArray(val)) {
+ var errText = "expected object";
+ log.warn("invalid command: " + errText);
+ client.sendMessage('error', errText);
+ return false;
+ }
+ return true;
+};
+
+PlayerServer.prototype.validateFloat = function(client, val) {
+ if (typeof val !== 'number' || isNaN(val)) {
+ var errText = "expected number";
+ log.warn("invalid command: " + errText);
+ client.sendMessage('error', errText);
+ return false;
+ }
+ return true;
+};
+
+PlayerServer.prototype.validateString = function(client, val, maxLength) {
+ var errText;
+ if (typeof val !== 'string') {
+ errText = "expected string";
+ log.warn("invalid command: " + errText);
+ client.sendMessage('error', errText);
+ return false;
+ }
+
+ if (maxLength != null && val.length > maxLength) {
+ errText = "string too long";
+ log.warn("invalid command:", errText);
+ client.sendMessage('error', errText);
+ return false;
+ }
+
+ return true;
+};
+
+PlayerServer.prototype.deleteUsers = function(ids) {
+ var cmds = [];
+
+ var haveAdminUserChange = false;
+ var eventsChange = false;
+ for (var i = 0; i < ids.length; i += 1) {
+ var userId = ids[i];
+ var user = this.users[userId];
+ if (!user || user === this.guestUser) continue;
+
+ var deleteEvents = [];
+ var ev;
+ for (var eventId in this.events) {
+ ev = this.events[eventId];
+ if (ev.userId === userId) {
+ deleteEvents.push(ev);
+ }
+ }
+ eventsChange = eventsChange || (deleteEvents.length > 0);
+ for (var j = 0; j < deleteEvents.length; j += 1) {
+ ev = deleteEvents[j];
+ cmds.push({type: 'del', key: eventKey(ev)});
+ delete this.events[ev.id];
+ }
+
+ cmds.push({type: 'del', key: userKey(user)});
+ haveAdminUserChange = haveAdminUserChange || user.perms.admin;
+ delete this.users[userId];
+ for (var clientId in this.clients) {
+ var client = this.clients[clientId];
+ if (client.user === user) {
+ this.logout(client);
+ break;
+ }
+ }
+ }
+
+ if (cmds.length > 0) {
+ this.computeUsersIndex();
+ this.db.batch(cmds, logIfError);
+ }
+
+ if (eventsChange) {
+ this.emit('events');
+ }
+ this.emit('users');
+ if (haveAdminUserChange) {
+ this.emit('haveAdminUser');
+ }
+
+ function logIfError(err) {
+ if (err) {
+ log.error("Unable to delete users:", err.stack);
+ }
+ }
+};
+
+PlayerServer.prototype.getOneLineAuth = function(passwordString) {
+ return this.oneLineAuth[passwordString];
+};
+
+PlayerServer.deleteAllUsers = function(db) {
+ var cmds = [];
+ var usersDeleted = 0;
+ var eventsDeleted = 0;
+
+ var pend = new Pend();
+ pend.go(function(cb) {
+ dbIterate(db, USERS_KEY_PREFIX, processOne, cb);
+
+ function processOne(key, value) {
+ cmds.push({type: 'del', key: key});
+ usersDeleted += 1;
+ }
+ });
+ pend.go(function(cb) {
+ dbIterate(db, EVENTS_KEY_PREFIX, processOne, cb);
+ function processOne(key, value) {
+ cmds.push({type: 'del', key: key});
+ eventsDeleted += 1;
+ }
+ });
+ pend.wait(function(err) {
+ if (err) throw err;
+ db.batch(cmds, function(err) {
+ if (err) throw err;
+ log.info("Users deleted: " + usersDeleted);
+ log.info("Events deleted: " + eventsDeleted);
+ process.exit(0);
+ });
+ });
+};
+
+function deleteEventCmd(cmds, ev) {
+ cmds.push({type: 'del', key: eventKey(ev)});
+}
+
+function serializeUser(user) {
+ return JSON.stringify(user);
+}
+
+function deserializeUser(payload) {
+ return JSON.parse(payload);
+}
+
+function serializeEvent(ev) {
+ return JSON.stringify(ev);
+}
+
+function deserializeEvent(payload) {
+ return JSON.parse(payload);
+}
+
+function extend(o, src) {
+ for (var key in src) o[key] = src[key];
+ return o;
+}
+
+function userKey(user) {
+ return USERS_KEY_PREFIX + user.id;
+}
+
+function eventKey(ev) {
+ return EVENTS_KEY_PREFIX + ev.id;
+}
+
+function operatorCompare(a, b) {
+ return a < b ? -1 : a > b ? 1 : 0;
}
diff --git a/lib/plugins/lastfm.js b/lib/plugins/lastfm.js
index d5f39b8..7b6c29b 100644
--- a/lib/plugins/lastfm.js
+++ b/lib/plugins/lastfm.js
@@ -1,5 +1,6 @@
var LastFmNode = require('lastfm').LastFmNode;
var PlayerServer = require('../player_server');
+var log = require('../log');
module.exports = LastFm;
@@ -32,7 +33,8 @@ LastFm.prototype.initialize = function(cb) {
self.gb.db.get(DB_KEY, function(err, value) {
if (err) {
- if (err.type !== 'NotFoundError') return cb(err);
+ var notFoundError = /^NotFound/.test(err.message);
+ if (!notFoundError) return cb(err);
} else {
var state = JSON.parse(value);
self.scrobblers = state.scrobblers;
@@ -53,7 +55,7 @@ LastFm.prototype.persist = function() {
};
self.gb.db.put(DB_KEY, JSON.stringify(state), function(err) {
if (err) {
- console.error("Unable to persist lastfm state to db:", err.stack);
+ log.error("Unable to persist lastfm state to db:", err.stack);
}
});
};
@@ -79,7 +81,7 @@ LastFm.prototype.initActions = function() {
client.sendMessage('LastFmGetSessionSuccess', data);
},
error: function(error){
- console.error("error from last.fm auth.getSession:", error.message);
+ log.error("error from last.fm auth.getSession:", error.message);
client.sendMessage('LastFmGetSessionError', error.message);
}
}
@@ -93,7 +95,7 @@ LastFm.prototype.initActions = function() {
fn: function(playerServer, client, params) {
var existingUser = self.scrobblers[params.username];
if (existingUser) {
- console.warn("Trying to overwrite a scrobbler:", params.username);
+ log.warn("Trying to overwrite a scrobbler:", params.username);
return;
}
self.scrobblers[params.username] = params.session_key;
@@ -107,7 +109,7 @@ LastFm.prototype.initActions = function() {
fn: function(playerServer, client, params) {
var sessionKey = self.scrobblers[params.username];
if (sessionKey !== params.session_key) {
- console.warn("Invalid session key from user trying to remove scrobbler:", params.username);
+ log.warn("Invalid session key from user trying to remove scrobbler:", params.username);
return;
}
delete self.scrobblers[params.username];
@@ -122,7 +124,7 @@ LastFm.prototype.flushScrobbleQueue = function() {
var maxSimultaneous = 10;
var count = 0;
while ((params = self.scrobbles.shift()) != null && count++ < maxSimultaneous) {
- console.info("scrobbling " + params.track + " for session " + params.sk);
+ log.debug("scrobbling " + params.track + " for session " + params.sk);
params.handlers = {
error: onError,
};
@@ -131,7 +133,7 @@ LastFm.prototype.flushScrobbleQueue = function() {
self.persist();
function onError(error){
- console.error("error from last.fm track.scrobble:", error.stack);
+ log.error("error from last.fm track.scrobble:", error.stack);
if (!error.code || error.code === 11 || error.code === 16) {
// try again
self.scrobbles.push(params);
@@ -141,7 +143,7 @@ LastFm.prototype.flushScrobbleQueue = function() {
};
LastFm.prototype.queueScrobble = function(params){
- console.info("queueScrobble", params);
+ log.debug("queueScrobble", params);
this.scrobbles.push(params);
this.persist();
};
@@ -184,10 +186,10 @@ function checkScrobble() {
}
self.flushScrobbleQueue();
} else {
- console.warn("Not scrobbling " + dbFile.name + " - missing artist.");
+ log.debug("Not scrobbling " + dbFile.name + " - missing artist.");
}
} else {
- console.info("not scrobbling", dbFile.name, " - only listened for", self.playingTime);
+ log.debug("not scrobbling", dbFile.name, " - only listened for", self.playingTime);
}
}
self.lastPlayingItem = thisItem;
@@ -209,7 +211,7 @@ function updateNowPlaying() {
var dbFile = self.gb.player.libraryIndex.trackTable[track.key];
if (!dbFile.artistName) {
- console.warn("Not updating last.fm now playing for " + dbFile.name + ": missing artist");
+ log.debug("Not updating last.fm now playing for " + dbFile.name + ": missing artist");
return;
}
@@ -227,11 +229,11 @@ function updateNowPlaying() {
error: onError
}
};
- console.info("updateNowPlaying", props);
+ log.debug("updateNowPlaying", props);
self.lastFm.request("track.updateNowPlaying", props);
}
function onError(error){
- console.error("unable to update last.fm now playing:", error.message);
+ log.error("unable to update last.fm now playing:", error.message);
}
}
diff --git a/lib/plugins/ytdl.js b/lib/plugins/ytdl.js
index 87c8b14..4e963ad 100644
--- a/lib/plugins/ytdl.js
+++ b/lib/plugins/ytdl.js
@@ -1,5 +1,6 @@
var ytdl = require('ytdl-core');
var url = require('url');
+var log = require('../log');
module.exports = YtDlPlugin;
@@ -48,11 +49,11 @@ YtDlPlugin.prototype.importUrl = function(urlString, cb) {
}
}
if (YTDL_AUDIO_ENCODINGS.indexOf(bestFormat.audioEncoding) === -1) {
- console.warn("YouTube Import: unrecognized audio format:", bestFormat.audioEncoding);
+ log.warn("YouTube Import: unrecognized audio format:", bestFormat.audioEncoding);
}
var req = ytdl.downloadFromInfo(info, {filter: filter});
- var filename = info.title + '.' + bestFormat.container;
- cb(null, req, filename);
+ var filenameHintWithoutPath = info.title + '.' + bestFormat.container;
+ cb(null, req, filenameHintWithoutPath);
function filter(format) {
return format.audioBitrate === bestFormat.audioBitrate &&
diff --git a/lib/protocol_parser.js b/lib/protocol_parser.js
index 5a2e363..3390478 100644
--- a/lib/protocol_parser.js
+++ b/lib/protocol_parser.js
@@ -1,5 +1,6 @@
var Duplex = require('stream').Duplex;
var util = require('util');
+var log = require('./log');
module.exports = ProtocolParser;
@@ -34,12 +35,12 @@ ProtocolParser.prototype._write = function(chunk, encoding, callback) {
try {
jsonObject = JSON.parse(line);
} catch (err) {
- console.warn("received invalid json:", err.message);
+ log.warn("received invalid json:", err.message);
self.sendMessage("error", "invalid json: " + err.message);
return;
}
if (typeof jsonObject !== 'object') {
- console.warn("received json not an object:", jsonObject);
+ log.warn("received json not an object:", jsonObject);
self.sendMessage("error", "expected json object");
return;
}
diff --git a/lib/uuid.js b/lib/uuid.js
new file mode 100644
index 0000000..cea0357
--- /dev/null
+++ b/lib/uuid.js
@@ -0,0 +1,24 @@
+var crypto = require('crypto');
+var htmlSafe = {'/': '_', '+': '-'};
+
+module.exports = uuid;
+uuid.len = len;
+
+function uuid() {
+ // random string which is safe to put in an html id
+ return rando(24).toString('base64').replace(/[\/\+]/g, function(x) {
+ return htmlSafe[x];
+ });
+}
+
+function len(size) {
+ return rando(size).toString('base64');
+}
+
+function rando(size) {
+ try {
+ return crypto.randomBytes(size);
+ } catch (err) {
+ return crypto.pseudoRandomBytes(size);
+ }
+}
diff --git a/lib/web_socket_api_client.js b/lib/web_socket_api_client.js
index 5a96157..dc320ea 100644
--- a/lib/web_socket_api_client.js
+++ b/lib/web_socket_api_client.js
@@ -1,5 +1,6 @@
var EventEmitter = require('events').EventEmitter;
var util = require('util');
+var log = require('./log');
module.exports = WebSocketApiClient;
@@ -30,20 +31,20 @@ WebSocketApiClient.prototype.initialize = function() {
var self = this;
self.ws.on('message', function(data, flags) {
if (flags.binary) {
- console.warn("ignoring binary web socket message");
+ log.warn("ignoring binary web socket message");
return;
}
var msg;
try {
msg = JSON.parse(data);
} catch (err) {
- console.warn("received invalid JSON from web socket:", err.message);
+ log.warn("received invalid JSON from web socket:", err.message);
return;
}
self.emit('message', msg.name, msg.args);
});
self.ws.on('error', function(err) {
- console.error("web socket error:", err.stack);
+ log.error("web socket error:", err.stack);
});
self.ws.on('close', function() {
self.emit('close');
diff --git a/package.json b/package.json
index 4ad3831..414812a 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "groovebasin",
"description": "Music player server with a web-based interface inspired by Amarok 1.4",
"author": "Andrew Kelley <superjoe30 at gmail.com>",
- "version": "1.2.0",
+ "version": "1.3.0",
"licenses": [
{
"type": "MIT",
@@ -20,40 +20,35 @@
"url": "git://github.com/andrewrk/groovebasin.git"
},
"dependencies": {
- "archiver": "~0.10.1",
- "body-parser": "~1.4.3",
- "connect-static": "~1.2.1",
- "cookies": "~0.4.1",
- "express": "~4.4.3",
+ "connect-static": "~1.2.2",
+ "cookies": "~0.5.0",
+ "curlydiff": "~2.0.1",
+ "express": "~4.8.4",
"findit": "~2.0.0",
- "groove": "~2.2.3",
- "jsondiffpatch": "~0.1.7",
- "keese": "~1.0.0",
- "lastfm": "~0.9.0",
- "level": "~0.18.0",
- "mess": "~0.1.1",
+ "groove": "~2.2.6",
+ "keese": "~1.0.1",
+ "lastfm": "~0.9.2",
+ "leveldown": "~1.0.0",
+ "mess": "~0.1.2",
"mkdirp": "~0.5.0",
- "multiparty": "~3.3.0",
- "music-library-index": "~1.2.0",
- "mv": "~2.0.1",
+ "multiparty": "~3.3.2",
+ "music-library-index": "~1.2.2",
+ "mv": "~2.0.3",
"osenv": "~0.1.0",
- "pend": "~1.1.1",
- "requireindex": "~1.1.0",
- "semver": "~2.3.1",
- "serve-static": "~1.2.3",
- "superagent": "~0.18.0",
- "uuid": "~1.4.1",
- "ws": "~0.4.31",
- "ytdl-core": "0.x.x",
- "zfill": "0.0.1"
+ "pend": "~1.1.3",
+ "semver": "~3.0.1",
+ "serve-static": "~1.5.3",
+ "ws": "~0.4.32",
+ "yazl": "~2.0.1",
+ "ytdl-core": ">=0.2.4"
},
"devDependencies": {
- "browserify": "~4.2.0",
- "stylus": "~0.47.1"
+ "stylus": "~0.47.3",
+ "browserify-lite": "0.0.1"
},
"scripts": {
"start": "node lib/server.js",
"build": "npm install && ./build",
- "dev": "npm run build && npm start"
+ "dev": "npm run build && node lib/server.js --verbose"
}
}
diff --git a/src/client/app.js b/src/client/app.js
index bd77b04..dacfcb2 100644
--- a/src/client/app.js
+++ b/src/client/app.js
@@ -1,17 +1,22 @@
var $ = window.$;
var shuffle = require('mess');
-var querystring = require('querystring');
-var zfill = require('zfill');
var PlayerClient = require('./playerclient');
-var streaming = require('./streaming');
var Socket = require('./socket');
-var uuid = require('uuid');
+var uuid = require('./uuid');
var dynamicModeOn = false;
var hardwarePlaybackOn = false;
+var haveAdminUser = true;
-var downloadMenuZipName = null;
+var eventsListScrolledToBottom = true;
+var isBrowserTabActive = true;
+
+var tryingToStream = false;
+var actuallyStreaming = false;
+var actuallyPlaying = false;
+var stillBuffering = false;
+var streamAudio = new Audio();
var selection = {
ids: {
@@ -357,14 +362,15 @@ var MARGIN = 10;
var AUTO_EXPAND_LIMIT = 30;
var ICON_COLLAPSED = 'ui-icon-triangle-1-e';
var ICON_EXPANDED = 'ui-icon-triangle-1-se';
-var permissions = {};
+var myUser = {
+ perms: {},
+};
var socket = null;
var player = null;
var userIsSeeking = false;
var userIsVolumeSliding = false;
var started_drag = false;
var abortDrag = function(){};
-var myUserId = null;
var lastFmApiKey = null;
var LoadStatus = {
Init: 'Loading...',
@@ -372,26 +378,23 @@ var LoadStatus = {
GoodToGo: '[good to go]'
};
var repeatModeNames = ["Off", "One", "All"];
-var load_status = LoadStatus.Init;
-var settings_ui = {
- auth: {
- show_edit: false,
- password: ""
- }
-};
+var loadStatus = LoadStatus.Init;
+
var localState = {
- myUserIds: {},
- userName: null,
lastfm: {
username: null,
session_key: null,
scrobbling_on: false
},
+ authUsername: null,
authPassword: null,
autoQueueUploads: true,
};
var $document = $(document);
var $window = $(window);
+var $streamBtn = $('#stream-btn');
+var $clientVolSlider = $('#client-vol-slider');
+var $clientVol = $('#client-vol');
var $queueWindow = $('#queue-window');
var $leftWindow = $('#left-window');
var $queueItems = $('#queue-items');
@@ -418,19 +421,19 @@ var $queueHeader = $('#queue-header');
var $autoQueueUploads = $('#auto-queue-uploads');
var uploadInput = document.getElementById("upload-input");
var $uploadWidget = $("#upload-widget");
-var $settingsEditPassword = $('#settings-edit-password');
-var $settingsShowPassword = $('#settings-show-password');
+var $settingsRegister = $('#settings-register');
+var $settingsShowAuth = $('#settings-show-auth');
var $settingsAuthCancel = $('#settings-auth-cancel');
var $settingsAuthSave = $('#settings-auth-save');
var $settingsAuthEdit = $('#settings-auth-edit');
-var $settingsAuthClear = $('#settings-auth-clear');
+var $settingsAuthRequest = $('#settings-auth-request');
+var $settingsAuthLogout = $('#settings-auth-logout');
var streamUrlDom = document.getElementById('settings-stream-url');
var $authPermRead = $('#auth-perm-read');
var $authPermAdd = $('#auth-perm-add');
var $authPermControl = $('#auth-perm-control');
var $authPermAdmin = $('#auth-perm-admin');
var $lastFmSignOut = $('#lastfm-sign-out');
-var $authPassword = $('#auth-password');
var lastFmAuthUrlDom = document.getElementById('lastfm-auth-url');
var $settingsLastFmIn = $('#settings-lastfm-in');
var $settingsLastFmOut = $('#settings-lastfm-out');
@@ -441,10 +444,36 @@ var $editTagsDialog = $('#edit-tags');
var $queueMenu = $('#menu-queue');
var $libraryMenu = $('#menu-library');
var $toggleHardwarePlayback = $('#toggle-hardware-playback');
+var $toggleHardwarePlaybackLabel = $('#toggle-hardware-playback-label');
var $newPlaylistBtn = $('#new-playlist-btn');
var $emptyLibraryMessage = $('#empty-library-message');
var $libraryNoItems = $('#library-no-items');
var $libraryArtists = $('#library-artists');
+var $volNum = $('#vol-num');
+var $volWarning = $('#vol-warning');
+var $ensureAdminDiv = $('#ensure-admin');
+var $ensureAdminBtn = $('#ensure-admin-btn');
+var $authShowPassword = $('#auth-show-password');
+var $authUsername = $('#auth-username');
+var $authUsernameDisplay = $('#auth-username-display');
+var $authPassword = $('#auth-password');
+var $settingsUsers = $('#settings-users');
+var $settingsUsersSelect = $('#settings-users-select');
+var $settingsRequests = $('#settings-requests');
+var $settingsRequest = $('#settings-request');
+var $userPermRead = $('#user-perm-read');
+var $userPermAdd = $('#user-perm-add');
+var $userPermControl = $('#user-perm-control');
+var $userPermAdmin = $('#user-perm-admin');
+var $settingsDeleteUser = $('#settings-delete-user');
+var $requestReplace = $('#request-replace');
+var $requestName = $('#request-name');
+var $requestApprove = $('#request-approve');
+var $requestDeny = $('#request-deny');
+var $eventsOnlineUsers = $('#events-online-users');
+var $eventsList = $('#events-list');
+var $chatBox = $('#chat-box');
+var $chatBoxInput = $('#chat-box-input');
var tabs = {
library: {
@@ -459,11 +488,17 @@ var tabs = {
$pane: $('#playlists-pane'),
$tab: $('#playlists-tab'),
},
+ events: {
+ $pane: $('#events-pane'),
+ $tab: $('#events-tab'),
+ },
settings: {
$pane: $('#settings-pane'),
$tab: $('#settings-tab'),
},
};
+var activeTab = tabs.library;
+var $eventsTabSpan = tabs.events.$tab.find('span');
function saveLocalState(){
localStorage.setItem('state', JSON.stringify(localState));
@@ -532,28 +567,6 @@ function scrollThingToSelection($scrollArea, helpers){
}
}
-function downloadKeys(keys, zipName) {
- var $form = $(document.createElement('form'));
- $form.attr('action', "/download/custom");
- $form.attr('method', "post");
- $form.attr('target', "_blank");
- for (var i = 0; i < keys.length; i += 1) {
- var key = keys[i];
- var $input = $(document.createElement('input'));
- $input.attr('type', 'hidden');
- $input.attr('name', 'key');
- $input.attr('value', key);
- $form.append($input);
- }
- var $zipNameInput = $(document.createElement('input'));
- $zipNameInput.attr('type', 'hidden');
- $zipNameInput.attr('name', 'zipName');
- $zipNameInput.attr('value', zipName);
- $form.append($zipNameInput);
-
- $form.submit();
-}
-
function getDragPosition(x, y){
var ref$;
var result = {};
@@ -588,6 +601,10 @@ function renderPlaylistButtons(){
.button("refresh");
}
+function updateHaveAdminUserUi() {
+ $ensureAdminDiv.toggle(!haveAdminUser);
+}
+
function renderQueue(){
var itemList = player.queue.itemList || [];
var scrollTop = $queueItems.scrollTop();
@@ -613,13 +630,12 @@ function renderQueue(){
// overwrite existing dom entries
var $domItems = $queueItems.children();
- var item, track;
for (i = 0; i < itemList.length; i += 1) {
var $domItem = $($domItems[i]);
- item = itemList[i];
+ var item = itemList[i];
$domItem.attr('id', 'playlist-track-' + item.id);
$domItem.attr('data-id', item.id);
- track = item.track;
+ var track = item.track;
$domItem.find('.track').text(track.track || "");
$domItem.find('.title').text(track.name || "");
$domItem.find('.artist').text(track.artistName || "");
@@ -956,11 +972,20 @@ function updateSliderPos() {
function renderVolumeSlider() {
if (userIsVolumeSliding) return;
- var enabled = player.volume != null;
- if (enabled) {
- $volSlider.slider('option', 'value', player.volume);
+ $volSlider.slider('option', 'value', player.volume);
+ $volNum.text(Math.round(player.volume * 100));
+ $volWarning.toggle(player.volume > 1);
+}
+
+function getNowPlayingText(track) {
+ if (!track) {
+ return "(Deleted Track)";
}
- $volSlider.slider('option', 'disabled', !enabled);
+ var str = track.name + " - " + track.artistName;
+ if (track.albumName) {
+ str += " - " + track.albumName;
+ }
+ return str;
}
function renderNowPlaying() {
@@ -968,13 +993,11 @@ function renderNowPlaying() {
if (player.currentItem != null) {
track = player.currentItem.track;
}
+
+ updateTitle();
var trackDisplay;
if (track != null) {
- trackDisplay = track.name + " - " + track.artistName;
- if (track.albumName.length) {
- trackDisplay += " - " + track.albumName;
- }
- document.title = trackDisplay + " - " + BASE_TITLE;
+ trackDisplay = getNowPlayingText(track);
if (/Groove Basin/.test(track.name)) {
$("html").addClass('groovebasin');
} else {
@@ -987,7 +1010,6 @@ function renderNowPlaying() {
}
} else {
trackDisplay = " ";
- document.title = BASE_TITLE;
}
$trackDisplay.html(trackDisplay);
var oldClass;
@@ -1006,14 +1028,14 @@ function renderNowPlaying() {
}
function render(){
- var hide_main_err = load_status === LoadStatus.GoodToGo;
- $queueWindow.toggle(hide_main_err);
- $leftWindow.toggle(hide_main_err);
- $nowplaying.toggle(hide_main_err);
- $mainErrMsg.toggle(!hide_main_err);
- if (!hide_main_err) {
+ var hideMainErr = loadStatus === LoadStatus.GoodToGo;
+ $queueWindow.toggle(hideMainErr);
+ $leftWindow.toggle(hideMainErr);
+ $nowplaying.toggle(hideMainErr);
+ $mainErrMsg.toggle(!hideMainErr);
+ if (!hideMainErr) {
document.title = BASE_TITLE;
- $mainErrMsgText.text(load_status);
+ $mainErrMsgText.text(loadStatus);
return;
}
renderQueue();
@@ -1336,7 +1358,7 @@ var keyboardHandlers = (function(){
alt: false,
shift: null,
handler: function(){
- player.setVolume(player.volume - 0.10);
+ bumpVolume(-0.1);
}
};
var volumeUpHandler = {
@@ -1344,7 +1366,7 @@ var keyboardHandlers = (function(){
alt: false,
shift: null,
handler: function(){
- player.setVolume(player.volume + 0.10);
+ bumpVolume(0.1);
}
};
return {
@@ -1425,8 +1447,12 @@ var keyboardHandlers = (function(){
ctrl: false,
alt: false,
shift: null,
- handler: function(event){
- handleDeletePressed(event.shiftKey);
+ handler: function(event) {
+ if ((havePerm('admin') && event.shiftKey) ||
+ (havePerm('control') && !event.shiftKey))
+ {
+ handleDeletePressed(event.shiftKey);
+ }
},
},
// =
@@ -1447,22 +1473,22 @@ var keyboardHandlers = (function(){
shift: false,
handler: toggleDynamicMode,
},
- // S
- 72: {
+ // e
+ 69: {
ctrl: false,
alt: false,
- shift: true,
+ shift: false,
handler: function(){
- player.shuffle();
+ clickTab(tabs.settings);
},
},
- // l
- 76: {
+ // S
+ 72: {
ctrl: false,
alt: false,
- shift: false,
+ shift: true,
handler: function(){
- clickTab(tabs.library);
+ player.shuffle();
},
},
// r
@@ -1477,7 +1503,18 @@ var keyboardHandlers = (function(){
ctrl: false,
alt: false,
shift: false,
- handler: streaming.toggleStatus
+ handler: toggleStreamStatus
+ },
+ // t
+ 84: {
+ ctrl: false,
+ alt: false,
+ shift: false,
+ handler: function() {
+ clickTab(tabs.events);
+ $chatBoxInput.focus().select();
+ scrollEventsToBottom();
+ },
},
// i
73: {
@@ -1536,6 +1573,14 @@ var keyboardHandlers = (function(){
};
})();
+function bumpVolume(v) {
+ if (tryingToStream) {
+ setStreamVolume(streamAudio.volume + v);
+ } else {
+ player.setVolume(player.volume + v);
+ }
+}
+
function removeContextMenu() {
if ($queueMenu.is(":visible")) {
$queueMenu.hide();
@@ -1669,22 +1714,28 @@ function selectTreeRange() {
}
function sendAuth() {
- var pass = localState.authPassword;
- if (!pass) return;
- socket.send('password', pass);
+ if (!localState.authPassword || !localState.authUsername) return;
+ socket.send('login', {
+ username: localState.authUsername,
+ password: localState.authPassword,
+ });
}
-function settingsAuthSave(){
- settings_ui.auth.show_edit = false;
+function settingsAuthSave() {
+ localState.authUsername = $authUsername.val();
localState.authPassword = $authPassword.val();
saveLocalState();
- updateSettingsAuthUi();
sendAuth();
+ hideShowAuthEdit(false);
}
-function settingsAuthCancel(){
- settings_ui.auth.show_edit = false;
- updateSettingsAuthUi();
+function settingsAuthCancel() {
+ hideShowAuthEdit(false);
+}
+
+function hideShowAuthEdit(visible) {
+ $settingsRegister.toggle(visible);
+ $settingsShowAuth.toggle(!visible);
}
function performDrag(event, callbacks){
@@ -1859,11 +1910,9 @@ function setUpPlayQueueUi() {
}
if (!selection.isMulti()) {
var item = player.queue.itemTable[trackId];
- downloadMenuZipName = null;
$queueMenu.find('.download').attr('href', encodeDownloadHref(item.track.file));
} else {
- downloadMenuZipName = "songs";
- $queueMenu.find('.download').attr('href', '#');
+ $queueMenu.find('.download').attr('href', makeMultifileDownloadHref());
}
$queueMenu.show().offset({
left: event.pageX + 1,
@@ -1915,16 +1964,10 @@ function stopPropagation(event) {
function onDownloadContextMenu() {
removeContextMenu();
-
- if (downloadMenuZipName) {
- downloadKeys(selection.toTrackKeys(), downloadMenuZipName);
- return false;
- }
-
return true;
}
function onDeleteContextMenu() {
- if (!permissions.admin) return false;
+ if (!havePerm('admin')) return false;
removeContextMenu();
handleDeletePressed(true);
return false;
@@ -1932,7 +1975,7 @@ function onDeleteContextMenu() {
var editTagsTrackKeys = null;
var editTagsTrackIndex = null;
function onEditTagsContextMenu() {
- if (!permissions.admin) return false;
+ if (!havePerm('admin')) return false;
removeContextMenu();
editTagsTrackKeys = selection.toTrackKeys();
editTagsTrackIndex = 0;
@@ -2205,13 +2248,21 @@ function setUpNowPlayingUi(){
});
function setVol(event, ui){
if (event.originalEvent == null) return;
- player.setVolume(ui.value);
+ var snap = 0.05;
+ var val = ui.value;
+ if (Math.abs(val - 1) < snap) {
+ val = 1;
+ }
+ player.setVolume(val);
+ $volNum.text(Math.round(val * 100));
+ $volWarning.toggle(val > 1);
}
$volSlider.slider({
step: 0.01,
min: 0,
- max: 1,
+ max: 2,
change: setVol,
+ slide: setVol,
start: function(event, ui){
userIsVolumeSliding = true;
},
@@ -2232,7 +2283,12 @@ function clickTab(tab) {
unselectTabs();
tab.$tab.addClass('ui-state-active');
tab.$pane.show();
+ activeTab = tab;
handleResize();
+ if (tab === tabs.events) {
+ player.markAllEventsSeen();
+ renderUnseenChatCount();
+ }
}
function setUpTabListener(tab) {
@@ -2375,7 +2431,7 @@ function updateLastFmSettingsUi() {
settingsLastFmUserDom.setAttribute('href', "http://last.fm/user/" +
encodeURIComponent(localState.lastfm.username));
settingsLastFmUserDom.textContent = localState.lastfm.username;
- var authUrl = "http://www.last.fm/api/auth/?api_key=" +
+ var authUrl = "https://www.last.fm/api/auth?api_key=" +
encodeURIComponent(lastFmApiKey) + "&cb=" +
encodeURIComponent(location.protocol + "//" + location.host + "/");
lastFmAuthUrlDom.setAttribute('href', authUrl);
@@ -2386,16 +2442,59 @@ function updateLastFmSettingsUi() {
}
function updateSettingsAuthUi() {
- var showEdit = !!(localState.authPassword == null || settings_ui.auth.show_edit);
- $settingsEditPassword.toggle(showEdit);
- $settingsShowPassword.toggle(!showEdit);
- $settingsAuthCancel.toggle(!!localState.authPassword);
- $authPassword.val("");
- $authPermRead.toggle(!!permissions.read);
- $authPermAdd.toggle(!!permissions.add);
- $authPermControl.toggle(!!permissions.control);
- $authPermAdmin.toggle(!!permissions.admin);
- streamUrlDom.setAttribute('href', streaming.getUrl());
+ var i, user;
+ var request = null;
+ var selectedUserId = $settingsUsersSelect.val();
+ $settingsUsersSelect.empty();
+ for (i = 0; i < player.usersList.length; i += 1) {
+ user = player.usersList[i];
+ if (user.approved) {
+ $settingsUsersSelect.append($("<option/>", {
+ value: user.id,
+ text: user.name,
+ }));
+ selectedUserId = selectedUserId || user.id;
+ }
+ if (!user.approved && user.requested) {
+ request = request || user;
+ }
+ }
+ $settingsUsersSelect.val(selectedUserId);
+ updatePermsForSelectedUser();
+
+ if (request) {
+ $requestReplace.empty();
+ for (i = 0; i < player.usersList.length; i += 1) {
+ user = player.usersList[i];
+ if (user.id === PlayerClient.GUEST_USER_ID) {
+ user = request;
+ }
+ if (user.approved || user === request) {
+ $requestReplace.append($("<option/>", {
+ value: user.id,
+ text: user.name,
+ }));
+ }
+ }
+ $requestReplace.val(request.id);
+ $requestName.val(request.name);
+ }
+
+ $authPermRead.toggle(havePerm('read'));
+ $authPermAdd.toggle(havePerm('add'));
+ $authPermControl.toggle(havePerm('control'));
+ $authPermAdmin.toggle(havePerm('admin'));
+ streamUrlDom.setAttribute('href', getStreamUrl());
+ $settingsAuthRequest.toggle(myUser.registered && !myUser.requested && !myUser.approved);
+ $settingsAuthLogout.toggle(myUser.registered);
+ $settingsAuthEdit.button('option', 'label', myUser.registered ? 'Edit' : 'Register');
+ $settingsUsers.toggle(havePerm('admin'));
+ $settingsRequests.toggle(havePerm('admin') && !!request);
+
+ $toggleHardwarePlayback
+ .prop('disabled', !havePerm('admin'))
+ .button('refresh');
+ $toggleHardwarePlaybackLabel.attr('title', havePerm('admin') ? "" : "Requires admin privilege.");
}
function updateSettingsAdminUi() {
@@ -2412,7 +2511,18 @@ function setUpSettingsUi(){
$settingsAuthCancel.button();
$settingsAuthSave.button();
$settingsAuthEdit.button();
- $settingsAuthClear.button();
+ $settingsAuthLogout.button();
+ $ensureAdminBtn.button();
+ $settingsAuthRequest.button();
+ $userPermRead.button();
+ $userPermAdd.button();
+ $userPermControl.button();
+ $userPermAdmin.button();
+ $settingsDeleteUser.button();
+
+ $ensureAdminDiv.on('click', function(event) {
+ socket.send('ensureAdminUser');
+ });
$lastFmSignOut.on('click', function(event) {
localState.lastfm.username = null;
@@ -2446,37 +2556,314 @@ function setUpSettingsUi(){
updateSettingsAdminUi();
});
$settingsAuthEdit.on('click', function(event) {
- settings_ui.auth.show_edit = true;
+ $authUsername.val(localState.authUsername);
+ $authPassword.val(localState.authPassword);
+ hideShowAuthEdit(true);
+ $authUsername.focus().select();
+ });
+ $settingsAuthSave.on('click', function(event){
+ settingsAuthSave();
+ });
+ $settingsAuthCancel.on('click', function(event) {
+ settingsAuthCancel();
+ });
+ $authUsername.on('keydown', handleUserOrPassKeyDown);
+ $authPassword.on('keydown', handleUserOrPassKeyDown);
+ $authShowPassword.on('change', function(event) {
+ var showPw = $authShowPassword.prop('checked');
+ $authPassword.get(0).type = showPw ? 'text' : 'password';
+ });
+ $settingsAuthRequest.on('click', function(event) {
+ socket.send('requestApproval');
+ myUser.requested = true;
updateSettingsAuthUi();
- $authPassword
- .focus()
- .val("")
- .select();
});
- $settingsAuthClear.on('click', function(event) {
+ $settingsAuthLogout.on('click', function(event) {
+ localState.authUsername = null;
localState.authPassword = null;
saveLocalState();
- settings_ui.auth.password = "";
+ socket.send('logout');
+ myUser.registered = false;
updateSettingsAuthUi();
});
- $settingsAuthSave.on('click', function(event){
- settingsAuthSave();
+ $userPermRead.on('change', updateSelectedUserPerms);
+ $userPermAdd.on('change', updateSelectedUserPerms);
+ $userPermControl.on('change', updateSelectedUserPerms);
+ $userPermAdmin.on('change', updateSelectedUserPerms);
+ $settingsUsersSelect.on('change', updatePermsForSelectedUser);
+
+ $settingsDeleteUser.on('click', function(event) {
+ var selectedUserId = $settingsUsersSelect.val();
+ socket.send('deleteUsers', [selectedUserId]);
});
- $settingsAuthCancel.on('click', function(event) {
+
+ $requestApprove.on('click', function(event) {
+ handleApproveDeny(true);
+ });
+ $requestDeny.on('click', function(event) {
+ handleApproveDeny(false);
+ });
+}
+
+function handleApproveDeny(approved) {
+ var request = null;
+ for (var i = 0; i < player.usersList.length; i += 1) {
+ var user = player.usersList[i];
+ if (!user.approved && user.requested) {
+ request = user;
+ break;
+ }
+ }
+ if (!request) return;
+ socket.send('approve', [{
+ id: request.id,
+ replaceId: $requestReplace.val(),
+ approved: approved,
+ name: $requestName.val(),
+ }]);
+}
+
+function updatePermsForSelectedUser() {
+ var selectedUserId = $settingsUsersSelect.val();
+ var user = player.usersTable[selectedUserId];
+ if (!user) return;
+ $userPermRead.prop('checked', user.perms.read).button('refresh');
+ $userPermAdd.prop('checked', user.perms.add).button('refresh');
+ $userPermControl.prop('checked', user.perms.control).button('refresh');
+ $userPermAdmin.prop('checked', user.perms.admin).button('refresh');
+
+ $settingsDeleteUser.prop('disabled', selectedUserId === PlayerClient.GUEST_USER_ID).button('refresh');
+}
+
+function updateSelectedUserPerms(event) {
+ socket.send('updateUser', {
+ userId: $settingsUsersSelect.val(),
+ perms: {
+ read: $userPermRead.prop('checked'),
+ add: $userPermAdd.prop('checked'),
+ control: $userPermControl.prop('checked'),
+ admin: $userPermAdmin.prop('checked'),
+ },
+ });
+ return false;
+}
+
+function handleUserOrPassKeyDown(event) {
+ event.stopPropagation();
+ if (event.which === 27) {
settingsAuthCancel();
+ } else if (event.which === 13) {
+ settingsAuthSave();
+ }
+}
+
+function setUpEventsUi() {
+ $eventsList.on('scroll', function(event) {
+ eventsListScrolledToBottom = ($eventsList.get(0).scrollHeight - $eventsList.scrollTop()) === $eventsList.outerHeight();
});
- $authPassword.on('keydown', function(event) {
+ $chatBoxInput.on('keydown', function(event) {
event.stopPropagation();
- settings_ui.auth.password = $authPassword.val();
if (event.which === 27) {
- settingsAuthCancel();
+ $chatBoxInput.blur();
+ return false;
} else if (event.which === 13) {
- settingsAuthSave();
+ var msg = $chatBoxInput.val().trim();
+ setTimeout(clearChatInputValue, 0);
+ if (!msg.length) {
+ return false;
+ }
+ socket.send('chat', msg);
+ return false;
}
});
- $authPassword.on('keyup', function(event) {
- settings_ui.auth.password = $authPassword.val();
- });
+
+}
+
+function clearChatInputValue() {
+ $chatBoxInput.val("");
+}
+
+function renderUnseenChatCount() {
+ var eventsTabText = (player.unseenChatCount > 0) ?
+ ("Events (" + player.unseenChatCount + ")") : "Events";
+ $eventsTabSpan.text(eventsTabText);
+ updateTitle();
+}
+
+function updateTitle() {
+ var track = player.currentItem && player.currentItem.track;
+ var prefix = (player.unseenChatCount > 0) ? ("(" + player.unseenChatCount + ") ") : "";
+ if (track) {
+ document.title = prefix + getNowPlayingText(track) + " - " + BASE_TITLE;
+ } else {
+ document.title = prefix + BASE_TITLE;
+ }
+}
+
+function renderEvents() {
+ var eventsListDom = $eventsList.get(0);
+ var scrollTop = $eventsList.scrollTop();
+
+ renderUnseenChatCount();
+
+ // add the missing dom entries
+ var i, ev;
+ for (i = eventsListDom.childElementCount; i < player.eventsList.length; i += 1) {
+ $eventsList.append(
+ '<div class="event">' +
+ '<span class="name"></span>' +
+ '<span class="msg"></span>' +
+ '<div style="clear: both;"></div>' +
+ '</div>');
+ }
+ // remove extra dom entries
+ var domItem;
+ while (player.eventsList.length < eventsListDom.childElementCount) {
+ eventsListDom.removeChild(eventsListDom.lastChild);
+ }
+ // overwrite existing dom entries
+ var $domItems = $eventsList.children();
+ for (i = 0; i < player.eventsList.length; i += 1) {
+ var $domItem = $($domItems[i]);
+ ev = player.eventsList[i];
+ var userText = ev.user ? ev.user.name : "*";
+ $domItem.removeClass().addClass('event').addClass(ev.type);
+ $domItem.find('.name').text(userText).attr('title', ev.date.toString());
+ $domItem.find('.msg').text(getEventMessage(ev));
+ }
+
+ if (eventsListScrolledToBottom) {
+ scrollEventsToBottom();
+ } else {
+ $eventsList.scrollTop(scrollTop);
+ }
+}
+
+function scrollEventsToBottom() {
+ eventsListScrolledToBottom = true;
+ $eventsList.scrollTop(1000000);
+}
+
+var eventTypeMessageFns = {
+ chat: function(ev) {
+ return ev.text;
+ },
+ currentTrack: function(ev) {
+ return "Now playing: " + getNowPlayingText(ev.track);
+ },
+ import: function(ev) {
+ if (ev.user) {
+ return "imported " + getNowPlayingText(ev.track);
+ } else {
+ return "anonymous user imported " + getNowPlayingText(ev.track);
+ }
+ },
+ login: function(ev) {
+ return "logged in";
+ },
+ move: function(ev) {
+ return "moved queue items";
+ },
+ part: function(ev) {
+ return "disconnected";
+ },
+ pause: function(ev) {
+ return "pressed pause";
+ },
+ play: function(ev) {
+ return "pressed play";
+ },
+ queue: function(ev) {
+ if (ev.track) {
+ return "added to the queue: " + getNowPlayingText(ev.track);
+ } else {
+ return "added " + ev.pos + " tracks to the queue";
+ }
+ },
+ remove: function(ev) {
+ if (ev.track) {
+ return "removed from the queue: " + getNowPlayingText(ev.track);
+ } else {
+ return "removed " + ev.pos + " tracks from the queue";
+ }
+ },
+ register: function(ev) {
+ return "registered";
+ },
+ seek: function(ev) {
+ if (ev.pos === 0) {
+ return "chose a different song";
+ } else {
+ return "seeked to " + formatTime(ev.pos);
+ }
+ },
+ stop: function(ev) {
+ return "pressed stop";
+ },
+ streamStart: function(ev) {
+ if (ev.user) {
+ return "started streaming";
+ } else {
+ return "anonymous user started streaming";
+ }
+ },
+ streamStop: function(ev) {
+ if (ev.user) {
+ return "stopped streaming";
+ } else {
+ return "anonymous user stopped streaming";
+ }
+ },
+};
+function getEventMessage(ev) {
+ var fn = eventTypeMessageFns[ev.type];
+ if (!fn) throw new Error("Unknown event type: " + ev.type);
+ return fn(ev);
+}
+
+function renderOnlineUsers() {
+ var i;
+ var user;
+ var sortedConnectedUsers = [];
+ for (i = 0; i < player.usersList.length; i += 1) {
+ user = player.usersList[i];
+ if (user.connected) {
+ sortedConnectedUsers.push(user);
+ }
+ }
+
+ var scrollTop = $eventsOnlineUsers.scrollTop();
+
+
+ // add the missing dom entries
+ var onlineUserDom = $eventsOnlineUsers.get(0);
+ var heightChanged = onlineUserDom.childElementCount !== sortedConnectedUsers.length;
+ for (i = onlineUserDom.childElementCount; i < sortedConnectedUsers.length; i += 1) {
+ $eventsOnlineUsers.append(
+ '<div class="user">' +
+ '<span class="streaming ui-icon ui-icon-signal-diag"></span>' +
+ '<span class="name"></span>' +
+ '</div>');
+ }
+ // remove extra dom entries
+ var domItem;
+ while (sortedConnectedUsers.length < onlineUserDom.childElementCount) {
+ onlineUserDom.removeChild(onlineUserDom.lastChild);
+ }
+ // overwrite existing dom entries
+ var $domItems = $eventsOnlineUsers.children();
+ for (i = 0; i < sortedConnectedUsers.length; i += 1) {
+ var $domItem = $($domItems[i]);
+ user = sortedConnectedUsers[i];
+ $domItem.find('.name').text(user.name);
+ $domItem.find('.streaming').toggle(user.streaming);
+ }
+
+ $eventsOnlineUsers.scrollTop(scrollTop);
+
+ if (heightChanged) {
+ handleResize();
+ }
}
var searchTimer = null;
@@ -2545,9 +2932,9 @@ function setUpLibraryUi(){
return false;
}
});
- $libFilter.on('keyup', function(event){
- ensureSearchHappensSoon();
- });
+ $libFilter.on('keyup', ensureSearchHappensSoon);
+ $libFilter.on('cut', ensureSearchHappensSoon);
+ $libFilter.on('paste', ensureSearchHappensSoon);
genericTreeUi($library, {
toggleExpansion: toggleLibraryExpansion,
isSelectionOwner: function(){
@@ -2682,12 +3069,12 @@ function genericTreeUi($elem, options){
selection.selectOnly(type, key);
refreshSelection();
}
- var track = null;
+ var singleTrack = null;
if (!selection.isMulti()) {
if (type === 'track') {
- track = player.searchResults.trackTable[key];
+ singleTrack = player.searchResults.trackTable[key];
} else if (type === 'stored_playlist_item') {
- track = player.stored_playlist_item_table[key].track;
+ singleTrack = player.stored_playlist_item_table[key].track;
}
}
var $deletePlaylistLi = $libraryMenu.find('.delete-playlist').closest('li');
@@ -2704,12 +3091,10 @@ function genericTreeUi($elem, options){
}
var $downloadItem = $libraryMenu.find('.download');
- if (track) {
- downloadMenuZipName = null;
- $downloadItem.attr('href', encodeDownloadHref(track.file));
+ if (singleTrack) {
+ $downloadItem.attr('href', encodeDownloadHref(singleTrack.file));
} else {
- downloadMenuZipName = zipNameForSelCursor();
- $downloadItem.attr('href', '#');
+ $downloadItem.attr('href', makeMultifileDownloadHref());
}
$libraryMenu.show().offset({
left: event.pageX + 1,
@@ -2728,21 +3113,9 @@ function encodeDownloadHref(file) {
return 'library/' + encodeURI(file).replace(/#/g, "%23");
}
-function zipNameForSelCursor() {
- switch (selection.type) {
- case 'artist':
- return player.library.artistTable[selection.cursor].name;
- case 'album':
- return player.library.albumTable[selection.cursor].name;
- case 'track':
- return "songs";
- case 'stored_playlist':
- return player.stored_playlist_table[selection.cursor].name;
- case 'stored_playlist_item':
- return "songs";
- default:
- throw new Error("bad selection cursor type: " + selection.type);
- }
+function makeMultifileDownloadHref() {
+ var keys = selection.toTrackKeys();
+ return "/download/keys?" + keys.join("&");
}
function updateMenuDisableState($menu) {
@@ -2752,7 +3125,7 @@ function updateMenuDisableState($menu) {
};
for (var permName in menuPermDoms) {
var $item = menuPermDoms[permName];
- if (permissions[permName]) {
+ if (havePerm(permName)) {
$item
.removeClass('ui-state-disabled')
.attr('title', '');
@@ -2774,6 +3147,26 @@ function setUpUi(){
setUpUploadUi();
setUpSettingsUi();
setUpEditTagsUi();
+ setUpEventsUi();
+ setUpStreamUi();
+}
+
+function setUpStreamUi() {
+ $streamBtn.button({
+ icons: {
+ primary: "ui-icon-signal-diag"
+ }
+ });
+ $streamBtn.on('click', toggleStreamStatus);
+ $clientVolSlider.slider({
+ step: 0.01,
+ min: 0,
+ max: 1,
+ value: localState.clientVolume || 1,
+ change: setVol,
+ slide: setVol,
+ });
+ $clientVol.hide();
}
function toAlbumId(s) {
@@ -2792,22 +3185,26 @@ function toStoredPlaylistId(s) {
return "stored-pl-pl-" + toHtmlId(s);
}
-function handleResize(){
+function handleResize() {
+ var eventsScrollTop = $eventsList.scrollTop();
+
$nowplaying.width(MARGIN);
+
+ setAllTabsHeight(MARGIN);
$queueWindow.height(MARGIN);
$leftWindow.height(MARGIN);
$library.height(MARGIN);
$upload.height(MARGIN);
$queueItems.height(MARGIN);
$nowplaying.width($document.width() - MARGIN * 2);
- var second_layer_top = $nowplaying.offset().top + $nowplaying.height() + MARGIN;
+ var secondLayerTop = $nowplaying.offset().top + $nowplaying.height() + MARGIN;
$leftWindow.offset({
left: MARGIN,
- top: second_layer_top
+ top: secondLayerTop
});
$queueWindow.offset({
left: $leftWindow.offset().left + $leftWindow.width() + MARGIN,
- top: second_layer_top
+ top: secondLayerTop
});
$queueWindow.width($window.width() - $queueWindow.offset().left - MARGIN);
$leftWindow.height($window.height() - $leftWindow.offset().top);
@@ -2815,16 +3212,147 @@ function handleResize(){
var tabContentsHeight = $leftWindow.height() - $tabs.height() - MARGIN;
$library.height(tabContentsHeight - $libHeader.height());
$upload.height(tabContentsHeight);
+ $eventsList.height(tabContentsHeight - $eventsOnlineUsers.height() - $chatBox.height());
+
+ setAllTabsHeight(tabContentsHeight);
$queueItems.height($queueWindow.height() - $queueHeader.position().top - $queueHeader.height());
+
+ if (eventsListScrolledToBottom) {
+ scrollEventsToBottom();
+ }
}
-function refreshPage(){
+
+function refreshPage() {
location.href = location.protocol + "//" + location.host + "/";
}
+function setAllTabsHeight(h) {
+ for (var name in tabs) {
+ var tab = tabs[name];
+ tab.$pane.height(h);
+ }
+}
+
+function onStreamLabelDown(event) {
+ event.stopPropagation();
+}
+
+function getStreamerCount() {
+ var count = player.streamers;
+ player.usersList.forEach(function(user) {
+ if (user.streaming) count += 1;
+ });
+ return count;
+}
+
+function getStreamStatusLabel() {
+ if (tryingToStream) {
+ if (actuallyStreaming) {
+ if (stillBuffering) {
+ return "Buffering";
+ } else {
+ return "On";
+ }
+ } else {
+ return "Paused";
+ }
+ } else {
+ return "Off";
+ }
+}
+
+function getStreamButtonLabel() {
+ return getStreamerCount() + " Stream: " + getStreamStatusLabel();
+}
+
+function renderStreamButton(){
+ var label = getStreamButtonLabel();
+ $streamBtn
+ .button("option", "label", label)
+ .prop("checked", tryingToStream)
+ .button("refresh");
+ $clientVol.toggle(tryingToStream);
+}
+
+function toggleStreamStatus() {
+ tryingToStream = !tryingToStream;
+ sendStreamingStatus();
+ renderStreamButton();
+ updateStreamPlayer();
+ return false;
+}
+
+function sendStreamingStatus() {
+ socket.send("setStreaming", tryingToStream);
+}
+
+function getStreamUrl() {
+ // keep the URL relative so that reverse proxies can work
+ return "stream.mp3";
+}
+
+function onStreamPlaying() {
+ stillBuffering = false;
+ renderStreamButton();
+}
+
+function clearStreamBuffer() {
+ if (tryingToStream) {
+ tryingToStream = !tryingToStream;
+ updateStreamPlayer();
+ tryingToStream = !tryingToStream;
+ updateStreamPlayer();
+ }
+}
+
+function updateStreamPlayer() {
+ if (actuallyStreaming !== tryingToStream || actuallyPlaying !== player.isPlaying) {
+ if (tryingToStream) {
+ streamAudio.src = getStreamUrl();
+ streamAudio.load();
+ if (player.isPlaying) {
+ streamAudio.play();
+ stillBuffering = true;
+ actuallyPlaying = true;
+ } else {
+ streamAudio.pause();
+ stillBuffering = false;
+ actuallyPlaying = false;
+ }
+ } else {
+ streamAudio.pause();
+ streamAudio.src = "";
+ streamAudio.load();
+ stillBuffering = false;
+ actuallyPlaying = false;
+ }
+ actuallyStreaming = tryingToStream;
+ }
+ renderStreamButton();
+}
+
+function setVol(event, ui) {
+ if (event.originalEvent == null) return;
+ setStreamVolume(ui.value);
+}
+
+function setStreamVolume(v) {
+ if (v < 0) v = 0;
+ if (v > 1) v = 1;
+ streamAudio.volume = v;
+ localState.clientVolume = v;
+ saveLocalState();
+ $clientVolSlider.slider('option', 'value', streamAudio.volume);
+}
+
+window.addEventListener('focus', onWindowFocus, false);
+window.addEventListener('blur', onWindowBlur, false);
+streamAudio.addEventListener('playing', onStreamPlaying, false);
+document.getElementById('stream-btn-label').addEventListener('mousedown', onStreamLabelDown, false);
$document.ready(function(){
loadLocalState();
socket = new Socket();
- var queryObj = querystring.parse(location.search.substring(1));
+ var queryObj = parseQueryString();
if (queryObj.token) {
socket.on('connect', function() {
socket.send('LastFmGetSession', queryObj.token);
@@ -2847,8 +3375,17 @@ $document.ready(function(){
updateSettingsAdminUi();
});
socket.on('LastFmApiKey', updateLastFmApiKey);
- socket.on('permissions', function(data){
- permissions = data;
+ socket.on('user', function(data) {
+ myUser = data;
+ $authUsernameDisplay.text(myUser.name);
+ if (!localState.authUsername || !localState.authPassword) {
+ // We didn't have a user account saved. The server assigned us a name.
+ // Generate a password and call dibs on the account.
+ localState.authUsername = myUser.name;
+ localState.authPassword = uuid();
+ saveLocalState();
+ sendAuth();
+ }
updateSettingsAuthUi();
});
socket.on('token', function(token) {
@@ -2863,14 +3400,26 @@ $document.ready(function(){
renderPlaylistButtons();
triggerRenderQueue();
});
+ socket.on('haveAdminUser', function(data) {
+ haveAdminUser = data;
+ updateHaveAdminUserUi();
+ });
socket.on('connect', function(){
sendAuth();
+ sendStreamingStatus();
socket.send('subscribe', {name: 'dynamicModeOn'});
socket.send('subscribe', {name: 'hardwarePlayback'});
- load_status = LoadStatus.GoodToGo;
+ socket.send('subscribe', {name: 'haveAdminUser'});
+ loadStatus = LoadStatus.GoodToGo;
render();
});
player = new PlayerClient(socket);
+ player.on('users', function() {
+ updateSettingsAuthUi();
+ renderEvents();
+ renderOnlineUsers();
+ renderStreamButton();
+ });
player.on('libraryupdate', triggerRenderLibrary);
player.on('queueUpdate', triggerRenderQueue);
player.on('scanningUpdate', triggerRenderQueue);
@@ -2880,17 +3429,41 @@ $document.ready(function(){
renderPlaylistButtons();
labelPlaylistItems();
});
+ player.on('events', function() {
+ if (activeTab === tabs.events && isBrowserTabActive) {
+ player.markAllEventsSeen();
+ }
+ renderEvents();
+ });
+ player.on('currentTrack', updateStreamPlayer);
+ player.on('streamers', renderStreamButton);
+ socket.on('seek', clearStreamBuffer);
socket.on('disconnect', function(){
- load_status = LoadStatus.NoServer;
+ loadStatus = LoadStatus.NoServer;
render();
});
+ socket.on('error', function(err) {
+ console.error(err);
+ });
+
setUpUi();
- streaming.init(player, socket);
render();
$window.resize(handleResize);
window._debug_player = player;
});
+function onWindowFocus() {
+ isBrowserTabActive = true;
+ if (activeTab === tabs.events) {
+ player.markAllEventsSeen();
+ renderUnseenChatCount();
+ }
+}
+
+function onWindowBlur() {
+ isBrowserTabActive = false;
+}
+
function compareArrays(arr1, arr2) {
for (var i1 = 0; i1 < arr1.length; i1 += 1) {
var val1 = arr1[i1];
@@ -2926,3 +3499,24 @@ function toHtmlId(string) {
return "_" + c.charCodeAt(0) + "_";
});
}
+
+function zfill(number, size) {
+ number = String(number);
+ while (number.length < size) number = "0" + number;
+ return number;
+}
+
+function havePerm(permName) {
+ return !!(myUser && myUser.perms[permName]);
+}
+
+function parseQueryString(s) {
+ s = s || location.search.substring(1);
+ var o = {};
+ var pairs = s.split('&');
+ pairs.forEach(function(pair) {
+ var keyValueArr = pair.split('=');
+ o[keyValueArr[0]] = keyValueArr[1];
+ });
+ return o;
+}
diff --git a/src/client/event_emitter.js b/src/client/event_emitter.js
new file mode 100644
index 0000000..473f26d
--- /dev/null
+++ b/src/client/event_emitter.js
@@ -0,0 +1,30 @@
+var slice = Array.prototype.slice;
+
+module.exports = EventEmitter;
+
+function EventEmitter() {
+ this.listeners = {};
+}
+
+EventEmitter.prototype.on = function(name, listener) {
+ var listeners = this.listeners[name] = (this.listeners[name] || []) ;
+ listeners.push(listener);
+};
+
+EventEmitter.prototype.emit = function(name) {
+ var args = slice.call(arguments, 1);
+ var listeners = this.listeners[name];
+ if (!listeners) return;
+ for (var i = 0; i < listeners.length; i += 1) {
+ var listener = listeners[i];
+ listener.apply(null, args);
+ }
+};
+
+EventEmitter.prototype.removeListener = function(name, listener) {
+ var listeners = this.listeners[name];
+ if (!listeners) return;
+ var badIndex = listeners.indexOf(listener);
+ if (badIndex === -1) return;
+ listeners.splice(badIndex, 1);
+};
diff --git a/src/client/inherits.js b/src/client/inherits.js
new file mode 100644
index 0000000..ba11491
--- /dev/null
+++ b/src/client/inherits.js
@@ -0,0 +1,11 @@
+module.exports = function inherits(ctor, superCtor) {
+ ctor.super_ = superCtor;
+ ctor.prototype = Object.create(superCtor.prototype, {
+ constructor: {
+ value: ctor,
+ enumerable: false,
+ writable: true,
+ configurable: true,
+ },
+ });
+};
diff --git a/src/client/playerclient.js b/src/client/playerclient.js
index 828c2cf..92940d1 100644
--- a/src/client/playerclient.js
+++ b/src/client/playerclient.js
@@ -1,9 +1,9 @@
-var EventEmitter = require('events').EventEmitter;
-var util = require('util');
-var uuid = require('uuid');
+var EventEmitter = require('./event_emitter');
+var inherits = require('./inherits');
+var uuid = require('./uuid');
var MusicLibraryIndex = require('music-library-index');
var keese = require('keese');
-var jsondiffpatch = require('jsondiffpatch');
+var curlydiff = require('curlydiff');
module.exports = PlayerClient;
@@ -14,7 +14,9 @@ PlayerClient.REPEAT_OFF = 0;
PlayerClient.REPEAT_ONE = 1;
PlayerClient.REPEAT_ALL = 2;
-util.inherits(PlayerClient, EventEmitter);
+PlayerClient.GUEST_USER_ID = "(guest)";
+
+inherits(PlayerClient, EventEmitter);
function PlayerClient(socket) {
EventEmitter.call(this);
@@ -22,6 +24,7 @@ function PlayerClient(socket) {
self.socket = socket;
self.serverTimeOffset = 0;
self.serverTrackStartDate = null;
+
self.queueFromServer = undefined;
self.queueFromServerVersion = null;
self.libraryFromServer = undefined;
@@ -30,14 +33,19 @@ function PlayerClient(socket) {
self.scanningFromServerVersion = null;
self.playlistsFromServer = undefined;
self.playlistsFromServerVersion = null;
+ self.eventsFromServer = undefined;
+ self.eventsFromServerVersion = null;
+ self.usersFromServer = undefined;
+ self.usersFromServerVersion = null;
+
self.resetServerState();
self.socket.on('disconnect', function() {
self.resetServerState();
});
if (self.socket.isConnected) {
- self.handleConnectionStart();
+ self.resubscribe();
} else {
- self.socket.on('connect', self.handleConnectionStart.bind(self));
+ self.socket.on('connect', self.resubscribe.bind(self));
}
self.socket.on('time', function(o) {
self.serverTimeOffset = new Date(o) - new Date();
@@ -70,8 +78,7 @@ function PlayerClient(socket) {
self.socket.on('queue', function(o) {
if (o.reset) self.queueFromServer = undefined;
- self.queueFromServer = jsondiffpatch.patch(self.queueFromServer, o.delta);
- deleteUndefineds(self.queueFromServer);
+ self.queueFromServer = curlydiff.apply(self.queueFromServer, o.delta);
self.queueFromServerVersion = o.version;
self.updateQueueIndex();
self.emit('statusupdate');
@@ -80,8 +87,7 @@ function PlayerClient(socket) {
self.socket.on('library', function(o) {
if (o.reset) self.libraryFromServer = undefined;
- self.libraryFromServer = jsondiffpatch.patch(self.libraryFromServer, o.delta);
- deleteUndefineds(self.libraryFromServer);
+ self.libraryFromServer = curlydiff.apply(self.libraryFromServer, o.delta);
self.libraryFromServerVersion = o.version;
self.library.clear();
for (var key in self.libraryFromServer) {
@@ -98,29 +104,38 @@ function PlayerClient(socket) {
self.socket.on('scanning', function(o) {
if (o.reset) self.scanningFromServer = undefined;
- self.scanningFromServer = jsondiffpatch.patch(self.scanningFromServer, o.delta);
- deleteUndefineds(self.scanningFromServer);
+ self.scanningFromServer = curlydiff.apply(self.scanningFromServer, o.delta);
self.scanningFromServerVersion = o.version;
self.emit('scanningUpdate');
});
self.socket.on('playlists', function(o) {
if (o.reset) self.playlistsFromServer = undefined;
- self.playlistsFromServer = jsondiffpatch.patch(self.playlistsFromServer, o.delta);
- deleteUndefineds(self.playlistsFromServer);
+ self.playlistsFromServer = curlydiff.apply(self.playlistsFromServer, o.delta);
self.playlistsFromServerVersion = o.version;
self.updatePlaylistsIndex();
self.emit('playlistsUpdate');
});
- function deleteUndefineds(o) {
- for (var key in o) {
- if (o[key] === undefined) delete o[key];
- }
- }
+ self.socket.on('events', function(o) {
+ if (o.reset) self.eventsFromServer = undefined;
+ self.eventsFromServer = curlydiff.apply(self.eventsFromServer, o.delta);
+ self.eventsFromServerVersion = o.version;
+ self.sortEventsFromServer();
+ if (o.reset) self.markAllEventsSeen();
+ self.emit('events');
+ });
+
+ self.socket.on('users', function(o) {
+ if (o.reset) self.usersFromServer = undefined;
+ self.usersFromServer = curlydiff.apply(self.usersFromServer, o.delta);
+ self.usersFromServerVersion = o.version;
+ self.sortUsersFromServer();
+ self.emit('users');
+ });
}
-PlayerClient.prototype.handleConnectionStart = function(){
+PlayerClient.prototype.resubscribe = function(){
this.sendCommand('subscribe', {
name: 'library',
delta: true,
@@ -145,6 +160,75 @@ PlayerClient.prototype.handleConnectionStart = function(){
version: this.playlistsFromServerVersion,
});
this.sendCommand('subscribe', {name: 'streamers'});
+ this.sendCommand('subscribe', {
+ name: 'users',
+ delta: true,
+ version: this.usersFromServerVersion,
+ });
+ this.sendCommand('subscribe', {
+ name: 'events',
+ delta: true,
+ version: this.eventsFromServerVersion,
+ });
+};
+
+PlayerClient.prototype.sortEventsFromServer = function() {
+ this.eventsList = [];
+ this.unseenChatCount = 0;
+ for (var id in this.eventsFromServer) {
+ var serverEvent = this.eventsFromServer[id];
+ var seen = !!this.seenEvents[id];
+ var ev = {
+ id: id,
+ date: new Date(serverEvent.date),
+ type: serverEvent.type,
+ sortKey: serverEvent.sortKey,
+ text: serverEvent.text,
+ pos: serverEvent.pos ? serverEvent.pos : 0,
+ seen: seen,
+ };
+ if (!seen && serverEvent.type === 'chat') {
+ this.unseenChatCount += 1;
+ }
+ if (serverEvent.trackId) {
+ ev.track = this.library.trackTable[serverEvent.trackId];
+ }
+ if (serverEvent.userId) {
+ ev.user = this.usersTable[serverEvent.userId];
+ }
+ this.eventsList.push(ev);
+ }
+ this.eventsList.sort(compareSortKeyAndId);
+};
+
+PlayerClient.prototype.markAllEventsSeen = function() {
+ this.seenEvents = {};
+ for (var i = 0; i < this.eventsList.length; i += 1) {
+ var ev = this.eventsList[i];
+ this.seenEvents[ev.id] = true;
+ ev.seen = true;
+ }
+ this.unseenChatCount = 0;
+};
+
+PlayerClient.prototype.sortUsersFromServer = function() {
+ this.usersList = [];
+ this.usersTable = {};
+ for (var id in this.usersFromServer) {
+ var serverUser = this.usersFromServer[id];
+ var user = {
+ id: id,
+ name: serverUser.name,
+ perms: serverUser.perms,
+ requested: !!serverUser.requested,
+ approved: !!serverUser.approved,
+ streaming: !!serverUser.streaming,
+ connected: !!serverUser.connected,
+ };
+ this.usersTable[id] = user;
+ this.usersList.push(user);
+ }
+ this.usersList.sort(compareUserNames);
};
PlayerClient.prototype.updateTrackStartDate = function() {
@@ -286,7 +370,7 @@ PlayerClient.prototype.queueTracks = function(keys, previousKey, nextKey) {
previousKey = sortKey;
}
this.refreshPlaylistList(this.queue);
- this.sendCommand('addid', items);
+ this.sendCommand('queue', items);
this.emit('queueUpdate');
};
@@ -466,20 +550,28 @@ PlayerClient.prototype.shiftIds = function(trackIdSet, offset) {
PlayerClient.prototype.removeIds = function(trackIds){
if (trackIds.length === 0) return;
- var ids = [];
+
+ var currentId = this.currentItem && this.currentItem.id;
+ var currentIndex = this.currentItem && this.currentItem.index;
+ var offset = 0;
for (var i = 0; i < trackIds.length; i += 1) {
var trackId = trackIds[i];
- var currentId = this.currentItem && this.currentItem.id;
- if (currentId === trackId) {
- this.currentItemId = null;
- this.currentItem = null;
+ if (trackId === currentId) {
+ this.trackStartDate = new Date();
+ this.pausedTime = 0;
}
- ids.push(trackId);
var item = this.queue.itemTable[trackId];
- delete this.queue.itemTable[item.id];
- this.refreshPlaylistList(this.queue);
+ if (item.index < currentIndex) {
+ offset -= 1;
+ }
+ delete this.queue.itemTable[trackId];
}
- this.sendCommand('deleteid', ids);
+ currentIndex += offset;
+ this.refreshPlaylistList(this.queue);
+ this.currentItem = (currentIndex == null) ? null : this.queue.itemList[currentIndex];
+ this.currentItemId = this.currentItem && this.currentItem.id;
+
+ this.sendCommand('remove', trackIds);
this.emit('queueUpdate');
};
@@ -534,7 +626,7 @@ PlayerClient.prototype.seek = function(id, pos) {
};
PlayerClient.prototype.setVolume = function(vol){
- if (vol > 1.0) vol = 1.0;
+ if (vol > 2.0) vol = 2.0;
if (vol < 0.0) vol = 0.0;
this.volume = vol;
this.sendCommand('setvol', this.volume);
@@ -606,10 +698,12 @@ PlayerClient.prototype.resetServerState = function(){
this.repeat = 0;
this.currentItem = null;
this.currentItemId = null;
- this.streamers = {
- anonCount: 0,
- clientIds: [],
- };
+ this.streamers = 0;
+ this.usersList = [];
+ this.usersTable = {};
+ this.eventsList = [];
+ this.seenEvents = {};
+ this.unseenChatCount = 0;
this.clearStoredPlaylists();
};
@@ -651,8 +745,7 @@ function noop(err){
function operatorCompare(a, b){
if (a === b) {
return 0;
- }
- if (a < b) {
+ } else if (a < b) {
return -1;
} else {
return 1;
@@ -669,3 +762,19 @@ function makeCompareProps(props){
return 0;
};
}
+
+function compareUserNames(a, b) {
+ var lowerA = a.name.toLowerCase();
+ var lowerB = b.name.toLowerCase();
+ if (a.id === PlayerClient.GUEST_USER_ID) {
+ return -1;
+ } else if (b.id === PlayerClient.GUEST_USER_ID) {
+ return 1;
+ } else if (lowerA < lowerB) {
+ return -1;
+ } else if (lowerA > lowerB) {
+ return 1;
+ } else {
+ return 0;
+ }
+}
diff --git a/src/client/socket.js b/src/client/socket.js
index 792dc7d..db3c817 100644
--- a/src/client/socket.js
+++ b/src/client/socket.js
@@ -1,9 +1,9 @@
-var EventEmitter = require('events').EventEmitter;
-var util = require('util');
+var EventEmitter = require('./event_emitter');
+var inherits = require('./inherits');
module.exports = Socket;
-util.inherits(Socket, EventEmitter);
+inherits(Socket, EventEmitter);
function Socket() {
var self = this;
EventEmitter.call(self);
diff --git a/src/client/streaming.js b/src/client/streaming.js
deleted file mode 100644
index 656d358..0000000
--- a/src/client/streaming.js
+++ /dev/null
@@ -1,114 +0,0 @@
-exports.getUrl = getUrl;
-exports.toggleStatus = toggleStatus;
-exports.init = init;
-
-var tryingToStream = false;
-var actuallyStreaming = false;
-var stillBuffering = false;
-var player = null;
-var audio = new Audio();
-audio.addEventListener('playing', onPlaying, false);
-
-var $ = window.$;
-var $streamBtn = $('#stream-btn');
-
-document.getElementById('stream-btn-label').addEventListener('mousedown', onLabelDown, false);
-
-function onLabelDown(event) {
- event.stopPropagation();
-}
-
-function getStreamerCount() {
- return player.streamers.anonCount + player.streamers.clientIds.length;
-}
-
-function getStatusLabel() {
- if (tryingToStream) {
- if (actuallyStreaming) {
- if (stillBuffering) {
- return "Buffering";
- } else {
- return "On";
- }
- } else {
- return "Paused";
- }
- } else {
- return "Off";
- }
-}
-
-function getButtonLabel() {
- return getStreamerCount() + " Stream: " + getStatusLabel();
-}
-
-function renderStreamButton(){
- var label = getButtonLabel();
- $streamBtn
- .button("option", "label", label)
- .prop("checked", tryingToStream)
- .button("refresh");
-}
-
-function toggleStatus() {
- tryingToStream = !tryingToStream;
- renderStreamButton();
- updatePlayer();
- return false;
-}
-
-function getUrl() {
- // keep the URL relative so that reverse proxies can work
- return "stream.mp3";
-}
-
-function onPlaying() {
- stillBuffering = false;
- renderStreamButton();
-}
-
-function clearBuffer() {
- if (tryingToStream) {
- tryingToStream = !tryingToStream;
- updatePlayer();
- tryingToStream = !tryingToStream;
- updatePlayer();
- }
-}
-
-function updatePlayer() {
- var shouldStream = tryingToStream && player.isPlaying === true;
- if (actuallyStreaming !== shouldStream) {
- if (shouldStream) {
- audio.src = getUrl();
- audio.load();
- audio.play();
- stillBuffering = true;
- } else {
- audio.pause();
- audio.src = "";
- audio.load();
- stillBuffering = false;
- }
- actuallyStreaming = shouldStream;
- }
- renderStreamButton();
-}
-
-function setUpUi() {
- $streamBtn.button({
- icons: {
- primary: "ui-icon-signal-diag"
- }
- });
- $streamBtn.on('click', toggleStatus);
-}
-
-function init(playerInstance, socket) {
- player = playerInstance;
-
- player.on('currentTrack', updatePlayer);
- player.on('streamers', renderStreamButton);
- socket.on('seek', clearBuffer);
- setUpUi();
-}
diff --git a/src/client/styles/app.styl b/src/client/styles/app.styl
index 3071344..6768b64 100644
--- a/src/client/styles/app.styl
+++ b/src/client/styles/app.styl
@@ -1,17 +1,15 @@
@import "vendor/reset.min.css"
@import "vendor/jquery-ui-1.10.4.custom.min.css"
+lightGray = #292929
+
user-select()
-moz-user-select arguments
-khtml-user-select arguments
+ -ms-user-select arguments
+ -webkit-user-select arguments
user-select arguments
-border-radius()
- -moz-border-radius arguments
- -webkit-border-radius arguments
- -khtml-border-radius arguments
- border-radius arguments
-
selected-div()
background #00498F url(images/ui-bg_dots-small_40_00498f_2x2.png) 50% 50% repeat
color #fff
@@ -65,15 +63,14 @@ a:hover,a:active,a:focus
.elapsed
float left
-#vol
+#client-vol, #vol
margin 10px
span
float left
margin -2px 2px 0px 10px
-#vol-slider
+#client-vol-slider, #vol-slider
float left
- width 60px
height 10px
.ui-slider-handle
@@ -81,10 +78,22 @@ a:hover,a:active,a:focus
width 16px
margin-top 2px
+#client-vol-slider
+ width 60px
+
+#vol-slider
+ width 300px
+
+#vol-warning
+ color #B63434
+
+#vol-warning a
+ color #57BF3B
+
#more-playback-btns
float left
font-size .6em
- margin -8px 0 0 8px
+ margin 2px 0 0 8px
#left-window
width 400px
@@ -220,7 +229,7 @@ a:hover,a:active,a:focus
div.current
border 1px solid #096AC8
- background-color #292929
+ background-color lightGray
font-weight bold
color #75abff
@@ -242,17 +251,20 @@ a:hover,a:active,a:focus
#upload
overflow-y auto
+ padding 4px
+ h1
+ font-size 1.4em
+ margin 4px 0
#upload-widget
padding 10px
#upload-by-url
- margin: 4px
- width: 90%
+ width 370px
.ui-menu
- width: 240px
- font-size: 1em
+ width 240px
+ font-size 1em
#menu-library .ui-state-disabled.ui-state-focus,
#menu-queue .ui-state-disabled.ui-state-focus
@@ -314,5 +326,69 @@ a:hover,a:active,a:focus
li:before
content "\2713"
+#auth-username, #auth-password
+ width 300px
+
+#settings-users-select
+ width 350px
+
+#settings-delete-user
+ margin-right 20px
+
+#settings-request
+ background-color lightGray
+ border-radius 4px
+ padding 4px
+
+#request-name
+ width 290px
+
+#request-replace
+ width 294px
+
+#events-online-users
+ max-height 90px
+ overflow-y auto
+
+ .user
+ width 180px
+ overflow-x hidden
+ float left
+ .streaming
+ float left
+ .name
+ float left
+
.accesskey
- text-decoration: underline
+ text-decoration underline
+
+.perm
+ background-color lightGray
+ color #96B7EB
+ padding 2px
+ border-radius 4px
+
+#events-list
+ overflow-y auto
+ font-size 0.9em
+
+ .name
+ width 29%
+ float left
+ overflow-x hidden
+ overflow-y hidden
+ text-align right
+ height 16px
+ white-space nowrap
+ .msg
+ float left
+ width 69%
+ margin-left 4px
+ word-wrap break-word
+ .event
+ padding-top 2px
+ .chat
+ background-color lightGray
+
+#chat-box-input
+ width 100%
diff --git a/src/client/uuid.js b/src/client/uuid.js
new file mode 100644
index 0000000..fcc1b4f
--- /dev/null
+++ b/src/client/uuid.js
@@ -0,0 +1,26 @@
+// all these characters are safe to put in an HTML id
+var b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+var crypto = window.crypto;
+var arr = new Uint8Array(24);
+module.exports = uuid;
+function uuid() {
+ crypto.getRandomValues(arr);
+ var s = "";
+ for (var m = 0, t = 0; t < arr.length; m = (m + 1) % 4) {
+ var x;
+ if (m === 0) {
+ x = arr[t] >> 2;
+ t += 1;
+ } else if (m === 1) {
+ x = ((0x3 & arr[t-1]) << 4) | (arr[t] >> 4);
+ } else if (m === 2) {
+ x = ((0xf & arr[t]) << 2) | (arr[t+1] >> 6);
+ t += 1;
+ } else { // m === 3
+ x = arr[t] & 0x3f;
+ t += 1;
+ }
+ s += b64[x];
+ }
+ return s;
+}
diff --git a/src/public/index.html b/src/public/index.html
index 3a72f14..8f34e4d 100644
--- a/src/public/index.html
+++ b/src/public/index.html
@@ -22,14 +22,14 @@
<span class="ui-icon ui-icon-seek-next"></span>
</li>
</ul>
- <div id="vol">
- <span class="ui-icon ui-icon-volume-off"></span>
- <div id="vol-slider"></div>
- <span class="ui-icon ui-icon-volume-on"></span>
- </div>
<div id="more-playback-btns">
<input class="jquery-button" type="checkbox" id="stream-btn"><label id="stream-btn-label" for="stream-btn">Stream</label>
</div>
+ <div id="client-vol">
+ <span class="ui-icon ui-icon-volume-off"></span>
+ <div id="client-vol-slider"></div>
+ <span class="ui-icon ui-icon-volume-on"></span>
+ </div>
<h1 id="track-display"></h1>
<div id="track-slider"></div>
<span class="time elapsed"></span>
@@ -42,6 +42,7 @@
<li id="library-tab" class="hoverable ui-state-default ui-corner-top ui-state-active"><span>Library</span></li>
<li id="upload-tab" class="hoverable ui-state-default ui-corner-top"><span>Import</span></li>
<li id="playlists-tab" class="hoverable ui-state-default ui-corner-top" style="display: none"><span>Playlists</span></li>
+ <li id="events-tab" class="hoverable ui-state-default ui-corner-top"><span>Events</span></li>
<li id="settings-tab" class="hoverable ui-state-default ui-corner-top"><span>Settings</span></li>
</ul>
</div>
@@ -63,10 +64,13 @@
</div>
<div id="upload-pane" class="ui-widget-content ui-corner-all" style="display: none">
<div id="upload">
- <input id="upload-by-url" type="text" placeholder="Paste URL here">
+ <h1>Import by URL</h1>
+ <input id="upload-by-url" type="text" placeholder="Paste URL and press enter. Supports YouTube">
+ <h1>Upload Files</h1>
<div id="upload-widget">
<input type="file" id="upload-input" multiple="multiple" placeholder="Drag and drop or click to browse">
</div>
+ <h1>Options</h1>
<div>
Automatically queue imported songs: <input class="jquery-button" type="checkbox" id="auto-queue-uploads"><label for="auto-queue-uploads">On</label>
</div>
@@ -79,27 +83,63 @@
</ul>
</div>
</div>
+ <div id="events-pane" class="ui-widget-content ui-corner-all" style="display: none">
+ <div id="events-online-users">
+ </div>
+ <div id="events-list">
+ </div>
+ <div id="chat-box">
+ <input type="text" id="chat-box-input" placeholder="chat">
+ </div>
+ </div>
<div id="settings-pane" class="ui-widget-content ui-corner-all" style="display: none">
<div id="settings">
<div class="section">
<h1>Authentication</h1>
- <div id="settings-edit-password">
- <input type="password" id="auth-password" placeholder="password" />
+ <div id="ensure-admin" style="display: none;">
+ <p>
+ There is currently no admin account. Press this button to create the
+ admin account and print the username and password to the server log:
+ </p>
+ <button id="ensure-admin-btn">Make Admin Account</button>
+ </div>
+ <div id="settings-register" style="display: none">
+ <input type="text" id="auth-username" placeholder="username" maxlength="64"><br>
+ <input type="password" id="auth-password" placeholder="password">
+ <input type="checkbox" id="auth-show-password" class="jquery-button"><label for="auth-show-password">Reveal</label><br>
<button id="settings-auth-save">Save</button>
<button id="settings-auth-cancel">Cancel</button>
</div>
- <div id="settings-show-password">
- Using a password
+ <div id="settings-show-auth">
+ <p>Authenticated as <span id="auth-username-display">...</span></p>
+ <p>Permissions:
+ <span class="perm" id="auth-perm-read" title="Reading the library, current queue, and playback status.">read</span>
+ <span class="perm" id="auth-perm-add" title="Adding songs, loading playlists, and uploading songs.">add</span>
+ <span class="perm" id="auth-perm-control" title="Control playback state, and manipulate playlists.">control</span>
+ <span class="perm" id="auth-perm-admin" title="Deleting songs, updating tags, organizing library.">admin</span>
+ </p>
<button id="settings-auth-edit">Edit</button>
- <button id="settings-auth-clear">Clear</button>
+ <button id="settings-auth-logout">Logout</button>
+ <button id="settings-auth-request">Request Validation</button>
+ </div>
+ <div id="settings-users">
+ <h1>Users</h1>
+ <select id="settings-users-select"></select><br>
+ <button id="settings-delete-user">Delete</button>
+ <input id="user-perm-read" type="checkbox"><label for="user-perm-read" title="Reading the library, current queue, and playback status.">read</label>
+ <input id="user-perm-add" type="checkbox"><label for="user-perm-add" title="Adding songs, loading playlists, and uploading songs.">add</label>
+ <input id="user-perm-control" type="checkbox"><label for="user-perm-control" title="Control playback state, and manipulate playlists.">control</label>
+ <input id="user-perm-admin" type="checkbox"><label for="user-perm-admin" title="Deleting songs, updating tags, organizing library.">admin</label>
+ </div>
+ <div id="settings-requests">
+ <h1>Requests</h1>
+ <div id="settings-request">
+ <input type="text" id="request-name"> Name<br>
+ <select id="request-replace"></select> Account<br>
+ <button class="jquery-button" id="request-approve">Approve</button>
+ <button class="jquery-button" id="request-deny">Deny</button>
+ </div>
</div>
- <h2>Permissions</h2>
- <ul>
- <li id="auth-perm-read">Reading the library, current queue, and playback status</li>
- <li id="auth-perm-add">Adding songs, loading playlists, and uploading songs.</li>
- <li id="auth-perm-control">Control playback state, and manipulate playlists.</li>
- <li id="auth-perm-admin">Deleting songs, updating tags, organizing library.</li>
- </ul>
</div>
<div class="section">
<h1>Last.fm</h1>
@@ -121,11 +161,22 @@
</div>
</div>
<div class="section">
- <h1>Admin</h1>
- <p>
+ <h1>Sound</h1>
+ <div>
Hardware audio playback is
- <input type="checkbox" id="toggle-hardware-playback"><label for="toggle-hardware-playback">On</label>
- </p>
+ <input type="checkbox" id="toggle-hardware-playback"><label for="toggle-hardware-playback" id="toggle-hardware-playback-label">On</label>
+ </div>
+ <div>Volume: <span id="vol-num"></span>%</div>
+ <div id="vol-warning" style="display: none">
+ Greater than 100% volume compromises audio quality.
+ <a target="_blank" href="https://www.youtube.com/watch?v=iuEtQqC-Sqo">Loudness Zen</a>
+ </div>
+ <div id="vol">
+ <span class="ui-icon ui-icon-volume-off"></span>
+ <div id="vol-slider"></div>
+ <span class="ui-icon ui-icon-volume-on"></span>
+ </div>
+ <div style="clear: both;"></div>
</div>
<div class="section">
<h1>About</h1>
@@ -230,13 +281,21 @@
<h1>Navigation</h1>
<dl>
- <dt>l</dt>
- <dd>Switch to Library tab</dd>
+ <dt>/</dt>
+ <dd>Switch to Library tab and focus the search box</dd>
</dl>
<dl>
<dt>i</dt>
<dd>Switch to Import tab and focus the import by URL box</dd>
</dl>
+ <dl>
+ <dt>t</dt>
+ <dd>Switch to Events tab and focus the chat box</dd>
+ </dl>
+ <dl>
+ <dt>e</dt>
+ <dd>Switch to Settings tab</dd>
+ </dl>
<h1>Library Search Box</h1>
<dl>
--
groovebasin packaging
More information about the pkg-multimedia-commits
mailing list