cda.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import base64
  2. import codecs
  3. import datetime as dt
  4. import hashlib
  5. import hmac
  6. import json
  7. import random
  8. import re
  9. import urllib.parse
  10. from .common import InfoExtractor
  11. from ..compat import compat_ord
  12. from ..utils import (
  13. ExtractorError,
  14. float_or_none,
  15. int_or_none,
  16. merge_dicts,
  17. multipart_encode,
  18. parse_duration,
  19. traverse_obj,
  20. try_call,
  21. try_get,
  22. urljoin,
  23. )
  24. class CDAIE(InfoExtractor):
  25. _VALID_URL = r'https?://(?:(?:www\.)?cda\.pl/video|ebd\.cda\.pl/[0-9]+x[0-9]+)/(?P<id>[0-9a-z]+)'
  26. _NETRC_MACHINE = 'cdapl'
  27. _BASE_URL = 'https://www.cda.pl'
  28. _BASE_API_URL = 'https://api.cda.pl'
  29. _API_HEADERS = {
  30. 'Accept': 'application/vnd.cda.public+json',
  31. }
  32. # hardcoded in the app
  33. _LOGIN_REQUEST_AUTH = 'Basic YzU3YzBlZDUtYTIzOC00MWQwLWI2NjQtNmZmMWMxY2Y2YzVlOklBTm95QlhRRVR6U09MV1hnV3MwMW0xT2VyNWJNZzV4clRNTXhpNGZJUGVGZ0lWUlo5UGVYTDhtUGZaR1U1U3Q'
  34. _BEARER_CACHE = 'cda-bearer'
  35. _TESTS = [{
  36. 'url': 'http://www.cda.pl/video/5749950c',
  37. 'md5': '6f844bf51b15f31fae165365707ae970',
  38. 'info_dict': {
  39. 'id': '5749950c',
  40. 'ext': 'mp4',
  41. 'height': 720,
  42. 'title': 'Oto dlaczego przed zakrętem należy zwolnić.',
  43. 'description': 'md5:269ccd135d550da90d1662651fcb9772',
  44. 'thumbnail': r're:^https?://.*\.jpg$',
  45. 'average_rating': float,
  46. 'duration': 39,
  47. 'age_limit': 0,
  48. 'upload_date': '20160221',
  49. 'timestamp': 1456078244,
  50. },
  51. }, {
  52. 'url': 'http://www.cda.pl/video/57413289',
  53. 'md5': 'a88828770a8310fc00be6c95faf7f4d5',
  54. 'info_dict': {
  55. 'id': '57413289',
  56. 'ext': 'mp4',
  57. 'title': 'Lądowanie na lotnisku na Maderze',
  58. 'description': 'md5:60d76b71186dcce4e0ba6d4bbdb13e1a',
  59. 'thumbnail': r're:^https?://.*\.jpg$',
  60. 'uploader': 'crash404',
  61. 'average_rating': float,
  62. 'duration': 137,
  63. 'age_limit': 0,
  64. 'upload_date': '20160220',
  65. 'timestamp': 1455968218,
  66. },
  67. }, {
  68. # Age-restricted with vfilm redirection
  69. 'url': 'https://www.cda.pl/video/8753244c4',
  70. 'md5': 'd8eeb83d63611289507010d3df3bb8b3',
  71. 'info_dict': {
  72. 'id': '8753244c4',
  73. 'ext': 'mp4',
  74. 'title': '[18+] Bez Filtra: Rezerwowe Psy czyli... najwulgarniejsza polska gra?',
  75. 'description': 'md5:ae80bac31bd6a9f077a6cce03c7c077e',
  76. 'height': 1080,
  77. 'uploader': 'arhn eu',
  78. 'thumbnail': r're:^https?://.*\.jpg$',
  79. 'duration': 991,
  80. 'age_limit': 18,
  81. 'average_rating': float,
  82. 'timestamp': 1633888264,
  83. 'upload_date': '20211010',
  84. },
  85. }, {
  86. # Age-restricted without vfilm redirection
  87. 'url': 'https://www.cda.pl/video/17028157b8',
  88. 'md5': 'c1fe5ff4582bace95d4f0ce0fbd0f992',
  89. 'info_dict': {
  90. 'id': '17028157b8',
  91. 'ext': 'mp4',
  92. 'title': 'STENDUPY MICHAŁ OGIŃSKI',
  93. 'description': 'md5:5851f3272bfc31f762d616040a1d609a',
  94. 'height': 480,
  95. 'uploader': 'oginski',
  96. 'thumbnail': r're:^https?://.*\.jpg$',
  97. 'duration': 18855,
  98. 'age_limit': 18,
  99. 'average_rating': float,
  100. 'timestamp': 1699705901,
  101. 'upload_date': '20231111',
  102. },
  103. }, {
  104. 'url': 'http://ebd.cda.pl/0x0/5749950c',
  105. 'only_matching': True,
  106. }]
  107. def _download_age_confirm_page(self, url, video_id, *args, **kwargs):
  108. data, content_type = multipart_encode({'age_confirm': ''})
  109. return self._download_webpage(
  110. url, video_id, *args,
  111. data=data, headers={
  112. 'Referer': url,
  113. 'Content-Type': content_type,
  114. }, **kwargs)
  115. def _perform_login(self, username, password):
  116. app_version = random.choice((
  117. '1.2.88 build 15306',
  118. '1.2.174 build 18469',
  119. ))
  120. android_version = random.randrange(8, 14)
  121. phone_model = random.choice((
  122. # x-kom.pl top selling Android smartphones, as of 2022-12-26
  123. # https://www.x-kom.pl/g-4/c/1590-smartfony-i-telefony.html?f201-system-operacyjny=61322-android
  124. 'ASUS ZenFone 8',
  125. 'Motorola edge 20 5G',
  126. 'Motorola edge 30 neo 5G',
  127. 'Motorola moto g22',
  128. 'OnePlus Nord 2T 5G',
  129. 'Samsung Galaxy A32 SM‑A325F',
  130. 'Samsung Galaxy M13',
  131. 'Samsung Galaxy S20 FE 5G',
  132. 'Xiaomi 11T',
  133. 'Xiaomi POCO M4 Pro',
  134. 'Xiaomi Redmi 10',
  135. 'Xiaomi Redmi 10C',
  136. 'Xiaomi Redmi 9C NFC',
  137. 'Xiaomi Redmi Note 10 Pro',
  138. 'Xiaomi Redmi Note 11 Pro',
  139. 'Xiaomi Redmi Note 11',
  140. 'Xiaomi Redmi Note 11S 5G',
  141. 'Xiaomi Redmi Note 11S',
  142. 'realme 10',
  143. 'realme 9 Pro+',
  144. 'vivo Y33s',
  145. ))
  146. self._API_HEADERS['User-Agent'] = f'pl.cda 1.0 (version {app_version}; Android {android_version}; {phone_model})'
  147. cached_bearer = self.cache.load(self._BEARER_CACHE, username) or {}
  148. if cached_bearer.get('valid_until', 0) > dt.datetime.now().timestamp() + 5:
  149. self._API_HEADERS['Authorization'] = f'Bearer {cached_bearer["token"]}'
  150. return
  151. password_hash = base64.urlsafe_b64encode(hmac.new(
  152. b's01m1Oer5IANoyBXQETzSOLWXgWs01m1Oer5bMg5xrTMMxRZ9Pi4fIPeFgIVRZ9PeXL8mPfXQETZGUAN5StRZ9P',
  153. ''.join(f'{bytes((bt & 255, )).hex():0>2}'
  154. for bt in hashlib.md5(password.encode()).digest()).encode(),
  155. hashlib.sha256).digest()).decode().replace('=', '')
  156. token_res = self._download_json(
  157. f'{self._BASE_API_URL}/oauth/token', None, 'Logging in', data=b'',
  158. headers={**self._API_HEADERS, 'Authorization': self._LOGIN_REQUEST_AUTH},
  159. query={
  160. 'grant_type': 'password',
  161. 'login': username,
  162. 'password': password_hash,
  163. })
  164. self.cache.store(self._BEARER_CACHE, username, {
  165. 'token': token_res['access_token'],
  166. 'valid_until': token_res['expires_in'] + dt.datetime.now().timestamp(),
  167. })
  168. self._API_HEADERS['Authorization'] = f'Bearer {token_res["access_token"]}'
  169. def _real_extract(self, url):
  170. video_id = self._match_id(url)
  171. if 'Authorization' in self._API_HEADERS:
  172. return self._api_extract(video_id)
  173. else:
  174. return self._web_extract(video_id)
  175. def _api_extract(self, video_id):
  176. meta = self._download_json(
  177. f'{self._BASE_API_URL}/video/{video_id}', video_id, headers=self._API_HEADERS)['video']
  178. uploader = traverse_obj(meta, 'author', 'login')
  179. formats = [{
  180. 'url': quality['file'],
  181. 'format': quality.get('title'),
  182. 'resolution': quality.get('name'),
  183. 'height': try_call(lambda: int(quality['name'][:-1])),
  184. 'filesize': quality.get('length'),
  185. } for quality in meta['qualities'] if quality.get('file')]
  186. if meta.get('premium') and not meta.get('premium_free') and not formats:
  187. raise ExtractorError(
  188. 'Video requires CDA Premium - subscription needed', expected=True)
  189. return {
  190. 'id': video_id,
  191. 'title': meta.get('title'),
  192. 'description': meta.get('description'),
  193. 'uploader': None if uploader == 'anonim' else uploader,
  194. 'average_rating': float_or_none(meta.get('rating')),
  195. 'thumbnail': meta.get('thumb'),
  196. 'formats': formats,
  197. 'duration': meta.get('duration'),
  198. 'age_limit': 18 if meta.get('for_adults') else 0,
  199. 'view_count': meta.get('views'),
  200. }
  201. def _web_extract(self, video_id):
  202. self._set_cookie('cda.pl', 'cda.player', 'html5')
  203. webpage, urlh = self._download_webpage_handle(
  204. f'{self._BASE_URL}/video/{video_id}/vfilm', video_id)
  205. if 'Ten film jest dostępny dla użytkowników premium' in webpage:
  206. self.raise_login_required('This video is only available for premium users')
  207. if re.search(r'niedostępn[ey] w(?:&nbsp;|\s+)Twoim kraju\s*<', webpage):
  208. self.raise_geo_restricted()
  209. need_confirm_age = False
  210. if self._html_search_regex(r'(<button[^>]+name="[^"]*age_confirm[^"]*")',
  211. webpage, 'birthday validate form', default=None):
  212. webpage = self._download_age_confirm_page(
  213. urlh.url, video_id, note='Confirming age')
  214. need_confirm_age = True
  215. formats = []
  216. uploader = self._search_regex(r'''(?x)
  217. <(span|meta)[^>]+itemprop=(["\'])author\2[^>]*>
  218. (?:<\1[^>]*>[^<]*</\1>|(?!</\1>)(?:.|\n))*?
  219. <(span|meta)[^>]+itemprop=(["\'])name\4[^>]*>(?P<uploader>[^<]+)</\3>
  220. ''', webpage, 'uploader', default=None, group='uploader')
  221. average_rating = self._search_regex(
  222. (r'<(?:span|meta)[^>]+itemprop=(["\'])ratingValue\1[^>]*>(?P<rating_value>[0-9.]+)',
  223. r'<span[^>]+\bclass=["\']rating["\'][^>]*>(?P<rating_value>[0-9.]+)'), webpage, 'rating', fatal=False,
  224. group='rating_value')
  225. info_dict = {
  226. 'id': video_id,
  227. 'title': self._og_search_title(webpage),
  228. 'description': self._og_search_description(webpage),
  229. 'uploader': uploader,
  230. 'average_rating': float_or_none(average_rating),
  231. 'thumbnail': self._og_search_thumbnail(webpage),
  232. 'formats': formats,
  233. 'duration': None,
  234. 'age_limit': 18 if need_confirm_age else 0,
  235. }
  236. info = self._search_json_ld(webpage, video_id, default={})
  237. # Source: https://www.cda.pl/js/player.js?t=1606154898
  238. def decrypt_file(a):
  239. for p in ('_XDDD', '_CDA', '_ADC', '_CXD', '_QWE', '_Q5', '_IKSDE'):
  240. a = a.replace(p, '')
  241. a = urllib.parse.unquote(a)
  242. b = []
  243. for c in a:
  244. f = compat_ord(c)
  245. b.append(chr(33 + (f + 14) % 94) if 33 <= f <= 126 else chr(f))
  246. a = ''.join(b)
  247. a = a.replace('.cda.mp4', '')
  248. for p in ('.2cda.pl', '.3cda.pl'):
  249. a = a.replace(p, '.cda.pl')
  250. if '/upstream' in a:
  251. a = a.replace('/upstream', '.mp4/upstream')
  252. return 'https://' + a
  253. return 'https://' + a + '.mp4'
  254. def extract_format(page, version):
  255. json_str = self._html_search_regex(
  256. r'player_data=(\\?["\'])(?P<player_data>.+?)\1', page,
  257. f'{version} player_json', fatal=False, group='player_data')
  258. if not json_str:
  259. return
  260. player_data = self._parse_json(
  261. json_str, f'{version} player_data', fatal=False)
  262. if not player_data:
  263. return
  264. video = player_data.get('video')
  265. if not video or 'file' not in video:
  266. self.report_warning(f'Unable to extract {version} version information')
  267. return
  268. if video['file'].startswith('uggc'):
  269. video['file'] = codecs.decode(video['file'], 'rot_13')
  270. if video['file'].endswith('adc.mp4'):
  271. video['file'] = video['file'].replace('adc.mp4', '.mp4')
  272. elif not video['file'].startswith('http'):
  273. video['file'] = decrypt_file(video['file'])
  274. video_quality = video.get('quality')
  275. qualities = video.get('qualities', {})
  276. video_quality = next((k for k, v in qualities.items() if v == video_quality), video_quality)
  277. info_dict['formats'].append({
  278. 'url': video['file'],
  279. 'format_id': video_quality,
  280. 'height': int_or_none(video_quality[:-1]),
  281. })
  282. for quality, cda_quality in qualities.items():
  283. if quality == video_quality:
  284. continue
  285. data = {'jsonrpc': '2.0', 'method': 'videoGetLink', 'id': 2,
  286. 'params': [video_id, cda_quality, video.get('ts'), video.get('hash2'), {}]}
  287. data = json.dumps(data).encode()
  288. video_url = self._download_json(
  289. f'https://www.cda.pl/video/{video_id}', video_id, headers={
  290. 'Content-Type': 'application/json',
  291. 'X-Requested-With': 'XMLHttpRequest',
  292. }, data=data, note=f'Fetching {quality} url',
  293. errnote=f'Failed to fetch {quality} url', fatal=False)
  294. if try_get(video_url, lambda x: x['result']['status']) == 'ok':
  295. video_url = try_get(video_url, lambda x: x['result']['resp'])
  296. info_dict['formats'].append({
  297. 'url': video_url,
  298. 'format_id': quality,
  299. 'height': int_or_none(quality[:-1]),
  300. })
  301. if not info_dict['duration']:
  302. info_dict['duration'] = parse_duration(video.get('duration'))
  303. extract_format(webpage, 'default')
  304. for href, resolution in re.findall(
  305. r'<a[^>]+data-quality="[^"]+"[^>]+href="([^"]+)"[^>]+class="quality-btn"[^>]*>([0-9]+p)',
  306. webpage):
  307. if need_confirm_age:
  308. handler = self._download_age_confirm_page
  309. else:
  310. handler = self._download_webpage
  311. webpage = handler(
  312. urljoin(self._BASE_URL, href), video_id,
  313. f'Downloading {resolution} version information', fatal=False)
  314. if not webpage:
  315. # Manually report warning because empty page is returned when
  316. # invalid version is requested.
  317. self.report_warning(f'Unable to download {resolution} version information')
  318. continue
  319. extract_format(webpage, resolution)
  320. return merge_dicts(info_dict, info)