[SCM] groovebasin/master: Imported Upstream version 1.4.0

andrewrk-guest at users.alioth.debian.org andrewrk-guest at users.alioth.debian.org
Fri Oct 17 06:30:25 UTC 2014


The following commit has been merged in the master branch:
commit 0ec87b5391b12f1fa65e3bdea68e4b3867e55cde
Author: Andrew Kelley <superjoe30 at gmail.com>
Date:   Fri Oct 17 06:03:57 2014 +0000

    Imported Upstream version 1.4.0

diff --git a/.gitignore b/.gitignore
index abcac95..1dd1d08 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,6 @@
 /node_modules
 /groovebasin.db
-/config.js
+/config.json
 
 # not shared with .npmignore
 /public/app.js
diff --git a/.npmignore b/.npmignore
index c838da9..e1c94f1 100644
--- a/.npmignore
+++ b/.npmignore
@@ -1,5 +1,5 @@
 /node_modules
 /groovebasin.db
-/config.js
+/config.json
 
 # not shared with .gitignore
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87abdcd..cc55c06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,39 @@
+### Version 1.4.0 (2014-10-16)
+
+ * Andrew Kelley:
+   - client: fix showing filter without filtered results when server restarts
+   - fix auto pause behavior and add event for it
+   - fix symlink behavior in music library
+   - import by url: respect content-disposition header
+   - fix serving invalid content-disposition header
+   - no longer accidentally shipping config.json in npm module
+   - uploaded files are imported in a streaming fashion instead of after all
+     files are finishing uploading.
+   - fix an uploading crash
+   - ability to import and upload .zip files.
+   - auto queue happens server side.
+   - play queue displays total duration and selection duration
+   - add progress reporting for ongoing imports
+   - fix aborted uploads getting stuck
+   - Remove the easter eggs. It was fun while it lasted. Maybe someday we will
+     live in a society where nothing is copyrighted.
+   - add Cache-Control header to static assets to help enforce caching rules.
+
+ * Josh Wolfe:
+   - fix crash when uploading 0 byte .zip file
+
+ * Felipe Sateler:
+   - open stream and homepage links in new tabs/windows
+
+ * Melissa Noelle:
+   - client supports /nick command to change name
+
+### Version 1.3.2 (2014-10-06)
+
+ * Andrew Kelley:
+   - style: fix messed up menus and volume slider from upgrading jquery ui
+   - config file is config.json instead of config.js
+
 ### Version 1.3.1 (2014-10-03)
 
  * Andrew Kelley:
diff --git a/README.md b/README.md
index d96508e..ca51bbc 100644
--- a/README.md
+++ b/README.md
@@ -59,16 +59,16 @@ groovebasin
 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.
+   libgroove is available in many package managers. See the libgroove README
+   for more details.
 3. Clone the source and cd to it.
 4. `npm run build`
 5. `npm start`
 
 ## Configuration
 
-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.
+When Groove Basin starts it will look for `config.json` in the current
+directory. If not found it creates one for you with default values.
 
 Use this to set your music library location and other settings.
 
@@ -77,10 +77,12 @@ instead of using the public one bundled with this source code.
 
 ## Screenshots
 
