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