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

panopto.py (25764B)


  1. import calendar
  2. import datetime as dt
  3. import functools
  4. import json
  5. import random
  6. import urllib.parse
  7. from .common import InfoExtractor
  8. from ..utils import (
  9. ExtractorError,
  10. OnDemandPagedList,
  11. bug_reports_message,
  12. get_first,
  13. int_or_none,
  14. parse_qs,
  15. srt_subtitles_timecode,
  16. traverse_obj,
  17. )
  18. class PanoptoBaseIE(InfoExtractor):
  19. BASE_URL_RE = r'(?P<base_url>https?://[\w.-]+\.panopto.(?:com|eu)/Panopto)'
  20. # see panopto core.js
  21. _SUB_LANG_MAPPING = {
  22. 0: 'en-US',
  23. 1: 'en-GB',
  24. 2: 'es-MX',
  25. 3: 'es-ES',
  26. 4: 'de-DE',
  27. 5: 'fr-FR',
  28. 6: 'nl-NL',
  29. 7: 'th-TH',
  30. 8: 'zh-CN',
  31. 9: 'zh-TW',
  32. 10: 'ko-KR',
  33. 11: 'ja-JP',
  34. 12: 'ru-RU',
  35. 13: 'pt-PT',
  36. 14: 'pl-PL',
  37. 15: 'en-AU',
  38. 16: 'da-DK',
  39. 17: 'fi-FI',
  40. 18: 'hu-HU',
  41. 19: 'nb-NO',
  42. 20: 'sv-SE',
  43. 21: 'it-IT',
  44. }
  45. def _call_api(self, base_url, path, video_id, data=None, fatal=True, **kwargs):
  46. response = self._download_json(
  47. base_url + path, video_id, data=json.dumps(data).encode('utf8') if data else None,
  48. fatal=fatal, headers={'accept': 'application/json', 'content-type': 'application/json'}, **kwargs)
  49. if not response:
  50. return
  51. error_code = traverse_obj(response, 'ErrorCode')
  52. if error_code == 2:
  53. self.raise_login_required(method='cookies')
  54. elif error_code is not None:
  55. msg = f'Panopto said: {response.get("ErrorMessage")}'
  56. if fatal:
  57. raise ExtractorError(msg, video_id=video_id, expected=True)
  58. else:
  59. self.report_warning(msg, video_id=video_id)
  60. return response
  61. @staticmethod
  62. def _parse_fragment(url):
  63. return {k: json.loads(v[0]) for k, v in urllib.parse.parse_qs(urllib.parse.urlparse(url).fragment).items()}
  64. class PanoptoIE(PanoptoBaseIE):
  65. _VALID_URL = PanoptoBaseIE.BASE_URL_RE + r'/Pages/(Viewer|Embed)\.aspx.*(?:\?|&)id=(?P<id>[a-f0-9-]+)'
  66. _EMBED_REGEX = [rf'<iframe[^>]+src=["\'](?P<url>{PanoptoBaseIE.BASE_URL_RE}/Pages/(Viewer|Embed|Sessions/List)\.aspx[^"\']+)']
  67. _TESTS = [
  68. {
  69. 'url': 'https://demo.hosted.panopto.com/Panopto/Pages/Viewer.aspx?id=26b3ae9e-4a48-4dcc-96ba-0befba08a0fb',
  70. 'info_dict': {
  71. 'id': '26b3ae9e-4a48-4dcc-96ba-0befba08a0fb',
  72. 'title': 'Panopto for Business - Use Cases',
  73. 'timestamp': 1459184200,
  74. 'thumbnail': r're:https://demo\.hosted\.panopto\.com/.+',
  75. 'upload_date': '20160328',
  76. 'ext': 'mp4',
  77. 'cast': [],
  78. 'chapters': [],
  79. 'duration': 88.17099999999999,
  80. 'average_rating': int,
  81. 'uploader_id': '2db6b718-47a0-4b0b-9e17-ab0b00f42b1e',
  82. 'channel_id': 'e4c6a2fc-1214-4ca0-8fb7-aef2e29ff63a',
  83. 'channel': 'Showcase Videos',
  84. },
  85. },
  86. {
  87. 'url': 'https://demo.hosted.panopto.com/Panopto/Pages/Viewer.aspx?id=ed01b077-c9e5-4c7b-b8ff-15fa306d7a59',
  88. 'info_dict': {
  89. 'id': 'ed01b077-c9e5-4c7b-b8ff-15fa306d7a59',
  90. 'title': 'Overcoming Top 4 Challenges of Enterprise Video',
  91. 'uploader': 'Panopto Support',
  92. 'timestamp': 1449409251,
  93. 'thumbnail': r're:https://demo\.hosted\.panopto\.com/.+',
  94. 'upload_date': '20151206',
  95. 'ext': 'mp4',
  96. 'chapters': 'count:12',
  97. 'cast': ['Panopto Support'],
  98. 'uploader_id': 'a96d1a31-b4de-489b-9eee-b4a5b414372c',
  99. 'average_rating': int,
  100. 'description': 'md5:4391837802b3fc856dadf630c4b375d1',
  101. 'duration': 1088.2659999999998,
  102. 'channel_id': '9f3c1921-43bb-4bda-8b3a-b8d2f05a8546',
  103. 'channel': 'Webcasts',
  104. },
  105. },
  106. {
  107. # Extra params in URL
  108. 'url': 'https://howtovideos.hosted.panopto.com/Panopto/Pages/Viewer.aspx?randomparam=thisisnotreal&id=5fa74e93-3d87-4694-b60e-aaa4012214ed&advance=true',
  109. 'info_dict': {
  110. 'id': '5fa74e93-3d87-4694-b60e-aaa4012214ed',
  111. 'ext': 'mp4',
  112. 'duration': 129.513,
  113. 'cast': ['Kathryn Kelly'],
  114. 'uploader_id': '316a0a58-7fa2-4cd9-be1c-64270d284a56',
  115. 'timestamp': 1569845768,
  116. 'tags': ['Viewer', 'Enterprise'],
  117. 'chapters': [],
  118. 'upload_date': '20190930',
  119. 'thumbnail': r're:https://howtovideos\.hosted\.panopto\.com/.+',
  120. 'description': 'md5:2d844aaa1b1a14ad0e2601a0993b431f',
  121. 'title': 'Getting Started: View a Video',
  122. 'average_rating': int,
  123. 'uploader': 'Kathryn Kelly',
  124. 'channel_id': 'fb93bc3c-6750-4b80-a05b-a921013735d3',
  125. 'channel': 'Getting Started',
  126. },
  127. },
  128. {
  129. # Does not allow normal Viewer.aspx. AUDIO livestream has no url, so should be skipped and only give one stream.
  130. 'url': 'https://unisa.au.panopto.com/Panopto/Pages/Embed.aspx?id=9d9a0fa3-e99a-4ebd-a281-aac2017f4da4',
  131. 'info_dict': {
  132. 'id': '9d9a0fa3-e99a-4ebd-a281-aac2017f4da4',
  133. 'ext': 'mp4',
  134. 'cast': ['LTS CLI Script'],
  135. 'chapters': [],
  136. 'duration': 2178.45,
  137. 'description': 'md5:ee5cf653919f55b72bce2dbcf829c9fa',
  138. 'channel_id': 'b23e673f-c287-4cb1-8344-aae9005a69f8',
  139. 'average_rating': int,
  140. 'uploader_id': '38377323-6a23-41e2-9ff6-a8e8004bf6f7',
  141. 'uploader': 'LTS CLI Script',
  142. 'timestamp': 1572458134,
  143. 'title': 'WW2 Vets Interview 3 Ronald Stanley George',
  144. 'thumbnail': r're:https://unisa\.au\.panopto\.com/.+',
  145. 'channel': 'World War II Veteran Interviews',
  146. 'upload_date': '20191030',
  147. },
  148. },
  149. {
  150. # Slides/storyboard
  151. 'url': 'https://demo.hosted.panopto.com/Panopto/Pages/Viewer.aspx?id=a7f12f1d-3872-4310-84b0-f8d8ab15326b',
  152. 'info_dict': {
  153. 'id': 'a7f12f1d-3872-4310-84b0-f8d8ab15326b',
  154. 'ext': 'mhtml',
  155. 'timestamp': 1448798857,
  156. 'duration': 4712.681,
  157. 'title': 'Cache Memory - CompSci 15-213, Lecture 12',
  158. 'channel_id': 'e4c6a2fc-1214-4ca0-8fb7-aef2e29ff63a',
  159. 'uploader_id': 'a96d1a31-b4de-489b-9eee-b4a5b414372c',
  160. 'upload_date': '20151129',
  161. 'average_rating': 0,
  162. 'uploader': 'Panopto Support',
  163. 'channel': 'Showcase Videos',
  164. 'description': 'md5:55e51d54233ddb0e6c2ed388ca73822c',
  165. 'cast': ['ISR Videographer', 'Panopto Support'],
  166. 'chapters': 'count:28',
  167. 'thumbnail': r're:https://demo\.hosted\.panopto\.com/.+',
  168. },
  169. 'params': {'format': 'mhtml', 'skip_download': True},
  170. },
  171. {
  172. 'url': 'https://na-training-1.hosted.panopto.com/Panopto/Pages/Viewer.aspx?id=8285224a-9a2b-4957-84f2-acb0000c4ea9',
  173. 'info_dict': {
  174. 'id': '8285224a-9a2b-4957-84f2-acb0000c4ea9',
  175. 'ext': 'mp4',
  176. 'chapters': [],
  177. 'title': 'Company Policy',
  178. 'average_rating': 0,
  179. 'timestamp': 1615058901,
  180. 'channel': 'Human Resources',
  181. 'tags': ['HumanResources'],
  182. 'duration': 1604.243,
  183. 'thumbnail': r're:https://na-training-1\.hosted\.panopto\.com/.+',
  184. 'uploader_id': '8e8ba0a3-424f-40df-a4f1-ab3a01375103',
  185. 'uploader': 'Cait M.',
  186. 'upload_date': '20210306',
  187. 'cast': ['Cait M.'],
  188. 'subtitles': {'en-US': [{'ext': 'srt', 'data': 'md5:a3f4d25963fdeace838f327097c13265'}],
  189. 'es-ES': [{'ext': 'srt', 'data': 'md5:57e9dad365fd0fbaf0468eac4949f189'}]},
  190. },
  191. 'params': {'writesubtitles': True, 'skip_download': True},
  192. }, {
  193. # On Panopto there are two subs: "Default" and en-US. en-US is blank and should be skipped.
  194. 'url': 'https://na-training-1.hosted.panopto.com/Panopto/Pages/Viewer.aspx?id=940cbd41-f616-4a45-b13e-aaf1000c915b',
  195. 'info_dict': {
  196. 'id': '940cbd41-f616-4a45-b13e-aaf1000c915b',
  197. 'ext': 'mp4',
  198. 'subtitles': 'count:1',
  199. 'title': 'HR Benefits Review Meeting*',
  200. 'cast': ['Panopto Support'],
  201. 'chapters': [],
  202. 'timestamp': 1575024251,
  203. 'thumbnail': r're:https://na-training-1\.hosted\.panopto\.com/.+',
  204. 'channel': 'Zoom',
  205. 'description': 'md5:04f90a9c2c68b7828144abfb170f0106',
  206. 'uploader': 'Panopto Support',
  207. 'average_rating': 0,
  208. 'duration': 409.34499999999997,
  209. 'uploader_id': 'b6ac04ad-38b8-4724-a004-a851004ea3df',
  210. 'upload_date': '20191129',
  211. },
  212. 'params': {'writesubtitles': True, 'skip_download': True},
  213. },
  214. {
  215. 'url': 'https://ucc.cloud.panopto.eu/Panopto/Pages/Viewer.aspx?id=0e8484a4-4ceb-4d98-a63f-ac0200b455cb',
  216. 'only_matching': True,
  217. },
  218. {
  219. 'url': 'https://brown.hosted.panopto.com/Panopto/Pages/Embed.aspx?id=0b3ff73b-36a0-46c5-8455-aadf010a3638',
  220. 'only_matching': True,
  221. },
  222. ]
  223. @classmethod
  224. def suitable(cls, url):
  225. return False if PanoptoPlaylistIE.suitable(url) else super().suitable(url)
  226. def _mark_watched(self, base_url, video_id, delivery_info):
  227. duration = traverse_obj(delivery_info, ('Delivery', 'Duration'), expected_type=float)
  228. invocation_id = delivery_info.get('InvocationId')
  229. stream_id = traverse_obj(delivery_info, ('Delivery', 'Streams', ..., 'PublicID'), get_all=False, expected_type=str)
  230. if invocation_id and stream_id and duration:
  231. timestamp_str = f'/Date({calendar.timegm(dt.datetime.now(dt.timezone.utc).timetuple())}000)/'
  232. data = {
  233. 'streamRequests': [
  234. {
  235. 'ClientTimeStamp': timestamp_str,
  236. 'ID': 0,
  237. 'InvocationID': invocation_id,
  238. 'PlaybackSpeed': 1,
  239. 'SecondsListened': duration - 1,
  240. 'SecondsRejected': 0,
  241. 'StartPosition': 0,
  242. 'StartReason': 2,
  243. 'StopReason': None,
  244. 'StreamID': stream_id,
  245. 'TimeStamp': timestamp_str,
  246. 'UpdatesRejected': 0,
  247. },
  248. ]}
  249. self._download_webpage(
  250. base_url + '/Services/Analytics.svc/AddStreamRequests', video_id,
  251. fatal=False, data=json.dumps(data).encode('utf8'), headers={'content-type': 'application/json'},
  252. note='Marking watched', errnote='Unable to mark watched')
  253. @staticmethod
  254. def _extract_chapters(timestamps):
  255. chapters = []
  256. for timestamp in timestamps or []:
  257. caption = timestamp.get('Caption')
  258. start, duration = int_or_none(timestamp.get('Time')), int_or_none(timestamp.get('Duration'))
  259. if not caption or start is None or duration is None:
  260. continue
  261. chapters.append({
  262. 'start_time': start,
  263. 'end_time': start + duration,
  264. 'title': caption,
  265. })
  266. return chapters
  267. @staticmethod
  268. def _extract_mhtml_formats(base_url, timestamps):
  269. image_frags = {}
  270. for timestamp in timestamps or []:
  271. duration = timestamp.get('Duration')
  272. obj_id, obj_sn = timestamp.get('ObjectIdentifier'), timestamp.get('ObjectSequenceNumber')
  273. if timestamp.get('EventTargetType') == 'PowerPoint' and obj_id is not None and obj_sn is not None:
  274. image_frags.setdefault('slides', []).append({
  275. 'url': base_url + f'/Pages/Viewer/Image.aspx?id={obj_id}&number={obj_sn}',
  276. 'duration': duration,
  277. })
  278. obj_pid, session_id, abs_time = timestamp.get('ObjectPublicIdentifier'), timestamp.get('SessionID'), timestamp.get('AbsoluteTime')
  279. if None not in (obj_pid, session_id, abs_time):
  280. image_frags.setdefault('chapter', []).append({
  281. 'url': base_url + f'/Pages/Viewer/Thumb.aspx?eventTargetPID={obj_pid}&sessionPID={session_id}&number={obj_sn}&isPrimary=false&absoluteTime={abs_time}',
  282. 'duration': duration,
  283. })
  284. for name, fragments in image_frags.items():
  285. yield {
  286. 'format_id': name,
  287. 'ext': 'mhtml',
  288. 'protocol': 'mhtml',
  289. 'acodec': 'none',
  290. 'vcodec': 'none',
  291. 'url': 'about:invalid',
  292. 'fragments': fragments,
  293. }
  294. @staticmethod
  295. def _json2srt(data, delivery):
  296. def _gen_lines():
  297. for i, line in enumerate(data):
  298. start_time = line['Time']
  299. duration = line.get('Duration')
  300. if duration:
  301. end_time = start_time + duration
  302. else:
  303. end_time = traverse_obj(data, (i + 1, 'Time')) or delivery['Duration']
  304. yield f'{i + 1}\n{srt_subtitles_timecode(start_time)} --> {srt_subtitles_timecode(end_time)}\n{line["Caption"]}'
  305. return '\n\n'.join(_gen_lines())
  306. def _get_subtitles(self, base_url, video_id, delivery):
  307. subtitles = {}
  308. for lang in delivery.get('AvailableLanguages') or []:
  309. response = self._call_api(
  310. base_url, '/Pages/Viewer/DeliveryInfo.aspx', video_id, fatal=False,
  311. note='Downloading captions JSON metadata', query={
  312. 'deliveryId': video_id,
  313. 'getCaptions': True,
  314. 'language': str(lang),
  315. 'responseType': 'json',
  316. },
  317. )
  318. if not isinstance(response, list):
  319. continue
  320. subtitles.setdefault(self._SUB_LANG_MAPPING.get(lang) or 'default', []).append({
  321. 'ext': 'srt',
  322. 'data': self._json2srt(response, delivery),
  323. })
  324. return subtitles
  325. def _extract_streams_formats_and_subtitles(self, video_id, streams, **fmt_kwargs):
  326. formats = []
  327. subtitles = {}
  328. for stream in streams or []:
  329. stream_formats = []
  330. http_stream_url = stream.get('StreamHttpUrl')
  331. stream_url = stream.get('StreamUrl')
  332. if http_stream_url:
  333. stream_formats.append({'url': http_stream_url})
  334. if stream_url:
  335. media_type = stream.get('ViewerMediaFileTypeName')
  336. if media_type in ('hls', ):
  337. m3u8_formats, stream_subtitles = self._extract_m3u8_formats_and_subtitles(stream_url, video_id)
  338. stream_formats.extend(m3u8_formats)
  339. subtitles = self._merge_subtitles(subtitles, stream_subtitles)
  340. else:
  341. stream_formats.append({
  342. 'url': stream_url,
  343. })
  344. for fmt in stream_formats:
  345. fmt.update({
  346. 'format_note': stream.get('Tag'),
  347. **fmt_kwargs,
  348. })
  349. formats.extend(stream_formats)
  350. return formats, subtitles
  351. def _real_extract(self, url):
  352. base_url, video_id = self._match_valid_url(url).group('base_url', 'id')
  353. delivery_info = self._call_api(
  354. base_url, '/Pages/Viewer/DeliveryInfo.aspx', video_id,
  355. query={
  356. 'deliveryId': video_id,
  357. 'invocationId': '',
  358. 'isLiveNotes': 'false',
  359. 'refreshAuthCookie': 'true',
  360. 'isActiveBroadcast': 'false',
  361. 'isEditing': 'false',
  362. 'isKollectiveAgentInstalled': 'false',
  363. 'isEmbed': 'false',
  364. 'responseType': 'json',
  365. },
  366. )
  367. delivery = delivery_info['Delivery']
  368. session_start_time = int_or_none(delivery.get('SessionStartTime'))
  369. timestamps = delivery.get('Timestamps')
  370. # Podcast stream is usually the combined streams. We will prefer that by default.
  371. podcast_formats, podcast_subtitles = self._extract_streams_formats_and_subtitles(
  372. video_id, delivery.get('PodcastStreams'), format_note='PODCAST')
  373. streams_formats, streams_subtitles = self._extract_streams_formats_and_subtitles(
  374. video_id, delivery.get('Streams'), preference=-10)
  375. formats = podcast_formats + streams_formats
  376. formats.extend(self._extract_mhtml_formats(base_url, timestamps))
  377. subtitles = self._merge_subtitles(
  378. podcast_subtitles, streams_subtitles, self.extract_subtitles(base_url, video_id, delivery))
  379. self.mark_watched(base_url, video_id, delivery_info)
  380. return {
  381. 'id': video_id,
  382. 'title': delivery.get('SessionName'),
  383. 'cast': traverse_obj(delivery, ('Contributors', ..., 'DisplayName'), expected_type=lambda x: x or None),
  384. 'timestamp': session_start_time - 11640000000 if session_start_time else None,
  385. 'duration': delivery.get('Duration'),
  386. 'thumbnail': base_url + f'/Services/FrameGrabber.svc/FrameRedirect?objectId={video_id}&mode=Delivery&random={random.random()}',
  387. 'average_rating': delivery.get('AverageRating'),
  388. 'chapters': self._extract_chapters(timestamps),
  389. 'uploader': delivery.get('OwnerDisplayName') or None,
  390. 'uploader_id': delivery.get('OwnerId'),
  391. 'description': delivery.get('SessionAbstract'),
  392. 'tags': traverse_obj(delivery, ('Tags', ..., 'Content')),
  393. 'channel_id': delivery.get('SessionGroupPublicID'),
  394. 'channel': traverse_obj(delivery, 'SessionGroupLongName', 'SessionGroupShortName', get_all=False),
  395. 'formats': formats,
  396. 'subtitles': subtitles,
  397. }
  398. class PanoptoPlaylistIE(PanoptoBaseIE):
  399. _VALID_URL = PanoptoBaseIE.BASE_URL_RE + r'/Pages/(Viewer|Embed)\.aspx.*(?:\?|&)pid=(?P<id>[a-f0-9-]+)'
  400. _TESTS = [
  401. {
  402. 'url': 'https://howtovideos.hosted.panopto.com/Panopto/Pages/Viewer.aspx?pid=f3b39fcf-882f-4849-93d6-a9f401236d36&id=5fa74e93-3d87-4694-b60e-aaa4012214ed&advance=true',
  403. 'info_dict': {
  404. 'title': 'Featured Video Tutorials',
  405. 'id': 'f3b39fcf-882f-4849-93d6-a9f401236d36',
  406. 'description': '',
  407. },
  408. 'playlist_mincount': 36,
  409. },
  410. {
  411. 'url': 'https://utsa.hosted.panopto.com/Panopto/Pages/Viewer.aspx?pid=e2900555-3ad4-4bdb-854d-ad2401686190',
  412. 'info_dict': {
  413. 'title': 'Library Website Introduction Playlist',
  414. 'id': 'e2900555-3ad4-4bdb-854d-ad2401686190',
  415. 'description': 'md5:f958bca50a1cbda15fdc1e20d32b3ecb',
  416. },
  417. 'playlist_mincount': 4,
  418. },
  419. ]
  420. def _entries(self, base_url, playlist_id, session_list_id):
  421. session_list_info = self._call_api(
  422. base_url, f'/Api/SessionLists/{session_list_id}?collections[0].maxCount=500&collections[0].name=items', playlist_id)
  423. items = session_list_info['Items']
  424. for item in items:
  425. if item.get('TypeName') != 'Session':
  426. self.report_warning('Got an item in the playlist that is not a Session' + bug_reports_message(), only_once=True)
  427. continue
  428. yield {
  429. '_type': 'url',
  430. 'id': item.get('Id'),
  431. 'url': item.get('ViewerUri'),
  432. 'title': item.get('Name'),
  433. 'description': item.get('Description'),
  434. 'duration': item.get('Duration'),
  435. 'channel': traverse_obj(item, ('Parent', 'Name')),
  436. 'channel_id': traverse_obj(item, ('Parent', 'Id')),
  437. }
  438. def _real_extract(self, url):
  439. base_url, playlist_id = self._match_valid_url(url).group('base_url', 'id')
  440. video_id = get_first(parse_qs(url), 'id')
  441. if video_id:
  442. if self.get_param('noplaylist'):
  443. self.to_screen(f'Downloading just video {video_id} because of --no-playlist')
  444. return self.url_result(base_url + f'/Pages/Viewer.aspx?id={video_id}', ie_key=PanoptoIE.ie_key(), video_id=video_id)
  445. else:
  446. self.to_screen(f'Downloading playlist {playlist_id}; add --no-playlist to just download video {video_id}')
  447. playlist_info = self._call_api(base_url, f'/Api/Playlists/{playlist_id}', playlist_id)
  448. return self.playlist_result(
  449. self._entries(base_url, playlist_id, playlist_info['SessionListId']),
  450. playlist_id=playlist_id, playlist_title=playlist_info.get('Name'),
  451. playlist_description=playlist_info.get('Description'))
  452. class PanoptoListIE(PanoptoBaseIE):
  453. _VALID_URL = PanoptoBaseIE.BASE_URL_RE + r'/Pages/Sessions/List\.aspx'
  454. _PAGE_SIZE = 250
  455. _TESTS = [
  456. {
  457. 'url': 'https://demo.hosted.panopto.com/Panopto/Pages/Sessions/List.aspx#folderID=%22e4c6a2fc-1214-4ca0-8fb7-aef2e29ff63a%22',
  458. 'info_dict': {
  459. 'id': 'e4c6a2fc-1214-4ca0-8fb7-aef2e29ff63a',
  460. 'title': 'Showcase Videos',
  461. },
  462. 'playlist_mincount': 140,
  463. },
  464. {
  465. 'url': 'https://demo.hosted.panopto.com/Panopto/Pages/Sessions/List.aspx#view=2&maxResults=250',
  466. 'info_dict': {
  467. 'id': 'panopto_list',
  468. 'title': 'panopto_list',
  469. },
  470. 'playlist_mincount': 300,
  471. },
  472. {
  473. # Folder that contains 8 folders and a playlist
  474. 'url': 'https://howtovideos.hosted.panopto.com/Panopto/Pages/Sessions/List.aspx?noredirect=true#folderID=%224b9de7ae-0080-4158-8496-a9ba01692c2e%22',
  475. 'info_dict': {
  476. 'id': '4b9de7ae-0080-4158-8496-a9ba01692c2e',
  477. 'title': 'Video Tutorials',
  478. },
  479. 'playlist_mincount': 9,
  480. },
  481. ]
  482. def _fetch_page(self, base_url, query_params, display_id, page):
  483. params = {
  484. 'sortColumn': 1,
  485. 'getFolderData': True,
  486. 'includePlaylists': True,
  487. **query_params,
  488. 'page': page,
  489. 'maxResults': self._PAGE_SIZE,
  490. }
  491. response = self._call_api(
  492. base_url, '/Services/Data.svc/GetSessions', f'{display_id} page {page + 1}',
  493. data={'queryParameters': params}, fatal=False)
  494. for result in get_first(response, 'Results', default=[]):
  495. # This could be a video, playlist (or maybe something else)
  496. item_id = result.get('DeliveryID')
  497. yield {
  498. '_type': 'url',
  499. 'id': item_id,
  500. 'title': result.get('SessionName'),
  501. 'url': traverse_obj(result, 'ViewerUrl', 'EmbedUrl', get_all=False) or (base_url + f'/Pages/Viewer.aspx?id={item_id}'),
  502. 'duration': result.get('Duration'),
  503. 'channel': result.get('FolderName'),
  504. 'channel_id': result.get('FolderID'),
  505. }
  506. for folder in get_first(response, 'Subfolders', default=[]):
  507. folder_id = folder.get('ID')
  508. yield self.url_result(
  509. base_url + f'/Pages/Sessions/List.aspx#folderID="{folder_id}"',
  510. ie_key=PanoptoListIE.ie_key(), video_id=folder_id, title=folder.get('Name'))
  511. def _extract_folder_metadata(self, base_url, folder_id):
  512. response = self._call_api(
  513. base_url, '/Services/Data.svc/GetFolderInfo', folder_id,
  514. data={'folderID': folder_id}, fatal=False)
  515. return {
  516. 'title': get_first(response, 'Name'),
  517. }
  518. def _real_extract(self, url):
  519. mobj = self._match_valid_url(url)
  520. base_url = mobj.group('base_url')
  521. query_params = self._parse_fragment(url)
  522. folder_id, display_id = query_params.get('folderID'), 'panopto_list'
  523. if query_params.get('isSubscriptionsPage'):
  524. display_id = 'subscriptions'
  525. if not query_params.get('subscribableTypes'):
  526. query_params['subscribableTypes'] = [0, 1, 2]
  527. elif query_params.get('isSharedWithMe'):
  528. display_id = 'sharedwithme'
  529. elif folder_id:
  530. display_id = folder_id
  531. query = query_params.get('query')
  532. if query:
  533. display_id += f': query "{query}"'
  534. info = {
  535. '_type': 'playlist',
  536. 'id': display_id,
  537. 'title': display_id,
  538. }
  539. if folder_id:
  540. info.update(self._extract_folder_metadata(base_url, folder_id))
  541. info['entries'] = OnDemandPagedList(
  542. functools.partial(self._fetch_page, base_url, query_params, display_id), self._PAGE_SIZE)
  543. return info