playsuisse.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import json
  2. from .common import InfoExtractor
  3. from ..utils import (
  4. ExtractorError,
  5. int_or_none,
  6. parse_qs,
  7. traverse_obj,
  8. update_url_query,
  9. urlencode_postdata,
  10. )
  11. class PlaySuisseIE(InfoExtractor):
  12. _NETRC_MACHINE = 'playsuisse'
  13. _VALID_URL = r'https?://(?:www\.)?playsuisse\.ch/(?:watch|detail)/(?:[^#]*[?&]episodeId=)?(?P<id>[0-9]+)'
  14. _TESTS = [
  15. {
  16. # Old URL
  17. 'url': 'https://www.playsuisse.ch/watch/763211/0',
  18. 'only_matching': True,
  19. },
  20. {
  21. # episode in a series
  22. 'url': 'https://www.playsuisse.ch/watch/763182?episodeId=763211',
  23. 'md5': '82df2a470b2dfa60c2d33772a8a60cf8',
  24. 'info_dict': {
  25. 'id': '763211',
  26. 'ext': 'mp4',
  27. 'title': 'Knochen',
  28. 'description': 'md5:8ea7a8076ba000cd9e8bc132fd0afdd8',
  29. 'duration': 3344,
  30. 'series': 'Wilder',
  31. 'season': 'Season 1',
  32. 'season_number': 1,
  33. 'episode': 'Knochen',
  34. 'episode_number': 1,
  35. 'thumbnail': 're:https://playsuisse-img.akamaized.net/',
  36. },
  37. }, {
  38. # film
  39. 'url': 'https://www.playsuisse.ch/watch/808675',
  40. 'md5': '818b94c1d2d7c4beef953f12cb8f3e75',
  41. 'info_dict': {
  42. 'id': '808675',
  43. 'ext': 'mp4',
  44. 'title': 'Der Läufer',
  45. 'description': 'md5:9f61265c7e6dcc3e046137a792b275fd',
  46. 'duration': 5280,
  47. 'thumbnail': 're:https://playsuisse-img.akamaized.net/',
  48. },
  49. }, {
  50. # series (treated as a playlist)
  51. 'url': 'https://www.playsuisse.ch/detail/1115687',
  52. 'info_dict': {
  53. 'description': 'md5:e4a2ae29a8895823045b5c3145a02aa3',
  54. 'id': '1115687',
  55. 'series': 'They all came out to Montreux',
  56. 'title': 'They all came out to Montreux',
  57. },
  58. 'playlist': [{
  59. 'info_dict': {
  60. 'description': 'md5:f2462744834b959a31adc6292380cda2',
  61. 'duration': 3180,
  62. 'episode': 'Folge 1',
  63. 'episode_number': 1,
  64. 'id': '1112663',
  65. 'season': 'Season 1',
  66. 'season_number': 1,
  67. 'series': 'They all came out to Montreux',
  68. 'thumbnail': 're:https://playsuisse-img.akamaized.net/',
  69. 'title': 'Folge 1',
  70. 'ext': 'mp4',
  71. },
  72. }, {
  73. 'info_dict': {
  74. 'description': 'md5:9dfd308699fe850d3bce12dc1bad9b27',
  75. 'duration': 2935,
  76. 'episode': 'Folge 2',
  77. 'episode_number': 2,
  78. 'id': '1112661',
  79. 'season': 'Season 1',
  80. 'season_number': 1,
  81. 'series': 'They all came out to Montreux',
  82. 'thumbnail': 're:https://playsuisse-img.akamaized.net/',
  83. 'title': 'Folge 2',
  84. 'ext': 'mp4',
  85. },
  86. }, {
  87. 'info_dict': {
  88. 'description': 'md5:14a93a3356b2492a8f786ab2227ef602',
  89. 'duration': 2994,
  90. 'episode': 'Folge 3',
  91. 'episode_number': 3,
  92. 'id': '1112664',
  93. 'season': 'Season 1',
  94. 'season_number': 1,
  95. 'series': 'They all came out to Montreux',
  96. 'thumbnail': 're:https://playsuisse-img.akamaized.net/',
  97. 'title': 'Folge 3',
  98. 'ext': 'mp4',
  99. },
  100. }],
  101. },
  102. ]
  103. _GRAPHQL_QUERY = '''
  104. query AssetWatch($assetId: ID!) {
  105. assetV2(id: $assetId) {
  106. ...Asset
  107. episodes {
  108. ...Asset
  109. }
  110. }
  111. }
  112. fragment Asset on AssetV2 {
  113. id
  114. name
  115. description
  116. duration
  117. episodeNumber
  118. seasonNumber
  119. seriesName
  120. medias {
  121. type
  122. url
  123. }
  124. thumbnail16x9 {
  125. ...ImageDetails
  126. }
  127. thumbnail2x3 {
  128. ...ImageDetails
  129. }
  130. thumbnail16x9WithTitle {
  131. ...ImageDetails
  132. }
  133. thumbnail2x3WithTitle {
  134. ...ImageDetails
  135. }
  136. }
  137. fragment ImageDetails on AssetImage {
  138. id
  139. url
  140. }'''
  141. _LOGIN_BASE_URL = 'https://login.srgssr.ch/srgssrlogin.onmicrosoft.com'
  142. _LOGIN_PATH = 'B2C_1A__SignInV2'
  143. _ID_TOKEN = None
  144. def _perform_login(self, username, password):
  145. login_page = self._download_webpage(
  146. 'https://www.playsuisse.ch/api/sso/login', None, note='Downloading login page',
  147. query={'x': 'x', 'locale': 'de', 'redirectUrl': 'https://www.playsuisse.ch/'})
  148. settings = self._search_json(r'var\s+SETTINGS\s*=', login_page, 'settings', None)
  149. csrf_token = settings['csrf']
  150. query = {'tx': settings['transId'], 'p': self._LOGIN_PATH}
  151. status = traverse_obj(self._download_json(
  152. f'{self._LOGIN_BASE_URL}/{self._LOGIN_PATH}/SelfAsserted', None, 'Logging in',
  153. query=query, headers={'X-CSRF-TOKEN': csrf_token}, data=urlencode_postdata({
  154. 'request_type': 'RESPONSE',
  155. 'signInName': username,
  156. 'password': password,
  157. }), expected_status=400), ('status', {int_or_none}))
  158. if status == 400:
  159. raise ExtractorError('Invalid username or password', expected=True)
  160. urlh = self._request_webpage(
  161. f'{self._LOGIN_BASE_URL}/{self._LOGIN_PATH}/api/CombinedSigninAndSignup/confirmed',
  162. None, 'Downloading ID token', query={
  163. 'rememberMe': 'false',
  164. 'csrf_token': csrf_token,
  165. **query,
  166. 'diags': '',
  167. })
  168. self._ID_TOKEN = traverse_obj(parse_qs(urlh.url), ('id_token', 0))
  169. if not self._ID_TOKEN:
  170. raise ExtractorError('Login failed')
  171. def _get_media_data(self, media_id):
  172. # NOTE In the web app, the "locale" header is used to switch between languages,
  173. # However this doesn't seem to take effect when passing the header here.
  174. response = self._download_json(
  175. 'https://www.playsuisse.ch/api/graphql',
  176. media_id, data=json.dumps({
  177. 'operationName': 'AssetWatch',
  178. 'query': self._GRAPHQL_QUERY,
  179. 'variables': {'assetId': media_id},
  180. }).encode(),
  181. headers={'Content-Type': 'application/json', 'locale': 'de'})
  182. return response['data']['assetV2']
  183. def _real_extract(self, url):
  184. if not self._ID_TOKEN:
  185. self.raise_login_required(method='password')
  186. media_id = self._match_id(url)
  187. media_data = self._get_media_data(media_id)
  188. info = self._extract_single(media_data)
  189. if media_data.get('episodes'):
  190. info.update({
  191. '_type': 'playlist',
  192. 'entries': map(self._extract_single, media_data['episodes']),
  193. })
  194. return info
  195. def _extract_single(self, media_data):
  196. thumbnails = traverse_obj(media_data, lambda k, _: k.startswith('thumbnail'))
  197. formats, subtitles = [], {}
  198. for media in traverse_obj(media_data, 'medias', default=[]):
  199. if not media.get('url') or media.get('type') != 'HLS':
  200. continue
  201. f, subs = self._extract_m3u8_formats_and_subtitles(
  202. update_url_query(media['url'], {'id_token': self._ID_TOKEN}),
  203. media_data['id'], 'mp4', m3u8_id='HLS', fatal=False)
  204. formats.extend(f)
  205. self._merge_subtitles(subs, target=subtitles)
  206. return {
  207. 'id': media_data['id'],
  208. 'title': media_data.get('name'),
  209. 'description': media_data.get('description'),
  210. 'thumbnails': thumbnails,
  211. 'duration': int_or_none(media_data.get('duration')),
  212. 'formats': formats,
  213. 'subtitles': subtitles,
  214. 'series': media_data.get('seriesName'),
  215. 'season_number': int_or_none(media_data.get('seasonNumber')),
  216. 'episode': media_data.get('name') if media_data.get('episodeNumber') else None,
  217. 'episode_number': int_or_none(media_data.get('episodeNumber')),
  218. }