[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
-
-
-
-
+
+
+
+
+
+
## 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