groovebasin/lib/player.js
2014-05-04 20:28:07 -07:00

1897 lines
54 KiB
JavaScript

var groove = require('groove');
var EventEmitter = require('events').EventEmitter;
var util = require('util');
var mkdirp = require('mkdirp');
var fs = require('fs');
var uuid = require('uuid');
var path = require('path');
var Pend = require('pend');
var DedupedQueue = require('./deduped_queue');
var findit = require('findit');
var shuffle = require('mess');
var mv = require('mv');
var zfill = require('zfill');
var MusicLibraryIndex = require('music-library-index');
var keese = require('keese');
var safePath = require('./safe_path');
var PassThrough = require('stream').PassThrough;
var url = require('url');
var superagent = require('superagent');
module.exports = Player;
groove.setLogging(groove.LOG_WARNING);
var cpuCount = require('os').cpus().length;
var PLAYER_KEY_PREFIX = "Player.";
var LIBRARY_KEY_PREFIX = "Library.";
var LIBRARY_DIR_PREFIX = "LibraryDir.";
var PLAYLIST_KEY_PREFIX = "Playlist.";
// db: store in the DB
// read: send to clients
// write: accept updates from clients
var DB_PROPS = {
key: {
db: true,
read: true,
write: false,
type: 'string',
},
name: {
db: true,
read: true,
write: true,
type: 'string',
},
artistName: {
db: true,
read: true,
write: true,
type: 'string',
},
albumArtistName: {
db: true,
read: true,
write: true,
type: 'string',
},
albumName: {
db: true,
read: true,
write: true,
type: 'string',
},
compilation: {
db: true,
read: true,
write: true,
type: 'boolean',
},
track: {
db: true,
read: true,
write: true,
type: 'integer',
},
trackCount: {
db: true,
read: true,
write: true,
type: 'integer',
},
disc: {
db: true,
read: true,
write: true,
type: 'integer',
},
discCount: {
db: true,
read: true,
write: true,
type: 'integer',
},
duration: {
db: true,
read: true,
write: false,
type: 'float',
},
year: {
db: true,
read: true,
write: true,
type: 'integer',
},
genre: {
db: true,
read: true,
write: true,
type: 'string',
},
file: {
db: true,
read: true,
write: false,
type: 'string',
},
mtime: {
db: true,
read: false,
write: false,
type: 'integer',
},
replayGainAlbumGain: {
db: true,
read: false,
write: false,
type: 'float',
},
replayGainAlbumPeak: {
db: true,
read: false,
write: false,
type: 'float',
},
replayGainTrackGain: {
db: true,
read: false,
write: false,
type: 'float',
},
replayGainTrackPeak: {
db: true,
read: false,
write: false,
type: 'float',
},
composerName: {
db: true,
read: true,
write: true,
type: 'string',
},
performerName: {
db: true,
read: true,
write: true,
type: 'string',
},
lastQueueDate: {
db: true,
read: false,
write: false,
type: 'date',
},
};
var PROP_TYPE_PARSERS = {
'string': function(value) {
return value ? String(value) : "";
},
'date': function(value) {
if (!value) return null;
var date = new Date(value);
if (isNaN(date.getTime())) return null;
return date;
},
'integer': parseIntOrNull,
'float': parseFloatOrNull,
'boolean': function(value) {
return value == null ? null : !!value;
},
};
// how many GrooveFiles to keep open, ready to be decoded
var OPEN_FILE_COUNT = 8;
var PREV_FILE_COUNT = Math.floor(OPEN_FILE_COUNT / 2);
var NEXT_FILE_COUNT = OPEN_FILE_COUNT - PREV_FILE_COUNT;
var DB_SCALE = Math.log(10.0) * 0.05;
var REPLAYGAIN_PREAMP = 0.75;
var REPLAYGAIN_DEFAULT = 0.25;
Player.REPEAT_OFF = 0;
Player.REPEAT_ONE = 1;
Player.REPEAT_ALL = 2;
Player.trackWithoutIndex = trackWithoutIndex;
util.inherits(Player, EventEmitter);
function Player(db, musicDirectory, instantBufferBytes) {
EventEmitter.call(this);
this.setMaxListeners(0);
this.db = db;
this.musicDirectory = musicDirectory;
this.dbFilesByPath = {};
this.libraryIndex = new MusicLibraryIndex();
this.addQueue = new DedupedQueue({processOne: this.addToLibrary.bind(this)});
// when a streaming client connects we send them many buffers quickly
// in order to get the stream started, then we slow down.
this.instantBufferBytes = instantBufferBytes;
this.dirs = {};
this.dirScanQueue = new DedupedQueue({
processOne: this.refreshFilesIndex.bind(this),
// only 1 dir scanning can happen at a time
// we'll pass the dir to scan as the ID so that not more than 1 of the
// same dir can queue up
maxAsync: 1,
});
this.groovePlayer = null; // initialized by initialize method
this.groovePlaylist = null; // initialized by initialize method
this.playlist = {};
this.currentTrack = null;
this.tracksInOrder = []; // another way to look at playlist
this.grooveItems = {}; // maps groove item id to track
this.seekRequestPos = -1; // set to >= 0 when we want to seek
this.invalidPaths = {}; // files that could not be opened
this.repeat = Player.REPEAT_OFF;
this.isPlaying = false;
this.trackStartDate = null;
this.pausedTime = 0;
this.dynamicModeOn = false;
this.dynamicModeHistorySize = 10;
this.dynamicModeFutureSize = 10;
this.ongoingTrackScans = {};
this.ongoingAlbumScans = {};
this.scanQueue = new Pend();
this.scanQueue.max = cpuCount;
this.headerBuffers = [];
this.recentBuffers = [];
this.recentBuffersByteCount = 0;
this.newHeaderBuffers = [];
this.openStreamers = [];
this.lastEncodeItem = null;
this.lastEncodePos = null;
this.expectHeaders = true;
this.playlistItemDeleteQueue = [];
this.importUrlFilters = [];
}
Player.prototype.initialize = function(cb) {
var self = this;
var pend = new Pend();
pend.go(initPlayer);
pend.go(initLibrary);
pend.wait(function(err) {
if (err) return cb(err);
self.requestUpdateDb();
playlistChanged(self);
lazyReplayGainScanPlaylist(self);
cacheAllOptions(cb);
});
function initPlayer(cb) {
var groovePlaylist = groove.createPlaylist();
var groovePlayer = groove.createPlayer();
var grooveEncoder = groove.createEncoder();
grooveEncoder.formatShortName = "mp3";
grooveEncoder.codecShortName = "mp3";
grooveEncoder.bitRate = 256 * 1000;
var pend = new Pend();
pend.go(function(cb) {
groovePlayer.attach(groovePlaylist, cb);
});
pend.go(function(cb) {
grooveEncoder.attach(groovePlaylist, cb);
});
pend.wait(doneAttaching);
function doneAttaching(err) {
if (err) {
cb(err);
return;
}
self.groovePlaylist = groovePlaylist;
self.groovePlayer = groovePlayer;
self.grooveEncoder = grooveEncoder;
self.groovePlaylist.pause();
self.volume = self.groovePlaylist.volume;
self.groovePlayer.on('nowplaying', onNowPlaying);
self.flushEncodedInterval = setInterval(flushEncoded, 10);
cb();
function flushEncoded() {
// poll the encoder for more buffers until either there are no buffers
// available or we get enough buffered
while (1) {
var bufferedSeconds = self.secondsIntoFuture(self.lastEncodeItem, self.lastEncodePos);
if (bufferedSeconds > 0.5 && self.recentBuffersByteCount >= self.instantBufferBytes) return;
var buf = self.grooveEncoder.getBuffer();
if (!buf) return;
if (buf.buffer) {
if (buf.item) {
if (self.expectHeaders) {
console.log("encoder: got first non-header");
self.headerBuffers = self.newHeaderBuffers;
self.newHeaderBuffers = [];
self.expectHeaders = false;
}
self.recentBuffers.push(buf.buffer);
self.recentBuffersByteCount += buf.buffer.length;
while (self.recentBuffers.length > 0 &&
self.recentBuffersByteCount - self.recentBuffers[0].length >= self.instantBufferBytes)
{
self.recentBuffersByteCount -= self.recentBuffers.shift().length;
}
for (var i = 0; i < self.openStreamers.length; i += 1) {
self.openStreamers[i].write(buf.buffer);
}
self.lastEncodeItem = buf.item;
self.lastEncodePos = buf.pos;
} else if (self.expectHeaders) {
// this is a header
console.log("encoder: got header");
self.newHeaderBuffers.push(buf.buffer);
} else {
// it's a footer, ignore the fuck out of it
console.info("ignoring encoded audio footer");
}
} else {
// end of playlist sentinel
console.log("encoder: end of playlist sentinel");
self.expectHeaders = true;
}
}
}
function onNowPlaying() {
var playHead = self.groovePlayer.position();
var decodeHead = self.groovePlaylist.position();
var playHeadDbKey = playHead.item && self.grooveItems[playHead.item.id].key;
var playHeadDbFile = playHeadDbKey && self.libraryIndex.trackTable[playHeadDbKey];
var playHeadFile = playHeadDbFile && playHeadDbFile.file;
console.info("onNowPlaying event. playhead:", playHeadFile);
if (playHead.item) {
var nowMs = (new Date()).getTime();
var posMs = playHead.pos * 1000;
self.trackStartDate = new Date(nowMs - posMs);
self.currentTrack = self.grooveItems[playHead.item.id];
playlistChanged(self);
self.emit('currentTrack');
} else if (!decodeHead.item) {
// both play head and decode head are null. end of playlist.
console.log("end of playlist");
self.currentTrack = null;
playlistChanged(self);
self.emit('currentTrack');
}
}
}
}
function initLibrary(cb) {
var pend = new Pend();
pend.go(cacheAllDb);
pend.go(cacheAllDirs);
pend.go(cacheAllPlaylist);
pend.wait(cb);
}
function cacheAllPlaylist(cb) {
var stream = self.db.createReadStream({
start: PLAYLIST_KEY_PREFIX,
});
stream.on('data', function(data) {
if (data.key.indexOf(PLAYLIST_KEY_PREFIX) !== 0) {
stream.removeAllListeners();
stream.destroy();
cb();
return;
}
var plEntry = JSON.parse(data.value);
self.playlist[plEntry.id] = plEntry;
});
stream.on('error', function(err) {
stream.removeAllListeners();
stream.destroy();
cb(err);
});
stream.on('close', function() {
cb();
});
}
function cacheAllOptions(cb) {
var options = {
repeat: null,
dynamicModeOn: null,
dynamicModeHistorySize: null,
dynamicModeFutureSize: null,
};
var pend = new Pend();
for (var name in options) {
pend.go(makeGetFn(name));
}
pend.wait(function(err) {
if (err) return cb(err);
if (options.repeat != null) {
self.setRepeat(options.repeat);
}
if (options.dynamicModeOn != null) {
self.setDynamicModeOn(options.dynamicModeOn);
}
if (options.dynamicModeHistorySize != null) {
self.setDynamicModeHistorySize(options.dynamicModeHistorySize);
}
if (options.dynamicModeFutureSize != null) {
self.setDynamicModeFutureSize(options.dynamicModeFutureSize);
}
cb();
});
function makeGetFn(name) {
return function(cb) {
self.db.get(PLAYER_KEY_PREFIX + name, function(err, value) {
if (!err && value != null) {
try {
options[name] = JSON.parse(value);
} catch (err) {
cb(err);
return;
}
}
cb();
});
};
}
}
function cacheAllDirs(cb) {
var stream = self.db.createReadStream({
start: LIBRARY_DIR_PREFIX,
});
stream.on('data', function(data) {
if (data.key.indexOf(LIBRARY_DIR_PREFIX) !== 0) {
stream.removeAllListeners();
stream.destroy();
cb();
return;
}
var dirEntry = JSON.parse(data.value);
self.dirs[dirEntry.dirName] = dirEntry;
});
stream.on('error', function(err) {
stream.removeAllListeners();
stream.destroy();
cb(err);
});
stream.on('close', function() {
cb();
});
}
function cacheAllDb(cb) {
var scrubCmds = [];
var stream = self.db.createReadStream({
start: LIBRARY_KEY_PREFIX,
});
stream.on('data', function(data) {
if (data.key.indexOf(LIBRARY_KEY_PREFIX) !== 0) {
stream.removeAllListeners();
stream.destroy();
scrubAndCb();
return;
}
var dbFile = deserializeFileData(data.value);
// scrub duplicates
if (self.dbFilesByPath[dbFile.file]) {
scrubCmds.push({type: 'del', key: data.key});
} else {
self.libraryIndex.addTrack(dbFile);
self.dbFilesByPath[dbFile.file] = dbFile;
}
});
stream.on('error', function(err) {
stream.removeAllListeners();
stream.destroy();
cb(err);
});
stream.on('close', function() {
scrubAndCb();
});
function scrubAndCb() {
if (scrubCmds.length === 0) return cb();
console.info("Scrubbing " + scrubCmds.length + " duplicate db entries");
self.db.batch(scrubCmds, function(err) {
if (err) console.error("Unable to scrub duplicate tracks from db:", err.stack);
cb();
});
}
}
};
Player.prototype.requestUpdateDb = function(dirName, forceRescan, cb) {
var fullPath = path.resolve(this.musicDirectory, dirName || "");
this.dirScanQueue.add(fullPath, {
dir: fullPath,
forceRescan: forceRescan,
}, cb);
};
Player.prototype.refreshFilesIndex = function(args, cb) {
var self = this;
var dir = args.dir;
var forceRescan = args.forceRescan;
var dirWithSlash = ensureSep(dir);
var walker = findit(dirWithSlash, {followSymlinks: true});
var thisScanId = uuid();
walker.on('directory', function(fullDirPath, stat, stop) {
var dirName = path.relative(self.musicDirectory, fullDirPath);
var baseName = path.basename(dirName);
if (isFileIgnored(baseName)) return;
var dirEntry = self.getOrCreateDir(dirName, stat);
if (fullDirPath === dirWithSlash) return; // ignore root search path
var parentDirName = path.dirname(dirName);
if (parentDirName === '.') parentDirName = '';
var parentDirEntry = self.getOrCreateDir(parentDirName);
parentDirEntry.dirEntries[baseName] = thisScanId;
});
walker.on('file', function(fullPath, stat) {
var relPath = path.relative(self.musicDirectory, fullPath);
var dirName = path.dirname(relPath);
if (dirName === '.') dirName = '';
var baseName = path.basename(relPath);
if (isFileIgnored(baseName)) return;
var dirEntry = self.getOrCreateDir(dirName);
dirEntry.entries[baseName] = thisScanId;
onAddOrChange(relPath, stat);
});
walker.on('error', function(err) {
console.error("library scanning error:", err.stack);
});
walker.on('end', function() {
var dirName = path.relative(self.musicDirectory, dir);
checkDirEntry(self.dirs[dirName]);
cb();
function checkDirEntry(dirEntry) {
if (!dirEntry) return;
var id;
var baseName;
var i;
var deletedFiles = [];
var deletedDirs = [];
for (baseName in dirEntry.entries) {
id = dirEntry.entries[baseName];
if (id !== thisScanId) deletedFiles.push(baseName);
}
for (i = 0; i < deletedFiles.length; i += 1) {
baseName = deletedFiles[i];
delete dirEntry.entries[baseName];
onFileMissing(dirEntry, baseName);
}
for (baseName in dirEntry.dirEntries) {
id = dirEntry.dirEntries[baseName];
var childEntry = self.dirs[path.join(dirEntry.dirName, baseName)];
checkDirEntry(childEntry);
if (id !== thisScanId) deletedDirs.push(baseName);
}
for (i = 0; i < deletedDirs.length; i += 1) {
baseName = deletedDirs[i];
delete dirEntry.dirEntries[baseName];
onDirMissing(dirEntry, baseName);
}
self.persistDirEntry(dirEntry);
}
});
function onDirMissing(parentDirEntry, baseName) {
var dirName = path.join(parentDirEntry.dirName, baseName);
console.log("directory deleted:", dirName);
var dirEntry = self.dirs[dirName];
var watcher = dirEntry.watcher;
if (watcher) watcher.close();
delete self.dirs[dirName];
delete parentDirEntry.dirEntries[baseName];
}
function onFileMissing(parentDirEntry, baseName) {
var relPath = path.join(parentDirEntry.dirName, baseName);
console.log("file deleted:", relPath);
delete parentDirEntry.entries[baseName];
var dbFile = self.dbFilesByPath[relPath];
if (dbFile) self.delDbEntry(dbFile);
}
function onAddOrChange(relPath, stat) {
// check the mtime against the mtime of the same file in the db
var dbFile = self.dbFilesByPath[relPath];
var fileMtime = stat.mtime.getTime();
if (dbFile && !forceRescan) {
var dbMtime = dbFile.mtime;
if (dbMtime >= fileMtime) {
// the info we have in our db for this file is fresh
return;
}
}
self.addQueue.add(relPath, {
relPath: relPath,
mtime: fileMtime,
});
}
};
Player.prototype.watchDirEntry = function(dirEntry) {
var self = this;
var changeTriggered = null;
var fullDirPath = path.join(self.musicDirectory, dirEntry.dirName);
var watcher;
try {
watcher = fs.watch(fullDirPath, onChange);
watcher.on('error', onWatchError);
} catch (err) {
console.error("Unable to fs.watch:", err.stack);
watcher = null;
}
dirEntry.watcher = watcher;
function onChange(eventName) {
if (changeTriggered) clearTimeout(changeTriggered);
changeTriggered = setTimeout(function() {
changeTriggered = null;
console.log("dir updated:", dirEntry.dirName);
self.dirScanQueue.add(fullDirPath, { dir: fullDirPath });
}, 100);
}
function onWatchError(err) {
console.error("watch error:", err.stack);
}
};
Player.prototype.getOrCreateDir = function (dirName, stat) {
var dirEntry = this.dirs[dirName];
if (!dirEntry) {
dirEntry = this.dirs[dirName] = {
dirName: dirName,
entries: {},
dirEntries: {},
watcher: null, // will be set just below
mtime: stat && stat.mtime,
};
} else if (stat && dirEntry.mtime !== stat.mtime) {
dirEntry.mtime = stat.mtime;
}
if (!dirEntry.watcher) this.watchDirEntry(dirEntry);
return dirEntry;
};
Player.prototype.getCurPos = function() {
return this.isPlaying ?
((new Date() - this.trackStartDate) / 1000.0) : this.pausedTime;
};
Player.prototype.secondsIntoFuture = function(groovePlaylistItem, pos) {
if (!groovePlaylistItem || !pos) {
return 0;
}
var item = this.grooveItems[groovePlaylistItem.id];
if (item === this.currentTrack) {
return pos - this.getCurPos();
} else {
return pos;
}
};
Player.prototype.streamMiddleware = function(req, resp, next) {
var self = this;
if (req.path !== '/stream.mp3') return next();
resp.setHeader('Content-Type', 'audio/mpeg');
resp.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
resp.setHeader('Pragma', 'no-cache');
resp.setHeader('Expires', '0');
resp.statusCode = 200;
var count = 0;
self.headerBuffers.forEach(function(headerBuffer) {
count += headerBuffer.length;
resp.write(headerBuffer);
});
self.recentBuffers.forEach(function(recentBuffer) {
resp.write(recentBuffer);
});
console.log("sent", count, "bytes of headers and", self.recentBuffersByteCount,
"bytes of unthrottled data");
self.openStreamers.push(resp);
req.on('abort', function() {
for (var i = 0; i < self.openStreamers.length; i += 1) {
if (self.openStreamers[i] === resp) {
self.openStreamers.splice(i, 1);
break;
}
}
resp.end();
});
};
Player.prototype.deleteFile = function(key) {
var self = this;
var dbFile = self.libraryIndex.trackTable[key];
if (!dbFile) {
console.error("Error deleting file - no entry:", key);
return;
}
var fullPath = path.join(self.musicDirectory, dbFile.file);
fs.unlink(fullPath, function(err) {
if (err) {
console.error("Error deleting", dbFile.file, err.stack);
}
});
self.delDbEntry(dbFile);
};
Player.prototype.delDbEntry = function(dbFile) {
// delete items from the queue that are being deleted from the library
var deleteQueueItems = [];
for (var queueId in this.playlist) {
var queueItem = this.playlist[queueId];
if (queueItem.key === dbFile.key) {
deleteQueueItems.push(queueId);
}
}
this.removePlaylistItems(deleteQueueItems);
this.libraryIndex.removeTrack(dbFile.key);
delete this.dbFilesByPath[dbFile.file];
var baseName = path.basename(dbFile.file);
var parentDirName = path.dirname(dbFile.file);
if (parentDirName === '.') parentDirName = '';
var parentDirEntry = this.dirs[parentDirName];
if (parentDirEntry) delete parentDirEntry[baseName];
this.emit('deleteDbTrack', dbFile);
this.db.del(LIBRARY_KEY_PREFIX + dbFile.key, function(err) {
if (err) {
console.error("Error deleting db entry", dbFile.key, err.stack);
}
});
};
Player.prototype.setVolume = function(value) {
value = Math.min(1.0, value);
value = Math.max(0.0, value);
this.volume = value;
this.groovePlaylist.setVolume(value);
this.emit("volumeUpdate");
};
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();
});
function tryImportFilter() {
var importPlugin = self.importUrlFilters[filterIndex];
if (importPlugin) {
importPlugin.importUrl(urlString, callNextFilter);
} else {
downloadRaw();
}
function callNextFilter(err, dlStream, filename) {
if (err || !dlStream) {
if (err) console.warn("import filter error, skipping:", err.stack);
filterIndex += 1;
tryImportFilter();
return;
}
handleDownload(dlStream, filename);
}
}
function downloadRaw() {
var parsedUrl = url.parse(urlString);
var remoteFilename = path.basename(parsedUrl.pathname);
var decodedFilename;
try {
decodedFilename = decodeURI(remoteFilename);
} catch (err) {
decodedFilename = remoteFilename;
}
var req = superagent.get(urlString);
handleDownload(req, decodedFilename);
}
function handleDownload(req, remoteFilename) {
var ext = path.extname(remoteFilename);
var destPath = path.join(tmpDir, uuid() + ext);
var ws = fs.createWriteStream(destPath);
var calledCallback = false;
req.pipe(ws);
ws.on('close', function(){
if (calledCallback) return;
self.importFile(ws.path, remoteFilename, function(err, dbFile) {
if (err) {
cleanAndCb(err);
} else {
calledCallback = true;
cb(null, dbFile);
}
});
});
ws.on('error', cleanAndCb);
req.on('error', cleanAndCb);
function cleanAndCb(err) {
fs.unlink(destPath, function(err) {
if (err) {
console.warn("Unable to clean up temp file:", err.stack);
}
});
if (calledCallback) return;
calledCallback = true;
cb(err);
}
}
function logIfError(err) {
if (err) {
console.error("Unable to import by URL.", err.stack, "URL:", urlString);
}
}
};
// moves the file at srcFullPath to the music library
Player.prototype.importFile = function(srcFullPath, filenameHint, cb) {
var self = this;
cb = cb || logIfError;
groove.open(srcFullPath, function(err, file) {
if (err) return cb(err);
var newDbFile = grooveFileToDbFile(file, filenameHint);
var suggestedPath = self.getSuggestedPath(newDbFile, filenameHint);
var pend = new Pend();
pend.go(function(cb) {
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 logIfError(err) {
if (err) {
console.error("unable to import file:", err.stack);
}
}
};
Player.prototype.persistDirEntry = function(dirEntry, cb) {
cb = cb || logIfError;
this.db.put(LIBRARY_DIR_PREFIX + dirEntry.dirName, serializeDirEntry(dirEntry), cb);
function logIfError(err) {
if (err) {
console.error("unable to persist db entry:", dirEntry, err.stack);
}
}
};
Player.prototype.persist = function(dbFile, cb) {
cb = cb || logIfError;
var prevDbFile = this.libraryIndex.trackTable[dbFile.key];
this.libraryIndex.addTrack(dbFile);
this.dbFilesByPath[dbFile.file] = dbFile;
this.emit('update', prevDbFile, dbFile);
this.db.put(LIBRARY_KEY_PREFIX + dbFile.key, serializeFileData(dbFile), cb);
function logIfError(err) {
if (err) {
console.error("unable to persist db entry:", dbFile, err.stack);
}
}
};
Player.prototype.persistPlaylistItem = function(item, cb) {
this.db.put(PLAYLIST_KEY_PREFIX + item.id, serializePlaylistItem(item), cb || logIfError);
function logIfError(err) {
if (err) {
console.error("unable to persist playlist item:", item, err.stack);
}
}
};
Player.prototype.persistOption = function(name, value, cb) {
this.db.put(PLAYER_KEY_PREFIX + name, JSON.stringify(value), cb || logIfError);
function logIfError(err) {
if (err) {
console.error("unable to persist player option:", err.stack);
}
}
};
Player.prototype.addToLibrary = function(args, cb) {
var self = this;
var relPath = args.relPath;
var mtime = args.mtime;
var fullPath = path.join(self.musicDirectory, relPath);
groove.open(fullPath, function(err, file) {
if (err) {
self.invalidPaths[relPath] = err.message;
cb();
return;
}
var dbFile = self.dbFilesByPath[relPath];
var eventType = dbFile ? 'updateDbTrack' : 'addDbTrack';
var newDbFile = grooveFileToDbFile(file, relPath, dbFile);
newDbFile.file = relPath;
newDbFile.mtime = mtime;
var pend = new Pend();
pend.go(function(cb) {
file.close(cb);
});
pend.go(function(cb) {
self.persist(newDbFile, function(err) {
if (err) console.error("Error saving", relPath, "to db:", err.stack);
cb();
});
});
self.emit(eventType, newDbFile);
pend.wait(cb);
});
};
Player.prototype.updateTags = function(obj) {
for (var key in obj) {
var track = this.libraryIndex.trackTable[key];
if (!track) continue;
var props = obj[key];
if (!props || typeof props !== 'object') continue;
for (var propName in DB_PROPS) {
var prop = DB_PROPS[propName];
if (! prop.write) continue;
if (! (propName in props)) continue;
var parser = PROP_TYPE_PARSERS[prop.type];
track[propName] = parser(props[propName]);
}
this.persist(track);
this.emit('updateDbTrack', track);
}
};
Player.prototype.insertTracks = function(index, keys, tagAsRandom) {
if (keys.length === 0) return;
if (index < 0) index = 0;
if (index > this.tracksInOrder.length) index = this.tracksInOrder.length;
var trackBeforeIndex = this.tracksInOrder[index - 1];
var trackAtIndex = this.tracksInOrder[index];
var prevSortKey = trackBeforeIndex ? trackBeforeIndex.sortKey : null;
var nextSortKey = trackAtIndex ? trackAtIndex.sortKey : null;
var items = {};
var ids = [];
keys.forEach(function(key) {
var id = uuid();
var thisSortKey = keese(prevSortKey, nextSortKey);
prevSortKey = thisSortKey;
items[id] = {
key: key,
sortKey: thisSortKey,
};
ids.push(id);
});
this.addItems(items, tagAsRandom);
return ids;
};
Player.prototype.appendTracks = function(keys, tagAsRandom) {
return this.insertTracks(this.tracksInOrder.length, keys, tagAsRandom);
};
// items looks like {id: {key, sortKey}}
Player.prototype.addItems = function(items, tagAsRandom) {
var self = this;
tagAsRandom = !!tagAsRandom;
for (var id in items) {
var item = items[id];
var dbFile = self.libraryIndex.trackTable[item.key];
if (!dbFile) continue;
dbFile.lastQueueDate = new Date();
self.persist(dbFile);
var playlistItem = {
id: id,
key: item.key,
sortKey: item.sortKey,
isRandom: tagAsRandom,
grooveFile: null,
pendingGrooveFile: false,
deleted: false,
};
self.playlist[id] = playlistItem;
self.persistPlaylistItem(playlistItem);
}
playlistChanged(self);
lazyReplayGainScanPlaylist(self);
};
Player.prototype.clearPlaylist = function() {
this.removePlaylistItems(Object.keys(this.playlist));
};
Player.prototype.shufflePlaylist = function() {
shuffle(this.tracksInOrder);
// fix sortKey and index properties
var nextSortKey = keese(null, null);
for (var i = 0; i < this.tracksInOrder.length; i += 1) {
var track = this.tracksInOrder[i];
track.index = i;
track.sortKey = nextSortKey;
this.persistPlaylistItem(track);
nextSortKey = keese(nextSortKey, null);
}
playlistChanged(this);
};
Player.prototype.removePlaylistItems = function(ids) {
if (ids.length === 0) return;
var delCmds = [];
var currentTrackChanged = false;
for (var i = 0; i < ids.length; i += 1) {
var id = ids[i];
var item = this.playlist[id];
if (!item) continue;
delCmds.push({type: 'del', key: PLAYLIST_KEY_PREFIX + id});
if (item.grooveFile) this.playlistItemDeleteQueue.push(item);
if (item === this.currentTrack) {
this.currentTrack = null;
currentTrackChanged = true;
}
delete this.playlist[id];
}
if (delCmds.length > 0) this.db.batch(delCmds, logIfError);
playlistChanged(this);
if (currentTrackChanged) this.emit('currentTrack');
function logIfError(err) {
if (err) {
console.error("Error deleting playlist entries from db:", err.stack);
}
}
};
// items looks like {id: {sortKey}}
Player.prototype.movePlaylistItems = function(items) {
for (var id in items) {
var track = this.playlist[id];
if (!track) continue; // race conditions, etc.
track.sortKey = items[id].sortKey;
this.persistPlaylistItem(track);
}
playlistChanged(this);
};
Player.prototype.moveRangeToPos = function(startPos, endPos, toPos) {
var ids = [];
for (var i = startPos; i < endPos; i += 1) {
var track = this.tracksInOrder[i];
if (!track) continue;
ids.push(track.id);
}
this.moveIdsToPos(ids, toPos);
};
Player.prototype.moveIdsToPos = function(ids, toPos) {
var trackBeforeIndex = this.tracksInOrder[toPos - 1];
var trackAtIndex = this.tracksInOrder[toPos];
var prevSortKey = trackBeforeIndex ? trackBeforeIndex.sortKey : null;
var nextSortKey = trackAtIndex ? trackAtIndex.sortKey : null;
for (var i = 0; i < ids.length; i += 1) {
var id = ids[i];
var track = this.playlist[id];
if (!track) continue;
var thisSortKey = keese(prevSortKey, nextSortKey);
prevSortKey = thisSortKey;
track.sortKey = thisSortKey;
this.persistPlaylistItem(track);
}
playlistChanged(this);
};
Player.prototype.pause = function() {
if (!this.isPlaying) return;
this.isPlaying = false;
this.pausedTime = (new Date() - this.trackStartDate) / 1000;
this.groovePlaylist.pause();
playlistChanged(this);
this.emit('currentTrack');
};
Player.prototype.play = function() {
if (!this.currentTrack) {
this.currentTrack = this.tracksInOrder[0];
} else if (!this.isPlaying) {
this.trackStartDate = new Date(new Date() - this.pausedTime * 1000);
}
this.groovePlaylist.play();
this.isPlaying = true;
playlistChanged(this);
this.emit('currentTrack');
};
// This function should be avoided in favor of seek. Note that it is called by
// some MPD protocol commands, because the MPD protocol is stupid.
Player.prototype.seekToIndex = function(index, pos) {
this.currentTrack = this.tracksInOrder[index];
this.isPlaying = true;
this.groovePlaylist.play();
this.seekRequestPos = pos;
playlistChanged(this);
this.emit('currentTrack');
};
Player.prototype.seek = function(id, pos) {
this.currentTrack = this.playlist[id];
this.isPlaying = true;
this.groovePlaylist.play();
this.seekRequestPos = pos;
playlistChanged(this);
this.emit('currentTrack');
};
Player.prototype.next = function() {
this.skipBy(1);
};
Player.prototype.prev = function() {
this.skipBy(-1);
};
Player.prototype.skipBy = function(amt) {
var defaultIndex = amt > 0 ? -1 : this.tracksInOrder.length;
var currentIndex = this.currentTrack ? this.currentTrack.index : defaultIndex;
var newIndex = currentIndex + amt;
this.seekToIndex(newIndex, 0);
};
Player.prototype.setRepeat = function(value) {
value = Math.floor(value);
if (value !== Player.REPEAT_ONE &&
value !== Player.REPEAT_ALL &&
value !== Player.REPEAT_OFF)
{
return;
}
if (value === this.repeat) return;
this.repeat = value;
this.persistOption('repeat', this.repeat);
playlistChanged(this);
this.emit('repeatUpdate');
};
Player.prototype.setDynamicModeOn = function(value) {
value = !!value;
if (value === this.dynamicModeOn) return;
this.dynamicModeOn = value;
this.persistOption('dynamicModeOn', this.dynamicModeOn);
this.emit('dynamicModeOn');
this.checkDynamicMode();
};
Player.prototype.setDynamicModeHistorySize = function(value) {
value = Math.floor(value);
if (value === this.dynamicModeHistorySize) return;
this.dynamicModeHistorySize = value;
this.persistOption('dynamicModeHistorySize', this.dynamicModeHistorySize);
this.emit('dynamicModeHistorySize');
this.checkDynamicMode();
};
Player.prototype.setDynamicModeFutureSize = function(value) {
value = Math.floor(value);
if (value === this.dynamicModeFutureSize) return;
this.dynamicModeFutureSize = value;
this.persistOption('dynamicModeFutureSize', this.dynamicModeFutureSize);
this.emit('dynamicModeFutureSize');
this.checkDynamicMode();
};
Player.prototype.stop = function() {
this.isPlaying = false;
this.groovePlaylist.pause();
this.seekRequestPos = 0;
this.pausedTime = 0;
playlistChanged(this);
};
Player.prototype.clearEncodedBuffer = function() {
while (this.recentBuffers.length > 0) {
this.recentBuffers.shift();
}
this.recentBuffersByteCount = 0;
};
Player.prototype.getSuggestedPath = function(track, filenameHint) {
var p = "";
if (track.albumArtistName) {
p = path.join(p, safePath(track.albumArtistName));
} else if (track.compilation) {
p = path.join(p, safePath(this.libraryIndex.variousArtistsName));
} else if (track.artistName) {
p = path.join(p, safePath(track.artistName));
}
if (track.albumName) {
p = path.join(p, safePath(track.albumName));
}
var t = "";
if (track.track != null) {
t += safePath(zfill(track.track, 2)) + " ";
}
t += safePath(track.name + path.extname(filenameHint));
return path.join(p, t);
};
Player.prototype.performScan = function(dbFile) {
var self = this;
var scanTable, scanKey, scanType;
if (dbFile.albumName) {
scanType = 'album';
scanKey = self.libraryIndex.getAlbumKey(dbFile);
scanTable = self.ongoingAlbumScans;
} else {
scanType = 'track';
scanKey = dbFile.key;
scanTable = self.ongoingTrackScans;
}
if (scanTable[scanKey]) {
console.warn("Not interrupting ongoing scan.");
return;
}
var scanContext = {
playlist: null,
detector: null,
files: {},
aborted: false,
started: false,
timeout: null,
};
scanTable[scanKey] = scanContext;
self.scanQueue.go(doIt);
function doIt(cb) {
var fileList = [];
var pend = new Pend();
pend.max = cpuCount;
if (scanContext.aborted) return cleanupAndCb();
var trackList;
if (scanType === 'album') {
var albumKey = scanKey;
self.libraryIndex.rebuild();
var album = self.libraryIndex.albumTable[albumKey];
if (!album) {
console.warn("wanted to scan album with key", JSON.stringify(albumKey), "but no longer exists.");
cleanupAndCb();
return;
}
console.info("Replaygain album scan starting:", JSON.stringify(albumKey));
trackList = album.trackList;
} else if (scanType === 'track') {
var trackKey = scanKey;
var dbFile = self.libraryIndex.trackTable[trackKey];
console.info("Track scan starting:", JSON.stringify(trackKey));
trackList = [dbFile];
} else {
throw new Error("unexpected scan type");
}
trackList.forEach(function(track) {
pend.go(function(cb) {
var fullPath = path.join(self.musicDirectory, track.file);
groove.open(fullPath, function(err, file) {
if (err) {
console.error("Error opening", fullPath, "in order to scan:", err.stack);
} else {
scanContext.files[file.id] = {
track: track,
progress: 0.0,
gain: null,
peak: null,
};
fileList.push(file);
}
cb();
});
});
});
pend.wait(function() {
if (scanContext.aborted) return cleanupAndCb();
var scanPlaylist = groove.createPlaylist();
var scanDetector = groove.createLoudnessDetector();
scanDetector.on('info', function() {
var info;
while (info = scanDetector.getInfo()) {
console.log("loudness", info.loudness);
var gain = groove.loudnessToReplayGain(info.loudness);
if (info.item) {
var fileInfo = scanContext.files[info.item.file.id];
console.info("replaygain scan file complete:", fileInfo.track.name, "gain", gain, "duration", info.duration);
fileInfo.progress = 1.0;
fileInfo.gain = gain;
fileInfo.peak = info.peak;
fileInfo.track.replayGainTrackGain = gain;
fileInfo.track.replayGainTrackPeak = info.peak;
fileInfo.track.duration = info.duration;
checkUpdateGroovePlaylist(self);
} else {
if (scanContext.aborted) return cleanupAndCb();
console.info("Replaygain scan complete:", JSON.stringify(scanKey), "gain", gain);
delete scanTable[scanKey];
for (var fileId in scanContext.files) {
var scanFileContext = scanContext.files[fileId];
var dbFile = scanFileContext.track;
dbFile.replayGainAlbumGain = gain;
dbFile.replayGainAlbumPeak = info.peak;
self.persist(dbFile);
self.emit('scanComplete', dbFile);
}
checkUpdateGroovePlaylist(self);
cleanupAndCb();
return;
}
}
});
scanDetector.attach(scanPlaylist, function(err) {
if (err) {
console.error("Error attaching loudness detector:", err.stack);
return cleanupAndCb();
}
scanContext.playlist = scanPlaylist;
scanContext.detector = scanDetector;
fileList.forEach(function(file) {
scanPlaylist.insert(file);
});
});
});
function cleanupAndCb() {
fileList.forEach(function(file) {
pend.go(function(cb) {
file.close(cb);
});
});
if (scanContext.detector) {
pend.go(function(cb) {
scanContext.detector.detach(cb);
});
}
pend.wait(cb);
}
}
};
Player.prototype.checkDynamicMode = function() {
var self = this;
if (!self.dynamicModeOn) return;
// if no track is playing, assume the first track is about to be
var currentIndex = self.currentTrack ? self.currentTrack.index : 0;
var deleteCount = Math.max(currentIndex - self.dynamicModeHistorySize, 0);
if (self.dynamicModeHistorySize < 0) deleteCount = 0;
var addCount = Math.max(self.dynamicModeFutureSize + 1 - (self.tracksInOrder.length - currentIndex), 0);
var idsToDelete = [];
for (var i = 0; i < deleteCount; i += 1) {
idsToDelete.push(self.tracksInOrder[i].id);
}
var keys = getRandomSongKeys(addCount);
self.removePlaylistItems(idsToDelete);
self.appendTracks(keys, true);
function getRandomSongKeys(count) {
if (count === 0) return [];
var neverQueued = [];
var sometimesQueued = [];
for (var key in self.libraryIndex.trackTable) {
var dbFile = self.libraryIndex.trackTable[key];
if (dbFile.lastQueueDate == null) {
neverQueued.push(dbFile);
} else {
sometimesQueued.push(dbFile);
}
}
// backwards by time
sometimesQueued.sort(function(a, b) {
return b.lastQueueDate - a.lastQueueDate;
});
// distribution is a triangle for ever queued, and a rectangle for never queued
// ___
// /| |
// / | |
// /__|_|
var maxWeight = sometimesQueued.length;
var triangleArea = Math.floor(maxWeight * maxWeight / 2);
if (maxWeight === 0) maxWeight = 1;
var rectangleArea = maxWeight * neverQueued.length;
var totalSize = triangleArea + rectangleArea;
if (totalSize === 0) return [];
// decode indexes through the distribution shape
var keys = [];
for (var i = 0; i < count; i += 1) {
var index = Math.random() * totalSize;
if (index < triangleArea) {
// triangle
keys.push(sometimesQueued[Math.floor(Math.sqrt(index))].key);
} else {
keys.push(neverQueued[Math.floor((index - triangleArea) / maxWeight)].key);
}
}
return keys;
}
};
function operatorCompare(a, b) {
return a < b ? -1 : a > b ? 1 : 0;
}
function disambiguateSortKeys(self) {
var previousUniqueKey = null;
var previousKey = null;
self.tracksInOrder.forEach(function(track, i) {
if (track.sortKey === previousKey) {
// move the repeat back
track.sortKey = keese(previousUniqueKey, track.sortKey);
previousUniqueKey = track.sortKey;
} else {
previousUniqueKey = previousKey;
previousKey = track.sortKey;
}
});
}
// generate self.tracksInOrder from self.playlist
function cacheTracksArray(self) {
self.tracksInOrder = Object.keys(self.playlist).map(trackById);
self.tracksInOrder.sort(asc);
self.tracksInOrder.forEach(function(track, index) {
track.index = index;
});
function asc(a, b) {
return operatorCompare(a.sortKey, b.sortKey);
}
function trackById(id) {
return self.playlist[id];
}
}
function lazyReplayGainScanPlaylist(self) {
var albumGain = {};
self.tracksInOrder.forEach(function(track) {
var dbFile = self.libraryIndex.trackTable[track.key];
if (!dbFile) return;
var albumKey = self.libraryIndex.getAlbumKey(dbFile);
var needScan = dbFile.replayGainAlbumGain == null ||
dbFile.replayGainTrackGain == null ||
(dbFile.albumName && albumGain[albumKey] && albumGain[albumKey] !== dbFile.replayGainAlbumGain);
if (needScan) {
self.performScan(dbFile);
} else {
albumGain[albumKey] = dbFile.replayGainAlbumGain;
}
});
}
function playlistChanged(self) {
cacheTracksArray(self);
disambiguateSortKeys(self);
self.lastEncodeItem = null;
self.lastEncodePos = null;
if (self.currentTrack) {
self.tracksInOrder.forEach(function(track, index) {
var withinPrev = (self.currentTrack.index - index) <= PREV_FILE_COUNT;
var withinNext = (index - self.currentTrack.index) <= NEXT_FILE_COUNT;
var shouldHaveGrooveFile = withinPrev || withinNext;
var hasGrooveFile = track.grooveFile != null || track.pendingGrooveFile;
if (hasGrooveFile && !shouldHaveGrooveFile) {
removePreloadFromTrack(self, track);
} else if (!hasGrooveFile && shouldHaveGrooveFile) {
preloadFile(self, track);
}
});
} else {
self.isPlaying = false;
self.trackStartDate = null;
self.pausedTime = 0;
}
checkUpdateGroovePlaylist(self);
performGrooveFileDeletes(self);
self.checkDynamicMode();
self.emit('playlistUpdate');
}
function performGrooveFileDeletes(self) {
while (self.playlistItemDeleteQueue.length) {
var item = self.playlistItemDeleteQueue.shift();
// we set this so that any callbacks that return which were trying to
// set the grooveItem can check if the item got deleted
item.deleted = true;
closeFile(item.grooveFile);
}
}
function preloadFile(self, track) {
var relPath = self.libraryIndex.trackTable[track.key].file;
var fullPath = path.join(self.musicDirectory, relPath);
track.pendingGrooveFile = true;
groove.open(fullPath, function(err, file) {
track.pendingGrooveFile = false;
if (err) {
console.error("Error opening", relPath, err.stack);
return;
}
if (track.deleted) {
closeFile(file);
return;
}
track.grooveFile = file;
checkUpdateGroovePlaylist(self);
});
}
function checkUpdateGroovePlaylist(self) {
if (!self.currentTrack) {
self.groovePlaylist.clear();
self.grooveItems = {};
return;
}
var groovePlaylist = self.groovePlaylist.items();
var playHead = self.groovePlayer.position();
var playHeadItemId = playHead.item && playHead.item.id;
var groovePlIndex = 0;
var grooveItem;
while (groovePlIndex < groovePlaylist.length) {
grooveItem = groovePlaylist[groovePlIndex];
if (grooveItem.id === playHeadItemId) break;
// this groove playlist item is before the current playhead. delete it!
self.groovePlaylist.remove(grooveItem);
delete self.grooveItems[grooveItem.id];
groovePlIndex += 1;
}
var plItemIndex = self.currentTrack.index;
var plTrack;
var currentGrooveItem = null; // might be different than playHead.item
var groovePlItemCount = 0;
while (groovePlIndex < groovePlaylist.length) {
grooveItem = groovePlaylist[groovePlIndex];
var grooveTrack = self.grooveItems[grooveItem.id];
// now we have deleted all items before the current track. we are now
// comparing the libgroove playlist and the groovebasin playlist
// side by side.
plTrack = self.tracksInOrder[plItemIndex];
if (grooveTrack === plTrack) {
// if they're the same, we advance
// but we might have to correct the gain
self.groovePlaylist.setItemGain(grooveItem, calcGain(plTrack));
currentGrooveItem = currentGrooveItem || grooveItem;
groovePlIndex += 1;
incrementPlIndex();
continue;
}
// this groove track is wrong. delete it.
self.groovePlaylist.remove(grooveItem);
delete self.grooveItems[grooveItem.id];
groovePlIndex += 1;
}
while (groovePlItemCount < NEXT_FILE_COUNT) {
plTrack = self.tracksInOrder[plItemIndex];
if (!plTrack || !plTrack.grooveFile) {
// we can't do anything
break;
}
// compute the gain adjustment
grooveItem = self.groovePlaylist.insert(plTrack.grooveFile, calcGain(plTrack));
self.grooveItems[grooveItem.id] = plTrack;
currentGrooveItem = currentGrooveItem || grooveItem;
incrementPlIndex();
}
if (currentGrooveItem) {
if (currentGrooveItem.id !== playHeadItemId && self.seekRequestPos < 0) {
self.seekRequestPos = 0;
}
if (self.seekRequestPos >= 0) {
var seekPos = self.seekRequestPos;
self.clearEncodedBuffer();
self.groovePlaylist.seek(currentGrooveItem, seekPos);
self.seekRequestPos = -1;
var nowMs = (new Date()).getTime();
var posMs = seekPos * 1000;
self.trackStartDate = new Date(nowMs - posMs);
self.emit('seek');
self.emit('currentTrack');
}
}
function calcGain(plTrack) {
// if the previous item is the previous item from the album, or the
// next item is the next item from the album, use album replaygain.
// else, use track replaygain.
var dbFile = self.libraryIndex.trackTable[plTrack.key];
var albumMode = albumInfoMatch(-1) || albumInfoMatch(1);
var gain = REPLAYGAIN_PREAMP;
if (dbFile.replayGainAlbumGain != null && albumMode) {
gain *= dBToFloat(dbFile.replayGainAlbumGain);
} else if (dbFile.replayGainTrackGain != null) {
gain *= dBToFloat(dbFile.replayGainTrackGain);
} else {
gain *= REPLAYGAIN_DEFAULT;
}
return gain;
function albumInfoMatch(dir) {
var otherPlTrack = self.tracksInOrder[plTrack.index + dir];
if (!otherPlTrack) return false;
var otherDbFile = self.libraryIndex.trackTable[otherPlTrack.key];
if (!otherDbFile) return false;
var albumMatch = self.libraryIndex.getAlbumKey(dbFile) === self.libraryIndex.getAlbumKey(otherDbFile);
if (!albumMatch) return false;
var trackMatch = (dbFile.track == null && otherDbFile.track == null) || dbFile.track + dir === otherDbFile.track;
if (!trackMatch) return false;
return true;
}
}
function incrementPlIndex() {
groovePlItemCount += 1;
if (self.repeat !== Player.REPEAT_ONE) {
plItemIndex += 1;
if (self.repeat === Player.REPEAT_ALL && plItemIndex >= self.tracksInOrder.length) {
plItemIndex = 0;
}
}
}
}
function removePreloadFromTrack(self, track) {
if (!track.grooveFile) return;
var file = track.grooveFile;
track.grooveFile = null;
closeFile(file);
}
function isFileIgnored(basename) {
return (/^\./).test(basename) || (/~$/).test(basename);
}
function deserializeFileData(dataStr) {
var dbFile = JSON.parse(dataStr);
for (var propName in DB_PROPS) {
var propInfo = DB_PROPS[propName];
if (!propInfo) continue;
var parser = PROP_TYPE_PARSERS[propInfo.type];
dbFile[propName] = parser(dbFile[propName]);
}
return dbFile;
}
function serializePlaylistItem(item) {
return JSON.stringify({
id: item.id,
key: item.key,
sortKey: item.sortKey,
isRandom: item.isRandom,
});
}
function trackWithoutIndex(category, dbFile) {
var out = {};
for (var propName in DB_PROPS) {
var prop = DB_PROPS[propName];
if (!prop[category]) continue;
// save space by leaving out null and undefined values
var value = dbFile[propName];
if (value == null) continue;
out[propName] = value;
}
return out;
}
function serializeFileData(dbFile) {
return JSON.stringify(trackWithoutIndex('db', dbFile));
}
function serializeDirEntry(dirEntry) {
return JSON.stringify({
dirName: dirEntry.dirName,
entries: dirEntry.entries,
dirEntries: dirEntry.dirEntries,
mtime: dirEntry.mtime,
});
}
function trackNameFromFile(filename) {
var basename = path.basename(filename);
var ext = path.extname(basename);
return basename.substring(0, basename.length - ext.length);
}
function closeFile(file) {
file.close(function(err) {
if (err) {
console.error("Error closing", file, err.stack);
}
});
}
function parseTrackString(trackStr) {
if (!trackStr) return {};
var parts = trackStr.split('/');
if (parts.length > 1) {
return {
value: parseIntOrNull(parts[0]),
total: parseIntOrNull(parts[1]),
};
}
return {
value: parseIntOrNull(parts[0]),
};
}
function parseIntOrNull(n) {
n = parseInt(n, 10);
if (isNaN(n)) return null;
return n;
}
function parseFloatOrNull(n) {
n = parseFloat(n);
if (isNaN(n)) return null;
return n;
}
function grooveFileToDbFile(file, filenameHint, object) {
object = object || {key: uuid()};
var parsedTrack = parseTrackString(file.getMetadata("track"));
var parsedDisc = parseTrackString(file.getMetadata("disc") || file.getMetadata("TPA"));
object.name = (file.getMetadata("title") || trackNameFromFile(filenameHint) || "").trim();
object.artistName = (file.getMetadata("artist") || "").trim();
object.composerName = (file.getMetadata("composer") ||
file.getMetadata("TCM") || "").trim();
object.performerName = (file.getMetadata("performer") || "").trim();
object.albumArtistName = (file.getMetadata("album_artist") || "").trim();
object.albumName = (file.getMetadata("album") || "").trim();
object.compilation = !!(parseInt(file.getMetadata("TCP"), 10) ||
parseInt(file.getMetadata("TCMP"), 10));
object.track = parsedTrack.value;
object.trackCount = parsedTrack.total;
object.disc = parsedDisc.value;
object.discCount = parsedDisc.total;
object.duration = file.duration();
object.year = parseIntOrNull(file.getMetadata("date"));
object.genre = file.getMetadata("genre");
object.replayGainTrackGain = parseFloatOrNull(file.getMetadata("REPLAYGAIN_TRACK_GAIN"));
object.replayGainTrackPeak = parseFloatOrNull(file.getMetadata("REPLAYGAIN_TRACK_PEAK"));
object.replayGainAlbumGain = parseFloatOrNull(file.getMetadata("REPLAYGAIN_ALBUM_GAIN"));
object.replayGainAlbumPeak = parseFloatOrNull(file.getMetadata("REPLAYGAIN_ALBUM_PEAK"));
return object;
}
function uniqueFilename(filename) {
// break into parts
var dirname = path.dirname(filename);
var basename = path.basename(filename);
var extname = path.extname(filename);
var withoutExt = basename.substring(0, basename.length - extname.length);
var match = withoutExt.match(/_(\d+)$/);
var withoutMatch;
var number;
if (match) {
number = parseInt(match[1], 10);
if (!number) number = 0;
withoutMatch = withoutExt.substring(0, match.index);
} else {
number = 0;
withoutMatch = withoutExt;
}
number += 1;
// put it back together
var newBasename = withoutMatch + "_" + number + extname;
return path.join(dirname, newBasename);
}
function dBToFloat(dB) {
return Math.exp(dB * DB_SCALE);
}
function ensureSep(dir) {
return (dir[dir.length - 1] === path.sep) ? dir : (dir + path.sep);
}