mediamonkeyserver
Version:
MediaMonkey Server
488 lines (419 loc) • 13.8 kB
JavaScript
/*jslint node: true, nomen: true, esversion: 6 */
;
const logger = require('./logger');
const Cacher = require('./cacher');
const TrackSorters = require('./util/trackSorters');
const PathNormalizer = require('./util/pathNormalizer');
const Async = require('async');
const assert = require('assert');
const Transcoder = require('./transcoder');
class MediaProvider extends Cacher {
constructor() {
super();
this.registry = undefined;
}
setRegistry(registry) {
this.registry = registry;
}
_getFilePresentationEntry(file, filter_fields) {
// this creates filtered UI file presentation entry:
// 1) with specified fields (in filter_fields param like ['title','album'])
// 2) filters unknown fields (empty string, empty array, null values)
// 3) including valid streamURL and artworkURL
var cache = this.registry._getCacheFor('filteredFileEntry');
if (cache.entries && cache.entries[file.db_id] && (cache.filter_fields == JSON.stringify(filter_fields)))
return cache.entries[file.db_id];
else {
var entry = {};
var node_id = this.registry._getNodeIdForFile(file);
var contentPath = this.registry._service.contentPath;
entry.streamURL = contentPath + node_id;
//entry.streamURL = '/api/stream/' + file.db_id;
if (file.albumArts && file.albumArts.length)
entry.artworkURL = contentPath + node_id + '/' + file.albumArts[0].contentHandlerKey + '/0';
for (var key in file) {
var addField = (key != 'ratings' /* already as rating there */ && key != 'albumArts' /* already as artworkURL*/ );
if (filter_fields)
addField = (filter_fields.indexOf(key) >= 0);
if (addField) {
var at = file[key];
if (at && at != '' && at != [] && at != -1 /* like unrated*/ )
entry[key] = file[key];
}
}
cache.entries = cache.entries || {};
cache.entries[file.db_id] = entry;
cache.filter_fields = JSON.stringify(filter_fields);
return entry;
}
}
_fileIsInFolders(f, folders) {
if (!folders)
return true;
for (var fld of folders)
if (f.path.startsWith(fld))
return true;
}
getTracklist(params, callback) {
params = params || {};
var dt = Date.now();
var _log_point = function (name, item_count) {
var s = Math.floor((Date.now() - dt));
logger.verbose(name + ' of ' + item_count + ' files took ' + s + ' milliseconds');
dt = Date.now();
};
var filter_fields;
if (params.filter && params.filter.fields)
filter_fields = params.filter.fields;
var _getFinalEntries = function (files) {
var res = [];
for (var i = 0; i < files.length; i++) {
var f = files[i];
if (!params.starting_index || (params.starting_index <= i))
if (!params.requested_count || (params.starting_index + params.requested_count > i)) {
var ff = this._getFilePresentationEntry(f, filter_fields);
res.push(ff);
}
}
_log_point('Final entries', res.length);
callback(res);
}.bind(this);
var cache = this.registry._getCacheFor('getTracklist');
if (cache.params && JSON.stringify(cache.params) == JSON.stringify(params)) {
_getFinalEntries(cache.prepared_list);
return;
}
var searchPhrase;
if (params.search && params.search.trim().length) {
searchPhrase = this.registry.validateFTS(params.search);
if (searchPhrase.length && searchPhrase[0] != '"' && searchPhrase[searchPhrase.length - 1] != '*')
searchPhrase = searchPhrase + '*'; // to search prefixes
}
this.registry.getFilesBy({
searchPhrase: searchPhrase
}, (err, files) => {
_log_point('Fetching', files.length);
var res = [];
for (var f of files) {
if (this._fileIsInFolders(f, params.folders))
res.push(f);
}
_log_point('Filtering by folders', res.length);
if (params.filters) {
res = TrackSorters.filterTracks(res, params.filters);
}
_log_point('Filtering', res.length);
logger.debug('Sorting by ' + params.sort);
var sortfn = TrackSorters.getSortFunc(params.sort || 'title');
if (sortfn)
res.sort(sortfn);
else
logger.warn('Undefined sorting');
_log_point('Sorting', res.length);
cache.prepared_list = res;
cache.params = params;
_getFinalEntries(res);
});
}
_cleanUpExpiredUploadSessions() {
var keys = Object.keys(this._uploadSessions);
for (var key of keys) {
var age = (Date.now() - this._uploadSessions[key].lastActivity);
var expired = (age > 60 * 60 * 1000); // 1 hour old session without any activity
if (expired)
delete this._uploadSessions[key];
}
}
createUploadSession(request, response) {
var content = request.body;
var path = PathNormalizer.normalize(content.path);
logger.info('createUploadSession: ' + path);
var sessionID = Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 6);
this._uploadSessions = this._uploadSessions || {};
this._cleanUpExpiredUploadSessions();
this._uploadSessions[sessionID] = {
path: path,
lastActivity: Date.now()
};
response.writeHead(200, 'OK');
response.end(sessionID);
}
processUpload(upload_id, request, response) {
var _failure = function (error_description) {
logger.error(error_description);
response.writeHead(400, 'Bad Request');
response.end(error_description);
};
var _success = function (s) {
response.writeHead(200, 'OK');
logger.info(s);
response.end(s);
};
var session = this._uploadSessions[upload_id];
if (!session)
_failure('Upload id ' + upload_id + ' not found');
session.lastActivity = Date.now();
var path = session.path;
var service = this.registry._service;
path = PathNormalizer.removeLastSlash(this.registry.getDefaultSyncLocation()) + path;
var range = request.headers['content-range'];
logger.info('File upload: ' + path + ', range: ' + range);
var startBytes = 0;
var endBytes = 0;
var totalBytes = 0;
var val = range && range.match(/^(?:bytes )?(\d+)-(\d+)\/(\d+|\*)$/);
if (val) {
startBytes = +val[1];
endBytes = +val[2];
totalBytes = val[3] === '*' ? Infinity : +val[3];
//logger.info(startBytes + '-' + endBytes + '/' + totalBytes);
}
var uploaded_len = 0;
var create_flags = 'w';
if (startBytes > 0)
create_flags = 'r+';
var file = service.newURL(path);
file.createWriteStream({
flags: create_flags,
start: startBytes
}, (error, stream) => {
if (error) {
_failure('Stream creation error: ' + path + ', error: ' + error);
} else {
request.on('data', (data) => {
uploaded_len += data.length;
stream.write(data);
});
request.on('end', () => {
stream.end(); // LS: to close handle to the file
if (endBytes == totalBytes || endBytes == totalBytes - 1) { // LS: this is the last upload part, scan the file:
var repository = service.getRepositoryForPath(session.path);
service.scanFile(path, repository, (err) => {
if (err)
_success('Uploaded whole file with scan error: ' + err + ', path: ' + path);
else
_success('Uploaded whole file: ' + path);
});
delete this._uploadSessions[upload_id];
} else {
_success('Uploade part: ' + uploaded_len + ' bytes, upload_id: ' + upload_id + ', path: ' + path);
}
});
}
});
}
processPlaylistUpload(request, response) {
var _failure = function (error_description) {
logger.error(error_description);
response.writeHead(400, 'Bad Request');
response.end(error_description);
};
try {
var playlist = request.body;
} catch (e) {
return _failure('Failure to parse JSON: ' + e);
}
var missingIds = [];
var trackDBIds = [];
Async.eachSeries(playlist.track_ids, (track_id, cbk) => {
this.registry.getFile({
sync_id: track_id
}, (err, file) => {
if (err)
missingIds.push(track_id);
else
trackDBIds.push(file.db_id);
cbk();
});
}, () => {
if (missingIds.length == 0) {
playlist.track_ids = trackDBIds;
this.registry.putPlaylist(playlist, this.registry._service, (err) => {
if (err) {
_failure(err);
} else {
response.writeHead(200, 'OK');
response.end();
}
});
} else {
_failure('Not updated, some tracks are missing: [' + missingIds.join(', ') + '] ' + ', playlist name: ' + playlist.name + ' , guid: ' + playlist.guid);
}
});
}
processUpdate(request, response) {
var _failure = function (error_description) {
logger.error('error: ' + error_description + ' body: ' + request.body);
response.writeHead(400, 'Bad Request');
response.end('error: ' + error_description);
};
var _success = function (description) {
logger.info(description);
response.writeHead(200, 'OK');
response.end(description);
};
try {
var metas = request.body;
} catch (e) {
return _failure('Failure to parse JSON: ' + e);
}
if (!(metas.db_id || metas.sync_id))
return _failure('Either db_id or sync_id needs to be specified!');
else {
this.registry.getFile({
id: metas.db_id,
sync_id: metas.sync_id
}, (err, file) => {
if (err)
_failure(err);
else {
this.updateMetas(file, metas, (err) => {
if (err)
_failure(err);
else
_success('Metas update successful, db_id: ' + file.db_id + ', name: ' + file.title + ', sync_id: ' + file.sync_id);
});
}
});
}
}
updateMetas(file, new_metas, callback) {
this.registry.getMetas(file.path, null, (error, metas) => {
if (error)
callback('Old metadata fetch has failed:' + error);
else {
var node_id = this.registry._getNodeIdForFile(file);
this.registry._service.getNodeById(node_id, (error, node) => {
// merge the old metas with new metas:
if (new_metas.rating)
new_metas.ratings = [{
rating: new_metas.rating,
type: 'userRating'
}];
for (var key in new_metas) {
if (metas)
metas[key] = new_metas[key];
if (node)
node.attributes[key] = new_metas[key];
}
if (!metas)
metas = new_metas;
// update the new metas into database:
this.registry.putMetas(file.path, null, metas, (error) => {
if (error) {
callback('Update failed, reason: ' + error);
} else {
callback(null);
}
});
});
}
});
}
getStreamInfo(file_id, request, response) {
this.registry.getFile({
id: file_id
}, (err, file) => {
if (err) {
response.status(400).json({
error: err
});
}
Transcoder.getStreamInfo(file).then(info => {
response.json(info);
}).catch(err => {
response.status(400).json({
error: err
});
});
});
}
getFileStream(file_id, request, response) {
var service = this.registry._service;
this.registry.getFile({
id: file_id
}, (err, file) => {
var clientId = request.params.clientId || request.ip;
Transcoder.cancelRunningForClient(clientId);
Transcoder.convert(service.newURL(file.path), file).then((transcoder) => {
if (transcoder) {
transcoder.clientId = clientId;
service.sendContentStream({
stream: transcoder.outStream,
size: transcoder.streamSize,
duration: file.duration,
mimeType: transcoder.mimeType,
}, request, response, () => {});
} else {
// No transcoding is necessary, just send the file directly
service.sendContentURL({
contentURL: service.newURL(file.path),
size: file.size,
mimeType: file.mimeType
}, request, response, () => {});
}
});
});
}
getContentChanges(token, callback) {
this.registry.getContentChanges(token, (err, changes) => {
if (err)
callback(err);
else
this.registry.getFiles((err, files) => {
var hashed_track_ids = {
added: {},
updated: {},
deleted: []
};
var playlist_ids = {
added: [],
updated: [],
deleted: []
};
for (var ch of changes) {
if (ch.item_type == 'media')
if (ch.item_oper == 'added' || ch.item_oper == 'updated') {
assert(playlist_ids[ch.item_oper], 'structure not prepared for ' + ch.item_oper);
hashed_track_ids[ch.item_oper][ch.item_sync_id] = true;
} else
hashed_track_ids.deleted.push(ch.item_sync_id);
else {
if (ch.item_type == 'playlist') {
assert(playlist_ids[ch.item_oper], 'structure not prepared for ' + ch.item_oper);
playlist_ids[ch.item_oper].push(ch.item_sync_id);
}
}
}
var tracks = {
added: [],
updated: [],
deleted: []
};
for (var f of files) {
if (hashed_track_ids.added[f.sync_id])
tracks.added.push(this._getFilePresentationEntry(f));
if (hashed_track_ids.updated[f.sync_id])
tracks.updated.push(this._getFilePresentationEntry(f));
}
tracks.deleted = hashed_track_ids.deleted;
// convert playlist ids to playlists:
Async.eachSeries(['added', 'updated'], (oper, cbk) => {
this.registry.getPlaylistsBy({
guidArray: playlist_ids[oper]
}, (err, playlists) => {
assert(!err, err);
playlist_ids[oper] = playlists;
cbk();
});
}, (_err) => {
callback(err || _err, {
playlists: playlist_ids,
tracks: tracks
});
});
});
});
}
}
module.exports = new MediaProvider();