2014-06-01 18:43:18 +00:00
|
|
|
var Logger = require("./logger");
|
|
|
|
var Config = require("./config");
|
2014-06-09 04:03:29 +00:00
|
|
|
var spawn = require("child_process").spawn;
|
2015-05-24 15:06:02 +00:00
|
|
|
var https = require("https");
|
|
|
|
var http = require("http");
|
|
|
|
var urlparse = require("url");
|
2015-07-31 03:45:47 +00:00
|
|
|
var path = require("path");
|
2015-07-08 18:00:40 +00:00
|
|
|
require("status-message-polyfill");
|
2014-06-01 18:43:18 +00:00
|
|
|
|
2015-05-20 02:07:55 +00:00
|
|
|
var USE_JSON = true;
|
2015-07-31 03:45:47 +00:00
|
|
|
var TIMEOUT = 30000;
|
2015-05-19 23:48:08 +00:00
|
|
|
|
2014-06-01 18:43:18 +00:00
|
|
|
var acceptedCodecs = {
|
|
|
|
"mov/h264": true,
|
2014-06-04 04:21:00 +00:00
|
|
|
"flv/h264": true,
|
|
|
|
"matroska/vp8": true,
|
|
|
|
"matroska/vp9": true,
|
2014-06-07 17:45:52 +00:00
|
|
|
"ogg/theora": true
|
2014-06-01 18:43:18 +00:00
|
|
|
};
|
|
|
|
|
2014-06-06 04:58:26 +00:00
|
|
|
var acceptedAudioCodecs = {
|
|
|
|
"mp3": true,
|
|
|
|
"vorbis": true
|
|
|
|
};
|
|
|
|
|
2014-06-07 17:45:52 +00:00
|
|
|
var audioOnlyContainers = {
|
|
|
|
"mp3": true
|
|
|
|
};
|
|
|
|
|
2015-07-31 03:45:47 +00:00
|
|
|
function fflog() { }
|
|
|
|
|
|
|
|
function initFFLog() {
|
2015-08-11 00:55:23 +00:00
|
|
|
if (fflog.initialized) return;
|
2015-07-31 03:45:47 +00:00
|
|
|
var logger = new Logger.Logger(path.resolve(__dirname, "..", "ffmpeg.log"));
|
|
|
|
fflog = function () {
|
|
|
|
logger.log.apply(logger, arguments);
|
|
|
|
};
|
2015-08-11 00:55:23 +00:00
|
|
|
fflog.initialized = true;
|
2015-07-31 03:45:47 +00:00
|
|
|
}
|
|
|
|
|
2015-05-25 20:04:27 +00:00
|
|
|
function testUrl(url, cb, redirCount) {
|
|
|
|
if (!redirCount) redirCount = 0;
|
2015-05-24 15:06:02 +00:00
|
|
|
var data = urlparse.parse(url);
|
|
|
|
if (!/https?:/.test(data.protocol)) {
|
|
|
|
return cb("Video links must start with http:// or https://");
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!data.hostname) {
|
|
|
|
return cb("Invalid link");
|
|
|
|
}
|
|
|
|
|
|
|
|
var transport = (data.protocol === "https:") ? https : http;
|
|
|
|
data.method = "HEAD";
|
|
|
|
var req = transport.request(data, function (res) {
|
|
|
|
req.abort();
|
|
|
|
|
|
|
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
2015-05-25 20:04:27 +00:00
|
|
|
if (redirCount > 2) {
|
2015-05-24 15:09:56 +00:00
|
|
|
return cb("Too many redirects. Please provide a direct link to the " +
|
2015-05-24 15:06:02 +00:00
|
|
|
"file");
|
|
|
|
}
|
2015-06-15 12:32:11 +00:00
|
|
|
return testUrl(res.headers["location"], cb, redirCount + 1);
|
2015-05-24 15:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (res.statusCode !== 200) {
|
2015-07-08 18:00:40 +00:00
|
|
|
var message = res.statusMessage;
|
2015-05-24 15:19:59 +00:00
|
|
|
if (!message) message = "";
|
|
|
|
return cb("HTTP " + res.statusCode + " " + message);
|
2015-05-24 15:06:02 +00:00
|
|
|
}
|
|
|
|
|
2015-06-15 12:32:11 +00:00
|
|
|
if (!/^audio|^video/.test(res.headers["content-type"])) {
|
|
|
|
return cb("Server did not return an audio or video file, or sent the " +
|
|
|
|
"wrong Content-Type");
|
2015-05-24 15:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
cb();
|
|
|
|
});
|
|
|
|
|
|
|
|
req.on("error", function (err) {
|
|
|
|
cb(err);
|
|
|
|
});
|
|
|
|
|
|
|
|
req.end();
|
|
|
|
}
|
|
|
|
|
2015-05-19 23:48:08 +00:00
|
|
|
function readOldFormat(buf) {
|
|
|
|
var lines = buf.split("\n");
|
|
|
|
var tmp = { tags: {} };
|
|
|
|
var data = {
|
|
|
|
streams: []
|
|
|
|
};
|
|
|
|
|
|
|
|
lines.forEach(function (line) {
|
|
|
|
if (line.match(/\[stream\]|\[format\]/i)) {
|
|
|
|
return;
|
|
|
|
} else if (line.match(/\[\/stream\]/i)) {
|
|
|
|
data.streams.push(tmp);
|
|
|
|
tmp = { tags: {} };
|
|
|
|
} else if (line.match(/\[\/format\]/i)) {
|
|
|
|
data.format = tmp;
|
|
|
|
tmp = { tags: {} };
|
|
|
|
} else {
|
|
|
|
var kv = line.split("=");
|
|
|
|
var key = kv[0].toLowerCase();
|
|
|
|
if (key.indexOf("tag:") === 0) {
|
|
|
|
tmp.tags[key.split(":")[1]] = kv[1];
|
|
|
|
} else {
|
|
|
|
tmp[key] = kv[1];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return data;
|
|
|
|
}
|
|
|
|
|
|
|
|
function reformatData(data) {
|
|
|
|
var reformatted = {};
|
|
|
|
|
|
|
|
var duration = parseInt(data.format.duration, 10);
|
|
|
|
if (isNaN(duration)) duration = "--:--";
|
|
|
|
reformatted.duration = Math.ceil(duration);
|
|
|
|
|
|
|
|
var bitrate = parseInt(data.format.bit_rate, 10) / 1000;
|
|
|
|
if (isNaN(bitrate)) bitrate = 0;
|
|
|
|
reformatted.bitrate = bitrate;
|
|
|
|
|
2015-05-20 02:07:55 +00:00
|
|
|
reformatted.title = data.format.tags ? data.format.tags.title : null;
|
2015-05-19 23:48:08 +00:00
|
|
|
var container = data.format.format_name.split(",")[0];
|
|
|
|
|
|
|
|
data.streams.forEach(function (stream) {
|
|
|
|
if (stream.codec_type === "video") {
|
|
|
|
reformatted.vcodec = stream.codec_name;
|
2015-05-20 02:07:55 +00:00
|
|
|
if (!reformatted.title && stream.tags) {
|
2015-05-19 23:48:08 +00:00
|
|
|
reformatted.title = stream.tags.title;
|
|
|
|
}
|
|
|
|
} else if (stream.codec_type === "audio") {
|
|
|
|
reformatted.acodec = stream.codec_name;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
if (reformatted.vcodec && !(audioOnlyContainers.hasOwnProperty(container))) {
|
|
|
|
reformatted.type = [container, reformatted.vcodec].join("/");
|
|
|
|
reformatted.medium = "video";
|
|
|
|
} else if (reformatted.acodec) {
|
|
|
|
reformatted.type = [container, reformatted.acodec].join("/");
|
|
|
|
reformatted.medium = "audio";
|
|
|
|
}
|
|
|
|
|
|
|
|
return reformatted;
|
|
|
|
}
|
|
|
|
|
|
|
|
exports.ffprobe = function ffprobe(filename, cb) {
|
2015-07-31 03:45:47 +00:00
|
|
|
fflog("Spawning ffprobe for " + filename);
|
2015-05-19 23:48:08 +00:00
|
|
|
var childErr;
|
|
|
|
var args = ["-show_streams", "-show_format", filename];
|
2015-05-20 02:07:55 +00:00
|
|
|
if (USE_JSON) args = ["-of", "json"].concat(args);
|
2015-05-19 23:48:08 +00:00
|
|
|
var child = spawn(Config.get("ffmpeg.ffprobe-exec"), args);
|
|
|
|
var stdout = "";
|
|
|
|
var stderr = "";
|
2015-07-31 03:45:47 +00:00
|
|
|
var timer = setTimeout(function () {
|
|
|
|
Logger.errlog.log("Possible runaway ffprobe process for file " + filename);
|
|
|
|
fflog("Killing ffprobe for " + filename + " after " + (TIMEOUT/1000) + " seconds");
|
2015-07-31 05:12:52 +00:00
|
|
|
childErr = new Error("File query exceeded time limit of " + (TIMEOUT/1000) +
|
|
|
|
" seconds");
|
2015-07-31 04:11:30 +00:00
|
|
|
child.kill("SIGKILL");
|
2015-07-31 03:45:47 +00:00
|
|
|
}, TIMEOUT);
|
2015-05-19 23:48:08 +00:00
|
|
|
|
|
|
|
child.on("error", function (err) {
|
|
|
|
childErr = err;
|
|
|
|
});
|
|
|
|
|
|
|
|
child.stdout.on("data", function (data) {
|
|
|
|
stdout += data;
|
|
|
|
});
|
|
|
|
|
|
|
|
child.stderr.on("data", function (data) {
|
|
|
|
stderr += data;
|
2015-08-12 01:25:14 +00:00
|
|
|
if (stderr.match(/the tls connection was non-properly terminated/i)) {
|
2015-07-31 04:11:30 +00:00
|
|
|
fflog("Killing ffprobe for " + filename + " due to TLS error");
|
|
|
|
childErr = new Error("Remote server closed connection unexpectedly");
|
|
|
|
child.kill("SIGKILL");
|
|
|
|
}
|
2015-05-19 23:48:08 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
child.on("close", function (code) {
|
2015-07-31 03:45:47 +00:00
|
|
|
clearTimeout(timer);
|
|
|
|
fflog("ffprobe exited with code " + code + " for file " + filename);
|
2015-05-19 23:48:08 +00:00
|
|
|
if (code !== 0) {
|
2015-05-20 02:07:55 +00:00
|
|
|
if (stderr.match(/unrecognized option|json/i) && USE_JSON) {
|
2015-05-19 23:48:08 +00:00
|
|
|
Logger.errlog.log("Warning: ffprobe does not support -of json. " +
|
|
|
|
"Assuming it will have old output format.");
|
2015-05-20 02:07:55 +00:00
|
|
|
USE_JSON = false;
|
2015-05-19 23:48:08 +00:00
|
|
|
return ffprobe(filename, cb);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!childErr) childErr = new Error(stderr);
|
|
|
|
return cb(childErr);
|
|
|
|
}
|
|
|
|
|
|
|
|
var result;
|
2015-05-20 02:07:55 +00:00
|
|
|
if (USE_JSON) {
|
2015-05-19 23:48:08 +00:00
|
|
|
try {
|
|
|
|
result = JSON.parse(stdout);
|
|
|
|
} catch (e) {
|
|
|
|
return cb(new Error("Unable to parse ffprobe output: " + e.message));
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
try {
|
|
|
|
result = readOldFormat(stdout);
|
|
|
|
} catch (e) {
|
|
|
|
return cb(new Error("Unable to parse ffprobe output: " + e.message));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return cb(null, result);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2014-06-01 18:43:18 +00:00
|
|
|
exports.query = function (filename, cb) {
|
2015-08-11 00:55:23 +00:00
|
|
|
if (Config.get("ffmpeg.log") && !fflog.initialized) {
|
2015-07-31 03:45:47 +00:00
|
|
|
initFFLog();
|
|
|
|
}
|
|
|
|
|
2014-06-09 04:03:29 +00:00
|
|
|
if (!Config.get("ffmpeg.enabled")) {
|
2014-06-01 18:43:18 +00:00
|
|
|
return cb("Raw file playback is not enabled on this server");
|
|
|
|
}
|
|
|
|
|
2014-06-07 17:45:52 +00:00
|
|
|
if (!filename.match(/^https?:\/\//)) {
|
|
|
|
return cb("Raw file playback is only supported for links accessible via HTTP " +
|
|
|
|
"or HTTPS");
|
|
|
|
}
|
|
|
|
|
2015-05-24 15:06:02 +00:00
|
|
|
testUrl(filename, function (err) {
|
2014-06-01 18:43:18 +00:00
|
|
|
if (err) {
|
2015-05-24 15:06:02 +00:00
|
|
|
return cb(err);
|
2014-06-01 18:43:18 +00:00
|
|
|
}
|
|
|
|
|
2015-05-24 15:06:02 +00:00
|
|
|
exports.ffprobe(filename, function (err, data) {
|
|
|
|
if (err) {
|
|
|
|
if (err.code && err.code === "ENOENT") {
|
|
|
|
return cb("Failed to execute `ffprobe`. Set ffmpeg.ffprobe-exec " +
|
|
|
|
"to the correct name of the executable in config.yaml. " +
|
|
|
|
"If you are using Debian or Ubuntu, it is probably " +
|
|
|
|
"avprobe.");
|
|
|
|
} else if (err.message) {
|
|
|
|
if (err.message.match(/protocol not found/i))
|
|
|
|
return cb("Link uses a protocol unsupported by this server's " +
|
|
|
|
"version of ffmpeg");
|
|
|
|
|
2015-07-31 04:11:30 +00:00
|
|
|
if (err.message.match(/exceeded time limit/) ||
|
|
|
|
err.message.match(/remote server closed/i)) {
|
2015-07-31 03:45:47 +00:00
|
|
|
return cb(err.message);
|
|
|
|
}
|
|
|
|
|
2015-06-15 12:32:11 +00:00
|
|
|
// Ignore ffprobe error messages, they are common and most often
|
|
|
|
// indicate a problem with the remote file, not with this code.
|
|
|
|
if (!/(av|ff)probe/.test(String(err)))
|
|
|
|
Logger.errlog.log(err.stack || err);
|
2015-05-24 15:06:02 +00:00
|
|
|
return cb("Unable to query file data with ffmpeg");
|
|
|
|
} else {
|
2015-06-15 12:32:11 +00:00
|
|
|
if (!/(av|ff)probe/.test(String(err)))
|
|
|
|
Logger.errlog.log(err.stack || err);
|
2015-05-24 15:06:02 +00:00
|
|
|
return cb("Unable to query file data with ffmpeg");
|
|
|
|
}
|
2014-06-06 04:58:26 +00:00
|
|
|
}
|
2014-06-01 18:43:18 +00:00
|
|
|
|
2015-05-24 15:06:02 +00:00
|
|
|
try {
|
|
|
|
data = reformatData(data);
|
|
|
|
} catch (e) {
|
|
|
|
Logger.errlog.log(e.stack || e);
|
|
|
|
return cb("Unable to query file data with ffmpeg");
|
2014-06-06 04:58:26 +00:00
|
|
|
}
|
2014-06-01 18:43:18 +00:00
|
|
|
|
2015-05-24 15:06:02 +00:00
|
|
|
if (data.medium === "video") {
|
|
|
|
if (!acceptedCodecs.hasOwnProperty(data.type)) {
|
|
|
|
return cb("Unsupported video codec " + data.type);
|
|
|
|
}
|
|
|
|
|
|
|
|
data = {
|
|
|
|
title: data.title || "Raw Video",
|
|
|
|
duration: data.duration,
|
|
|
|
bitrate: data.bitrate,
|
|
|
|
codec: data.type
|
|
|
|
};
|
|
|
|
|
|
|
|
cb(null, data);
|
|
|
|
} else if (data.medium === "audio") {
|
|
|
|
if (!acceptedAudioCodecs.hasOwnProperty(data.acodec)) {
|
|
|
|
return cb("Unsupported audio codec " + data.acodec);
|
|
|
|
}
|
|
|
|
|
|
|
|
data = {
|
|
|
|
title: data.title || "Raw Audio",
|
|
|
|
duration: data.duration,
|
|
|
|
bitrate: data.bitrate,
|
|
|
|
codec: data.acodec
|
|
|
|
};
|
|
|
|
|
|
|
|
cb(null, data);
|
|
|
|
} else {
|
|
|
|
return cb("Parsed metadata did not contain a valid video or audio " +
|
|
|
|
"stream. Either the file is invalid or it has a format " +
|
|
|
|
"unsupported by this server's version of ffmpeg.");
|
|
|
|
}
|
|
|
|
});
|
2014-06-01 18:43:18 +00:00
|
|
|
});
|
|
|
|
};
|