arte.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. import re
  2. from .common import InfoExtractor
  3. from ..utils import (
  4. ExtractorError,
  5. GeoRestrictedError,
  6. int_or_none,
  7. join_nonempty,
  8. parse_iso8601,
  9. parse_qs,
  10. strip_or_none,
  11. traverse_obj,
  12. url_or_none,
  13. )
  14. class ArteTVBaseIE(InfoExtractor):
  15. _ARTE_LANGUAGES = 'fr|de|en|es|it|pl'
  16. _API_BASE = 'https://api.arte.tv/api/player/v2'
  17. class ArteTVIE(ArteTVBaseIE):
  18. _VALID_URL = rf'''(?x)
  19. (?:https?://
  20. (?:
  21. (?:www\.)?arte\.tv/(?P<lang>{ArteTVBaseIE._ARTE_LANGUAGES})/videos|
  22. api\.arte\.tv/api/player/v\d+/config/(?P<lang_2>{ArteTVBaseIE._ARTE_LANGUAGES})
  23. )
  24. |arte://program)
  25. /(?P<id>\d{{6}}-\d{{3}}-[AF]|LIVE)
  26. '''
  27. _TESTS = [{
  28. 'url': 'https://www.arte.tv/en/videos/088501-000-A/mexico-stealing-petrol-to-survive/',
  29. 'only_matching': True,
  30. }, {
  31. 'note': 'No alt_title',
  32. 'url': 'https://www.arte.tv/fr/videos/110371-000-A/la-chaleur-supplice-des-arbres-de-rue/',
  33. 'only_matching': True,
  34. }, {
  35. 'url': 'https://api.arte.tv/api/player/v2/config/de/100605-013-A',
  36. 'only_matching': True,
  37. }, {
  38. 'url': 'https://api.arte.tv/api/player/v2/config/de/LIVE',
  39. 'only_matching': True,
  40. }, {
  41. 'url': 'https://www.arte.tv/de/videos/110203-006-A/zaz/',
  42. 'only_matching': True,
  43. }, {
  44. 'url': 'https://www.arte.tv/fr/videos/109067-000-A/la-loi-de-teheran/',
  45. 'info_dict': {
  46. 'id': '109067-000-A',
  47. 'ext': 'mp4',
  48. 'description': 'md5:d2ca367b8ecee028dddaa8bd1aebc739',
  49. 'timestamp': 1713927600,
  50. 'thumbnail': 'https://api-cdn.arte.tv/img/v2/image/3rR6PLzfbigSkkeHtkCZNF/940x530',
  51. 'duration': 7599,
  52. 'title': 'La loi de Téhéran',
  53. 'upload_date': '20240424',
  54. 'subtitles': {
  55. 'fr': 'mincount:1',
  56. 'fr-acc': 'mincount:1',
  57. 'fr-forced': 'mincount:1',
  58. },
  59. },
  60. }, {
  61. 'note': 'age-restricted',
  62. 'url': 'https://www.arte.tv/de/videos/006785-000-A/the-element-of-crime/',
  63. 'info_dict': {
  64. 'id': '006785-000-A',
  65. 'description': 'md5:c2f94fdfefc8a280e4dab68ab96ab0ba',
  66. 'title': 'The Element of Crime',
  67. 'timestamp': 1696111200,
  68. 'duration': 5849,
  69. 'thumbnail': 'https://api-cdn.arte.tv/img/v2/image/q82dTTfyuCXupPsGxXsd7B/940x530',
  70. 'upload_date': '20230930',
  71. 'ext': 'mp4',
  72. },
  73. 'skip': '404 Not Found',
  74. }]
  75. _GEO_BYPASS = True
  76. _LANG_MAP = { # ISO639 -> French abbreviations
  77. 'fr': 'F',
  78. 'de': 'A',
  79. 'en': 'E[ANG]',
  80. 'es': 'E[ESP]',
  81. 'it': 'E[ITA]',
  82. 'pl': 'E[POL]',
  83. # XXX: probably means mixed; <https://www.arte.tv/en/videos/107710-029-A/dispatches-from-ukraine-local-journalists-report/>
  84. # uses this code for audio that happens to be in Ukrainian, but the manifest uses the ISO code 'mul' (mixed)
  85. 'mul': 'EU',
  86. }
  87. _VERSION_CODE_RE = re.compile(r'''(?x)
  88. V
  89. (?P<original_voice>O?)
  90. (?P<vlang>[FA]|E\[[A-Z]+\]|EU)?
  91. (?P<audio_desc>AUD|)
  92. (?:
  93. (?P<has_sub>-ST)
  94. (?P<sdh_sub>M?)
  95. (?P<sub_lang>[FA]|E\[[A-Z]+\]|EU)
  96. )?
  97. ''')
  98. # all obtained by exhaustive testing
  99. _COUNTRIES_MAP = {
  100. 'DE_FR': (
  101. 'BL', 'DE', 'FR', 'GF', 'GP', 'MF', 'MQ', 'NC',
  102. 'PF', 'PM', 'RE', 'WF', 'YT',
  103. ),
  104. # with both of the below 'BE' sometimes works, sometimes doesn't
  105. 'EUR_DE_FR': (
  106. 'AT', 'BL', 'CH', 'DE', 'FR', 'GF', 'GP', 'LI',
  107. 'MC', 'MF', 'MQ', 'NC', 'PF', 'PM', 'RE', 'WF',
  108. 'YT',
  109. ),
  110. 'SAT': (
  111. 'AD', 'AT', 'AX', 'BG', 'BL', 'CH', 'CY', 'CZ',
  112. 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GB', 'GF',
  113. 'GR', 'HR', 'HU', 'IE', 'IS', 'IT', 'KN', 'LI',
  114. 'LT', 'LU', 'LV', 'MC', 'MF', 'MQ', 'MT', 'NC',
  115. 'NL', 'NO', 'PF', 'PL', 'PM', 'PT', 'RE', 'RO',
  116. 'SE', 'SI', 'SK', 'SM', 'VA', 'WF', 'YT',
  117. ),
  118. }
  119. @staticmethod
  120. def _fix_accessible_subs_locale(subs):
  121. updated_subs = {}
  122. for lang, sub_formats in subs.items():
  123. for fmt in sub_formats:
  124. url = fmt.get('url') or ''
  125. suffix = ('acc' if url.endswith('-MAL.m3u8')
  126. else 'forced' if '_VO' not in url
  127. else None)
  128. updated_subs.setdefault(join_nonempty(lang, suffix), []).append(fmt)
  129. return updated_subs
  130. def _real_extract(self, url):
  131. mobj = self._match_valid_url(url)
  132. video_id = mobj.group('id')
  133. lang = mobj.group('lang') or mobj.group('lang_2')
  134. language_code = self._LANG_MAP.get(lang)
  135. config = self._download_json(f'{self._API_BASE}/config/{lang}/{video_id}', video_id, headers={
  136. 'x-validated-age': '18',
  137. })
  138. geoblocking = traverse_obj(config, ('data', 'attributes', 'restriction', 'geoblocking')) or {}
  139. if geoblocking.get('restrictedArea'):
  140. raise GeoRestrictedError(f'Video restricted to {geoblocking["code"]!r}',
  141. countries=self._COUNTRIES_MAP.get(geoblocking['code'], ('DE', 'FR')))
  142. if not traverse_obj(config, ('data', 'attributes', 'rights')):
  143. # Eg: https://www.arte.tv/de/videos/097407-215-A/28-minuten
  144. # Eg: https://www.arte.tv/es/videos/104351-002-A/serviteur-du-peuple-1-23
  145. raise ExtractorError(
  146. 'Video is not available in this language edition of Arte or broadcast rights expired', expected=True)
  147. formats, subtitles = [], {}
  148. secondary_formats = []
  149. for stream in config['data']['attributes']['streams']:
  150. # official player contains code like `e.get("versions")[0].eStat.ml5`
  151. stream_version = stream['versions'][0]
  152. stream_version_code = stream_version['eStat']['ml5']
  153. lang_pref = -1
  154. m = self._VERSION_CODE_RE.match(stream_version_code)
  155. if m:
  156. lang_pref = int(''.join('01'[x] for x in (
  157. m.group('vlang') == language_code, # we prefer voice in the requested language
  158. not m.group('audio_desc'), # and not the audio description version
  159. bool(m.group('original_voice')), # but if voice is not in the requested language, at least choose the original voice
  160. m.group('sub_lang') == language_code, # if subtitles are present, we prefer them in the requested language
  161. not m.group('has_sub'), # but we prefer no subtitles otherwise
  162. not m.group('sdh_sub'), # and we prefer not the hard-of-hearing subtitles if there are subtitles
  163. )))
  164. short_label = traverse_obj(stream_version, 'shortLabel', expected_type=str, default='?')
  165. if 'HLS' in stream['protocol']:
  166. fmts, subs = self._extract_m3u8_formats_and_subtitles(
  167. stream['url'], video_id=video_id, ext='mp4', m3u8_id=stream_version_code, fatal=False)
  168. for fmt in fmts:
  169. fmt.update({
  170. 'format_note': f'{stream_version.get("label", "unknown")} [{short_label}]',
  171. 'language_preference': lang_pref,
  172. })
  173. if any(map(short_label.startswith, ('cc', 'OGsub'))):
  174. secondary_formats.extend(fmts)
  175. else:
  176. formats.extend(fmts)
  177. subs = self._fix_accessible_subs_locale(subs)
  178. self._merge_subtitles(subs, target=subtitles)
  179. elif stream['protocol'] in ('HTTPS', 'RTMP'):
  180. formats.append({
  181. 'format_id': f'{stream["protocol"]}-{stream_version_code}',
  182. 'url': stream['url'],
  183. 'format_note': f'{stream_version.get("label", "unknown")} [{short_label}]',
  184. 'language_preference': lang_pref,
  185. # 'ext': 'mp4', # XXX: may or may not be necessary, at least for HTTPS
  186. })
  187. else:
  188. self.report_warning(f'Skipping stream with unknown protocol {stream["protocol"]}')
  189. formats.extend(secondary_formats)
  190. self._remove_duplicate_formats(formats)
  191. metadata = config['data']['attributes']['metadata']
  192. return {
  193. 'id': metadata['providerId'],
  194. 'webpage_url': traverse_obj(metadata, ('link', 'url')),
  195. 'title': traverse_obj(metadata, 'subtitle', 'title'),
  196. 'alt_title': metadata.get('subtitle') and metadata.get('title'),
  197. 'description': metadata.get('description'),
  198. 'duration': traverse_obj(metadata, ('duration', 'seconds')),
  199. 'language': metadata.get('language'),
  200. 'timestamp': traverse_obj(config, ('data', 'attributes', 'rights', 'begin'), expected_type=parse_iso8601),
  201. 'is_live': config['data']['attributes'].get('live', False),
  202. 'formats': formats,
  203. 'subtitles': subtitles,
  204. 'thumbnails': [
  205. {'url': image['url'], 'id': image.get('caption')}
  206. for image in metadata.get('images') or [] if url_or_none(image.get('url'))
  207. ],
  208. # TODO: chapters may also be in stream['segments']?
  209. 'chapters': traverse_obj(config, ('data', 'attributes', 'chapters', 'elements', ..., {
  210. 'start_time': 'startTime',
  211. 'title': 'title',
  212. })) or None,
  213. }
  214. class ArteTVEmbedIE(InfoExtractor):
  215. _VALID_URL = r'https?://(?:www\.)?arte\.tv/player/v\d+/index\.php\?.*?\bjson_url=.+'
  216. _EMBED_REGEX = [r'<(?:iframe|script)[^>]+src=(["\'])(?P<url>(?:https?:)?//(?:www\.)?arte\.tv/player/v\d+/index\.php\?.*?\bjson_url=.+?)\1']
  217. _TESTS = [{
  218. 'url': 'https://www.arte.tv/player/v5/index.php?json_url=https%3A%2F%2Fapi.arte.tv%2Fapi%2Fplayer%2Fv2%2Fconfig%2Fde%2F100605-013-A&lang=de&autoplay=true&mute=0100605-013-A',
  219. 'info_dict': {
  220. 'id': '100605-013-A',
  221. 'ext': 'mp4',
  222. 'title': 'United we Stream November Lockdown Edition #13',
  223. 'description': 'md5:be40b667f45189632b78c1425c7c2ce1',
  224. 'upload_date': '20201116',
  225. },
  226. 'skip': 'No video available',
  227. }, {
  228. 'url': 'https://www.arte.tv/player/v3/index.php?json_url=https://api.arte.tv/api/player/v2/config/de/100605-013-A',
  229. 'only_matching': True,
  230. }]
  231. def _real_extract(self, url):
  232. qs = parse_qs(url)
  233. json_url = qs['json_url'][0]
  234. video_id = ArteTVIE._match_id(json_url)
  235. return self.url_result(
  236. json_url, ie=ArteTVIE.ie_key(), video_id=video_id)
  237. class ArteTVPlaylistIE(ArteTVBaseIE):
  238. _VALID_URL = rf'https?://(?:www\.)?arte\.tv/(?P<lang>{ArteTVBaseIE._ARTE_LANGUAGES})/videos/(?P<id>RC-\d{{6}})'
  239. _TESTS = [{
  240. 'url': 'https://www.arte.tv/en/videos/RC-016954/earn-a-living/',
  241. 'only_matching': True,
  242. }, {
  243. 'url': 'https://www.arte.tv/pl/videos/RC-014123/arte-reportage/',
  244. 'playlist_mincount': 100,
  245. 'info_dict': {
  246. 'description': 'md5:84e7bf1feda248bc325ebfac818c476e',
  247. 'id': 'RC-014123',
  248. 'title': 'ARTE Reportage - najlepsze reportaże',
  249. },
  250. }]
  251. def _real_extract(self, url):
  252. lang, playlist_id = self._match_valid_url(url).group('lang', 'id')
  253. playlist = self._download_json(
  254. f'{self._API_BASE}/playlist/{lang}/{playlist_id}', playlist_id)['data']['attributes']
  255. entries = [{
  256. '_type': 'url_transparent',
  257. 'url': video['config']['url'],
  258. 'ie_key': ArteTVIE.ie_key(),
  259. 'id': video.get('providerId'),
  260. 'title': video.get('title'),
  261. 'alt_title': video.get('subtitle'),
  262. 'thumbnail': url_or_none(traverse_obj(video, ('mainImage', 'url'))),
  263. 'duration': int_or_none(traverse_obj(video, ('duration', 'seconds'))),
  264. } for video in traverse_obj(playlist, ('items', lambda _, v: v['config']['url']))]
  265. return self.playlist_result(entries, playlist_id,
  266. traverse_obj(playlist, ('metadata', 'title')),
  267. traverse_obj(playlist, ('metadata', 'description')))
  268. class ArteTVCategoryIE(ArteTVBaseIE):
  269. _VALID_URL = rf'https?://(?:www\.)?arte\.tv/(?P<lang>{ArteTVBaseIE._ARTE_LANGUAGES})/videos/(?P<id>[\w-]+(?:/[\w-]+)*)/?\s*$'
  270. _TESTS = [{
  271. 'url': 'https://www.arte.tv/en/videos/politics-and-society/',
  272. 'info_dict': {
  273. 'id': 'politics-and-society',
  274. 'title': 'Politics and society',
  275. 'description': 'Investigative documentary series, geopolitical analysis, and international commentary',
  276. },
  277. 'playlist_mincount': 13,
  278. }]
  279. @classmethod
  280. def suitable(cls, url):
  281. return (
  282. not any(ie.suitable(url) for ie in (ArteTVIE, ArteTVPlaylistIE))
  283. and super().suitable(url))
  284. def _real_extract(self, url):
  285. lang, playlist_id = self._match_valid_url(url).groups()
  286. webpage = self._download_webpage(url, playlist_id)
  287. items = []
  288. for video in re.finditer(
  289. rf'<a\b[^>]*?href\s*=\s*(?P<q>"|\'|\b)(?P<url>https?://www\.arte\.tv/{lang}/videos/[\w/-]+)(?P=q)',
  290. webpage):
  291. video = video.group('url')
  292. if video == url:
  293. continue
  294. if any(ie.suitable(video) for ie in (ArteTVIE, ArteTVPlaylistIE)):
  295. items.append(video)
  296. title = strip_or_none(self._generic_title('', webpage, default='').rsplit('|', 1)[0]) or None
  297. return self.playlist_from_matches(items, playlist_id=playlist_id, playlist_title=title,
  298. description=self._og_search_description(webpage, default=None))