apply ratelimit to f4m
[ytdl] / youtube_dl / downloader / f4m.py
1 from __future__ import unicode_literals
2
3 import base64
4 import io
5 import itertools
6 import os
7 import time
8 import xml.etree.ElementTree as etree
9
10 from .common import FileDownloader
11 from .http import HttpFD
12 from ..utils import (
13     struct_pack,
14     struct_unpack,
15     compat_urlparse,
16     format_bytes,
17     encodeFilename,
18     sanitize_open,
19     xpath_text,
20 )
21
22
23 class FlvReader(io.BytesIO):
24     """
25     Reader for Flv files
26     The file format is documented in https://www.adobe.com/devnet/f4v.html
27     """
28
29     # Utility functions for reading numbers and strings
30     def read_unsigned_long_long(self):
31         return struct_unpack('!Q', self.read(8))[0]
32
33     def read_unsigned_int(self):
34         return struct_unpack('!I', self.read(4))[0]
35
36     def read_unsigned_char(self):
37         return struct_unpack('!B', self.read(1))[0]
38
39     def read_string(self):
40         res = b''
41         while True:
42             char = self.read(1)
43             if char == b'\x00':
44                 break
45             res += char
46         return res
47
48     def read_box_info(self):
49         """
50         Read a box and return the info as a tuple: (box_size, box_type, box_data)
51         """
52         real_size = size = self.read_unsigned_int()
53         box_type = self.read(4)
54         header_end = 8
55         if size == 1:
56             real_size = self.read_unsigned_long_long()
57             header_end = 16
58         return real_size, box_type, self.read(real_size-header_end)
59
60     def read_asrt(self):
61         # version
62         self.read_unsigned_char()
63         # flags
64         self.read(3)
65         quality_entry_count = self.read_unsigned_char()
66         # QualityEntryCount
67         for i in range(quality_entry_count):
68             self.read_string()
69
70         segment_run_count = self.read_unsigned_int()
71         segments = []
72         for i in range(segment_run_count):
73             first_segment = self.read_unsigned_int()
74             fragments_per_segment = self.read_unsigned_int()
75             segments.append((first_segment, fragments_per_segment))
76
77         return {
78             'segment_run': segments,
79         }
80
81     def read_afrt(self):
82         # version
83         self.read_unsigned_char()
84         # flags
85         self.read(3)
86         # time scale
87         self.read_unsigned_int()
88
89         quality_entry_count = self.read_unsigned_char()
90         # QualitySegmentUrlModifiers
91         for i in range(quality_entry_count):
92             self.read_string()
93
94         fragments_count = self.read_unsigned_int()
95         fragments = []
96         for i in range(fragments_count):
97             first = self.read_unsigned_int()
98             first_ts = self.read_unsigned_long_long()
99             duration = self.read_unsigned_int()
100             if duration == 0:
101                 discontinuity_indicator = self.read_unsigned_char()
102             else:
103                 discontinuity_indicator = None
104             fragments.append({
105                 'first': first,
106                 'ts': first_ts,
107                 'duration': duration,
108                 'discontinuity_indicator': discontinuity_indicator,
109             })
110
111         return {
112             'fragments': fragments,
113         }
114
115     def read_abst(self):
116         # version
117         self.read_unsigned_char()
118         # flags
119         self.read(3)
120
121         self.read_unsigned_int()  # BootstrapinfoVersion
122         # Profile,Live,Update,Reserved
123         self.read(1)
124         # time scale
125         self.read_unsigned_int()
126         # CurrentMediaTime
127         self.read_unsigned_long_long()
128         # SmpteTimeCodeOffset
129         self.read_unsigned_long_long()
130
131         self.read_string()  # MovieIdentifier
132         server_count = self.read_unsigned_char()
133         # ServerEntryTable
134         for i in range(server_count):
135             self.read_string()
136         quality_count = self.read_unsigned_char()
137         # QualityEntryTable
138         for i in range(quality_count):
139             self.read_string()
140         # DrmData
141         self.read_string()
142         # MetaData
143         self.read_string()
144
145         segments_count = self.read_unsigned_char()
146         segments = []
147         for i in range(segments_count):
148             box_size, box_type, box_data = self.read_box_info()
149             assert box_type == b'asrt'
150             segment = FlvReader(box_data).read_asrt()
151             segments.append(segment)
152         fragments_run_count = self.read_unsigned_char()
153         fragments = []
154         for i in range(fragments_run_count):
155             box_size, box_type, box_data = self.read_box_info()
156             assert box_type == b'afrt'
157             fragments.append(FlvReader(box_data).read_afrt())
158
159         return {
160             'segments': segments,
161             'fragments': fragments,
162         }
163
164     def read_bootstrap_info(self):
165         total_size, box_type, box_data = self.read_box_info()
166         assert box_type == b'abst'
167         return FlvReader(box_data).read_abst()
168
169
170 def read_bootstrap_info(bootstrap_bytes):
171     return FlvReader(bootstrap_bytes).read_bootstrap_info()
172
173
174 def build_fragments_list(boot_info):
175     """ Return a list of (segment, fragment) for each fragment in the video """
176     res = []
177     segment_run_table = boot_info['segments'][0]
178     # I've only found videos with one segment
179     segment_run_entry = segment_run_table['segment_run'][0]
180     n_frags = segment_run_entry[1]
181     fragment_run_entry_table = boot_info['fragments'][0]['fragments']
182     first_frag_number = fragment_run_entry_table[0]['first']
183     for (i, frag_number) in zip(range(1, n_frags+1), itertools.count(first_frag_number)):
184         res.append((1, frag_number))
185     return res
186
187
188 def write_flv_header(stream, metadata):
189     """Writes the FLV header and the metadata to stream"""
190     # FLV header
191     stream.write(b'FLV\x01')
192     stream.write(b'\x05')
193     stream.write(b'\x00\x00\x00\x09')
194     # FLV File body
195     stream.write(b'\x00\x00\x00\x00')
196     # FLVTAG
197     # Script data
198     stream.write(b'\x12')
199     # Size of the metadata with 3 bytes
200     stream.write(struct_pack('!L', len(metadata))[1:])
201     stream.write(b'\x00\x00\x00\x00\x00\x00\x00')
202     stream.write(metadata)
203     # Magic numbers extracted from the output files produced by AdobeHDS.php
204     #(https://github.com/K-S-V/Scripts)
205     stream.write(b'\x00\x00\x01\x73')
206
207
208 def _add_ns(prop):
209     return '{http://ns.adobe.com/f4m/1.0}%s' % prop
210
211
212 class HttpQuietDownloader(HttpFD):
213     def to_screen(self, *args, **kargs):
214         pass
215
216
217 class F4mFD(FileDownloader):
218     """
219     A downloader for f4m manifests or AdobeHDS.
220     """
221
222     def real_download(self, filename, info_dict):
223         man_url = info_dict['url']
224         requested_bitrate = info_dict.get('tbr')
225         self.to_screen('[download] Downloading f4m manifest')
226         manifest = self.ydl.urlopen(man_url).read()
227         self.report_destination(filename)
228         http_dl = HttpQuietDownloader(self.ydl,
229             {
230                 'continuedl': True,
231                 'quiet': True,
232                 'noprogress': True,
233                 'ratelimit': self.params.get('ratelimit', None),
234                 'test': self.params.get('test', False),
235             })
236
237         doc = etree.fromstring(manifest)
238         formats = [(int(f.attrib.get('bitrate', -1)), f) for f in doc.findall(_add_ns('media'))]
239         if requested_bitrate is None:
240             # get the best format
241             formats = sorted(formats, key=lambda f: f[0])
242             rate, media = formats[-1]
243         else:
244             rate, media = list(filter(
245                 lambda f: int(f[0]) == requested_bitrate, formats))[0]
246
247         base_url = compat_urlparse.urljoin(man_url, media.attrib['url'])
248         bootstrap = base64.b64decode(doc.find(_add_ns('bootstrapInfo')).text)
249         metadata = base64.b64decode(media.find(_add_ns('metadata')).text)
250         boot_info = read_bootstrap_info(bootstrap)
251         fragments_list = build_fragments_list(boot_info)
252         if self.params.get('test', False):
253             # We only download the first fragment
254             fragments_list = fragments_list[:1]
255         total_frags = len(fragments_list)
256         # For some akamai manifests we'll need to add a query to the fragment url
257         akamai_pv = xpath_text(doc, _add_ns('pv-2.0'))
258
259         tmpfilename = self.temp_name(filename)
260         (dest_stream, tmpfilename) = sanitize_open(tmpfilename, 'wb')
261         write_flv_header(dest_stream, metadata)
262
263         # This dict stores the download progress, it's updated by the progress
264         # hook
265         state = {
266             'downloaded_bytes': 0,
267             'frag_counter': 0,
268         }
269         start = time.time()
270
271         def frag_progress_hook(status):
272             frag_total_bytes = status.get('total_bytes', 0)
273             estimated_size = (state['downloaded_bytes'] +
274                 (total_frags - state['frag_counter']) * frag_total_bytes)
275             if status['status'] == 'finished':
276                 state['downloaded_bytes'] += frag_total_bytes
277                 state['frag_counter'] += 1
278                 progress = self.calc_percent(state['frag_counter'], total_frags)
279                 byte_counter = state['downloaded_bytes']
280             else:
281                 frag_downloaded_bytes = status['downloaded_bytes']
282                 byte_counter = state['downloaded_bytes'] + frag_downloaded_bytes
283                 frag_progress = self.calc_percent(frag_downloaded_bytes,
284                     frag_total_bytes)
285                 progress = self.calc_percent(state['frag_counter'], total_frags)
286                 progress += frag_progress / float(total_frags)
287
288             eta = self.calc_eta(start, time.time(), estimated_size, byte_counter)
289             self.report_progress(progress, format_bytes(estimated_size),
290                 status.get('speed'), eta)
291         http_dl.add_progress_hook(frag_progress_hook)
292
293         frags_filenames = []
294         for (seg_i, frag_i) in fragments_list:
295             name = 'Seg%d-Frag%d' % (seg_i, frag_i)
296             url = base_url + name
297             if akamai_pv:
298                 url += '?' + akamai_pv.strip(';')
299             frag_filename = '%s-%s' % (tmpfilename, name)
300             success = http_dl.download(frag_filename, {'url': url})
301             if not success:
302                 return False
303             with open(frag_filename, 'rb') as down:
304                 down_data = down.read()
305                 reader = FlvReader(down_data)
306                 while True:
307                     _, box_type, box_data = reader.read_box_info()
308                     if box_type == b'mdat':
309                         dest_stream.write(box_data)
310                         break
311             frags_filenames.append(frag_filename)
312
313         dest_stream.close()
314         self.report_finish(format_bytes(state['downloaded_bytes']), time.time() - start)
315
316         self.try_rename(tmpfilename, filename)
317         for frag_file in frags_filenames:
318             os.remove(frag_file)
319
320         fsize = os.path.getsize(encodeFilename(filename))
321         self._hook_progress({
322             'downloaded_bytes': fsize,
323             'total_bytes': fsize,
324             'filename': filename,
325             'status': 'finished',
326         })
327
328         return True