logo

oasis-root

Compiled tree of Oasis Linux based on own branch at <https://hacktivis.me/git/oasis/> git clone https://anongit.hacktivis.me/git/oasis-root.git

adn.py (13983B)


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