1 # coding: utf-8
2 from __future__ import unicode_literals
3
4 import base64
5 import binascii
6 import json
7 import os
8 import random
9
10 from .common import InfoExtractor
11 from ..aes import aes_cbc_decrypt
12 from ..compat import (
13 compat_HTTPError,
14 compat_b64decode,
15 compat_ord,
16 )
17 from ..utils import (
18 bytes_to_intlist,
19 bytes_to_long,
20 ExtractorError,
21 float_or_none,
22 int_or_none,
23 intlist_to_bytes,
24 long_to_bytes,
25 pkcs1pad,
26 strip_or_none,
27 try_get,
28 unified_strdate,
29 urlencode_postdata,
30 )
31
32
33 class ADNIE(InfoExtractor):
34 IE_DESC = 'Animation Digital Network'
35 _VALID_URL = r'https?://(?:www\.)?(?:animation|anime)digitalnetwork\.fr/video/[^/]+/(?P<id>\d+)'
36 _TESTS = [{
37 'url': 'https://animationdigitalnetwork.fr/video/fruits-basket/9841-episode-1-a-ce-soir',
38 'md5': '1c9ef066ceb302c86f80c2b371615261',
39 'info_dict': {
40 'id': '9841',
41 'ext': 'mp4',
42 'title': 'Fruits Basket - Episode 1',
43 'description': 'md5:14be2f72c3c96809b0ca424b0097d336',
44 'series': 'Fruits Basket',
45 'duration': 1437,
46 'release_date': '20190405',
47 'comment_count': int,
48 'average_rating': float,
49 'season_number': 1,
50 'episode': 'À ce soir !',
51 'episode_number': 1,
52 },
53 'skip': 'Only available in region (FR, ...)',
54 }, {
55 'url': 'http://animedigitalnetwork.fr/video/blue-exorcist-kyoto-saga/7778-episode-1-debut-des-hostilites',
56 'only_matching': True,
57 }]
58
59 _NETRC_MACHINE = 'animationdigitalnetwork'
60 _BASE = 'animationdigitalnetwork.fr'
61 _API_BASE_URL = 'https://gw.api.' + _BASE + '/'
62 _PLAYER_BASE_URL = _API_BASE_URL + 'player/'
63 _HEADERS = {}
64 _LOGIN_ERR_MESSAGE = 'Unable to log in'
65 _RSA_KEY = (0x9B42B08905199A5CCE2026274399CA560ECB209EE9878A708B1C0812E1BB8CB5D1FB7441861147C1A1F2F3A0476DD63A9CAC20D3E983613346850AA6CB38F16DC7D720FD7D86FC6E5B3D5BBC72E14CD0BF9E869F2CEA2CCAD648F1DCE38F1FF916CEFB2D339B64AA0264372344BC775E265E8A852F88144AB0BD9AA06C1A4ABB, 65537)
66 _POS_ALIGN_MAP = {
67 'start': 1,
68 'end': 3,
69 }
70 _LINE_ALIGN_MAP = {
71 'middle': 8,
72 'end': 4,
73 }
74
75 @staticmethod
76 def _ass_subtitles_timecode(seconds):
77 return '%01d:%02d:%02d.%02d' % (seconds / 3600, (seconds % 3600) / 60, seconds % 60, (seconds % 1) * 100)
78
79 def _get_subtitles(self, sub_url, video_id):
80 if not sub_url:
81 return None
82
83 enc_subtitles = self._download_webpage(
84 sub_url, video_id, 'Downloading subtitles location', fatal=False) or '{}'
85 subtitle_location = (self._parse_json(enc_subtitles, video_id, fatal=False) or {}).get('location')
86 if subtitle_location:
87 enc_subtitles = self._download_webpage(
88 subtitle_location, video_id, 'Downloading subtitles data',
89 fatal=False, headers={'Origin': 'https://' + self._BASE})
90 if not enc_subtitles:
91 return None
92
93 # http://animationdigitalnetwork.fr/components/com_vodvideo/videojs/adn-vjs.min.js
94 dec_subtitles = intlist_to_bytes(aes_cbc_decrypt(
95 bytes_to_intlist(compat_b64decode(enc_subtitles[24:])),
96 bytes_to_intlist(binascii.unhexlify(self._K + '7fac1178830cfe0c')),
97 bytes_to_intlist(compat_b64decode(enc_subtitles[:24]))
98 ))
99 subtitles_json = self._parse_json(
100 dec_subtitles[:-compat_ord(dec_subtitles[-1])].decode(),
101 None, fatal=False)
102 if not subtitles_json:
103 return None
104
105 subtitles = {}
106 for sub_lang, sub in subtitles_json.items():
107 ssa = '''[Script Info]
108 ScriptType:V4.00
109 [V4 Styles]
110 Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,TertiaryColour,BackColour,Bold,Italic,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,AlphaLevel,Encoding
111 Style: Default,Arial,18,16777215,16777215,16777215,0,-1,0,1,1,0,2,20,20,20,0,0
112 [Events]
113 Format: Marked,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text'''
114 for current in sub:
115 start, end, text, line_align, position_align = (
116 float_or_none(current.get('startTime')),
117 float_or_none(current.get('endTime')),
118 current.get('text'), current.get('lineAlign'),
119 current.get('positionAlign'))
120 if start is None or end is None or text is None:
121 continue
122 alignment = self._POS_ALIGN_MAP.get(position_align, 2) + self._LINE_ALIGN_MAP.get(line_align, 0)
123 ssa += os.linesep + 'Dialogue: Marked=0,%s,%s,Default,,0,0,0,,%s%s' % (
124 self._ass_subtitles_timecode(start),
125 self._ass_subtitles_timecode(end),
126 '{\\a%d}' % alignment if alignment != 2 else '',
127 text.replace('\n', '\\N').replace('<i>', '{\\i1}').replace('</i>', '{\\i0}'))
128
129 if sub_lang == 'vostf':
130 sub_lang = 'fr'
131 subtitles.setdefault(sub_lang, []).extend([{
132 'ext': 'json',
133 'data': json.dumps(sub),
134 }, {
135 'ext': 'ssa',
136 'data': ssa,
137 }])
138 return subtitles
139
140 def _real_initialize(self):
141 username, password = self._get_login_info()
142 if not username:
143 return
144 try:
145 url = self._API_BASE_URL + 'authentication/login'
146 access_token = (self._download_json(
147 url, None, 'Logging in', self._LOGIN_ERR_MESSAGE, fatal=False,
148 data=urlencode_postdata({
149 'password': password,
150 'rememberMe': False,
151 'source': 'Web',
152 'username': username,
153 })) or {}).get('accessToken')
154 if access_token:
155 self._HEADERS = {'authorization': 'Bearer ' + access_token}
156 except ExtractorError as e:
157 message = None
158 if isinstance(e.cause, compat_HTTPError) and e.cause.code == 401:
159 resp = self._parse_json(
160 self._webpage_read_content(e.cause, url, username),
161 username, fatal=False) or {}
162 message = resp.get('message') or resp.get('code')
163 self.report_warning(message or self._LOGIN_ERR_MESSAGE)
164
165 def _real_extract(self, url):
166 video_id = self._match_id(url)
167 video_base_url = self._PLAYER_BASE_URL + 'video/%s/' % video_id
168 player = self._download_json(
169 video_base_url + 'configuration', video_id,
170 'Downloading player config JSON metadata',
171 headers=self._HEADERS)['player']
172 options = player['options']
173
174 user = options['user']
175 if not user.get('hasAccess'):
176 self.raise_login_required()
177
178 token = self._download_json(
179 user.get('refreshTokenUrl') or (self._PLAYER_BASE_URL + 'refresh/token'),
180 video_id, 'Downloading access token', headers={
181 'x-player-refresh-token': user['refreshToken']
182 }, data=b'')['token']
183
184 links_url = try_get(options, lambda x: x['video']['url']) or (video_base_url + 'link')
185 self._K = ''.join([random.choice('0123456789abcdef') for _ in range(16)])
186 message = bytes_to_intlist(json.dumps({
187 'k': self._K,
188 't': token,
189 }))
190
191 # Sometimes authentication fails for no good reason, retry with
192 # a different random padding
193 links_data = None
194 for _ in range(3):
195 padded_message = intlist_to_bytes(pkcs1pad(message, 128))
196 n, e = self._RSA_KEY
197 encrypted_message = long_to_bytes(pow(bytes_to_long(padded_message), e, n))
198 authorization = base64.b64encode(encrypted_message).decode()
199
200 try:
201 links_data = self._download_json(
202 links_url, video_id, 'Downloading links JSON metadata', headers={
203 'X-Player-Token': authorization
204 }, query={
205 'freeWithAds': 'true',
206 'adaptive': 'false',
207 'withMetadata': 'true',
208 'source': 'Web'
209 })
210 break
211 except ExtractorError as e:
212 if not isinstance(e.cause, compat_HTTPError):
213 raise e
214
215 if e.cause.code == 401:
216 # This usually goes away with a different random pkcs1pad, so retry
217 continue
218
219 error = self._parse_json(
220 self._webpage_read_content(e.cause, links_url, video_id),
221 video_id, fatal=False) or {}
222 message = error.get('message')
223 if e.cause.code == 403 and error.get('code') == 'player-bad-geolocation-country':
224 self.raise_geo_restricted(msg=message)
225 raise ExtractorError(message)
226 else:
227 raise ExtractorError('Giving up retrying')
228
229 links = links_data.get('links') or {}
230 metas = links_data.get('metadata') or {}
231 sub_url = (links.get('subtitles') or {}).get('all')
232 video_info = links_data.get('video') or {}
233 title = metas['title']
234
235 formats = []
236 for format_id, qualities in (links.get('streaming') or {}).items():
237 if not isinstance(qualities, dict):
238 continue
239 for quality, load_balancer_url in qualities.items():
240 load_balancer_data = self._download_json(
241 load_balancer_url, video_id,
242 'Downloading %s %s JSON metadata' % (format_id, quality),
243 fatal=False) or {}
244 m3u8_url = load_balancer_data.get('location')
245 if not m3u8_url:
246 continue
247 m3u8_formats = self._extract_m3u8_formats(
248 m3u8_url, video_id, 'mp4', 'm3u8_native',
249 m3u8_id=format_id, fatal=False)
250 if format_id == 'vf':
251 for f in m3u8_formats:
252 f['language'] = 'fr'
253 formats.extend(m3u8_formats)
254 self._sort_formats(formats)
255
256 video = (self._download_json(
257 self._API_BASE_URL + 'video/%s' % video_id, video_id,
258 'Downloading additional video metadata', fatal=False) or {}).get('video') or {}
259 show = video.get('show') or {}
260
261 return {
262 'id': video_id,
263 'title': title,
264 'description': strip_or_none(metas.get('summary') or video.get('summary')),
265 'thumbnail': video_info.get('image') or player.get('image'),
266 'formats': formats,
267 'subtitles': self.extract_subtitles(sub_url, video_id),
268 'episode': metas.get('subtitle') or video.get('name'),
269 'episode_number': int_or_none(video.get('shortNumber')),
270 'series': show.get('title'),
271 'season_number': int_or_none(video.get('season')),
272 'duration': int_or_none(video_info.get('duration') or video.get('duration')),
273 'release_date': unified_strdate(video.get('releaseDate')),
274 'average_rating': float_or_none(video.get('rating') or metas.get('rating')),
275 'comment_count': int_or_none(video.get('commentsCount')),
276 }
|