]> jfr.im git - yt-dlp.git/blob - yt_dlp/downloader/common.py
[cleanup] Minor fixes (See desc)
[yt-dlp.git] / yt_dlp / downloader / common.py
1 import contextlib
2 import errno
3 import os
4 import random
5 import re
6 import time
7
8 from ..minicurses import (
9 BreaklineStatusPrinter,
10 MultilineLogger,
11 MultilinePrinter,
12 QuietMultilinePrinter,
13 )
14 from ..utils import (
15 NUMBER_RE,
16 LockingUnsupportedError,
17 Namespace,
18 decodeArgument,
19 encodeFilename,
20 error_to_compat_str,
21 format_bytes,
22 sanitize_open,
23 shell_quote,
24 timeconvert,
25 timetuple_from_msec,
26 )
27
28
29 class FileDownloader:
30 """File Downloader class.
31
32 File downloader objects are the ones responsible of downloading the
33 actual video file and writing it to disk.
34
35 File downloaders accept a lot of parameters. In order not to saturate
36 the object constructor with arguments, it receives a dictionary of
37 options instead.
38
39 Available options:
40
41 verbose: Print additional info to stdout.
42 quiet: Do not print messages to stdout.
43 ratelimit: Download speed limit, in bytes/sec.
44 throttledratelimit: Assume the download is being throttled below this speed (bytes/sec)
45 retries: Number of times to retry for HTTP error 5xx
46 file_access_retries: Number of times to retry on file access error
47 buffersize: Size of download buffer in bytes.
48 noresizebuffer: Do not automatically resize the download buffer.
49 continuedl: Try to continue downloads if possible.
50 noprogress: Do not print the progress bar.
51 nopart: Do not use temporary .part files.
52 updatetime: Use the Last-modified header to set output file timestamps.
53 test: Download only first bytes to test the downloader.
54 min_filesize: Skip files smaller than this size
55 max_filesize: Skip files larger than this size
56 xattr_set_filesize: Set ytdl.filesize user xattribute with expected size.
57 external_downloader_args: A dictionary of downloader keys (in lower case)
58 and a list of additional command-line arguments for the
59 executable. Use 'default' as the name for arguments to be
60 passed to all downloaders. For compatibility with youtube-dl,
61 a single list of args can also be used
62 hls_use_mpegts: Use the mpegts container for HLS videos.
63 http_chunk_size: Size of a chunk for chunk-based HTTP downloading. May be
64 useful for bypassing bandwidth throttling imposed by
65 a webserver (experimental)
66 progress_template: See YoutubeDL.py
67
68 Subclasses of this one must re-define the real_download method.
69 """
70
71 _TEST_FILE_SIZE = 10241
72 params = None
73
74 def __init__(self, ydl, params):
75 """Create a FileDownloader object with the given options."""
76 self._set_ydl(ydl)
77 self._progress_hooks = []
78 self.params = params
79 self._prepare_multiline_status()
80 self.add_progress_hook(self.report_progress)
81
82 def _set_ydl(self, ydl):
83 self.ydl = ydl
84
85 for func in (
86 'deprecation_warning',
87 'report_error',
88 'report_file_already_downloaded',
89 'report_warning',
90 'to_console_title',
91 'to_stderr',
92 'trouble',
93 'write_debug',
94 ):
95 if not hasattr(self, func):
96 setattr(self, func, getattr(ydl, func))
97
98 def to_screen(self, *args, **kargs):
99 self.ydl.to_screen(*args, quiet=self.params.get('quiet'), **kargs)
100
101 @staticmethod
102 def format_seconds(seconds):
103 time = timetuple_from_msec(seconds * 1000)
104 if time.hours > 99:
105 return '--:--:--'
106 if not time.hours:
107 return '%02d:%02d' % time[1:-1]
108 return '%02d:%02d:%02d' % time[:-1]
109
110 @staticmethod
111 def calc_percent(byte_counter, data_len):
112 if data_len is None:
113 return None
114 return float(byte_counter) / float(data_len) * 100.0
115
116 @staticmethod
117 def format_percent(percent):
118 if percent is None:
119 return '---.-%'
120 elif percent == 100:
121 return '100%'
122 return '%6s' % ('%3.1f%%' % percent)
123
124 @staticmethod
125 def calc_eta(start, now, total, current):
126 if total is None:
127 return None
128 if now is None:
129 now = time.time()
130 dif = now - start
131 if current == 0 or dif < 0.001: # One millisecond
132 return None
133 rate = float(current) / dif
134 return int((float(total) - float(current)) / rate)
135
136 @staticmethod
137 def format_eta(eta):
138 if eta is None:
139 return '--:--'
140 return FileDownloader.format_seconds(eta)
141
142 @staticmethod
143 def calc_speed(start, now, bytes):
144 dif = now - start
145 if bytes == 0 or dif < 0.001: # One millisecond
146 return None
147 return float(bytes) / dif
148
149 @staticmethod
150 def format_speed(speed):
151 if speed is None:
152 return '%10s' % '---b/s'
153 return '%10s' % ('%s/s' % format_bytes(speed))
154
155 @staticmethod
156 def format_retries(retries):
157 return 'inf' if retries == float('inf') else '%.0f' % retries
158
159 @staticmethod
160 def best_block_size(elapsed_time, bytes):
161 new_min = max(bytes / 2.0, 1.0)
162 new_max = min(max(bytes * 2.0, 1.0), 4194304) # Do not surpass 4 MB
163 if elapsed_time < 0.001:
164 return int(new_max)
165 rate = bytes / elapsed_time
166 if rate > new_max:
167 return int(new_max)
168 if rate < new_min:
169 return int(new_min)
170 return int(rate)
171
172 @staticmethod
173 def parse_bytes(bytestr):
174 """Parse a string indicating a byte quantity into an integer."""
175 matchobj = re.match(rf'(?i)^({NUMBER_RE})([kMGTPEZY]?)$', bytestr)
176 if matchobj is None:
177 return None
178 number = float(matchobj.group(1))
179 multiplier = 1024.0 ** 'bkmgtpezy'.index(matchobj.group(2).lower())
180 return int(round(number * multiplier))
181
182 def slow_down(self, start_time, now, byte_counter):
183 """Sleep if the download speed is over the rate limit."""
184 rate_limit = self.params.get('ratelimit')
185 if rate_limit is None or byte_counter == 0:
186 return
187 if now is None:
188 now = time.time()
189 elapsed = now - start_time
190 if elapsed <= 0.0:
191 return
192 speed = float(byte_counter) / elapsed
193 if speed > rate_limit:
194 sleep_time = float(byte_counter) / rate_limit - elapsed
195 if sleep_time > 0:
196 time.sleep(sleep_time)
197
198 def temp_name(self, filename):
199 """Returns a temporary filename for the given filename."""
200 if self.params.get('nopart', False) or filename == '-' or \
201 (os.path.exists(encodeFilename(filename)) and not os.path.isfile(encodeFilename(filename))):
202 return filename
203 return filename + '.part'
204
205 def undo_temp_name(self, filename):
206 if filename.endswith('.part'):
207 return filename[:-len('.part')]
208 return filename
209
210 def ytdl_filename(self, filename):
211 return filename + '.ytdl'
212
213 def wrap_file_access(action, *, fatal=False):
214 def outer(func):
215 def inner(self, *args, **kwargs):
216 file_access_retries = self.params.get('file_access_retries', 0)
217 retry = 0
218 while True:
219 try:
220 return func(self, *args, **kwargs)
221 except OSError as err:
222 retry = retry + 1
223 if retry > file_access_retries or err.errno not in (errno.EACCES, errno.EINVAL):
224 if not fatal:
225 self.report_error(f'unable to {action} file: {err}')
226 return
227 raise
228 self.to_screen(
229 f'[download] Unable to {action} file due to file access error. '
230 f'Retrying (attempt {retry} of {self.format_retries(file_access_retries)}) ...')
231 time.sleep(0.01)
232 return inner
233 return outer
234
235 @wrap_file_access('open', fatal=True)
236 def sanitize_open(self, filename, open_mode):
237 f, filename = sanitize_open(filename, open_mode)
238 if not getattr(f, 'locked', None):
239 self.write_debug(f'{LockingUnsupportedError.msg}. Proceeding without locking', only_once=True)
240 return f, filename
241
242 @wrap_file_access('remove')
243 def try_remove(self, filename):
244 os.remove(filename)
245
246 @wrap_file_access('rename')
247 def try_rename(self, old_filename, new_filename):
248 if old_filename == new_filename:
249 return
250 os.replace(old_filename, new_filename)
251
252 def try_utime(self, filename, last_modified_hdr):
253 """Try to set the last-modified time of the given file."""
254 if last_modified_hdr is None:
255 return
256 if not os.path.isfile(encodeFilename(filename)):
257 return
258 timestr = last_modified_hdr
259 if timestr is None:
260 return
261 filetime = timeconvert(timestr)
262 if filetime is None:
263 return filetime
264 # Ignore obviously invalid dates
265 if filetime == 0:
266 return
267 with contextlib.suppress(Exception):
268 os.utime(filename, (time.time(), filetime))
269 return filetime
270
271 def report_destination(self, filename):
272 """Report destination filename."""
273 self.to_screen('[download] Destination: ' + filename)
274
275 def _prepare_multiline_status(self, lines=1):
276 if self.params.get('noprogress'):
277 self._multiline = QuietMultilinePrinter()
278 elif self.ydl.params.get('logger'):
279 self._multiline = MultilineLogger(self.ydl.params['logger'], lines)
280 elif self.params.get('progress_with_newline'):
281 self._multiline = BreaklineStatusPrinter(self.ydl._out_files['screen'], lines)
282 else:
283 self._multiline = MultilinePrinter(self.ydl._out_files['screen'], lines, not self.params.get('quiet'))
284 self._multiline.allow_colors = self._multiline._HAVE_FULLCAP and not self.params.get('no_color')
285
286 def _finish_multiline_status(self):
287 self._multiline.end()
288
289 ProgressStyles = Namespace(
290 downloaded_bytes='light blue',
291 percent='light blue',
292 eta='yellow',
293 speed='green',
294 elapsed='bold white',
295 total_bytes='',
296 total_bytes_estimate='',
297 )
298
299 def _report_progress_status(self, s, default_template):
300 for name, style in self.ProgressStyles._asdict().items():
301 name = f'_{name}_str'
302 if name not in s:
303 continue
304 s[name] = self._format_progress(s[name], style)
305 s['_default_template'] = default_template % s
306
307 progress_dict = s.copy()
308 progress_dict.pop('info_dict')
309 progress_dict = {'info': s['info_dict'], 'progress': progress_dict}
310
311 progress_template = self.params.get('progress_template', {})
312 self._multiline.print_at_line(self.ydl.evaluate_outtmpl(
313 progress_template.get('download') or '[download] %(progress._default_template)s',
314 progress_dict), s.get('progress_idx') or 0)
315 self.to_console_title(self.ydl.evaluate_outtmpl(
316 progress_template.get('download-title') or 'yt-dlp %(progress._default_template)s',
317 progress_dict))
318
319 def _format_progress(self, *args, **kwargs):
320 return self.ydl._format_text(
321 self._multiline.stream, self._multiline.allow_colors, *args, **kwargs)
322
323 def report_progress(self, s):
324 if s['status'] == 'finished':
325 if self.params.get('noprogress'):
326 self.to_screen('[download] Download completed')
327 msg_template = '100%%'
328 if s.get('total_bytes') is not None:
329 s['_total_bytes_str'] = format_bytes(s['total_bytes'])
330 msg_template += ' of %(_total_bytes_str)s'
331 if s.get('elapsed') is not None:
332 s['_elapsed_str'] = self.format_seconds(s['elapsed'])
333 msg_template += ' in %(_elapsed_str)s'
334 s['_percent_str'] = self.format_percent(100)
335 self._report_progress_status(s, msg_template)
336 return
337
338 if s['status'] != 'downloading':
339 return
340
341 if s.get('eta') is not None:
342 s['_eta_str'] = self.format_eta(s['eta'])
343 else:
344 s['_eta_str'] = 'Unknown'
345
346 if s.get('total_bytes') and s.get('downloaded_bytes') is not None:
347 s['_percent_str'] = self.format_percent(100 * s['downloaded_bytes'] / s['total_bytes'])
348 elif s.get('total_bytes_estimate') and s.get('downloaded_bytes') is not None:
349 s['_percent_str'] = self.format_percent(100 * s['downloaded_bytes'] / s['total_bytes_estimate'])
350 else:
351 if s.get('downloaded_bytes') == 0:
352 s['_percent_str'] = self.format_percent(0)
353 else:
354 s['_percent_str'] = 'Unknown %'
355
356 if s.get('speed') is not None:
357 s['_speed_str'] = self.format_speed(s['speed'])
358 else:
359 s['_speed_str'] = 'Unknown speed'
360
361 if s.get('total_bytes') is not None:
362 s['_total_bytes_str'] = format_bytes(s['total_bytes'])
363 msg_template = '%(_percent_str)s of %(_total_bytes_str)s at %(_speed_str)s ETA %(_eta_str)s'
364 elif s.get('total_bytes_estimate') is not None:
365 s['_total_bytes_estimate_str'] = format_bytes(s['total_bytes_estimate'])
366 msg_template = '%(_percent_str)s of ~%(_total_bytes_estimate_str)s at %(_speed_str)s ETA %(_eta_str)s'
367 else:
368 if s.get('downloaded_bytes') is not None:
369 s['_downloaded_bytes_str'] = format_bytes(s['downloaded_bytes'])
370 if s.get('elapsed'):
371 s['_elapsed_str'] = self.format_seconds(s['elapsed'])
372 msg_template = '%(_downloaded_bytes_str)s at %(_speed_str)s (%(_elapsed_str)s)'
373 else:
374 msg_template = '%(_downloaded_bytes_str)s at %(_speed_str)s'
375 else:
376 msg_template = '%(_percent_str)s at %(_speed_str)s ETA %(_eta_str)s'
377 if s.get('fragment_index') and s.get('fragment_count'):
378 msg_template += ' (frag %(fragment_index)s/%(fragment_count)s)'
379 elif s.get('fragment_index'):
380 msg_template += ' (frag %(fragment_index)s)'
381 self._report_progress_status(s, msg_template)
382
383 def report_resuming_byte(self, resume_len):
384 """Report attempt to resume at given byte."""
385 self.to_screen('[download] Resuming download at byte %s' % resume_len)
386
387 def report_retry(self, err, count, retries):
388 """Report retry in case of HTTP error 5xx"""
389 self.to_screen(
390 '[download] Got server HTTP error: %s. Retrying (attempt %d of %s) ...'
391 % (error_to_compat_str(err), count, self.format_retries(retries)))
392
393 def report_unable_to_resume(self):
394 """Report it was impossible to resume download."""
395 self.to_screen('[download] Unable to resume')
396
397 @staticmethod
398 def supports_manifest(manifest):
399 """ Whether the downloader can download the fragments from the manifest.
400 Redefine in subclasses if needed. """
401 pass
402
403 def download(self, filename, info_dict, subtitle=False):
404 """Download to a filename using the info from info_dict
405 Return True on success and False otherwise
406 """
407
408 nooverwrites_and_exists = (
409 not self.params.get('overwrites', True)
410 and os.path.exists(encodeFilename(filename))
411 )
412
413 if not hasattr(filename, 'write'):
414 continuedl_and_exists = (
415 self.params.get('continuedl', True)
416 and os.path.isfile(encodeFilename(filename))
417 and not self.params.get('nopart', False)
418 )
419
420 # Check file already present
421 if filename != '-' and (nooverwrites_and_exists or continuedl_and_exists):
422 self.report_file_already_downloaded(filename)
423 self._hook_progress({
424 'filename': filename,
425 'status': 'finished',
426 'total_bytes': os.path.getsize(encodeFilename(filename)),
427 }, info_dict)
428 self._finish_multiline_status()
429 return True, False
430
431 if subtitle:
432 sleep_interval = self.params.get('sleep_interval_subtitles') or 0
433 else:
434 min_sleep_interval = self.params.get('sleep_interval') or 0
435 sleep_interval = random.uniform(
436 min_sleep_interval, self.params.get('max_sleep_interval') or min_sleep_interval)
437 if sleep_interval > 0:
438 self.to_screen(f'[download] Sleeping {sleep_interval:.2f} seconds ...')
439 time.sleep(sleep_interval)
440
441 ret = self.real_download(filename, info_dict)
442 self._finish_multiline_status()
443 return ret, True
444
445 def real_download(self, filename, info_dict):
446 """Real download process. Redefine in subclasses."""
447 raise NotImplementedError('This method must be implemented by subclasses')
448
449 def _hook_progress(self, status, info_dict):
450 if not self._progress_hooks:
451 return
452 status['info_dict'] = info_dict
453 # youtube-dl passes the same status object to all the hooks.
454 # Some third party scripts seems to be relying on this.
455 # So keep this behavior if possible
456 for ph in self._progress_hooks:
457 ph(status)
458
459 def add_progress_hook(self, ph):
460 # See YoutubeDl.py (search for progress_hooks) for a description of
461 # this interface
462 self._progress_hooks.append(ph)
463
464 def _debug_cmd(self, args, exe=None):
465 if not self.params.get('verbose', False):
466 return
467
468 str_args = [decodeArgument(a) for a in args]
469
470 if exe is None:
471 exe = os.path.basename(str_args[0])
472
473 self.write_debug(f'{exe} command line: {shell_quote(str_args)}')