-![Search + drag/drop support](http://superjoesoftware.com/temp/groove-basin-0.0.4.png)
-![Multi-select and context menu](http://superjoesoftware.com/temp/groove-basin-0.0.4-lib-menu.png)
-![Keyboard shortcuts](http://superjoesoftware.com/temp/groove-basin-0.0.4-shortcuts.png)
-![Last.fm Scrobbling](http://superjoesoftware.com/temp/groove-basin-0.0.4-lastfm.png)
+![Search + drag/drop support](http://groovebasin.com/img/groovebasin-1.3.2-searchdragdrop.png)
+![Multi-select and context menu](http://groovebasin.com/img/groovebasin-1.3.2-libmenu.png)
+![Keyboard shortcuts](http://groovebasin.com/img/groovebasin-1.3.2-shortcuts.png)
+![Settings](http://groovebasin.com/img/groovebasin-1.3.2-settings.png)
+![Import](http://groovebasin.com/img/groovebasin-1.3.2-import.png)
+![Events](http://groovebasin.com/img/groovebasin-1.3.2-events.png)
 
 ## Developing
 
diff --git a/lib/groovebasin.js b/lib/groovebasin.js
index 26cee3f..5fca286 100644
--- a/lib/groovebasin.js
+++ b/lib/groovebasin.js
@@ -10,9 +10,7 @@ var path = require('path');
 var Pend = require('pend');
 var express = require('express');
 var osenv = require('osenv');
-var spawn = require('child_process').spawn;
 var plugins = [
-  require('./plugins/ytdl'),
   require('./plugins/lastfm'),
 ];
 var Player = require('./player');
@@ -28,6 +26,7 @@ var createGzipStatic = require('connect-static');
 var serveStatic = require('serve-static');
 var Cookies = require('cookies');
 var log = require('./log');
+var contentDisposition = require('content-disposition');
 
 module.exports = GrooveBasin;
 
@@ -60,7 +59,7 @@ GrooveBasin.prototype.initConfigVar = function(name, defaultValue) {
 
 GrooveBasin.prototype.loadConfig = function(cb) {
   var self = this;
-  var pathToConfig = "config.js";
+  var pathToConfig = "config.json";
   fs.readFile(pathToConfig, {encoding: 'utf8'}, function(err, contents) {
     var anythingAdded = false;
     var config;
@@ -68,7 +67,7 @@ GrooveBasin.prototype.loadConfig = function(cb) {
       if (err.code === 'ENOENT') {
         anythingAdded = true;
         self.config = defaultConfig;
-        log.warn("No config.js found; writing default.");
+        log.warn("No " + pathToConfig + " found; writing default.");
       } else {
         return cb(err);
       }
@@ -76,7 +75,7 @@ GrooveBasin.prototype.loadConfig = function(cb) {
       try {
         self.config = JSON.parse(contents);
       } catch (err) {
-        cb(err);
+        cb(new Error("Unable to parse " + pathToConfig + ": " + err.message));
         return;
       }
     }
@@ -236,7 +235,7 @@ GrooveBasin.prototype.initializeDownload = function() {
     req.on('close', cleanupEverything);
 
     resp.setHeader("Content-Type", "application/zip");
-    resp.setHeader("Content-Disposition", "attachment; filename=" + zipName);
+    resp.setHeader("Content-Disposition", contentDisposition(zipName, {type: "attachment"}));
 
     var zipfile = new yazl.ZipFile();
     zipfile.on('error', function(err) {
@@ -253,8 +252,6 @@ GrooveBasin.prototype.initializeDownload = function() {
     });
 
     function cleanupEverything() {
-      // TODO: clean shutdown?
-      // zipfile.destroy();
       resp.end();
     }
   }
@@ -273,41 +270,53 @@ GrooveBasin.prototype.initializeDownload = function() {
 GrooveBasin.prototype.initializeUpload = function() {
   var self = this;
   self.app.post('/upload', self.hasPermAdd, function(request, response, next) {
-    var form = new MultipartForm();
-    form.parse(request, function(err, fields, files) {
-      if (err) return next(err);
+    var form = new MultipartForm({
+      maxFields: 100000, // let them eat cake
+      autoFields: true,
+    });
+    var allDbFiles = [];
+    var pend = new Pend();
+    var autoQueue = false;
+    var size;
 
-      var keys = [];
-      var pend = new Pend();
-      for (var key in files) {
-        var arr = files[key];
-        for (var i = 0; i < arr.length; i += 1) {
-          var file = arr[i];
-          pend.go(makeImportFn(file));
-        }
+    form.on('error', next);
+    form.on('part', function(part) {
+      pend.go(function(cb) {
+        log.debug("import part", part.filename);
+        self.player.importStream(part, part.filename, size, function(err, dbFiles) {
+          if (err) {
+            log.error("Unable to import stream:", err.stack);
+          } else if (!dbFiles) {
+            log.warn("Unable to import stream, unrecognized format");
+          } else {
+            allDbFiles = allDbFiles.concat(dbFiles);
+          }
+          log.debug("done importing part", part.filename);
+          cb();
+        });
+      });
+    });
+    form.on('field', function(name, value) {
+      if (name === 'autoQueue') {
+        autoQueue = true;
+      } else if (name === 'size') {
+        size = parseInt(value, 10);
       }
+    });
+    form.on('close', function() {
       pend.wait(function() {
-        response.json(keys);
+        if (allDbFiles.length >= 1) {
+          var user = request.client && request.client.user;
+          self.playerServer.addEvent(user, 'import', null, allDbFiles[0].key, allDbFiles.length);
+          if (autoQueue) {
+            self.player.sortAndQueueTracks(allDbFiles);
+            self.playerServer.addEvent(user, 'queue', null, allDbFiles[0].key, allDbFiles.length);
+          }
+        }
+        response.json({});
       });
-
-      function makeImportFn(file) {
-        return function(cb) {
-          var filenameHintWithoutPath = path.basename(file.originalFilename);
-          self.player.importFile(file.path, filenameHintWithoutPath, function(err, dbFile) {
-            if (err) {
-              log.error("Unable to import file:", file.path, "error:", err.stack);
-            } else if (!dbFile) {
-              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();
-          });
-        };
-      }
     });
+    form.parse(request);
   });
 };
 
@@ -477,3 +486,7 @@ GrooveBasin.prototype.startServer = function() {
     }
   }
 };
+
+function getKey(o) {
+  return o.key;
+}
diff --git a/lib/plugins/ytdl.js b/lib/import_url_filters.js
similarity index 50%
rename from lib/plugins/ytdl.js
rename to lib/import_url_filters.js
index 4e963ad..e51f90e 100644
--- a/lib/plugins/ytdl.js
+++ b/lib/import_url_filters.js
@@ -1,8 +1,9 @@
 var ytdl = require('ytdl-core');
 var url = require('url');
-var log = require('../log');
-
-module.exports = YtDlPlugin;
+var log = require('./log');
+var path = require('path');
+var download = require('./download').download;
+var parseContentDisposition = require('content-disposition').parse;
 
 // sorted from worst to best
 var YTDL_AUDIO_ENCODINGS = [
@@ -14,11 +15,18 @@ var YTDL_AUDIO_ENCODINGS = [
   'flac',
 ];
 
-function YtDlPlugin(gb) {
-  gb.player.importUrlFilters.push(this);
-}
+module.exports = [
+  {
+    name: "YouTube Download",
+    fn: ytdlImportUrl,
+  },
+  {
+    name: "Raw Download",
+    fn: downloadRawImportUrl,
+  },
+];
 
-YtDlPlugin.prototype.importUrl = function(urlString, cb) {
+function ytdlImportUrl(urlString, cb) {
   var parsedUrl = url.parse(urlString);
 
   var isYouTube = (parsedUrl.pathname === '/watch' &&
@@ -53,11 +61,52 @@ YtDlPlugin.prototype.importUrl = function(urlString, cb) {
     }
     var req = ytdl.downloadFromInfo(info, {filter: filter});
     var filenameHintWithoutPath = info.title + '.' + bestFormat.container;
-    cb(null, req, filenameHintWithoutPath);
+    var callbackCalled = false;
+    req.on('error', onError);
+    req.on('format', function(format) {
+      req.removeListener('error', onError);
+      if (callbackCalled) return;
+      callbackCalled = true;
+      cb(null, req, filenameHintWithoutPath, format.size);
+    });
+
+    function onError(err) {
+      if (callbackCalled) return;
+      callbackCalled = true;
+      cb(err);
+    }
 
     function filter(format) {
       return format.audioBitrate === bestFormat.audioBitrate &&
         format.audioEncoding === bestFormat.audioEncoding;
     }
   }
-};
+}
+
+function downloadRawImportUrl(urlString, cb) {
+  var parsedUrl = url.parse(urlString);
+  var remoteFilename = path.basename(parsedUrl.pathname);
+  var decodedFilename;
+  try {
+    decodedFilename = decodeURI(remoteFilename);
+  } catch (err) {
+    decodedFilename = remoteFilename;
+  }
+  download(urlString, function(err, resp) {
+    if (err) return cb(err);
+    var contentDisposition = resp.headers['content-disposition'];
+    if (contentDisposition) {
+      var filename;
+      try {
+        filename = parseContentDisposition(contentDisposition).parameters.filename;
+      } catch (err) {
+        // do nothing
+      }
+      if (filename) {
+        decodedFilename = filename;
+      }
+    }
+    var contentLength = parseInt(resp.headers['content-length'], 10);
+    cb(null, resp, decodedFilename, contentLength);
+  });
+}
diff --git a/lib/player.js b/lib/player.js
index 8a523f1..2405022 100644
--- a/lib/player.js
+++ b/lib/player.js
@@ -8,7 +8,7 @@ var uuid = require('./uuid');
 var path = require('path');
 var Pend = require('pend');
 var DedupedQueue = require('./deduped_queue');
-var findit = require('findit');
+var findit = require('findit2');
 var shuffle = require('mess');
 var mv = require('mv');
 var MusicLibraryIndex = require('music-library-index');
@@ -16,9 +16,21 @@ var keese = require('keese');
 var safePath = require('./safe_path');
 var PassThrough = require('stream').PassThrough;
 var url = require('url');
-var download = require('./download').download;
 var dbIterate = require('./db_iterate');
 var log = require('./log');
+var importUrlFilters = require('./import_url_filters');
+var yauzl = require('yauzl');
+
+var importFileFilters = [
+  {
+    name: 'zip',
+    fn: importFileAsZip,
+  },
+  {
+    name: 'song',
+    fn: importFileAsSong,
+  },
+];
 
 module.exports = Player;
 
@@ -293,7 +305,8 @@ function Player(db, musicDirectory, encodeQueueDuration) {
   this.grooveEncoder.codecShortName = "mp3";
   this.grooveEncoder.bitRate = 256 * 1000;
 
-  this.importUrlFilters = [];
+  this.importProgress = {};
+  this.lastImportProgressEvent = new Date();
 }
 
 Player.prototype.initialize = function(cb) {
@@ -628,7 +641,6 @@ 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);
@@ -654,16 +666,10 @@ 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();
@@ -893,7 +899,8 @@ Player.prototype.stopStreaming = function(resp) {
 Player.prototype.lastStreamerDisconnected = function() {
   log.debug("last streamer disconnected");
   this.startDetachEncoderTimeout();
-  if (!this.desiredPlayerHardwareState) {
+  if (!this.desiredPlayerHardwareState && this.isPlaying) {
+    this.emit("autoPause");
     this.pause();
   }
 };
@@ -976,134 +983,138 @@ Player.prototype.importUrl = function(urlString, cb) {
   var self = this;
   cb = cb || logIfError;
 
-  var tmpDir = path.join(self.musicDirectory, '.tmp');
   var filterIndex = 0;
-
-  mkdirp(tmpDir, function(err) {
-    if (err) return cb(err);
-
-    tryImportFilter();
-  });
+  tryImportFilter();
 
   function tryImportFilter() {
-    var importPlugin = self.importUrlFilters[filterIndex];
-    if (importPlugin) {
-      importPlugin.importUrl(urlString, callNextFilter);
-    } else {
-      downloadRaw();
-    }
-    function callNextFilter(err, dlStream, filenameHintWithoutPath) {
+    var importFilter = importUrlFilters[filterIndex];
+    if (!importFilter) return cb();
+    importFilter.fn(urlString, callNextFilter);
+    function callNextFilter(err, dlStream, filenameHintWithoutPath, size) {
       if (err || !dlStream) {
-        if (err) log.error("import filter error, skipping:", err.stack);
+        if (err) {
+          log.error(importFilter.name + " import filter error, skipping:", err.stack);
+        }
         filterIndex += 1;
         tryImportFilter();
         return;
       }
-      importStream(dlStream, filenameHintWithoutPath);
+      self.importStream(dlStream, filenameHintWithoutPath, size, cb);
     }
   }
 
-  function downloadRaw() {
-    var parsedUrl = url.parse(urlString);
-    var remoteFilename = path.basename(parsedUrl.pathname);
-    var decodedFilename;
-    try {
-      decodedFilename = decodeURI(remoteFilename);
-    } catch (err) {
-      decodedFilename = remoteFilename;
+  function logIfError(err) {
+    if (err) {
+      log.error("Unable to import by URL.", err.stack, "URL:", urlString);
     }
-    download(urlString, function(err, resp) {
-      if (err) return cb(err);
-      importStream(resp, decodedFilename);
-    });
   }
+};
 
-  function importStream(readStream, filenameHintWithoutPath) {
-    var ext = path.extname(filenameHintWithoutPath);
-    var destPath = path.join(tmpDir, uuid() + ext);
-    var writeStream = fs.createWriteStream(destPath);
+Player.prototype.importStream = function(readStream, filenameHintWithoutPath, size, cb) {
+  var self = this;
+  var ext = path.extname(filenameHintWithoutPath);
+  var tmpDir = path.join(self.musicDirectory, '.tmp');
+  var id = uuid();
+  var destPath = path.join(tmpDir, id + ext);
+  var calledCallback = false;
+  var writeStream = null;
+  var progressTimer = null;
+  var importEvent = {
+    id: id,
+    filenameHintWithoutPath: filenameHintWithoutPath,
+    bytesWritten: 0,
+    size: size,
+    date: new Date(),
+  };
+
+  readStream.on('error', cleanAndCb);
+  self.importProgress[importEvent.id] = importEvent;
+  self.emit('importStart', importEvent);
 
-    var calledCallback = false;
+  mkdirp(tmpDir, function(err) {
+    if (calledCallback) return;
+    if (err) return cleanAndCb(err);
+
+    writeStream = fs.createWriteStream(destPath);
     readStream.pipe(writeStream);
-    writeStream.on('close', function(){
+    progressTimer = setInterval(checkProgress, 100);
+    writeStream.on('close', onClose);
+    writeStream.on('error', cleanAndCb);
+
+    function checkProgress() {
+      importEvent.bytesWritten = writeStream.bytesWritten;
+      self.maybeEmitImportProgress();
+    }
+    function onClose(){
       if (calledCallback) return;
-      self.importFile(writeStream.path, filenameHintWithoutPath, function(err, dbFile) {
+      checkProgress();
+      self.importFile(destPath, filenameHintWithoutPath, function(err, dbFiles) {
+        if (calledCallback) return;
         if (err) {
           cleanAndCb(err);
         } else {
           calledCallback = true;
-          cb(null, dbFile);
+          cleanTimer();
+          delete self.importProgress[importEvent.id];
+          self.emit('importEnd', importEvent);
+          cb(null, dbFiles);
         }
       });
-    });
-    writeStream.on('error', cleanAndCb);
-    readStream.on('error', cleanAndCb);
+    }
+  });
 
-    function cleanAndCb(err) {
-      fs.unlink(destPath, function(err) {
-        if (err) {
-          log.warn("Unable to clean up temp file:", err.stack);
-        }
-      });
-      if (calledCallback) return;
-      calledCallback = true;
-      cb(err);
+  function cleanTimer() {
+    if (progressTimer) {
+      clearInterval(progressTimer);
+      progressTimer = null;
     }
   }
 
-  function logIfError(err) {
-    if (err) {
-      log.error("Unable to import by URL.", err.stack, "URL:", urlString);
+  function cleanAndCb(err) {
+    if (writeStream) {
+      fs.unlink(destPath, onUnlinkDone);
+      writeStream = null;
+    }
+    cleanTimer();
+    if (calledCallback) return;
+    calledCallback = true;
+    delete self.importProgress[importEvent.id];
+    self.emit('importAbort', importEvent);
+    cb(err);
+
+    function onUnlinkDone(err) {
+      if (err) {
+        log.warn("Unable to clean up temp file:", err.stack);
+      }
     }
   }
 };
 
-// moves the file at srcFullPath to the music library
 Player.prototype.importFile = function(srcFullPath, filenameHintWithoutPath, cb) {
   var self = this;
   cb = cb || logIfError;
+  var filterIndex = 0;
 
   log.debug("importFile open file:", srcFullPath);
-  groove.open(srcFullPath, function(err, file) {
-    if (err) return cb(err);
-    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) {
-      tryMv(suggestedPath, cb);
-    });
-    pend.wait(function(err) {
-      if (err) return cb(err);
-      cb(null, newDbFile);
-    });
 
-    function tryMv(destRelPath, cb) {
-      var destFullPath = path.join(self.musicDirectory, destRelPath);
-      mv(srcFullPath, destFullPath, {mkdirp: true, clobber: false}, function(err) {
+  tryImportFilter();
+
+  function tryImportFilter() {
+    var importFilter = importFileFilters[filterIndex];
+    if (!importFilter) return cb();
+    importFilter.fn(self, srcFullPath, filenameHintWithoutPath, callNextFilter);
+    function callNextFilter(err, dbFiles) {
+      if (err || !dbFiles) {
         if (err) {
-          if (err.code === 'EEXIST') {
-            tryMv(uniqueFilename(destRelPath), cb);
-          } else {
-            cb(err);
-          }
-          return;
+          log.debug(importFilter.name + " import filter error, skipping:", err.message);
         }
-        // in case it doesn't get picked up by a watcher
-        self.requestUpdateDb(path.dirname(destRelPath), false, function(err) {
-          if (err) return cb(err);
-          self.addQueue.waitForId(destRelPath, function(err) {
-            if (err) return cb(err);
-            newDbFile = self.dbFilesByPath[destRelPath];
-            cb(null, newDbFile);
-          });
-        });
-      });
+        filterIndex += 1;
+        tryImportFilter();
+        return;
+      }
+      cb(null, dbFiles);
     }
-  });
+  }
 
   function logIfError(err) {
     if (err) {
@@ -1112,6 +1123,15 @@ Player.prototype.importFile = function(srcFullPath, filenameHintWithoutPath, cb)
   }
 };
 
+Player.prototype.maybeEmitImportProgress = function() {
+  var now = new Date();
+  var passedTime = now - this.lastImportProgressEvent;
+  if (passedTime > 500) {
+    this.lastImportProgressEvent = now;
+    this.emit("importProgress");
+  }
+};
+
 Player.prototype.persistDirEntry = function(dirEntry, cb) {
   cb = cb || logIfError;
   this.db.put(LIBRARY_DIR_PREFIX + dirEntry.dirName, serializeDirEntry(dirEntry), cb);
@@ -1680,6 +1700,11 @@ Player.prototype.performScan = function(args, cb) {
   } else if (scanType === 'track') {
     var trackKey = scanKey;
     var dbFile = self.libraryIndex.trackTable[trackKey];
+    if (!dbFile) {
+      log.warn("wanted to scan track with key", JSON.stringify(trackKey), "but no longer exists.");
+      cb();
+      return;
+    }
     log.debug("Scanning track for loudness:", JSON.stringify(trackKey));
     dbFilesToOpen = [dbFile];
   } else {
@@ -1948,6 +1973,57 @@ Player.prototype.persistCurrentTrack = function(cb) {
   this.persistOption('currentTrackInfo', currentTrackInfo, cb);
 };
 
+Player.prototype.sortAndQueueTracks = function(tracks) {
+  // given an array of tracks, sort them according to the library sorting
+  // and then queue them in the best place
+  if (!tracks.length) return;
+  var sortedTracks = sortTracks(tracks);
+  this.queueTracks(sortTracks(tracks));
+};
+
+Player.prototype.queueTracks = function(tracks, previousKey, nextKey) {
+  // given an array of tracks, and a previous sort key and a next sort key,
+  // call addItems correctly
+  if (!tracks.length) return;
+  if (previousKey == null && nextKey == null) {
+    var defaultPos = this.getDefaultQueuePosition();
+    previousKey = defaultPos.previousKey;
+    nextKey = defaultPos.nextKey;
+  }
+
+  var items = {};
+  for (var i = 0; i < tracks.length; i += 1) {
+    var track = tracks[i];
+    var sortKey = keese(previousKey, nextKey);
+    var id = uuid();
+    items[id] = {
+      key: track.key,
+      sortKey: sortKey,
+    };
+    previousKey = sortKey;
+  }
+  this.addItems(items, false);
+};
+
+Player.prototype.getDefaultQueuePosition = function() {
+  var previousKey = this.currentTrack && this.currentTrack.sortKey;
+  var nextKey = null;
+  var startPos = this.currentTrack ? this.currentTrack.index + 1 : 0;
+  for (var i = startPos; i < this.tracksInOrder.length; i += 1) {
+    var track = this.tracksInOrder[i];
+    var sortKey = track.sortKey;
+    if (track.isRandom) {
+      nextKey = sortKey;
+      break;
+    }
+    previousKey = sortKey;
+  }
+  return {
+    previousKey: previousKey,
+    nextKey: nextKey
+  };
+};
+
 function operatorCompare(a, b) {
   return a < b ? -1 : a > b ? 1 : 0;
 }
@@ -2458,3 +2534,120 @@ function setGrooveLoggingLevel() {
       break;
   }
 }
+
+function importFileAsSong(self, srcFullPath, filenameHintWithoutPath, cb) {
+  groove.open(srcFullPath, function(err, file) {
+    if (err) return cb(err);
+    var newDbFile = grooveFileToDbFile(file, filenameHintWithoutPath);
+    var suggestedPath = self.getSuggestedPath(newDbFile, filenameHintWithoutPath);
+    var pend = new Pend();
+    pend.go(function(cb) {
+      log.debug("importFileAsSong close file:", file.filename);
+      file.close(cb);
+    });
+    pend.go(function(cb) {
+      tryMv(suggestedPath, cb);
+    });
+    pend.wait(function(err) {
+      if (err) return cb(err);
+      cb(null, [newDbFile]);
+    });
+
+    function tryMv(destRelPath, cb) {
+      var destFullPath = path.join(self.musicDirectory, destRelPath);
+      mv(srcFullPath, destFullPath, {mkdirp: true, clobber: false}, function(err) {
+        if (err) {
+          if (err.code === 'EEXIST') {
+            tryMv(uniqueFilename(destRelPath), cb);
+          } else {
+            cb(err);
+          }
+          return;
+        }
+        // in case it doesn't get picked up by a watcher
+        self.requestUpdateDb(path.dirname(destRelPath), false, function(err) {
+          if (err) return cb(err);
+          self.addQueue.waitForId(destRelPath, function(err) {
+            if (err) return cb(err);
+            newDbFile = self.dbFilesByPath[destRelPath];
+            cb();
+          });
+        });
+      });
+    }
+  });
+}
+
+function importFileAsZip(self, srcFullPath, filenameHintWithoutPath, cb) {
+  yauzl.open(srcFullPath, function(err, zipfile) {
+    if (err) return cb(err);
+    var allDbFiles = [];
+    var pend = new Pend();
+    zipfile.on('error', handleError);
+    zipfile.on('entry', onEntry);
+    zipfile.on('end', onEnd);
+
+    function onEntry(entry) {
+      if (/\/$/.test(entry.fileName)) {
+        // ignore directories
+        return;
+      }
+      pend.go(function(cb) {
+        zipfile.openReadStream(entry, function(err, readStream) {
+          if (err) {
+            log.warn("Error reading zip file:", err.stack);
+            cb();
+            return;
+          }
+          var entryBaseName = path.basename(entry.fileName);
+          self.importStream(readStream, entryBaseName, entry.uncompressedSize, function(err, dbFiles) {
+            if (err) {
+              log.warn("unable to import entry from zip file:", err.stack);
+            } else {
+              allDbFiles = allDbFiles.concat(dbFiles);
+            }
+            cb();
+          });
+        });
+      });
+    }
+
+    function onEnd() {
+      pend.wait(function() {
+        unlinkZipFile();
+        cb(null, allDbFiles);
+      });
+    }
+
+    function handleError(err) {
+      unlinkZipFile();
+      cb(err);
+    }
+
+    function unlinkZipFile() {
+      fs.unlink(srcFullPath, function(err) {
+        if (err) {
+          log.error("Unable to remove zip file after importing:", err.stack);
+        }
+      });
+    }
+  });
+}
+
+// sort keys according to how they appear in the library
+function sortTracks(tracks) {
+  var lib = new MusicLibraryIndex();
+  tracks.forEach(function(track) {
+    lib.addTrack(track);
+  });
+  lib.rebuild();
+  var results = [];
+  lib.artistList.forEach(function(artist) {
+    artist.albumList.forEach(function(album) {
+      album.trackList.forEach(function(track) {
+        results.push(track);
+      });
+    });
+  });
+  return results;
+}
diff --git a/lib/player_server.js b/lib/player_server.js
index 52e94de..3b22fa1 100644
--- a/lib/player_server.js
+++ b/lib/player_server.js
@@ -99,19 +99,22 @@ PlayerServer.actions = {
     fn: function(self, client, args) {
       var urlString = args.url;
       var id = args.id;
+      var autoQueue = args.autoQueue;
       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 (!self.validateBoolean(client, autoQueue)) return;
+      self.player.importUrl(urlString, function(err, dbFiles) {
         if (err) {
-          log.error("Unable to import url:", urlString, "error:", err.stack);
-        } else if (!dbFile) {
-          log.error("Unable to import file due to race condition.");
-        } else {
-          key = dbFile.key;
+          log.error("Unable to import url:", urlString, err.stack);
+        } else if (!dbFiles) {
+          log.warn("Unable to import url, unrecognized format");
+        } else if (dbFiles.length > 0) {
+          self.addEvent(client.user, 'import', null, dbFiles[0].key, dbFiles.length);
+          if (autoQueue) {
+            self.player.sortAndQueueTracks(dbFiles);
+            self.addEvent(client.user, 'queue', null, dbFiles[0].key, dbFiles.length);
+          }
         }
-        client.sendMessage('importUrl', {id: id, key: key});
-        self.addEvent(client.user, 'import', null, key);
       });
     },
   },
@@ -477,6 +480,12 @@ PlayerServer.prototype.initialize = function() {
     });
   });
 
+  var onImportProgress = addSubscription('importProgress', serializeImportProgress);
+  self.player.on('importStart', onImportProgress);
+  self.player.on('importEnd', onImportProgress);
+  self.player.on('importAbort', onImportProgress);
+  self.player.on('importProgress', onImportProgress);
+
   // this is only anonymous streamers
   var onStreamersUpdate = addSubscription('streamers', serializeStreamers);
   self.player.on('streamerConnect', onStreamersUpdate);
@@ -503,8 +512,10 @@ PlayerServer.prototype.initialize = function() {
   self.player.on('streamerConnect', maybeAddAnonStreamerConnectEvent);
   self.player.on('streamerDisconnect', maybeAddAnonStreamerDisconnectEvent);
 
-
   self.player.on('streamerDisconnect', self.checkLastStreamerDisconnected.bind(self));
+  self.on('streamStop', self.checkLastStreamerDisconnected.bind(self));
+
+  self.player.on('autoPause', addAutoPauseEvent);
 
   var prevCurrentTrackKey = null;
   function addCurrentTrackEvent() {
@@ -515,6 +526,10 @@ PlayerServer.prototype.initialize = function() {
     }
   }
 
+  function addAutoPauseEvent() {
+    self.addEvent(null, 'autoPause');
+  }
+
   function addStreamerConnectEvent(client) {
     self.addEvent(client.user, 'streamStart');
   }
@@ -703,6 +718,21 @@ PlayerServer.prototype.initialize = function() {
   function getHaveAdminUser() {
     return self.haveAdminUser();
   }
+
+  function serializeImportProgress() {
+    var out = {};
+    for (var id in self.player.importProgress) {
+      var ev = self.player.importProgress[id];
+      var outEvent = {
+        date: ev.date,
+        filenameHintWithoutPath: ev.filenameHintWithoutPath,
+        bytesWritten: ev.bytesWritten,
+        size: ev.size,
+      };
+      out[ev.id] = outEvent;
+    }
+    return out;
+  }
 };
 
 PlayerServer.prototype.checkLastStreamerDisconnected = function() {
@@ -1170,6 +1200,18 @@ PlayerServer.prototype.validateString = function(client, val, maxLength) {
   return true;
 };
 
+PlayerServer.prototype.validateBoolean = function(client, val) {
+  var errText;
+  if (typeof val !== 'boolean') {
+    errText = "expected boolean";
+    log.warn("invalid command: " + errText);
+    client.sendMessage('error', errText);
+    return false;
+  }
+
+  return true;
+};
+
 PlayerServer.prototype.deleteUsers = function(ids) {
   var cmds = [];
 
diff --git a/lib/uuid.js b/lib/uuid.js
index cea0357..aeb4c6f 100644
--- a/lib/uuid.js
+++ b/lib/uuid.js
@@ -1,18 +1,18 @@
 var crypto = require('crypto');
 var htmlSafe = {'/': '_', '+': '-'};
 
+// random string which is safe to put in an html id
 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];
-  });
+  return len(24);
 }
 
 function len(size) {
-  return rando(size).toString('base64');
+  return rando(size).toString('base64').replace(/[\/\+]/g, function(x) {
+    return htmlSafe[x];
+  });
 }
 
 function rando(size) {
diff --git a/package.json b/package.json
index 83f546f..b8b580f 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.3.1",
+  "version": "1.4.0",
   "licenses": [
     {
       "type": "MIT",
@@ -20,31 +20,34 @@
     "url": "git://github.com/andrewrk/groovebasin.git"
   },
   "dependencies": {
-    "connect-static": "~1.2.2",
+    "connect-static": "~1.3.1",
     "cookies": "~0.5.0",
     "curlydiff": "~2.0.1",
-    "express": "~4.8.4",
-    "findit": "~2.0.0",
+    "express": "~4.9.7",
     "groove": "~2.2.6",
-    "keese": "~1.0.1",
+    "keese": "~1.0.4",
     "lastfm": "~0.9.2",
     "leveldown": "~1.0.0",
     "mess": "~0.1.2",
     "mkdirp": "~0.5.0",
-    "multiparty": "~3.3.2",
+    "multiparty": "~4.0.0",
     "music-library-index": "~1.2.2",
     "mv": "~2.0.3",
     "osenv": "~0.1.0",
     "pend": "~1.1.3",
-    "semver": "~3.0.1",
-    "serve-static": "~1.5.3",
+    "semver": "~4.1.0",
+    "serve-static": "~1.7.0",
     "ws": "~0.4.32",
-    "yazl": "~2.0.1",
-    "ytdl-core": ">=0.2.4"
+    "yazl": "~2.0.2",
+    "ytdl-core": ">=0.2.4",
+    "findit2": "~2.2.2",
+    "content-disposition": "~0.5.0",
+    "yauzl": "~2.1.0"
   },
   "devDependencies": {
     "stylus": "~0.49.1",
-    "browserify-lite": "~0.2.2"
+    "browserify-lite": "~0.2.3",
+    "human-size": "~1.1.0"
   },
   "scripts": {
     "start": "node lib/server.js",
diff --git a/src/client/app.js b/src/client/app.js
index dacfcb2..2ca229e 100644
--- a/src/client/app.js
+++ b/src/client/app.js
@@ -1,6 +1,7 @@
 var $ = window.$;
 
 var shuffle = require('mess');
+var humanSize = require('human-size');
 var PlayerClient = require('./playerclient');
 var Socket = require('./socket');
 var uuid = require('./uuid');
@@ -474,6 +475,10 @@ var $eventsOnlineUsers = $('#events-online-users');
 var $eventsList = $('#events-list');
 var $chatBox = $('#chat-box');
 var $chatBoxInput = $('#chat-box-input');
+var $queueDuration = $('#queue-duration');
+var $queueDurationLabel = $('#queue-duration-label');
+var $importProgress = $('#import-progress');
+var $importProgressList = $('#import-progress-list');
 
 var tabs = {
   library: {
@@ -499,6 +504,7 @@ var tabs = {
 };
 var activeTab = tabs.library;
 var $eventsTabSpan = tabs.events.$tab.find('span');
+var $importTabSpan = tabs.upload.$tab.find('span');
 
 function saveLocalState(){
   localStorage.setItem('state', JSON.stringify(localState));
@@ -649,6 +655,30 @@ function renderQueue(){
   $queueItems.scrollTop(scrollTop);
 }
 
+function updateQueueDuration() {
+  var duration = 0;
+
+  if (selection.isQueue()) {
+    selection.toTrackKeys().forEach(addKeyDuration);
+    $queueDurationLabel.text("Selection:");
+  } else {
+    player.queue.itemList.forEach(addItemDuration);
+    $queueDurationLabel.text("Play Queue:");
+  }
+  $queueDuration.text(formatTime(duration));
+
+  function addKeyDuration(key) {
+    var track = player.library.trackTable[key];
+    if (track) {
+      duration += track.duration;
+    }
+  }
+
+  function addItemDuration(item) {
+    duration += item.track.duration;
+  }
+}
+
 function labelPlaylistItems() {
   var item;
   var curItem = player.currentItem;
@@ -730,11 +760,17 @@ function getSelectionHelpers(){
 
 function refreshSelection() {
   var helpers = getSelectionHelpers();
-  if (!helpers) return;
+  if (!helpers) {
+    updateQueueDuration();
+    return;
+  }
   $queueItems.find(".pl-item").removeClass('selected').removeClass('cursor');
   $libraryArtists.find(".clickable").removeClass('selected').removeClass('cursor');
   $playlistsList.find(".clickable").removeClass('selected').removeClass('cursor');
-  if (selection.type == null) return;
+  if (selection.type == null) {
+    updateQueueDuration();
+    return;
+  }
   for (var selectionType in helpers) {
     var helper = helpers[selectionType];
     var id;
@@ -765,6 +801,7 @@ function refreshSelection() {
       }
     }
   }
+  updateQueueDuration();
 }
 
 function getValidIds(selectionType) {
@@ -998,16 +1035,6 @@ function renderNowPlaying() {
   var trackDisplay;
   if (track != null) {
     trackDisplay = getNowPlayingText(track);
-    if (/Groove Basin/.test(track.name)) {
-      $("html").addClass('groovebasin');
-    } else {
-      $("html").removeClass('groovebasin');
-    }
-    if (/Never Gonna Give You Up/.test(track.name) && /Rick Astley/.test(track.artistName)) {
-      $("html").addClass('nggyu');
-    } else {
-      $("html").removeClass('nggyu');
-    }
   } else {
     trackDisplay = " ";
   }
@@ -1729,6 +1756,12 @@ function settingsAuthSave() {
   hideShowAuthEdit(false);
 }
 
+function changeUserName(username) {
+  localState.authUsername = username;
+  saveLocalState();
+  sendAuth();
+}
+
 function settingsAuthCancel() {
   hideShowAuthEdit(false);
 }
@@ -2317,8 +2350,13 @@ function uploadFiles(files) {
 
   var formData = new FormData();
 
+  if (localState.autoQueueUploads) {
+    formData.append('autoQueue', '1');
+  }
+
   for (var i = 0; i < files.length; i += 1) {
     var file = files[i];
+    formData.append("size", String(file.size));
     formData.append("file", file);
   }
 
@@ -2344,11 +2382,6 @@ function uploadFiles(files) {
   }
 
   function onLoad(e) {
-    if (localState.autoQueueUploads) {
-      var keys = JSON.parse(this.response);
-      // sort them the same way the library is sorted
-      player.queueTracks(player.sortKeys(keys));
-    }
     cleanup();
   }
 
@@ -2398,20 +2431,11 @@ function setUpUploadUi(){
     var url = $uploadByUrl.val();
     var id = uuid();
     $uploadByUrl.val("").blur();
-    socket.on('importUrl', onImportUrl);
     socket.send('importUrl', {
       url: url,
       id: id,
+      autoQueue: !!localState.autoQueueUploads,
     });
-
-    function onImportUrl(args) {
-      if (args.id !== id) return;
-      socket.removeListener('importUrl', onImportUrl);
-      if (!args.key) return;
-      if (localState.autoQueueUploads) {
-        player.queueTracks([args.key]);
-      }
-    }
   }
 }
 
@@ -2672,6 +2696,11 @@ function setUpEventsUi() {
       if (!msg.length) {
         return false;
       }
+      var match = msg.match(/^\/(\w+)\s+(.+)/);
+      if (match) {
+        handleSlashCommands(match[1], match[2]);
+        return false;
+      }
       socket.send('chat', msg);
       return false;
     }
@@ -2679,6 +2708,12 @@ function setUpEventsUi() {
 
 }
 
+function handleSlashCommands(command, message) {
+  if (command === 'nick') {
+    changeUserName(message);
+  }
+}
+
 function clearChatInputValue() {
   $chatBoxInput.val("");
 }
@@ -2700,6 +2735,45 @@ function updateTitle() {
   }
 }
 
+function renderImportProgress() {
+  var importProgressListDom = $importProgressList.get(0);
+  var scrollTop = $importProgressList.scrollTop();
+
+  var importTabText = (player.importProgressList.length > 0) ?
+    ("Import (" + player.importProgressList.length + ")") : "Import";
+  $importTabSpan.text(importTabText);
+
+  // add the missing dom entries
+  var i, ev;
+  for (i = importProgressListDom.childElementCount; i < player.importProgressList.length; i += 1) {
+    $importProgressList.append(
+      '<li class="progress">' +
+        '<span class="name"></span> ' +
+        '<span class="percent"></span>' +
+      '</li>');
+  }
+  // remove extra dom entries
+  var domItem;
+  while (player.importProgressList.length < importProgressListDom.childElementCount) {
+    importProgressListDom.removeChild(importProgressListDom.lastChild);
+  }
+  // overwrite existing dom entries
+  var $domItems = $importProgressList.children();
+  for (i = 0; i < player.importProgressList.length; i += 1) {
+    var $domItem = $($domItems[i]);
+    ev = player.importProgressList[i];
+    $domItem.find('.name').text(ev.filenameHintWithoutPath);
+    var percent = humanSize(ev.bytesWritten, 1);
+    if (ev.size) {
+      percent += " / " + humanSize(ev.size, 1);
+    }
+    $domItem.find('.percent').text(percent);
+  }
+
+  $importProgress.toggle(player.importProgressList.length > 0);
+  $importProgressList.scrollTop(scrollTop);
+}
+
 function renderEvents() {
   var eventsListDom = $eventsList.get(0);
   var scrollTop = $eventsList.scrollTop();
@@ -2745,6 +2819,9 @@ function scrollEventsToBottom() {
 }
 
 var eventTypeMessageFns = {
+  autoPause: function(ev) {
+    return "auto pause because nobody is listening";
+  },
   chat: function(ev) {
     return ev.text;
   },
@@ -2752,10 +2829,11 @@ var eventTypeMessageFns = {
     return "Now playing: " + getNowPlayingText(ev.track);
   },
   import: function(ev) {
-    if (ev.user) {
-      return "imported " + getNowPlayingText(ev.track);
+    var prefix = ev.user ? "imported " : "anonymous user imported ";
+    if (ev.pos > 1) {
+      return prefix + ev.pos + " tracks";
     } else {
-      return "anonymous user imported " + getNowPlayingText(ev.track);
+      return prefix + getNowPlayingText(ev.track);
     }
   },
   login: function(ev) {
@@ -2774,14 +2852,14 @@ var eventTypeMessageFns = {
     return "pressed play";
   },
   queue: function(ev) {
-    if (ev.track) {
+    if (ev.pos === 1) {
       return "added to the queue: " + getNowPlayingText(ev.track);
     } else {
       return "added " + ev.pos + " tracks to the queue";
     }
   },
   remove: function(ev) {
-    if (ev.track) {
+    if (ev.pos === 1) {
       return "removed from the queue: " + getNowPlayingText(ev.track);
     } else {
       return "removed " + ev.pos + " tracks from the queue";
@@ -3412,6 +3490,7 @@ $document.ready(function(){
     socket.send('subscribe', {name: 'haveAdminUser'});
     loadStatus = LoadStatus.GoodToGo;
     render();
+    ensureSearchHappensSoon();
   });
   player = new PlayerClient(socket);
   player.on('users', function() {
@@ -3420,6 +3499,7 @@ $document.ready(function(){
     renderOnlineUsers();
     renderStreamButton();
   });
+  player.on('importProgress', renderImportProgress);
   player.on('libraryupdate', triggerRenderLibrary);
   player.on('queueUpdate', triggerRenderQueue);
   player.on('scanningUpdate', triggerRenderQueue);
diff --git a/src/client/playerclient.js b/src/client/playerclient.js
index 92940d1..3b42fbd 100644
--- a/src/client/playerclient.js
+++ b/src/client/playerclient.js
@@ -9,6 +9,7 @@ module.exports = PlayerClient;
 
 var compareSortKeyAndId = makeCompareProps(['sortKey', 'id']);
 var compareNameAndId = makeCompareProps(['name', 'id']);
+var compareDates = makeCompareProps(['date', 'id']);
 
 PlayerClient.REPEAT_OFF = 0;
 PlayerClient.REPEAT_ONE = 1;
@@ -37,6 +38,8 @@ function PlayerClient(socket) {
   self.eventsFromServerVersion = null;
   self.usersFromServer = undefined;
   self.usersFromServerVersion = null;
+  self.importProgressFromServer = undefined;
+  self.importProgressFromServerVersion = null;
 
   self.resetServerState();
   self.socket.on('disconnect', function() {
@@ -133,6 +136,14 @@ function PlayerClient(socket) {
     self.sortUsersFromServer();
     self.emit('users');
   });
+
+  self.socket.on('importProgress', function(o) {
+    if (o.reset) self.importProgressFromServer = undefined;
+    self.importProgressFromServer = curlydiff.apply(self.importProgressFromServer, o.delta);
+    self.importProgressFromServerVersion = o.version;
+    self.sortImportProgressFromServer();
+    self.emit('importProgress');
+  });
 }
 
 PlayerClient.prototype.resubscribe = function(){
@@ -170,6 +181,11 @@ PlayerClient.prototype.resubscribe = function(){
     delta: true,
     version: this.eventsFromServerVersion,
   });
+  this.sendCommand('subscribe', {
+    name: 'importProgress',
+    delta: true,
+    version: this.importProgressFromServerVersion,
+  });
 };
 
 PlayerClient.prototype.sortEventsFromServer = function() {
@@ -231,6 +247,24 @@ PlayerClient.prototype.sortUsersFromServer = function() {
   this.usersList.sort(compareUserNames);
 };
 
+PlayerClient.prototype.sortImportProgressFromServer = function() {
+  this.importProgressList = [];
+  this.importProgressTable = {};
+  for (var id in this.importProgressFromServer) {
+    var ev = this.importProgressFromServer[id];
+    var importEvent = {
+      id: id,
+      date: new Date(ev.date),
+      filenameHintWithoutPath: ev.filenameHintWithoutPath,
+      bytesWritten: ev.bytesWritten,
+      size: ev.size,
+    };
+    this.importProgressTable[id] = importEvent;
+    this.importProgressList.push(importEvent);
+  }
+  this.importProgressList.sort(compareDates);
+};
+
 PlayerClient.prototype.updateTrackStartDate = function() {
   this.trackStartDate = (this.serverTrackStartDate != null) ?
     new Date(new Date(this.serverTrackStartDate) - this.serverTimeOffset) : null;
@@ -667,26 +701,6 @@ PlayerClient.prototype.refreshPlaylistList = function(playlist) {
   }
 };
 
-// sort keys according to how they appear in the library
-PlayerClient.prototype.sortKeys = function(keys) {
-  var realLib = this.library;
-  var lib = new MusicLibraryIndex();
-  keys.forEach(function(key) {
-    var track = realLib.trackTable[key];
-    if (track) lib.addTrack(track);
-  });
-  lib.rebuild();
-  var results = [];
-  lib.artistList.forEach(function(artist) {
-    artist.albumList.forEach(function(album) {
-      album.trackList.forEach(function(track) {
-        results.push(track.key);
-      });
-    });
-  });
-  return results;
-};
-
 PlayerClient.prototype.resetServerState = function(){
   this.haveFileListCache = false;
   this.library = new MusicLibraryIndex({
@@ -704,6 +718,8 @@ PlayerClient.prototype.resetServerState = function(){
   this.eventsList = [];
   this.seenEvents = {};
   this.unseenChatCount = 0;
+  this.importProgressList = [];
+  this.importProgressTable = {};
 
   this.clearStoredPlaylists();
 };
diff --git a/src/client/styles/app.styl b/src/client/styles/app.styl
index 57ef7bd..cca624b 100644
--- a/src/client/styles/app.styl
+++ b/src/client/styles/app.styl
@@ -18,10 +18,6 @@ selected-div()
 
 html
   background url(images/ui-bg_diagonals-thick_15_0b3e6f_40x40.png)
-html.groovebasin
-  background url(img/groovebasin.jpg)
-html.nggyu
-  background url(img/nggyu.jpg)
 
 body
   font-family Arial, Helvetica, sans-serif
@@ -66,10 +62,9 @@ a:hover,a:active,a:focus
     float left
 
 #client-vol, #vol
-  margin 10px
+  margin 8px
   span
     float left
-    margin -2px 2px 0px 10px
 
 #client-vol-slider, #vol-slider
   float left
@@ -171,6 +166,9 @@ a:hover,a:active,a:focus
     margin-bottom 4px
     font-size .6em
 
+#queue-duration, #queue-duration-label
+  font-size 1.4em
+
 #queue-list
   .header
     font-weight bold
@@ -265,12 +263,11 @@ a:hover,a:active,a:focus
   width 370px
 
 .ui-menu
-  width 240px
+  width 250px
   font-size 1em
-
-#menu-library .ui-state-disabled.ui-state-focus,
-#menu-queue .ui-state-disabled.ui-state-focus
-  margin: .3em -1px .2em
+  a
+    text-decoration none
+    display block
 
 #shortcuts
   h1
@@ -392,5 +389,8 @@ a:hover,a:active,a:focus
   .chat
     background-color lightGray
 
+#import-progress-list
+  overflow-y auto
+
 #chat-box-input
   width 100%
diff --git a/src/public/img/groovebasin.jpg b/src/public/img/groovebasin.jpg
deleted file mode 100644
index 383ee8d..0000000
Binary files a/src/public/img/groovebasin.jpg and /dev/null differ
diff --git a/src/public/img/nggyu.jpg b/src/public/img/nggyu.jpg
deleted file mode 100644
index 5fc755a..0000000
Binary files a/src/public/img/nggyu.jpg and /dev/null differ
diff --git a/src/public/index.html b/src/public/index.html
index 71f5d0e..11e3f08 100644
--- a/src/public/index.html
+++ b/src/public/index.html
@@ -74,6 +74,13 @@
         <div>
           Automatically queue imported songs: <input class="jquery-button" type="checkbox" id="auto-queue-uploads"><label for="auto-queue-uploads">On</label>
         </div>
+        <div id="import-progress" style="display: none">
+          <h1>Ongoing Imports</h1>
+          <div>
+            <ul id="import-progress-list">
+            </ul>
+          </div>
+        </div>
       </div>
     </div>
     <div id="playlists-pane" class="ui-widget-content ui-corner-all" style="display: none">
@@ -181,8 +188,8 @@
         <div class="section">
           <h1>About</h1>
           <ul>
-            <li><a id="settings-stream-url" href="#">Stream URL</a></li>
-            <li><a href="http://github.com/andrewrk/groovebasin">GrooveBasin on GitHub</a></li>
+            <li><a id="settings-stream-url" href="#" target="_blank">Stream URL</a></li>
+            <li><a href="http://github.com/andrewrk/groovebasin" target="_blank">GrooveBasin on GitHub</a></li>
           </ul>
         </div>
       </div>
@@ -194,6 +201,7 @@
       <button class="jquery-button shuffle">Shuffle</button>
       <input class="jquery-button" type="checkbox" id="dynamic-mode"><label id="dynamic-mode-label" for="dynamic-mode">Dynamic Mode</label>
       <input class="jquery-button" type="checkbox" id="queue-btn-repeat"><label id="queue-btn-repeat-label" for="queue-btn-repeat">Repeat: Off</label>
+      <span id="queue-duration-label"></span> <span id="queue-duration"></span>
     </div>
     <div id="queue-list">
       <div id="queue-header" class="header">

-- 
groovebasin packaging



More information about the pkg-multimedia-commits mailing list