1897 lines
54 KiB
JavaScript
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);
|
|
}
|