[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 @@
-# ![Groove Basin](http://groovebasin.com.s3.amazonaws.com/img/logo.png)
+# ![Groove Basin](http://groovebasin.com.s3.amazonaws.com/img/logo-text.png)
 
 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