diff --git a/config.template.yaml b/config.template.yaml index 85ecd3d8..4da9d424 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -202,6 +202,9 @@ channel-blacklist: [] # * ffmpeg must be installed on the server ffmpeg: enabled: false +# Executable name for ffprobe if it is not "ffprobe". On Debian and Ubuntu (on which +# libav is used rather than ffmpeg proper), this is "avprobe" + ffprobe-exec: 'ffprobe' link-domain-blacklist: [] diff --git a/lib/config.js b/lib/config.js index d549fe0a..9f49afe1 100644 --- a/lib/config.js +++ b/lib/config.js @@ -100,7 +100,8 @@ var defaults = { }, "channel-blacklist": [], ffmpeg: { - enabled: false + enabled: false, + "ffprobe-exec": "ffprobe" }, "link-domain-blacklist": [], setuid: { diff --git a/lib/ffmpeg.js b/lib/ffmpeg.js index b2739fb7..972af6c8 100644 --- a/lib/ffmpeg.js +++ b/lib/ffmpeg.js @@ -2,6 +2,8 @@ var Logger = require("./logger"); var Config = require("./config"); var spawn = require("child_process").spawn; +var USE_JSON = true; + var acceptedCodecs = { "mov/h264": true, "flv/h264": true, @@ -19,6 +21,124 @@ var audioOnlyContainers = { "mp3": true }; +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; + + reformatted.title = data.format.tags ? data.format.tags.title : null; + var container = data.format.format_name.split(",")[0]; + + data.streams.forEach(function (stream) { + if (stream.codec_type === "video") { + reformatted.vcodec = stream.codec_name; + if (!reformatted.title && stream.tags) { + 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) { + var childErr; + var args = ["-show_streams", "-show_format", filename]; + if (USE_JSON) args = ["-of", "json"].concat(args); + var child = spawn(Config.get("ffmpeg.ffprobe-exec"), args); + var stdout = ""; + var stderr = ""; + + child.on("error", function (err) { + childErr = err; + }); + + child.stdout.on("data", function (data) { + stdout += data; + }); + + child.stderr.on("data", function (data) { + stderr += data; + }); + + child.on("close", function (code) { + if (code !== 0) { + if (stderr.match(/unrecognized option|json/i) && USE_JSON) { + Logger.errlog.log("Warning: ffprobe does not support -of json. " + + "Assuming it will have old output format."); + USE_JSON = false; + return ffprobe(filename, cb); + } + + if (!childErr) childErr = new Error(stderr); + return cb(childErr); + } + + var result; + if (USE_JSON) { + 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); + }); +} + exports.query = function (filename, cb) { if (!Config.get("ffmpeg.enabled")) { return cb("Raw file playback is not enabled on this server"); @@ -29,56 +149,53 @@ exports.query = function (filename, cb) { "or HTTPS"); } - ffprobe(filename, function (err, meta) { + exports.ffprobe(filename, function (err, data) { if (err) { - if (meta && meta.stderr && meta.stderr.match(/Protocol not found/)) { + if (err.message && err.message.match(/protocol not found/i)) { return cb("Link uses a protocol unsupported by this server's ffmpeg"); } else if (err.code && err.code === "ENOENT") { - return cb("Server is missing ffprobe"); + 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 { Logger.errlog.log(err.stack || err); return cb("Unable to query file data with ffmpeg"); } } - meta = parse(meta); - if (meta == null) { - return cb("Unknown error"); + try { + data = reformatData(data); + } catch (e) { + Logger.errlog.log(e.stack || e); + return cb("Unable to query file data with ffmpeg"); } - if (isVideo(meta)) { - var codec = meta.container + "/" + meta.vcodec; - - if (!(codec in acceptedCodecs)) { - return cb("Unsupported video codec " + codec); + if (data.medium === "video") { + if (!acceptedCodecs.hasOwnProperty(data.type)) { + return cb("Unsupported video codec " + data.type); } - var data = { - title: meta.title || "Raw Video", - duration: Math.ceil(meta.seconds) || "--:--", - bitrate: meta.bitrate, - codec: codec + data = { + title: data.title || "Raw Video", + duration: data.duration, + bitrate: data.bitrate, + codec: data.type }; cb(null, data); - } else if (isAudio(meta)) { - var codec = meta.acodec; - - if (!(codec in acceptedAudioCodecs)) { - return cb("Unsupported audio codec " + codec); + } else if (data.medium === "audio") { + if (!acceptedAudioCodecs.hasOwnProperty(data.acodec)) { + return cb("Unsupported audio codec " + data.acodec); } - var data = { - title: meta.title || "Raw Audio", - duration: Math.ceil(meta.seconds) || "--:--", - bitrate: meta.bitrate, - codec: codec + data = { + title: data.title || "Raw Audio", + duration: data.duration, + bitrate: data.bitrate, + codec: data.acodec }; cb(null, data); - } else if (data.ffmpegErr.match(/Protocol not found/)) { - return cb("This server is unable to load videos over the " + - filename.split(":")[0] + " protocol."); } 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 " + @@ -86,88 +203,3 @@ exports.query = function (filename, cb) { } }); }; - -function isVideo(meta) { - return meta.vcodec && !(meta.container in audioOnlyContainers); -} - -function isAudio(meta) { - return meta.acodec; -} - -function parse(meta) { - if (meta == null) { - return null; - } - - if (!meta.format) { - return null; - } - - var data = {}; - meta.streams.forEach(function (s) { - if (s.codec_type === "video") { - data.vcodec = s.codec_name; - } else if (s.codec_type === "audio") { - data.acodec = s.codec_name; - } - }); - - data.container = meta.format.format_name.split(",")[0]; - data.bitrate = parseInt(meta.format.bit_rate) / 1000; - if (meta.format["tag:title"]) { - data.title = meta.format["tag:title"]; - } - data.seconds = Math.ceil(parseFloat(meta.format.duration)); - return data; -} - -function ffprobe(filename, cb) { - var err; - var ff = spawn("ffprobe", ["-show_streams", "-show_format", filename]); - ff.on("error", function (err_) { - err = err_; - }); - - var outbuf = ""; - var errbuf = ""; - ff.stdout.on("data", function (data) { - outbuf += data; - }); - ff.stderr.on("data", function (data) { - errbuf += data; - }); - - ff.on("close", function (code) { - if (code !== 0) { - if (!err) { - err = "ffprobe exited with nonzero exit code"; - } - return cb(err, { stderr: errbuf }); - } - - var lines = outbuf.split("\n"); - var streams = []; - var format = {}; - var data = {}; - lines.forEach(function (line) { - if (line.match(/\[stream\]|\[format\]/i)) { - return; - } else if (line.match(/\[\/stream\]/i)) { - streams.push(data); - data = {}; - } else if (line.match(/\[\/format\]/i)) { - format = data; - data = {}; - } else { - var kv = line.split("="); - data[kv[0].toLowerCase()] = kv[1]; - } - }); - - cb(null, { - streams: streams, - format: format - }); - }); -}