379 lines
11 KiB
JavaScript
379 lines
11 KiB
JavaScript
var EventEmitter = require('events').EventEmitter;
|
|
var http = require('http');
|
|
var assert = require('assert');
|
|
var WebSocketServer = require('ws').Server;
|
|
var fs = require('fs');
|
|
var archiver = require('archiver');
|
|
var util = require('util');
|
|
var path = require('path');
|
|
var Pend = require('pend');
|
|
var express = require('express');
|
|
var osenv = require('osenv');
|
|
var spawn = require('child_process').spawn;
|
|
var requireIndex = require('requireindex');
|
|
var plugins = requireIndex(path.join(__dirname, 'plugins'));
|
|
var Player = require('./player');
|
|
var PlayerServer = require('./player_server');
|
|
var MpdProtocol = require('./mpd_protocol');
|
|
var MpdApiServer = require('./mpd_api_server');
|
|
var WebSocketApiClient = require('./web_socket_api_client');
|
|
var levelup = require('level');
|
|
var crypto = require('crypto');
|
|
var net = require('net');
|
|
var safePath = require('./safe_path');
|
|
var MultipartForm = require('multiparty').Form;
|
|
var createGzipStatic = require('connect-static');
|
|
var serveStatic = require('serve-static');
|
|
var bodyParser = require('body-parser');
|
|
|
|
module.exports = GrooveBasin;
|
|
|
|
var defaultConfig = {
|
|
host: '0.0.0.0',
|
|
port: 16242,
|
|
dbPath: "groovebasin.db",
|
|
musicDirectory: path.join(osenv.home(), "music"),
|
|
permissions: {},
|
|
defaultPermissions: {
|
|
read: true,
|
|
add: true,
|
|
control: true,
|
|
},
|
|
lastFmApiKey: "7d831eff492e6de5be8abb736882c44d",
|
|
lastFmApiSecret: "8713e8e893c5264608e584a232dd10a0",
|
|
mpdHost: '0.0.0.0',
|
|
mpdPort: 6600,
|
|
acoustidAppKey: 'bgFvC4vW',
|
|
instantBufferBytes: 220 * 1024,
|
|
};
|
|
|
|
defaultConfig.permissions[genPassword()] = {
|
|
admin: true,
|
|
read: true,
|
|
add: true,
|
|
control: true,
|
|
};
|
|
|
|
util.inherits(GrooveBasin, EventEmitter);
|
|
function GrooveBasin() {
|
|
EventEmitter.call(this);
|
|
|
|
this.app = express();
|
|
}
|
|
|
|
GrooveBasin.prototype.initConfigVar = function(name, defaultValue) {
|
|
this.configVars.push(name);
|
|
this[name] = defaultValue;
|
|
};
|
|
|
|
GrooveBasin.prototype.loadConfig = function(cb) {
|
|
var self = this;
|
|
var pathToConfig = "config.js";
|
|
fs.readFile(pathToConfig, {encoding: 'utf8'}, function(err, contents) {
|
|
var anythingAdded = false;
|
|
var config;
|
|
if (err) {
|
|
if (err.code === 'ENOENT') {
|
|
anythingAdded = true;
|
|
self.config = defaultConfig;
|
|
console.warn("No config.js found; writing default.");
|
|
} else {
|
|
return cb(err);
|
|
}
|
|
} else {
|
|
try {
|
|
self.config = JSON.parse(contents);
|
|
} catch (err) {
|
|
cb(err);
|
|
}
|
|
}
|
|
// this ensures that even old files get new config values when we add them
|
|
for (var key in defaultConfig) {
|
|
if (self.config[key] === undefined) {
|
|
anythingAdded = true;
|
|
self.config[key] = defaultConfig[key];
|
|
}
|
|
}
|
|
if (anythingAdded) {
|
|
fs.writeFile(pathToConfig, JSON.stringify(self.config, null, 4), cb);
|
|
} else {
|
|
cb();
|
|
}
|
|
});
|
|
};
|
|
|
|
GrooveBasin.prototype.start = function() {
|
|
var self = this;
|
|
|
|
var pend = new Pend();
|
|
pend.go(function(cb) {
|
|
self.loadConfig(cb);
|
|
});
|
|
pend.go(function(cb) {
|
|
var options = {
|
|
dir: path.join(__dirname, "../public"),
|
|
aliases: [],
|
|
};
|
|
createGzipStatic(options, function(err, middleware) {
|
|
if (err) return cb(err);
|
|
self.app.use(middleware);
|
|
cb();
|
|
});
|
|
});
|
|
pend.go(function(cb) {
|
|
createGzipStatic({dir: path.join(__dirname, "../src/public")}, function(err, middleware) {
|
|
if (err) return cb(err);
|
|
self.app.use(middleware);
|
|
cb();
|
|
});
|
|
});
|
|
pend.wait(function(err) {
|
|
if (err) {
|
|
console.error(err.stack);
|
|
return;
|
|
}
|
|
|
|
self.httpHost = self.config.host;
|
|
self.httpPort = self.config.port;
|
|
self.db = levelup(self.config.dbPath);
|
|
|
|
self.initializeDownload();
|
|
self.initializeUpload();
|
|
|
|
self.player = new Player(self.db, self.config.musicDirectory, self.config.instantBufferBytes);
|
|
self.player.initialize(function(err) {
|
|
if (err) {
|
|
console.error("unable to initialize player:", err.stack);
|
|
return;
|
|
}
|
|
console.info("Player initialization complete.");
|
|
|
|
self.app.use(self.player.streamMiddleware.bind(self.player));
|
|
|
|
var pend = new Pend();
|
|
for (var pluginName in plugins) {
|
|
var PluginClass = plugins[pluginName];
|
|
var plugin = new PluginClass(self);
|
|
if (plugin.initialize) pend.go(plugin.initialize.bind(plugin));
|
|
}
|
|
pend.wait(function(err) {
|
|
if (err) {
|
|
console.error("Error initializing plugin:", err.stack);
|
|
return;
|
|
}
|
|
self.startServer();
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
GrooveBasin.prototype.initializeDownload = function() {
|
|
var self = this;
|
|
var musicDir = self.config.musicDirectory;
|
|
self.app.use('/library', serveStatic(musicDir));
|
|
self.app.get('/library/', function(req, resp) {
|
|
downloadPath("", "library.zip", req, resp);
|
|
});
|
|
self.app.get(/^\/library\/(.*)\/$/, function(req, resp){
|
|
var reqDir = req.params[0];
|
|
var zipName = safePath(reqDir.replace(/\//g, " - ")) + ".zip";
|
|
downloadPath(reqDir, zipName, req, resp);
|
|
});
|
|
self.app.post('/download/custom', bodyParser(), function(req, resp) {
|
|
var reqKeys = req.body.key;
|
|
if (!Array.isArray(reqKeys)) {
|
|
reqKeys = [reqKeys];
|
|
}
|
|
var files = [];
|
|
for (var i = 0; i < reqKeys.length; i += 1) {
|
|
var key = reqKeys[i];
|
|
var dbFile = self.player.libraryIndex.trackTable[key];
|
|
if (dbFile) files.push(path.join(musicDir, dbFile.file));
|
|
}
|
|
var reqZipName = (req.body.zipName || "music").toString();
|
|
var zipName = safePath(reqZipName) + ".zip";
|
|
sendZipOfFiles(zipName, files, req, resp);
|
|
});
|
|
|
|
function downloadPath(dirName, zipName, req, resp) {
|
|
var files = [];
|
|
var dirEntry = self.player.dirs[dirName];
|
|
if (!dirEntry) {
|
|
resp.statusCode = 404;
|
|
resp.end("Not found");
|
|
return;
|
|
}
|
|
sendZipOfFiles(zipName, files, req, resp);
|
|
|
|
function addOneDir(dirEntry) {
|
|
var baseName, relPath;
|
|
for (baseName in dirEntry.entries) {
|
|
relPath = path.join(dirEntry.dirName, baseName);
|
|
var dbTrack = self.player.dbFilesByPath[relPath];
|
|
if (dbTrack) files.push(dbTrack.file);
|
|
}
|
|
for (baseName in dirEntry.dirEntries) {
|
|
relPath = path.join(dirEntry.dirName, baseName);
|
|
var childEntry = self.player.dirs[relPath];
|
|
if (childEntry) addOneDir(childEntry);
|
|
}
|
|
}
|
|
}
|
|
|
|
function sendZipOfFiles(zipName, files, req, resp) {
|
|
var cleanup = [];
|
|
req.on('close', cleanupEverything);
|
|
|
|
resp.setHeader("Content-Type", "application/zip");
|
|
resp.setHeader("Content-Disposition", "attachment; filename=" + zipName);
|
|
|
|
var archive = archiver('zip');
|
|
archive.on('error', function(err) {
|
|
console.log("Error while sending zip of files:", err.stack);
|
|
cleanupEverything();
|
|
});
|
|
|
|
cleanup.push(function(){
|
|
archive.destroy();
|
|
});
|
|
archive.pipe(resp);
|
|
|
|
files.forEach(function(file) {
|
|
var options = {
|
|
name: path.relative(self.config.musicDirectory, file),
|
|
};
|
|
var readStream = fs.createReadStream(file);
|
|
readStream.on('error', function(err) {
|
|
console.error("zip read stream error:", err.stack);
|
|
});
|
|
cleanup.push(function() {
|
|
readStream.destroy();
|
|
});
|
|
archive.append(readStream, options);
|
|
});
|
|
archive.finalize(function(err) {
|
|
if (err) {
|
|
console.error("Error finalizing zip:", err.stack);
|
|
cleanupEverything();
|
|
}
|
|
});
|
|
|
|
function cleanupEverything() {
|
|
cleanup.forEach(function(fn) {
|
|
try {
|
|
fn();
|
|
} catch(err) {}
|
|
});
|
|
resp.end();
|
|
}
|
|
}
|
|
};
|
|
|
|
GrooveBasin.prototype.initializeUpload = function() {
|
|
var self = this;
|
|
self.app.post('/upload', function(request, response, next) {
|
|
var form = new MultipartForm();
|
|
form.parse(request, function(err, fields, files) {
|
|
if (err) return next(err);
|
|
|
|
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));
|
|
}
|
|
}
|
|
pend.wait(function() {
|
|
response.json(keys);
|
|
});
|
|
|
|
function makeImportFn(file) {
|
|
return function(cb) {
|
|
self.player.importFile(file.path, file.originalFilename, function(err, dbFile) {
|
|
if (err) {
|
|
console.error("Unable to import file:", file.path, "error:", err.stack);
|
|
} else if (!dbFile) {
|
|
console.error("Unable to locate new file due to race condition");
|
|
} else {
|
|
keys.push(dbFile.key);
|
|
}
|
|
cb();
|
|
});
|
|
};
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
GrooveBasin.prototype.startServer = function() {
|
|
var self = this;
|
|
|
|
assert.ok(self.httpServer == null);
|
|
|
|
self.playerServer = new PlayerServer({
|
|
player: self.player,
|
|
authenticate: authenticate,
|
|
defaultPermissions: self.config.defaultPermissions,
|
|
});
|
|
self.mpdApiServer = new MpdApiServer(self.player);
|
|
|
|
self.httpServer = http.createServer(self.app);
|
|
self.wss = new WebSocketServer({
|
|
server: self.httpServer,
|
|
clientTracking: false,
|
|
});
|
|
self.wss.on('connection', function(ws) {
|
|
self.playerServer.handleNewClient(new WebSocketApiClient(ws));
|
|
});
|
|
self.httpServer.listen(self.httpPort, self.httpHost, function() {
|
|
self.emit('listening');
|
|
console.info("Listening at http://" + self.httpHost + ":" + self.httpPort + "/");
|
|
});
|
|
self.httpServer.on('close', function() {
|
|
console.info("server closed");
|
|
});
|
|
var mpdPort = self.config.mpdPort;
|
|
var mpdHost = self.config.mpdHost;
|
|
if (mpdPort == null || mpdHost == null) {
|
|
console.info("MPD Protocol disabled");
|
|
} else {
|
|
self.protocolServer = net.createServer(function(socket) {
|
|
socket.setEncoding('utf8');
|
|
var protocol = new MpdProtocol({
|
|
player: self.player,
|
|
playerServer: self.playerServer,
|
|
apiServer: self.mpdApiServer,
|
|
authenticate: authenticate,
|
|
permissions: self.config.defaultPermissions,
|
|
});
|
|
protocol.on('error', handleError);
|
|
socket.on('error', handleError);
|
|
socket.pipe(protocol).pipe(socket);
|
|
socket.on('close', cleanup);
|
|
self.mpdApiServer.handleNewClient(protocol);
|
|
|
|
function handleError(err) {
|
|
console.error("socket error:", err.stack);
|
|
socket.destroy();
|
|
cleanup();
|
|
}
|
|
function cleanup() {
|
|
self.mpdApiServer.handleClientEnd(protocol);
|
|
}
|
|
});
|
|
self.protocolServer.listen(mpdPort, mpdHost, function() {
|
|
console.info("MPD/GrooveBasin Protocol listening at " +
|
|
mpdHost + ":" + mpdPort);
|
|
});
|
|
}
|
|
|
|
function authenticate(password) {
|
|
return self.config.permissions[password];
|
|
}
|
|
};
|
|
|
|
function genPassword() {
|
|
return crypto.pseudoRandomBytes(9).toString('base64');
|
|
}
|