]> jfr.im git - yt-dlp.git/blame - youtube_dl/FileDownloader.py
[viki] Fix subtitles extraction
[yt-dlp.git] / youtube_dl / FileDownloader.py
CommitLineData
d77c3dfd
FV
1import os
2import re
d77c3dfd
FV
3import subprocess
4import sys
5import time
d77c3dfd 6
76e67c2c
PH
7from .utils import (
8 compat_urllib_error,
9 compat_urllib_request,
10 ContentTooShortError,
11 determine_ext,
12 encodeFilename,
02dbf93f 13 format_bytes,
76e67c2c
PH
14 sanitize_open,
15 timeconvert,
16)
d77c3dfd
FV
17
18
19class FileDownloader(object):
59ae15a5
PH
20 """File Downloader class.
21
22 File downloader objects are the ones responsible of downloading the
8222d8de 23 actual video file and writing it to disk.
59ae15a5
PH
24
25 File downloaders accept a lot of parameters. In order not to saturate
26 the object constructor with arguments, it receives a dictionary of
8222d8de 27 options instead.
59ae15a5
PH
28
29 Available options:
30
8222d8de 31 verbose: Print additional info to stdout.
59ae15a5 32 quiet: Do not print messages to stdout.
59ae15a5 33 ratelimit: Download speed limit, in bytes/sec.
59ae15a5
PH
34 retries: Number of times to retry for HTTP error 5xx
35 buffersize: Size of download buffer in bytes.
36 noresizebuffer: Do not automatically resize the download buffer.
37 continuedl: Try to continue downloads if possible.
38 noprogress: Do not print the progress bar.
59ae15a5
PH
39 logtostderr: Log messages to stderr instead of stdout.
40 consoletitle: Display progress in console window's titlebar.
41 nopart: Do not use temporary .part files.
42 updatetime: Use the Last-modified header to set output file timestamps.
37c8fd48 43 test: Download only first bytes to test the downloader.
9e982f9e
JC
44 min_filesize: Skip files smaller than this size
45 max_filesize: Skip files larger than this size
59ae15a5
PH
46 """
47
48 params = None
59ae15a5 49
8222d8de 50 def __init__(self, ydl, params):
59ae15a5 51 """Create a FileDownloader object with the given options."""
8222d8de 52 self.ydl = ydl
bffbd5f0 53 self._progress_hooks = []
59ae15a5
PH
54 self.params = params
55
af8bd6a8
JMF
56 @staticmethod
57 def format_seconds(seconds):
58 (mins, secs) = divmod(seconds, 60)
061b2889 59 (hours, mins) = divmod(mins, 60)
af8bd6a8
JMF
60 if hours > 99:
61 return '--:--:--'
62 if hours == 0:
63 return '%02d:%02d' % (mins, secs)
64 else:
65 return '%02d:%02d:%02d' % (hours, mins, secs)
66
59ae15a5
PH
67 @staticmethod
68 def calc_percent(byte_counter, data_len):
69 if data_len is None:
4ae72004
JMF
70 return None
71 return float(byte_counter) / float(data_len) * 100.0
72
73 @staticmethod
74 def format_percent(percent):
75 if percent is None:
59ae15a5 76 return '---.-%'
4ae72004 77 return '%6s' % ('%3.1f%%' % percent)
59ae15a5
PH
78
79 @staticmethod
80 def calc_eta(start, now, total, current):
81 if total is None:
4ae72004 82 return None
59ae15a5
PH
83 dif = now - start
84 if current == 0 or dif < 0.001: # One millisecond
4ae72004 85 return None
59ae15a5 86 rate = float(current) / dif
4ae72004
JMF
87 return int((float(total) - float(current)) / rate)
88
89 @staticmethod
90 def format_eta(eta):
91 if eta is None:
92 return '--:--'
af8bd6a8 93 return FileDownloader.format_seconds(eta)
59ae15a5
PH
94
95 @staticmethod
96 def calc_speed(start, now, bytes):
97 dif = now - start
98 if bytes == 0 or dif < 0.001: # One millisecond
4ae72004
JMF
99 return None
100 return float(bytes) / dif
101
102 @staticmethod
103 def format_speed(speed):
104 if speed is None:
59ae15a5 105 return '%10s' % '---b/s'
02dbf93f 106 return '%10s' % ('%s/s' % format_bytes(speed))
59ae15a5
PH
107
108 @staticmethod
109 def best_block_size(elapsed_time, bytes):
110 new_min = max(bytes / 2.0, 1.0)
111 new_max = min(max(bytes * 2.0, 1.0), 4194304) # Do not surpass 4 MB
112 if elapsed_time < 0.001:
113 return int(new_max)
114 rate = bytes / elapsed_time
115 if rate > new_max:
116 return int(new_max)
117 if rate < new_min:
118 return int(new_min)
119 return int(rate)
120
121 @staticmethod
122 def parse_bytes(bytestr):
123 """Parse a string indicating a byte quantity into an integer."""
124 matchobj = re.match(r'(?i)^(\d+(?:\.\d+)?)([kMGTPEZY]?)$', bytestr)
125 if matchobj is None:
126 return None
127 number = float(matchobj.group(1))
128 multiplier = 1024.0 ** 'bkmgtpezy'.index(matchobj.group(2).lower())
129 return int(round(number * multiplier))
130
8222d8de
JMF
131 def to_screen(self, *args, **kargs):
132 self.ydl.to_screen(*args, **kargs)
59ae15a5
PH
133
134 def to_stderr(self, message):
8222d8de 135 self.ydl.to_screen(message)
59ae15a5 136
1e5b9a95
PH
137 def to_console_title(self, message):
138 self.ydl.to_console_title(message)
59ae15a5 139
8222d8de
JMF
140 def trouble(self, *args, **kargs):
141 self.ydl.trouble(*args, **kargs)
142
143 def report_warning(self, *args, **kargs):
144 self.ydl.report_warning(*args, **kargs)
145
146 def report_error(self, *args, **kargs):
2e325280 147 self.ydl.report_error(*args, **kargs)
4e1582f3 148
59ae15a5
PH
149 def slow_down(self, start_time, byte_counter):
150 """Sleep if the download speed is over the rate limit."""
151 rate_limit = self.params.get('ratelimit', None)
152 if rate_limit is None or byte_counter == 0:
153 return
154 now = time.time()
155 elapsed = now - start_time
156 if elapsed <= 0.0:
157 return
158 speed = float(byte_counter) / elapsed
159 if speed > rate_limit:
160 time.sleep((byte_counter - rate_limit * (now - start_time)) / rate_limit)
161
162 def temp_name(self, filename):
163 """Returns a temporary filename for the given filename."""
164 if self.params.get('nopart', False) or filename == u'-' or \
165 (os.path.exists(encodeFilename(filename)) and not os.path.isfile(encodeFilename(filename))):
166 return filename
167 return filename + u'.part'
168
169 def undo_temp_name(self, filename):
170 if filename.endswith(u'.part'):
171 return filename[:-len(u'.part')]
172 return filename
173
174 def try_rename(self, old_filename, new_filename):
175 try:
176 if old_filename == new_filename:
177 return
178 os.rename(encodeFilename(old_filename), encodeFilename(new_filename))
76e67c2c 179 except (IOError, OSError):
6622d22c 180 self.report_error(u'unable to rename file')
59ae15a5
PH
181
182 def try_utime(self, filename, last_modified_hdr):
183 """Try to set the last-modified time of the given file."""
184 if last_modified_hdr is None:
185 return
186 if not os.path.isfile(encodeFilename(filename)):
187 return
188 timestr = last_modified_hdr
189 if timestr is None:
190 return
191 filetime = timeconvert(timestr)
192 if filetime is None:
193 return filetime
bb474376
PH
194 # Ignore obviously invalid dates
195 if filetime == 0:
196 return
59ae15a5
PH
197 try:
198 os.utime(filename, (time.time(), filetime))
199 except:
200 pass
201 return filetime
202
59ae15a5
PH
203 def report_destination(self, filename):
204 """Report destination filename."""
205 self.to_screen(u'[download] Destination: ' + filename)
206
4ae72004 207 def report_progress(self, percent, data_len_str, speed, eta):
59ae15a5
PH
208 """Report download progress."""
209 if self.params.get('noprogress', False):
210 return
4ae9e558 211 clear_line = (u'\x1b[K' if sys.stderr.isatty() and os.name != 'nt' else u'')
4ac5306a
JMF
212 if eta is not None:
213 eta_str = self.format_eta(eta)
214 else:
215 eta_str = 'Unknown ETA'
216 if percent is not None:
217 percent_str = self.format_percent(percent)
218 else:
219 percent_str = 'Unknown %'
4ae72004 220 speed_str = self.format_speed(speed)
5717d91a 221 if self.params.get('progress_with_newline', False):
1528d664 222 self.to_screen(u'[download] %s of %s at %s ETA %s' %
7311fef8 223 (percent_str, data_len_str, speed_str, eta_str))
5717d91a 224 else:
4ae9e558
PH
225 self.to_screen(u'\r%s[download] %s of %s at %s ETA %s' %
226 (clear_line, percent_str, data_len_str, speed_str, eta_str), skip_eol=True)
1e5b9a95 227 self.to_console_title(u'youtube-dl - %s of %s at %s ETA %s' %
59ae15a5
PH
228 (percent_str.strip(), data_len_str.strip(), speed_str.strip(), eta_str.strip()))
229
230 def report_resuming_byte(self, resume_len):
231 """Report attempt to resume at given byte."""
232 self.to_screen(u'[download] Resuming download at byte %s' % resume_len)
233
234 def report_retry(self, count, retries):
235 """Report retry in case of HTTP error 5xx"""
236 self.to_screen(u'[download] Got server HTTP error. Retrying (attempt %d of %d)...' % (count, retries))
237
238 def report_file_already_downloaded(self, file_name):
239 """Report file has already been fully downloaded."""
240 try:
241 self.to_screen(u'[download] %s has already been downloaded' % file_name)
76e67c2c 242 except UnicodeEncodeError:
59ae15a5
PH
243 self.to_screen(u'[download] The file has already been downloaded')
244
245 def report_unable_to_resume(self):
246 """Report it was impossible to resume download."""
247 self.to_screen(u'[download] Unable to resume')
248
968b5e01 249 def report_finish(self, data_len_str, tot_time):
59ae15a5
PH
250 """Report download finished."""
251 if self.params.get('noprogress', False):
252 self.to_screen(u'[download] Download completed')
253 else:
6d38616e 254 clear_line = (u'\x1b[K' if sys.stderr.isatty() and os.name != 'nt' else u'')
af8bd6a8
JMF
255 self.to_screen(u'\r%s[download] 100%% of %s in %s' %
256 (clear_line, data_len_str, self.format_seconds(tot_time)))
59ae15a5 257
31366066 258 def _download_with_rtmpdump(self, filename, url, player_url, page_url, play_path, tc_url, live):
59ae15a5
PH
259 self.report_destination(filename)
260 tmpfilename = self.temp_name(filename)
9026dd38 261 test = self.params.get('test', False)
59ae15a5
PH
262
263 # Check for rtmpdump first
264 try:
967897fd 265 subprocess.call(['rtmpdump', '-h'], stdout=(open(os.path.devnull, 'w')), stderr=subprocess.STDOUT)
59ae15a5 266 except (OSError, IOError):
6622d22c 267 self.report_error(u'RTMP download detected but "rtmpdump" could not be run')
59ae15a5 268 return False
8cd252f1 269 verbosity_option = '--verbose' if self.params.get('verbose', False) else '--quiet'
59ae15a5
PH
270
271 # Download using rtmpdump. rtmpdump returns exit code 2 when
272 # the connection was interrumpted and resuming appears to be
273 # possible. This is part of rtmpdump's normal usage, AFAIK.
8cd252f1 274 basic_args = ['rtmpdump', verbosity_option, '-r', url, '-o', tmpfilename]
f5ebb614 275 if player_url is not None:
8cd252f1 276 basic_args += ['--swfVfy', player_url]
f5ebb614
PH
277 if page_url is not None:
278 basic_args += ['--pageUrl', page_url]
adb029ed 279 if play_path is not None:
8cd252f1 280 basic_args += ['--playpath', play_path]
de5d66d4 281 if tc_url is not None:
282 basic_args += ['--tcUrl', url]
9026dd38 283 if test:
ad7a071a 284 basic_args += ['--stop', '1']
31366066 285 if live:
286 basic_args += ['--live']
8cd252f1 287 args = basic_args + [[], ['--resume', '--skip', '1']][self.params.get('continuedl', False)]
59ae15a5
PH
288 if self.params.get('verbose', False):
289 try:
290 import pipes
291 shell_quote = lambda args: ' '.join(map(pipes.quote, args))
292 except ImportError:
293 shell_quote = repr
294 self.to_screen(u'[debug] rtmpdump command line: ' + shell_quote(args))
295 retval = subprocess.call(args)
9026dd38 296 while (retval == 2 or retval == 1) and not test:
59ae15a5
PH
297 prevsize = os.path.getsize(encodeFilename(tmpfilename))
298 self.to_screen(u'\r[rtmpdump] %s bytes' % prevsize, skip_eol=True)
299 time.sleep(5.0) # This seems to be needed
300 retval = subprocess.call(basic_args + ['-e'] + [[], ['-k', '1']][retval == 1])
301 cursize = os.path.getsize(encodeFilename(tmpfilename))
302 if prevsize == cursize and retval == 1:
303 break
304 # Some rtmp streams seem abort after ~ 99.8%. Don't complain for those
305 if prevsize == cursize and retval == 2 and cursize > 1024:
306 self.to_screen(u'\r[rtmpdump] Could not download the whole video. This can happen for some advertisements.')
307 retval = 0
308 break
9026dd38 309 if retval == 0 or (test and retval == 2):
bffbd5f0
PH
310 fsize = os.path.getsize(encodeFilename(tmpfilename))
311 self.to_screen(u'\r[rtmpdump] %s bytes' % fsize)
59ae15a5 312 self.try_rename(tmpfilename, filename)
bffbd5f0
PH
313 self._hook_progress({
314 'downloaded_bytes': fsize,
315 'total_bytes': fsize,
316 'filename': filename,
317 'status': 'finished',
318 })
59ae15a5
PH
319 return True
320 else:
6622d22c
JMF
321 self.to_stderr(u"\n")
322 self.report_error(u'rtmpdump exited with code %d' % retval)
59ae15a5
PH
323 return False
324
f2cd958c 325 def _download_with_mplayer(self, filename, url):
326 self.report_destination(filename)
327 tmpfilename = self.temp_name(filename)
328
f2cd958c 329 args = ['mplayer', '-really-quiet', '-vo', 'null', '-vc', 'dummy', '-dumpstream', '-dumpfile', tmpfilename, url]
330 # Check for mplayer first
331 try:
3054ff0c 332 subprocess.call(['mplayer', '-h'], stdout=(open(os.path.devnull, 'w')), stderr=subprocess.STDOUT)
f2cd958c 333 except (OSError, IOError):
334 self.report_error(u'MMS or RTSP download detected but "%s" could not be run' % args[0] )
335 return False
336
337 # Download using mplayer.
338 retval = subprocess.call(args)
339 if retval == 0:
340 fsize = os.path.getsize(encodeFilename(tmpfilename))
341 self.to_screen(u'\r[%s] %s bytes' % (args[0], fsize))
342 self.try_rename(tmpfilename, filename)
343 self._hook_progress({
344 'downloaded_bytes': fsize,
345 'total_bytes': fsize,
346 'filename': filename,
347 'status': 'finished',
348 })
349 return True
350 else:
351 self.to_stderr(u"\n")
3054ff0c 352 self.report_error(u'mplayer exited with code %d' % retval)
f2cd958c 353 return False
354
b15d4f62
JMF
355 def _download_m3u8_with_ffmpeg(self, filename, url):
356 self.report_destination(filename)
357 tmpfilename = self.temp_name(filename)
358
801dbbdf
JMF
359 args = ['-y', '-i', url, '-f', 'mp4', '-c', 'copy',
360 '-bsf:a', 'aac_adtstoasc', tmpfilename]
b15d4f62 361
801dbbdf
JMF
362 for program in ['avconv', 'ffmpeg']:
363 try:
364 subprocess.call([program, '-version'], stdout=(open(os.path.devnull, 'w')), stderr=subprocess.STDOUT)
365 break
366 except (OSError, IOError):
367 pass
368 else:
369 self.report_error(u'm3u8 download detected but ffmpeg or avconv could not be found')
370 cmd = [program] + args
371
372 retval = subprocess.call(cmd)
b15d4f62
JMF
373 if retval == 0:
374 fsize = os.path.getsize(encodeFilename(tmpfilename))
375 self.to_screen(u'\r[%s] %s bytes' % (args[0], fsize))
376 self.try_rename(tmpfilename, filename)
377 self._hook_progress({
378 'downloaded_bytes': fsize,
379 'total_bytes': fsize,
380 'filename': filename,
381 'status': 'finished',
382 })
383 return True
384 else:
385 self.to_stderr(u"\n")
386 self.report_error(u'ffmpeg exited with code %d' % retval)
387 return False
388
f2cd958c 389
59ae15a5
PH
390 def _do_download(self, filename, info_dict):
391 url = info_dict['url']
59ae15a5
PH
392
393 # Check file already present
394 if self.params.get('continuedl', False) and os.path.isfile(encodeFilename(filename)) and not self.params.get('nopart', False):
395 self.report_file_already_downloaded(filename)
bffbd5f0
PH
396 self._hook_progress({
397 'filename': filename,
398 'status': 'finished',
dd5d2eb0 399 'total_bytes': os.path.getsize(encodeFilename(filename)),
bffbd5f0 400 })
59ae15a5
PH
401 return True
402
403 # Attempt to download using rtmpdump
404 if url.startswith('rtmp'):
f5ebb614
PH
405 return self._download_with_rtmpdump(filename, url,
406 info_dict.get('player_url', None),
adb029ed 407 info_dict.get('page_url', None),
de5d66d4 408 info_dict.get('play_path', None),
31366066 409 info_dict.get('tc_url', None),
0ed05a1d 410 info_dict.get('rtmp_live', False))
59ae15a5 411
f2cd958c 412 # Attempt to download using mplayer
413 if url.startswith('mms') or url.startswith('rtsp'):
414 return self._download_with_mplayer(filename, url)
415
b15d4f62
JMF
416 # m3u8 manifest are downloaded with ffmpeg
417 if determine_ext(url) == u'm3u8':
418 return self._download_m3u8_with_ffmpeg(filename, url)
419
59ae15a5
PH
420 tmpfilename = self.temp_name(filename)
421 stream = None
422
423 # Do not include the Accept-Encoding header
424 headers = {'Youtubedl-no-compression': 'True'}
3446dfb7
PH
425 if 'user_agent' in info_dict:
426 headers['Youtubedl-user-agent'] = info_dict['user_agent']
59ae15a5
PH
427 basic_request = compat_urllib_request.Request(url, None, headers)
428 request = compat_urllib_request.Request(url, None, headers)
429
37c8fd48
FV
430 if self.params.get('test', False):
431 request.add_header('Range','bytes=0-10240')
432
59ae15a5
PH
433 # Establish possible resume length
434 if os.path.isfile(encodeFilename(tmpfilename)):
435 resume_len = os.path.getsize(encodeFilename(tmpfilename))
436 else:
437 resume_len = 0
438
439 open_mode = 'wb'
440 if resume_len != 0:
441 if self.params.get('continuedl', False):
442 self.report_resuming_byte(resume_len)
443 request.add_header('Range','bytes=%d-' % resume_len)
444 open_mode = 'ab'
445 else:
446 resume_len = 0
447
448 count = 0
449 retries = self.params.get('retries', 0)
450 while count <= retries:
451 # Establish connection
452 try:
453 if count == 0 and 'urlhandle' in info_dict:
454 data = info_dict['urlhandle']
455 data = compat_urllib_request.urlopen(request)
456 break
457 except (compat_urllib_error.HTTPError, ) as err:
458 if (err.code < 500 or err.code >= 600) and err.code != 416:
459 # Unexpected HTTP error
460 raise
461 elif err.code == 416:
462 # Unable to resume (requested range not satisfiable)
463 try:
464 # Open the connection again without the range header
465 data = compat_urllib_request.urlopen(basic_request)
466 content_length = data.info()['Content-Length']
467 except (compat_urllib_error.HTTPError, ) as err:
468 if err.code < 500 or err.code >= 600:
469 raise
470 else:
471 # Examine the reported length
472 if (content_length is not None and
473 (resume_len - 100 < int(content_length) < resume_len + 100)):
474 # The file had already been fully downloaded.
475 # Explanation to the above condition: in issue #175 it was revealed that
476 # YouTube sometimes adds or removes a few bytes from the end of the file,
477 # changing the file size slightly and causing problems for some users. So
478 # I decided to implement a suggested change and consider the file
479 # completely downloaded if the file size differs less than 100 bytes from
480 # the one in the hard drive.
481 self.report_file_already_downloaded(filename)
482 self.try_rename(tmpfilename, filename)
bffbd5f0
PH
483 self._hook_progress({
484 'filename': filename,
485 'status': 'finished',
486 })
59ae15a5
PH
487 return True
488 else:
489 # The length does not match, we start the download over
490 self.report_unable_to_resume()
491 open_mode = 'wb'
492 break
493 # Retry
494 count += 1
495 if count <= retries:
496 self.report_retry(count, retries)
497
498 if count > retries:
6622d22c 499 self.report_error(u'giving up after %s retries' % retries)
59ae15a5
PH
500 return False
501
502 data_len = data.info().get('Content-length', None)
503 if data_len is not None:
504 data_len = int(data_len) + resume_len
9e982f9e
JC
505 min_data_len = self.params.get("min_filesize", None)
506 max_data_len = self.params.get("max_filesize", None)
507 if min_data_len is not None and data_len < min_data_len:
508 self.to_screen(u'\r[download] File is smaller than min-filesize (%s bytes < %s bytes). Aborting.' % (data_len, min_data_len))
509 return False
510 if max_data_len is not None and data_len > max_data_len:
511 self.to_screen(u'\r[download] File is larger than max-filesize (%s bytes > %s bytes). Aborting.' % (data_len, max_data_len))
512 return False
513
02dbf93f 514 data_len_str = format_bytes(data_len)
59ae15a5
PH
515 byte_counter = 0 + resume_len
516 block_size = self.params.get('buffersize', 1024)
517 start = time.time()
518 while True:
519 # Download and write
520 before = time.time()
521 data_block = data.read(block_size)
522 after = time.time()
523 if len(data_block) == 0:
524 break
525 byte_counter += len(data_block)
526
527 # Open file just in time
528 if stream is None:
529 try:
530 (stream, tmpfilename) = sanitize_open(tmpfilename, open_mode)
531 assert stream is not None
532 filename = self.undo_temp_name(tmpfilename)
533 self.report_destination(filename)
534 except (OSError, IOError) as err:
6622d22c 535 self.report_error(u'unable to open for writing: %s' % str(err))
59ae15a5
PH
536 return False
537 try:
538 stream.write(data_block)
539 except (IOError, OSError) as err:
6622d22c
JMF
540 self.to_stderr(u"\n")
541 self.report_error(u'unable to write data: %s' % str(err))
59ae15a5
PH
542 return False
543 if not self.params.get('noresizebuffer', False):
544 block_size = self.best_block_size(after - before, len(data_block))
545
546 # Progress message
4ae72004 547 speed = self.calc_speed(start, time.time(), byte_counter - resume_len)
59ae15a5 548 if data_len is None:
4ac5306a 549 eta = percent = None
59ae15a5 550 else:
4ae72004
JMF
551 percent = self.calc_percent(byte_counter, data_len)
552 eta = self.calc_eta(start, time.time(), data_len - resume_len, byte_counter - resume_len)
4ac5306a 553 self.report_progress(percent, data_len_str, speed, eta)
59ae15a5 554
bffbd5f0
PH
555 self._hook_progress({
556 'downloaded_bytes': byte_counter,
557 'total_bytes': data_len,
558 'tmpfilename': tmpfilename,
559 'filename': filename,
560 'status': 'downloading',
4ae72004
JMF
561 'eta': eta,
562 'speed': speed,
bffbd5f0
PH
563 })
564
59ae15a5
PH
565 # Apply rate limit
566 self.slow_down(start, byte_counter - resume_len)
567
568 if stream is None:
6622d22c
JMF
569 self.to_stderr(u"\n")
570 self.report_error(u'Did not get any data blocks')
59ae15a5
PH
571 return False
572 stream.close()
968b5e01 573 self.report_finish(data_len_str, (time.time() - start))
59ae15a5
PH
574 if data_len is not None and byte_counter != data_len:
575 raise ContentTooShortError(byte_counter, int(data_len))
576 self.try_rename(tmpfilename, filename)
577
578 # Update file modification time
579 if self.params.get('updatetime', True):
580 info_dict['filetime'] = self.try_utime(filename, data.info().get('last-modified', None))
581
bffbd5f0
PH
582 self._hook_progress({
583 'downloaded_bytes': byte_counter,
584 'total_bytes': byte_counter,
585 'filename': filename,
586 'status': 'finished',
587 })
588
59ae15a5 589 return True
bffbd5f0
PH
590
591 def _hook_progress(self, status):
592 for ph in self._progress_hooks:
593 ph(status)
594
595 def add_progress_hook(self, ph):
596 """ ph gets called on download progress, with a dictionary with the entries
597 * filename: The final filename
598 * status: One of "downloading" and "finished"
599
600 It can also have some of the following entries:
601
602 * downloaded_bytes: Bytes on disks
603 * total_bytes: Total bytes, None if unknown
604 * tmpfilename: The filename we're currently writing to
4ae72004
JMF
605 * eta: The estimated time in seconds, None if unknown
606 * speed: The download speed in bytes/second, None if unknown
bffbd5f0
PH
607
608 Hooks are guaranteed to be called at least once (with status "finished")
609 if the download is successful.
610 """
611 self._progress_hooks.append(ph)