1 var Logger
= require("./logger");
2 var Config
= require("./config");
3 var spawn
= require("child_process").spawn
;
4 var https
= require("https");
5 var http
= require("http");
6 var urlparse
= require("url");
7 var path
= require("path");
9 import { callOnce
} from './util/call-once';
11 const CYTUBE_VERSION
= require('../package.json').version
;
13 const LOGGER
= require('@calzoneman/jsli')('ffmpeg');
14 const ECODE_MESSAGES
= {
16 `Unknown host "${e.hostname}". ` +
17 'Please check that the link is correct.'
19 EPROTO
: _e
=> 'The remote server does not support HTTPS.',
20 ECONNRESET
: _e
=> 'The remote server unexpectedly closed the connection.',
22 'The remote server refused the connection. ' +
23 'Please check that the link is correct and the server is running.'
26 'The connection to the remote server timed out. ' +
27 'Please check that the link is correct.'
30 "The remote server's network is unreachable from this server. " +
31 "Please contact an administrator for assistance."
34 "The remote server is unreachable from this server. " +
35 "Please contact the video server's administrator for assistance."
38 "An out of memory error caused the request to fail. Please contact an " +
39 "administrator for assistance."
42 DEPTH_ZERO_SELF_SIGNED_CERT
: _e
=> (
43 'The remote server provided an invalid ' +
44 '(self-signed) SSL certificate. Raw file support requires a ' +
45 'trusted certificate. See https://letsencrypt.org/ to get ' +
46 'a free, trusted certificate.'
48 SELF_SIGNED_CERT_IN_CHAIN
: _e
=> (
49 'The remote server provided an invalid ' +
50 '(self-signed) SSL certificate. Raw file support requires a ' +
51 'trusted certificate. See https://letsencrypt.org/ to get ' +
52 'a free, trusted certificate.'
54 UNABLE_TO_VERIFY_LEAF_SIGNATURE
: _e
=> (
55 "The remote server's SSL certificate chain could not be validated. " +
56 "Please contact the administrator of the server to correct their " +
57 "SSL certificate configuration."
59 CERT_HAS_EXPIRED
: _e
=> (
60 "The remote server's SSL certificate has expired. Please contact " +
61 "the administrator of the server to renew the certificate."
63 ERR_TLS_CERT_ALTNAME_INVALID
: _e
=> (
64 "The remote server's SSL connection is misconfigured and has served " +
65 "a certificate invalid for the given link."
68 // node's http parser barfs when careless servers ignore RFC 2616 and send a
69 // response body in reply to a HEAD request
70 HPE_INVALID_CONSTANT
: _e
=> (
71 "The remote server for this link is misconfigured."
78 var acceptedCodecs
= {
88 var acceptedAudioCodecs
= {
95 var audioOnlyContainers
= {
101 /* eslint no-func-assign: off */
102 function initFFLog() {
103 if (fflog
.initialized
) return;
104 var logger
= new Logger
.Logger(path
.resolve(__dirname
, "..", "ffmpeg.log"));
105 fflog = function () {
106 logger
.log
.apply(logger
, arguments
);
108 fflog
.initialized
= true;
111 function fixRedirectIfNeeded(urldata
, redirect
) {
112 let parsedRedirect
= urlparse
.parse(redirect
);
113 if (parsedRedirect
.host
=== null) {
114 // Relative path, munge it to absolute
115 redirect
= urldata
.protocol
+ "//" + urldata
.host
+ redirect
;
121 function translateStatusCode(statusCode
) {
122 switch (statusCode
) {
124 return "The request for the audio/video link was rejected as invalid. " +
125 "Contact support for troubleshooting assistance.";
128 return "Access to the link was denied. Contact the owner of the " +
129 "website hosting the audio/video file to grant permission for " +
130 "the file to be downloaded.";
132 return "The requested link could not be found (404).";
134 return "The website hosting the link does not support HEAD requests, " +
135 "so the link could not be retrieved.";
137 return "The requested link does not exist (410 Gone).";
139 return "The requested link could not be retrieved because the server " +
140 "hosting it does not support CyTube's request.";
143 return "The website hosting the audio/video link encountered an error " +
144 "and was unable to process the request. Try again in a few minutes, " +
145 "and if the issue persists, contact the owner of the website hosting " +
148 return "An unknown issue occurred when requesting the audio/video link. " +
149 "Contact support for troubleshooting assistance.";
153 function getCookie(res
) {
154 if (!res
.headers
['set-cookie']) {
158 return res
.headers
['set-cookie'].map(c
=> c
.split(';')[0]).join(';') + ';';
161 function testUrl(url
, cb
, params
= { redirCount
: 0, cookie
: '' }) {
162 const { redirCount
, cookie
} = params
;
163 var data
= urlparse
.parse(url
);
164 if (!/https:/.test(data
.protocol
)) {
165 if (redirCount
> 0) {
166 // If the original URL redirected, the user is probably not aware
167 // that the link they entered (which was HTTPS) is redirecting to a
168 // non-HTTPS endpoint
169 return cb(`Unexpected redirect to a non-HTTPS link: ${url}`);
172 return cb("Only links starting with 'https://' are supported " +
173 "for raw audio/video support");
176 if (!data
.hostname
) {
177 return cb("The link to the file is missing the website address and can't " +
181 var transport
= (data
.protocol
=== "https:") ? https
: http
;
182 data
.method
= "HEAD";
184 'User-Agent': `CyTube/${CYTUBE_VERSION}`
187 data
.headers
['Cookie'] = cookie
;
191 var req
= transport
.request(data
, function (res
) {
194 if (res
.statusCode
=== 301 || res
.statusCode
=== 302) {
195 if (redirCount
> 2) {
196 return cb("The request for the audio/video file has been redirected " +
197 "more than twice. This could indicate a misconfiguration " +
198 "on the website hosting the link. For best results, use " +
199 "a direct link. See https://git.io/vrE75 for details.");
203 redirCount
: redirCount
+ 1,
204 cookie
: cookie
+ getCookie(res
)
206 return testUrl(fixRedirectIfNeeded(data
, res
.headers
["location"]), cb
,
210 if (res
.statusCode
!== 200) {
211 return cb(translateStatusCode(res
.statusCode
));
214 if (!/^audio|^video/.test(res
.headers
["content-type"])) {
215 cb("Could not detect a supported audio/video type. See " +
216 "https://git.io/fjtOK for a list of supported providers. " +
217 "(Content-Type was: '" + res
.headers
["content-type"] + "')");
224 req
.on("error", function (err
) {
225 if (/hostname\/ip doesn't match/i.test(err
.message
)) {
226 cb("The remote server provided an invalid SSL certificate. Details: "
229 } else if (ECODE_MESSAGES
.hasOwnProperty(err
.code
)) {
230 cb(`${ECODE_MESSAGES[err.code](err)} (error code: ${err.code})`);
235 "Error sending preflight request: %s (code=%s) (link: %s)",
241 cb("An unexpected error occurred while trying to process the link. " +
242 "If this link is hosted on a server you own, it is likely " +
243 "misconfigured and you can join community support for assistance. " +
244 "If you are attempting to add links from third party websites, the " +
245 "developers do not provide support for this." +
246 (err
.code
? (" Error code: " + err
.code
) : ""));
251 LOGGER
.error('Unable to make raw file probe request: %s', error
.stack
);
252 cb("An unexpected error occurred while trying to process the link. " +
253 "Try again, and contact support for further troubleshooting if the " +
254 "problem continues.");
258 function readOldFormat(buf
) {
259 var lines
= buf
.split("\n");
260 var tmp
= { tags
: {} };
265 lines
.forEach(function (line
) {
266 if (line
.match(/\[stream\]|\[format\]/i)) {
268 } else if (line
.match(/\[\/stream\]/i)) {
269 data
.streams
.push(tmp
);
271 } else if (line
.match(/\[\/format\]/i)) {
275 var kv
= line
.split("=");
276 var key
= kv
[0].toLowerCase();
277 if (key
.indexOf("tag:") === 0) {
278 tmp
.tags
[key
.split(":")[1]] = kv
[1];
288 function isAlternateDisposition(stream
) {
289 if (!stream
.disposition
) {
293 for (var key
in stream
) {
294 if (key
!== "default" && stream
.disposition
[key
]) {
302 function reformatData(data
) {
303 var reformatted
= {};
305 var duration
= parseInt(data
.format
.duration
, 10);
306 if (isNaN(duration
)) duration
= "--:--";
307 reformatted
.duration
= Math
.ceil(duration
);
309 var bitrate
= parseInt(data
.format
.bit_rate
, 10) / 1000;
310 if (isNaN(bitrate
)) bitrate
= 0;
311 reformatted
.bitrate
= bitrate
;
313 reformatted
.title
= data
.format
.tags
? data
.format
.tags
.title
: null;
314 var container
= data
.format
.format_name
.split(",")[0];
318 for (var i
= 0; i
< data
.streams
.length
; i
++) {
319 const stream
= data
.streams
[i
];
321 // Trash streams with alternate dispositions, e.g. `attached_pic` for
322 // embedded album art on MP3s (not a real video stream)
323 if (isAlternateDisposition(stream
)) {
327 if (stream
.codec_type
=== "video" &&
328 !audioOnlyContainers
.hasOwnProperty(container
)) {
330 if (acceptedCodecs
.hasOwnProperty(container
+ "/" + stream
.codec_name
)) {
331 reformatted
.vcodec
= stream
.codec_name
;
332 reformatted
.medium
= "video";
333 reformatted
.type
= [container
, reformatted
.vcodec
].join("/");
335 if (stream
.tags
&& stream
.tags
.title
) {
336 reformatted
.title
= stream
.tags
.title
;
341 } else if (stream
.codec_type
=== "audio" && !audio
&&
342 acceptedAudioCodecs
.hasOwnProperty(stream
.codec_name
)) {
344 acodec
: stream
.codec_name
,
348 if (stream
.tags
&& stream
.tags
.title
) {
349 audio
.title
= stream
.tags
.title
;
354 // Override to make sure video files with no valid video streams but some
355 // acceptable audio stream are rejected.
361 for (var key
in audio
) {
362 reformatted
[key
] = audio
[key
];
369 exports
.ffprobe
= function ffprobe(filename
, cb
) {
370 fflog("Spawning ffprobe for " + filename
);
372 var args
= ["-show_streams", "-show_format", filename
];
373 if (USE_JSON
) args
= ["-of", "json"].concat(args
);
376 child
= spawn(Config
.get("ffmpeg.ffprobe-exec"), args
);
378 LOGGER
.error("Unable to spawn() ffprobe process: %s", error
.stack
);
384 var timer
= setTimeout(function () {
385 LOGGER
.warn("Timed out when probing " + filename
);
386 fflog("Killing ffprobe for " + filename
+ " after " + (TIMEOUT
/1000) + " seconds");
387 childErr
= new Error(
388 "File query exceeded time limit of " + (TIMEOUT
/1000) +
389 " seconds. This can be caused if the remote server is far " +
390 "away or if you did not encode the video " +
391 "using the 'faststart' option: " +
392 "https://trac.ffmpeg.org/wiki/Encode/H.264#faststartforwebvideo"
394 child
.kill("SIGKILL");
397 child
.on("error", function (err
) {
401 child
.stdout
.on("data", function (data
) {
405 child
.stderr
.on("data", function (data
) {
407 if (stderr
.match(/the tls connection was non-properly terminated/i)) {
408 fflog("Killing ffprobe for " + filename
+ " due to TLS error");
409 childErr
= new Error("The connection was closed unexpectedly. " +
410 "If the problem continues, contact support " +
411 "for troubleshooting assistance.");
412 child
.kill("SIGKILL");
416 child
.on("close", function (code
) {
418 fflog("ffprobe exited with code " + code
+ " for file " + filename
);
420 if (stderr
.match(/unrecognized option|json/i) && USE_JSON
) {
421 LOGGER
.warn("ffprobe does not support -of json. " +
422 "Assuming it will have old output format.");
424 return ffprobe(filename
, cb
);
427 if (!childErr
) childErr
= new Error(stderr
);
434 result
= JSON
.parse(stdout
);
436 return cb(new Error("Unable to parse ffprobe output: " + e
.message
));
440 result
= readOldFormat(stdout
);
442 return cb(new Error("Unable to parse ffprobe output: " + e
.message
));
446 return cb(null, result
);
450 exports
.query = function (filename
, cb
) {
451 if (Config
.get("ffmpeg.log") && !fflog
.initialized
) {
455 if (!Config
.get("ffmpeg.enabled")) {
456 return cb("Raw file playback is not enabled on this server");
459 if (!filename
.match(/^https:\/\//)) {
460 return cb("Raw file playback is only supported for links accessible via HTTPS. " +
461 "Ensure that the link begins with 'https://'.");
464 testUrl(filename
, callOnce(function (err
) {
469 exports
.ffprobe(filename
, function (err
, data
) {
471 if (err
.code
&& err
.code
=== "ENOENT") {
472 return cb("Failed to execute `ffprobe`. Set ffmpeg.ffprobe-exec " +
473 "to the correct name of the executable in config.yaml. " +
474 "If you are using Debian or Ubuntu, it is probably " +
476 } else if (err
.message
) {
477 if (err
.message
.match(/protocol not found/i))
478 return cb("Link uses a protocol unsupported by this server's " +
479 "version of ffmpeg. Some older versions of " +
480 "ffprobe/avprobe do not support HTTPS.");
482 if (err
.message
.match(/exceeded time limit/) ||
483 err
.message
.match(/closed unexpectedly/i)) {
484 return cb(err
.message
);
487 // Ignore ffprobe error messages, they are common and most often
488 // indicate a problem with the remote file, not with this code.
489 if (!/(av|ff)probe/.test(String(err
)))
490 LOGGER
.error(err
.stack
|| err
);
491 return cb("An unexpected error occurred while trying to process " +
492 "the link. Contact support for troubleshooting " +
495 if (!/(av|ff)probe/.test(String(err
)))
496 LOGGER
.error(err
.stack
|| err
);
497 return cb("An unexpected error occurred while trying to process " +
498 "the link. Contact support for troubleshooting " +
504 data
= reformatData(data
);
506 LOGGER
.error(e
.stack
|| e
);
507 return cb("An unexpected error occurred while trying to process " +
508 "the link. Contact support for troubleshooting " +
512 if (data
.medium
=== "video") {
514 title
: data
.title
|| "Raw Video",
515 duration
: data
.duration
,
516 bitrate
: data
.bitrate
,
521 } else if (data
.medium
=== "audio") {
523 title
: data
.title
|| "Raw Audio",
524 duration
: data
.duration
,
525 bitrate
: data
.bitrate
,
531 return cb("File did not contain an acceptable codec. See " +
532 "https://git.io/vrE75 for details.");