]> jfr.im git - yt-dlp.git/blob - youtube_dlc/downloader/external.py
[ffmpeg] Allow passing custom arguments before -i
[yt-dlp.git] / youtube_dlc / downloader / external.py
1 from __future__ import unicode_literals
2
3 import os.path
4 import re
5 import subprocess
6 import sys
7 import time
8
9 try:
10 from Crypto.Cipher import AES
11 can_decrypt_frag = True
12 except ImportError:
13 can_decrypt_frag = False
14
15 from .common import FileDownloader
16 from ..compat import (
17 compat_setenv,
18 compat_str,
19 )
20 from ..postprocessor.ffmpeg import FFmpegPostProcessor, EXT_TO_OUT_FORMATS
21 from ..utils import (
22 cli_option,
23 cli_valueless_option,
24 cli_bool_option,
25 cli_configuration_args,
26 encodeFilename,
27 error_to_compat_str,
28 encodeArgument,
29 handle_youtubedl_headers,
30 check_executable,
31 is_outdated_version,
32 process_communicate_or_kill,
33 sanitized_Request,
34 sanitize_open,
35 )
36
37
38 class ExternalFD(FileDownloader):
39 SUPPORTED_PROTOCOLS = ('http', 'https', 'ftp', 'ftps')
40
41 def real_download(self, filename, info_dict):
42 self.report_destination(filename)
43 tmpfilename = self.temp_name(filename)
44
45 try:
46 started = time.time()
47 retval = self._call_downloader(tmpfilename, info_dict)
48 except KeyboardInterrupt:
49 if not info_dict.get('is_live'):
50 raise
51 # Live stream downloading cancellation should be considered as
52 # correct and expected termination thus all postprocessing
53 # should take place
54 retval = 0
55 self.to_screen('[%s] Interrupted by user' % self.get_basename())
56
57 if retval == 0:
58 status = {
59 'filename': filename,
60 'status': 'finished',
61 'elapsed': time.time() - started,
62 }
63 if filename != '-':
64 fsize = os.path.getsize(encodeFilename(tmpfilename))
65 self.to_screen('\r[%s] Downloaded %s bytes' % (self.get_basename(), fsize))
66 self.try_rename(tmpfilename, filename)
67 status.update({
68 'downloaded_bytes': fsize,
69 'total_bytes': fsize,
70 })
71 self._hook_progress(status)
72 return True
73 else:
74 self.to_stderr('\n')
75 self.report_error('%s exited with code %d' % (
76 self.get_basename(), retval))
77 return False
78
79 @classmethod
80 def get_basename(cls):
81 return cls.__name__[:-2].lower()
82
83 @property
84 def exe(self):
85 return self.params.get('external_downloader')
86
87 @classmethod
88 def available(cls):
89 return check_executable(cls.get_basename(), [cls.AVAILABLE_OPT])
90
91 @classmethod
92 def supports(cls, info_dict):
93 return info_dict['protocol'] in cls.SUPPORTED_PROTOCOLS
94
95 @classmethod
96 def can_download(cls, info_dict):
97 return cls.available() and cls.supports(info_dict)
98
99 def _option(self, command_option, param):
100 return cli_option(self.params, command_option, param)
101
102 def _bool_option(self, command_option, param, true_value='true', false_value='false', separator=None):
103 return cli_bool_option(self.params, command_option, param, true_value, false_value, separator)
104
105 def _valueless_option(self, command_option, param, expected_value=True):
106 return cli_valueless_option(self.params, command_option, param, expected_value)
107
108 def _configuration_args(self, *args, **kwargs):
109 return cli_configuration_args(
110 self.params.get('external_downloader_args'),
111 self.get_basename(), *args, **kwargs)
112
113 def _call_downloader(self, tmpfilename, info_dict):
114 """ Either overwrite this or implement _make_cmd """
115 cmd = [encodeArgument(a) for a in self._make_cmd(tmpfilename, info_dict)]
116
117 self._debug_cmd(cmd)
118
119 p = subprocess.Popen(
120 cmd, stderr=subprocess.PIPE)
121 _, stderr = process_communicate_or_kill(p)
122 if p.returncode != 0:
123 self.to_stderr(stderr.decode('utf-8', 'replace'))
124
125 if 'url_list' in info_dict:
126 file_list = []
127 for [i, url] in enumerate(info_dict['url_list']):
128 tmpsegmentname = '%s_%s.frag' % (tmpfilename, i)
129 file_list.append(tmpsegmentname)
130 key_list = info_dict.get('key_list')
131 decrypt_info = None
132 dest, _ = sanitize_open(tmpfilename, 'wb')
133 for i, file in enumerate(file_list):
134 src, _ = sanitize_open(file, 'rb')
135 if key_list:
136 decrypt_info = next((x for x in key_list if x['INDEX'] == i), decrypt_info)
137 if decrypt_info['METHOD'] == 'AES-128':
138 iv = decrypt_info.get('IV')
139 decrypt_info['KEY'] = decrypt_info.get('KEY') or self.ydl.urlopen(
140 self._prepare_url(info_dict, info_dict.get('_decryption_key_url') or decrypt_info['URI'])).read()
141 encrypted_data = src.read()
142 decrypted_data = AES.new(
143 decrypt_info['KEY'], AES.MODE_CBC, iv).decrypt(encrypted_data)
144 dest.write(decrypted_data)
145 else:
146 fragment_data = src.read()
147 dest.write(fragment_data)
148 else:
149 fragment_data = src.read()
150 dest.write(fragment_data)
151 src.close()
152 dest.close()
153 if not self.params.get('keep_fragments', False):
154 for file_path in file_list:
155 try:
156 os.remove(file_path)
157 except OSError as ose:
158 self.report_error("Unable to delete file %s; %s" % (file_path, error_to_compat_str(ose)))
159 try:
160 file_path = '%s.frag.urls' % tmpfilename
161 os.remove(file_path)
162 except OSError as ose:
163 self.report_error("Unable to delete file %s; %s" % (file_path, error_to_compat_str(ose)))
164
165 return p.returncode
166
167 def _prepare_url(self, info_dict, url):
168 headers = info_dict.get('http_headers')
169 return sanitized_Request(url, None, headers) if headers else url
170
171
172 class CurlFD(ExternalFD):
173 AVAILABLE_OPT = '-V'
174
175 def _make_cmd(self, tmpfilename, info_dict):
176 cmd = [self.exe, '--location', '-o', tmpfilename]
177 if info_dict.get('http_headers') is not None:
178 for key, val in info_dict['http_headers'].items():
179 cmd += ['--header', '%s: %s' % (key, val)]
180
181 cmd += self._bool_option('--continue-at', 'continuedl', '-', '0')
182 cmd += self._valueless_option('--silent', 'noprogress')
183 cmd += self._valueless_option('--verbose', 'verbose')
184 cmd += self._option('--limit-rate', 'ratelimit')
185 retry = self._option('--retry', 'retries')
186 if len(retry) == 2:
187 if retry[1] in ('inf', 'infinite'):
188 retry[1] = '2147483647'
189 cmd += retry
190 cmd += self._option('--max-filesize', 'max_filesize')
191 cmd += self._option('--interface', 'source_address')
192 cmd += self._option('--proxy', 'proxy')
193 cmd += self._valueless_option('--insecure', 'nocheckcertificate')
194 cmd += self._configuration_args()
195 cmd += ['--', info_dict['url']]
196 return cmd
197
198 def _call_downloader(self, tmpfilename, info_dict):
199 cmd = [encodeArgument(a) for a in self._make_cmd(tmpfilename, info_dict)]
200
201 self._debug_cmd(cmd)
202
203 # curl writes the progress to stderr so don't capture it.
204 p = subprocess.Popen(cmd)
205 process_communicate_or_kill(p)
206 return p.returncode
207
208
209 class AxelFD(ExternalFD):
210 AVAILABLE_OPT = '-V'
211
212 def _make_cmd(self, tmpfilename, info_dict):
213 cmd = [self.exe, '-o', tmpfilename]
214 if info_dict.get('http_headers') is not None:
215 for key, val in info_dict['http_headers'].items():
216 cmd += ['-H', '%s: %s' % (key, val)]
217 cmd += self._configuration_args()
218 cmd += ['--', info_dict['url']]
219 return cmd
220
221
222 class WgetFD(ExternalFD):
223 AVAILABLE_OPT = '--version'
224
225 def _make_cmd(self, tmpfilename, info_dict):
226 cmd = [self.exe, '-O', tmpfilename, '-nv', '--no-cookies']
227 if info_dict.get('http_headers') is not None:
228 for key, val in info_dict['http_headers'].items():
229 cmd += ['--header', '%s: %s' % (key, val)]
230 cmd += self._option('--limit-rate', 'ratelimit')
231 retry = self._option('--tries', 'retries')
232 if len(retry) == 2:
233 if retry[1] in ('inf', 'infinite'):
234 retry[1] = '0'
235 cmd += retry
236 cmd += self._option('--bind-address', 'source_address')
237 cmd += self._option('--proxy', 'proxy')
238 cmd += self._valueless_option('--no-check-certificate', 'nocheckcertificate')
239 cmd += self._configuration_args()
240 cmd += ['--', info_dict['url']]
241 return cmd
242
243
244 class Aria2cFD(ExternalFD):
245 AVAILABLE_OPT = '-v'
246 SUPPORTED_PROTOCOLS = ('http', 'https', 'ftp', 'ftps', 'frag_urls')
247
248 def _make_cmd(self, tmpfilename, info_dict):
249 cmd = [self.exe, '-c']
250 dn = os.path.dirname(tmpfilename)
251 if 'url_list' not in info_dict:
252 cmd += ['--out', os.path.basename(tmpfilename)]
253 verbose_level_args = ['--console-log-level=warn', '--summary-interval=0']
254 cmd += self._configuration_args(['--file-allocation=none', '-x16', '-j16', '-s16'] + verbose_level_args)
255 if dn:
256 cmd += ['--dir', dn]
257 if info_dict.get('http_headers') is not None:
258 for key, val in info_dict['http_headers'].items():
259 cmd += ['--header', '%s: %s' % (key, val)]
260 cmd += self._option('--interface', 'source_address')
261 cmd += self._option('--all-proxy', 'proxy')
262 cmd += self._bool_option('--check-certificate', 'nocheckcertificate', 'false', 'true', '=')
263 cmd += self._bool_option('--remote-time', 'updatetime', 'true', 'false', '=')
264 cmd += ['--auto-file-renaming=false']
265 if 'url_list' in info_dict:
266 cmd += verbose_level_args
267 cmd += ['--uri-selector', 'inorder', '--download-result=hide']
268 url_list_file = '%s.frag.urls' % tmpfilename
269 url_list = []
270 for [i, url] in enumerate(info_dict['url_list']):
271 tmpsegmentname = '%s_%s.frag' % (os.path.basename(tmpfilename), i)
272 url_list.append('%s\n\tout=%s' % (url, tmpsegmentname))
273 stream, _ = sanitize_open(url_list_file, 'wb')
274 stream.write('\n'.join(url_list).encode('utf-8'))
275 stream.close()
276
277 cmd += ['-i', url_list_file]
278 else:
279 cmd += ['--', info_dict['url']]
280 return cmd
281
282
283 class HttpieFD(ExternalFD):
284 @classmethod
285 def available(cls):
286 return check_executable('http', ['--version'])
287
288 def _make_cmd(self, tmpfilename, info_dict):
289 cmd = ['http', '--download', '--output', tmpfilename, info_dict['url']]
290
291 if info_dict.get('http_headers') is not None:
292 for key, val in info_dict['http_headers'].items():
293 cmd += ['%s:%s' % (key, val)]
294 return cmd
295
296
297 class FFmpegFD(ExternalFD):
298 SUPPORTED_PROTOCOLS = ('http', 'https', 'ftp', 'ftps', 'm3u8', 'rtsp', 'rtmp', 'mms')
299
300 @classmethod
301 def available(cls):
302 return FFmpegPostProcessor().available
303
304 def _call_downloader(self, tmpfilename, info_dict):
305 url = info_dict['url']
306 ffpp = FFmpegPostProcessor(downloader=self)
307 if not ffpp.available:
308 self.report_error('m3u8 download detected but ffmpeg could not be found. Please install')
309 return False
310 ffpp.check_version()
311
312 args = [ffpp.executable, '-y']
313
314 for log_level in ('quiet', 'verbose'):
315 if self.params.get(log_level, False):
316 args += ['-loglevel', log_level]
317 break
318
319 seekable = info_dict.get('_seekable')
320 if seekable is not None:
321 # setting -seekable prevents ffmpeg from guessing if the server
322 # supports seeking(by adding the header `Range: bytes=0-`), which
323 # can cause problems in some cases
324 # https://github.com/ytdl-org/youtube-dl/issues/11800#issuecomment-275037127
325 # http://trac.ffmpeg.org/ticket/6125#comment:10
326 args += ['-seekable', '1' if seekable else '0']
327
328 args += self._configuration_args()
329
330 # start_time = info_dict.get('start_time') or 0
331 # if start_time:
332 # args += ['-ss', compat_str(start_time)]
333 # end_time = info_dict.get('end_time')
334 # if end_time:
335 # args += ['-t', compat_str(end_time - start_time)]
336
337 if info_dict.get('http_headers') is not None and re.match(r'^https?://', url):
338 # Trailing \r\n after each HTTP header is important to prevent warning from ffmpeg/avconv:
339 # [http @ 00000000003d2fa0] No trailing CRLF found in HTTP header.
340 headers = handle_youtubedl_headers(info_dict['http_headers'])
341 args += [
342 '-headers',
343 ''.join('%s: %s\r\n' % (key, val) for key, val in headers.items())]
344
345 env = None
346 proxy = self.params.get('proxy')
347 if proxy:
348 if not re.match(r'^[\da-zA-Z]+://', proxy):
349 proxy = 'http://%s' % proxy
350
351 if proxy.startswith('socks'):
352 self.report_warning(
353 '%s does not support SOCKS proxies. Downloading is likely to fail. '
354 'Consider adding --hls-prefer-native to your command.' % self.get_basename())
355
356 # Since December 2015 ffmpeg supports -http_proxy option (see
357 # http://git.videolan.org/?p=ffmpeg.git;a=commit;h=b4eb1f29ebddd60c41a2eb39f5af701e38e0d3fd)
358 # We could switch to the following code if we are able to detect version properly
359 # args += ['-http_proxy', proxy]
360 env = os.environ.copy()
361 compat_setenv('HTTP_PROXY', proxy, env=env)
362 compat_setenv('http_proxy', proxy, env=env)
363
364 protocol = info_dict.get('protocol')
365
366 if protocol == 'rtmp':
367 player_url = info_dict.get('player_url')
368 page_url = info_dict.get('page_url')
369 app = info_dict.get('app')
370 play_path = info_dict.get('play_path')
371 tc_url = info_dict.get('tc_url')
372 flash_version = info_dict.get('flash_version')
373 live = info_dict.get('rtmp_live', False)
374 conn = info_dict.get('rtmp_conn')
375 if player_url is not None:
376 args += ['-rtmp_swfverify', player_url]
377 if page_url is not None:
378 args += ['-rtmp_pageurl', page_url]
379 if app is not None:
380 args += ['-rtmp_app', app]
381 if play_path is not None:
382 args += ['-rtmp_playpath', play_path]
383 if tc_url is not None:
384 args += ['-rtmp_tcurl', tc_url]
385 if flash_version is not None:
386 args += ['-rtmp_flashver', flash_version]
387 if live:
388 args += ['-rtmp_live', 'live']
389 if isinstance(conn, list):
390 for entry in conn:
391 args += ['-rtmp_conn', entry]
392 elif isinstance(conn, compat_str):
393 args += ['-rtmp_conn', conn]
394
395 args += ['-i', url, '-c', 'copy']
396
397 if self.params.get('test', False):
398 args += ['-fs', compat_str(self._TEST_FILE_SIZE)]
399
400 if protocol in ('m3u8', 'm3u8_native'):
401 if self.params.get('hls_use_mpegts', False) or tmpfilename == '-':
402 args += ['-f', 'mpegts']
403 else:
404 args += ['-f', 'mp4']
405 if (ffpp.basename == 'ffmpeg' and is_outdated_version(ffpp._versions['ffmpeg'], '3.2', False)) and (not info_dict.get('acodec') or info_dict['acodec'].split('.')[0] in ('aac', 'mp4a')):
406 args += ['-bsf:a', 'aac_adtstoasc']
407 elif protocol == 'rtmp':
408 args += ['-f', 'flv']
409 else:
410 args += ['-f', EXT_TO_OUT_FORMATS.get(info_dict['ext'], info_dict['ext'])]
411
412 args = [encodeArgument(opt) for opt in args]
413 args.append(encodeFilename(ffpp._ffmpeg_filename_argument(tmpfilename), True))
414
415 self._debug_cmd(args)
416
417 proc = subprocess.Popen(args, stdin=subprocess.PIPE, env=env)
418 try:
419 retval = proc.wait()
420 except BaseException as e:
421 # subprocces.run would send the SIGKILL signal to ffmpeg and the
422 # mp4 file couldn't be played, but if we ask ffmpeg to quit it
423 # produces a file that is playable (this is mostly useful for live
424 # streams). Note that Windows is not affected and produces playable
425 # files (see https://github.com/ytdl-org/youtube-dl/issues/8300).
426 if isinstance(e, KeyboardInterrupt) and sys.platform != 'win32':
427 process_communicate_or_kill(proc, b'q')
428 else:
429 proc.kill()
430 proc.wait()
431 raise
432 return retval
433
434
435 class AVconvFD(FFmpegFD):
436 pass
437
438
439 _BY_NAME = dict(
440 (klass.get_basename(), klass)
441 for name, klass in globals().items()
442 if name.endswith('FD') and name != 'ExternalFD'
443 )
444
445
446 def list_external_downloaders():
447 return sorted(_BY_NAME.keys())
448
449
450 def get_external_downloader(external_downloader):
451 """ Given the name of the executable, see whether we support the given
452 downloader . """
453 # Drop .exe extension on Windows
454 bn = os.path.splitext(os.path.basename(external_downloader))[0]
455 return _BY_NAME[bn]