]> asedeno.scripts.mit.edu Git - youtube-dl.git/blob - youtube_dl/extractor/nrk.py
[nrk] improve format extraction
[youtube-dl.git] / youtube_dl / extractor / nrk.py
1 # coding: utf-8
2 from __future__ import unicode_literals
3
4 import itertools
5 import random
6 import re
7
8 from .common import InfoExtractor
9 from ..compat import (
10     compat_str,
11     compat_urllib_parse_unquote,
12 )
13 from ..utils import (
14     determine_ext,
15     ExtractorError,
16     int_or_none,
17     parse_age_limit,
18     parse_duration,
19     try_get,
20     urljoin,
21     url_or_none,
22 )
23
24
25 class NRKBaseIE(InfoExtractor):
26     _GEO_COUNTRIES = ['NO']
27     _CDN_REPL_REGEX = r'''(?x)://
28         (?:
29             nrkod\d{1,2}-httpcache0-47115-cacheod0\.dna\.ip-only\.net/47115-cacheod0|
30             nrk-od-no\.telenorcdn\.net|
31             minicdn-od\.nrk\.no/od/nrkhd-osl-rr\.netwerk\.no/no
32         )/'''
33
34     def _extract_nrk_formats(self, asset_url, video_id):
35         if re.match(r'https?://[^/]+\.akamaihd\.net/i/', asset_url):
36             return self._extract_akamai_formats(
37                 re.sub(r'(?:b=\d+-\d+|__a__=off)&?', '', asset_url), video_id)
38         asset_url = re.sub(r'(?:bw_(?:low|high)=\d+|no_audio_only)&?', '', asset_url)
39         formats = self._extract_m3u8_formats(
40             asset_url, video_id, 'mp4', 'm3u8_native', fatal=False)
41         if not formats and re.search(self._CDN_REPL_REGEX, asset_url):
42             formats = self._extract_m3u8_formats(
43                 re.sub(self._CDN_REPL_REGEX, '://nrk-od-%02d.akamaized.net/no/' % random.randint(0, 99), asset_url),
44                 video_id, 'mp4', 'm3u8_native', fatal=False)
45         return formats
46
47     def _raise_error(self, data):
48         MESSAGES = {
49             'ProgramRightsAreNotReady': 'Du kan dessverre ikke se eller høre programmet',
50             'ProgramRightsHasExpired': 'Programmet har gått ut',
51             'NoProgramRights': 'Ikke tilgjengelig',
52             'ProgramIsGeoBlocked': 'NRK har ikke rettigheter til å vise dette programmet utenfor Norge',
53         }
54         message_type = data.get('messageType', '')
55         # Can be ProgramIsGeoBlocked or ChannelIsGeoBlocked*
56         if 'IsGeoBlocked' in message_type or try_get(data, lambda x: x['usageRights']['isGeoBlocked']) is True:
57             self.raise_geo_restricted(
58                 msg=MESSAGES.get('ProgramIsGeoBlocked'),
59                 countries=self._GEO_COUNTRIES)
60         message = data.get('endUserMessage') or MESSAGES.get(message_type, message_type)
61         raise ExtractorError('%s said: %s' % (self.IE_NAME, message), expected=True)
62
63     def _call_api(self, path, video_id, item=None, note=None, fatal=True, query=None):
64         return self._download_json(
65             urljoin('http://psapi.nrk.no/', path),
66             video_id, note or 'Downloading %s JSON' % item,
67             fatal=fatal, query=query)
68
69
70 class NRKIE(NRKBaseIE):
71     _VALID_URL = r'''(?x)
72                         (?:
73                             nrk:|
74                             https?://
75                                 (?:
76                                     (?:www\.)?nrk\.no/video/(?:PS\*|[^_]+_)|
77                                     v8[-.]psapi\.nrk\.no/mediaelement/
78                                 )
79                             )
80                             (?P<id>[^?\#&]+)
81                         '''
82
83     _TESTS = [{
84         # video
85         'url': 'http://www.nrk.no/video/PS*150533',
86         'md5': 'f46be075326e23ad0e524edfcb06aeb6',
87         'info_dict': {
88             'id': '150533',
89             'ext': 'mp4',
90             'title': 'Dompap og andre fugler i Piip-Show',
91             'description': 'md5:d9261ba34c43b61c812cb6b0269a5c8f',
92             'duration': 262,
93         }
94     }, {
95         # audio
96         'url': 'http://www.nrk.no/video/PS*154915',
97         # MD5 is unstable
98         'info_dict': {
99             'id': '154915',
100             'ext': 'mp4',
101             'title': 'Slik høres internett ut når du er blind',
102             'description': 'md5:a621f5cc1bd75c8d5104cb048c6b8568',
103             'duration': 20,
104         }
105     }, {
106         'url': 'nrk:ecc1b952-96dc-4a98-81b9-5296dc7a98d9',
107         'only_matching': True,
108     }, {
109         'url': 'nrk:clip/7707d5a3-ebe7-434a-87d5-a3ebe7a34a70',
110         'only_matching': True,
111     }, {
112         'url': 'https://v8-psapi.nrk.no/mediaelement/ecc1b952-96dc-4a98-81b9-5296dc7a98d9',
113         'only_matching': True,
114     }, {
115         'url': 'https://www.nrk.no/video/dompap-og-andre-fugler-i-piip-show_150533',
116         'only_matching': True,
117     }, {
118         'url': 'https://www.nrk.no/video/humor/kommentatorboksen-reiser-til-sjos_d1fda11f-a4ad-437a-a374-0398bc84e999',
119         'only_matching': True,
120     }]
121
122     def _extract_from_playback(self, video_id):
123         path_templ = 'playback/%s/' + video_id
124         def call_playback_api(item, query=None):
125             return self._call_api(path_templ % item, video_id, item, query=query)
126         # known values for preferredCdn: akamai, iponly, minicdn and telenor
127         manifest = call_playback_api('manifest', {'preferredCdn': 'akamai'})
128
129         if manifest.get('playability') == 'nonPlayable':
130             self._raise_error(manifest['nonPlayable'])
131
132         playable = manifest['playable']
133
134         formats = []
135         for asset in playable['assets']:
136             if not isinstance(asset, dict):
137                 continue
138             if asset.get('encrypted'):
139                 continue
140             format_url = url_or_none(asset.get('url'))
141             if not format_url:
142                 continue
143             if asset.get('format') == 'HLS' or determine_ext(format_url) == 'm3u8':
144                 formats.extend(self._extract_nrk_formats(format_url, video_id))
145         self._sort_formats(formats)
146
147         data = call_playback_api('metadata')
148
149         preplay = data['preplay']
150         titles = preplay['titles']
151         title = titles['title']
152         alt_title = titles.get('subtitle')
153
154         description = preplay.get('description')
155         duration = parse_duration(playable.get('duration')) or parse_duration(data.get('duration'))
156
157         thumbnails = []
158         for image in try_get(
159                 preplay, lambda x: x['poster']['images'], list) or []:
160             if not isinstance(image, dict):
161                 continue
162             image_url = url_or_none(image.get('url'))
163             if not image_url:
164                 continue
165             thumbnails.append({
166                 'url': image_url,
167                 'width': int_or_none(image.get('pixelWidth')),
168                 'height': int_or_none(image.get('pixelHeight')),
169             })
170
171         return {
172             'id': video_id,
173             'title': title,
174             'alt_title': alt_title,
175             'description': description,
176             'duration': duration,
177             'thumbnails': thumbnails,
178             'formats': formats,
179         }
180
181     def _real_extract(self, url):
182         video_id = self._match_id(url)
183         return self._extract_from_playback(video_id)
184
185
186 class NRKTVIE(NRKBaseIE):
187     IE_DESC = 'NRK TV and NRK Radio'
188     _EPISODE_RE = r'(?P<id>[a-zA-Z]{4}\d{8})'
189     _VALID_URL = r'https?://(?:tv|radio)\.nrk(?:super)?\.no/(?:[^/]+/)*%s' % _EPISODE_RE
190     _API_HOSTS = ('psapi-ne.nrk.no', 'psapi-we.nrk.no')
191     _TESTS = [{
192         'url': 'https://tv.nrk.no/program/MDDP12000117',
193         'md5': 'c4a5960f1b00b40d47db65c1064e0ab1',
194         'info_dict': {
195             'id': 'MDDP12000117AA',
196             'ext': 'mp4',
197             'title': 'Alarm Trolltunga',
198             'description': 'md5:46923a6e6510eefcce23d5ef2a58f2ce',
199             'duration': 2223.44,
200             'age_limit': 6,
201         },
202     }, {
203         'url': 'https://tv.nrk.no/serie/20-spoersmaal-tv/MUHH48000314/23-05-2014',
204         'md5': '8d40dab61cea8ab0114e090b029a0565',
205         'info_dict': {
206             'id': 'MUHH48000314AA',
207             'ext': 'mp4',
208             'title': '20 spørsmål 23.05.2014',
209             'description': 'md5:bdea103bc35494c143c6a9acdd84887a',
210             'duration': 1741,
211             'series': '20 spørsmål',
212             'episode': '23.05.2014',
213         },
214     }, {
215         'url': 'https://tv.nrk.no/program/mdfp15000514',
216         'info_dict': {
217             'id': 'MDFP15000514CA',
218             'ext': 'mp4',
219             'title': 'Grunnlovsjubiléet - Stor ståhei for ingenting 24.05.2014',
220             'description': 'md5:89290c5ccde1b3a24bb8050ab67fe1db',
221             'duration': 4605.08,
222             'series': 'Kunnskapskanalen',
223             'episode': '24.05.2014',
224         },
225         'params': {
226             'skip_download': True,
227         },
228     }, {
229         # single playlist video
230         'url': 'https://tv.nrk.no/serie/tour-de-ski/MSPO40010515/06-01-2015#del=2',
231         'info_dict': {
232             'id': 'MSPO40010515AH',
233             'ext': 'mp4',
234             'title': 'Sprint fri teknikk, kvinner og menn 06.01.2015',
235             'description': 'md5:c03aba1e917561eface5214020551b7a',
236         },
237         'params': {
238             'skip_download': True,
239         },
240         'expected_warnings': ['Failed to download m3u8 information'],
241         'skip': 'particular part is not supported currently',
242     }, {
243         'url': 'https://tv.nrk.no/serie/tour-de-ski/MSPO40010515/06-01-2015',
244         'info_dict': {
245             'id': 'MSPO40010515AH',
246             'ext': 'mp4',
247             'title': 'Sprint fri teknikk, kvinner og menn 06.01.2015',
248             'description': 'md5:c03aba1e917561eface5214020551b7a',
249         },
250         'expected_warnings': ['Failed to download m3u8 information'],
251     }, {
252         'url': 'https://tv.nrk.no/serie/anno/KMTE50001317/sesong-3/episode-13',
253         'info_dict': {
254             'id': 'KMTE50001317AA',
255             'ext': 'mp4',
256             'title': 'Anno 13:30',
257             'description': 'md5:11d9613661a8dbe6f9bef54e3a4cbbfa',
258             'duration': 2340,
259             'series': 'Anno',
260             'episode': '13:30',
261             'season_number': 3,
262             'episode_number': 13,
263         },
264         'params': {
265             'skip_download': True,
266         },
267     }, {
268         'url': 'https://tv.nrk.no/serie/nytt-paa-nytt/MUHH46000317/27-01-2017',
269         'info_dict': {
270             'id': 'MUHH46000317AA',
271             'ext': 'mp4',
272             'title': 'Nytt på Nytt 27.01.2017',
273             'description': 'md5:5358d6388fba0ea6f0b6d11c48b9eb4b',
274             'duration': 1796,
275             'series': 'Nytt på nytt',
276             'episode': '27.01.2017',
277         },
278         'params': {
279             'skip_download': True,
280         },
281         'skip': 'ProgramRightsHasExpired',
282     }, {
283         'url': 'https://radio.nrk.no/serie/dagsnytt/NPUB21019315/12-07-2015#',
284         'only_matching': True,
285     }, {
286         'url': 'https://tv.nrk.no/serie/lindmo/2018/MUHU11006318/avspiller',
287         'only_matching': True,
288     }, {
289         'url': 'https://radio.nrk.no/serie/dagsnytt/sesong/201507/NPUB21019315',
290         'only_matching': True,
291     }]
292
293     _api_host = None
294
295     def _extract_from_mediaelement(self, video_id):
296         api_hosts = (self._api_host, ) if self._api_host else self._API_HOSTS
297
298         for api_host in api_hosts:
299             data = self._download_json(
300                 'http://%s/mediaelement/%s' % (api_host, video_id),
301                 video_id, 'Downloading mediaelement JSON',
302                 fatal=api_host == api_hosts[-1])
303             if not data:
304                 continue
305             self._api_host = api_host
306             break
307
308         title = data.get('fullTitle') or data.get('mainTitle') or data['title']
309         video_id = data.get('id') or video_id
310
311         urls = []
312         entries = []
313
314         conviva = data.get('convivaStatistics') or {}
315         live = (data.get('mediaElementType') == 'Live'
316                 or data.get('isLive') is True or conviva.get('isLive'))
317
318         def make_title(t):
319             return self._live_title(t) if live else t
320
321         media_assets = data.get('mediaAssets')
322         if media_assets and isinstance(media_assets, list):
323             def video_id_and_title(idx):
324                 return ((video_id, title) if len(media_assets) == 1
325                         else ('%s-%d' % (video_id, idx), '%s (Part %d)' % (title, idx)))
326             for num, asset in enumerate(media_assets, 1):
327                 asset_url = asset.get('url')
328                 if not asset_url or asset_url in urls:
329                     continue
330                 urls.append(asset_url)
331                 formats = self._extract_nrk_formats(asset_url, video_id)
332                 if not formats:
333                     continue
334                 self._sort_formats(formats)
335
336                 entry_id, entry_title = video_id_and_title(num)
337                 duration = parse_duration(asset.get('duration'))
338                 subtitles = {}
339                 for subtitle in ('webVtt', 'timedText'):
340                     subtitle_url = asset.get('%sSubtitlesUrl' % subtitle)
341                     if subtitle_url:
342                         subtitles.setdefault('no', []).append({
343                             'url': compat_urllib_parse_unquote(subtitle_url)
344                         })
345                 entries.append({
346                     'id': asset.get('carrierId') or entry_id,
347                     'title': make_title(entry_title),
348                     'duration': duration,
349                     'subtitles': subtitles,
350                     'formats': formats,
351                     'is_live': live,
352                 })
353
354         if not entries:
355             media_url = data.get('mediaUrl')
356             if media_url and media_url not in urls:
357                 formats = self._extract_nrk_formats(media_url, video_id)
358                 if formats:
359                     self._sort_formats(formats)
360                     duration = parse_duration(data.get('duration'))
361                     entries = [{
362                         'id': video_id,
363                         'title': make_title(title),
364                         'duration': duration,
365                         'formats': formats,
366                         'is_live': live,
367                     }]
368
369         if not entries:
370             self._raise_error(data)
371
372         series = conviva.get('seriesName') or data.get('seriesTitle')
373         episode = conviva.get('episodeName') or data.get('episodeNumberOrDate')
374
375         season_number = None
376         episode_number = None
377         if data.get('mediaElementType') == 'Episode':
378             _season_episode = data.get('scoresStatistics', {}).get('springStreamStream') or \
379                 data.get('relativeOriginUrl', '')
380             EPISODENUM_RE = [
381                 r'/s(?P<season>\d{,2})e(?P<episode>\d{,2})\.',
382                 r'/sesong-(?P<season>\d{,2})/episode-(?P<episode>\d{,2})',
383             ]
384             season_number = int_or_none(self._search_regex(
385                 EPISODENUM_RE, _season_episode, 'season number',
386                 default=None, group='season'))
387             episode_number = int_or_none(self._search_regex(
388                 EPISODENUM_RE, _season_episode, 'episode number',
389                 default=None, group='episode'))
390
391         thumbnails = None
392         images = data.get('images')
393         if images and isinstance(images, dict):
394             web_images = images.get('webImages')
395             if isinstance(web_images, list):
396                 thumbnails = [{
397                     'url': image['imageUrl'],
398                     'width': int_or_none(image.get('width')),
399                     'height': int_or_none(image.get('height')),
400                 } for image in web_images if image.get('imageUrl')]
401
402         description = data.get('description')
403         category = data.get('mediaAnalytics', {}).get('category')
404
405         common_info = {
406             'description': description,
407             'series': series,
408             'episode': episode,
409             'season_number': season_number,
410             'episode_number': episode_number,
411             'categories': [category] if category else None,
412             'age_limit': parse_age_limit(data.get('legalAge')),
413             'thumbnails': thumbnails,
414         }
415
416         vcodec = 'none' if data.get('mediaType') == 'Audio' else None
417
418         for entry in entries:
419             entry.update(common_info)
420             for f in entry['formats']:
421                 f['vcodec'] = vcodec
422
423         points = data.get('shortIndexPoints')
424         if isinstance(points, list):
425             chapters = []
426             for next_num, point in enumerate(points, start=1):
427                 if not isinstance(point, dict):
428                     continue
429                 start_time = parse_duration(point.get('startPoint'))
430                 if start_time is None:
431                     continue
432                 end_time = parse_duration(
433                     data.get('duration')
434                     if next_num == len(points)
435                     else points[next_num].get('startPoint'))
436                 if end_time is None:
437                     continue
438                 chapters.append({
439                     'start_time': start_time,
440                     'end_time': end_time,
441                     'title': point.get('title'),
442                 })
443             if chapters and len(entries) == 1:
444                 entries[0]['chapters'] = chapters
445
446         return self.playlist_result(entries, video_id, title, description)
447
448     def _real_extract(self, url):
449         video_id = self._match_id(url)
450         return self._extract_from_mediaelement(video_id)
451
452
453 class NRKTVEpisodeIE(InfoExtractor):
454     _VALID_URL = r'https?://tv\.nrk\.no/serie/(?P<id>[^/]+/sesong/\d+/episode/\d+)'
455     _TESTS = [{
456         'url': 'https://tv.nrk.no/serie/hellums-kro/sesong/1/episode/2',
457         'info_dict': {
458             'id': 'MUHH36005220BA',
459             'ext': 'mp4',
460             'title': 'Kro, krig og kjærlighet 2:6',
461             'description': 'md5:b32a7dc0b1ed27c8064f58b97bda4350',
462             'duration': 1563,
463             'series': 'Hellums kro',
464             'season_number': 1,
465             'episode_number': 2,
466             'episode': '2:6',
467             'age_limit': 6,
468         },
469         'params': {
470             'skip_download': True,
471         },
472     }, {
473         'url': 'https://tv.nrk.no/serie/backstage/sesong/1/episode/8',
474         'info_dict': {
475             'id': 'MSUI14000816AA',
476             'ext': 'mp4',
477             'title': 'Backstage 8:30',
478             'description': 'md5:de6ca5d5a2d56849e4021f2bf2850df4',
479             'duration': 1320,
480             'series': 'Backstage',
481             'season_number': 1,
482             'episode_number': 8,
483             'episode': '8:30',
484         },
485         'params': {
486             'skip_download': True,
487         },
488         'skip': 'ProgramRightsHasExpired',
489     }]
490
491     def _real_extract(self, url):
492         display_id = self._match_id(url)
493
494         webpage = self._download_webpage(url, display_id)
495
496         info = self._search_json_ld(webpage, display_id, default={})
497         nrk_id = info.get('@id') or self._html_search_meta(
498             'nrk:program-id', webpage, default=None) or self._search_regex(
499             r'data-program-id=["\'](%s)' % NRKTVIE._EPISODE_RE, webpage,
500             'nrk id')
501         assert re.match(NRKTVIE._EPISODE_RE, nrk_id)
502
503         info.update({
504             '_type': 'url_transparent',
505             'id': nrk_id,
506             'url': 'nrk:%s' % nrk_id,
507             'ie_key': NRKIE.ie_key(),
508         })
509         return info
510
511
512 class NRKTVSerieBaseIE(NRKBaseIE):
513     def _extract_entries(self, entry_list):
514         if not isinstance(entry_list, list):
515             return []
516         entries = []
517         for episode in entry_list:
518             nrk_id = episode.get('prfId') or episode.get('episodeId')
519             if not nrk_id or not isinstance(nrk_id, compat_str):
520                 continue
521             if not re.match(NRKTVIE._EPISODE_RE, nrk_id):
522                 continue
523             entries.append(self.url_result(
524                 'nrk:%s' % nrk_id, ie=NRKIE.ie_key(), video_id=nrk_id))
525         return entries
526
527     _ASSETS_KEYS = ('episodes', 'instalments',)
528
529     def _extract_assets_key(self, embedded):
530         for asset_key in self._ASSETS_KEYS:
531             if embedded.get(asset_key):
532                 return asset_key
533
534     def _entries(self, data, display_id):
535         for page_num in itertools.count(1):
536             embedded = data.get('_embedded') or data
537             if not isinstance(embedded, dict):
538                 break
539             assets_key = self._extract_assets_key(embedded)
540             if not assets_key:
541                 break
542             # Extract entries
543             entries = try_get(
544                 embedded,
545                 (lambda x: x[assets_key]['_embedded'][assets_key],
546                  lambda x: x[assets_key]),
547                 list)
548             for e in self._extract_entries(entries):
549                 yield e
550             # Find next URL
551             next_url_path = try_get(
552                 data,
553                 (lambda x: x['_links']['next']['href'],
554                  lambda x: x['_embedded'][assets_key]['_links']['next']['href']),
555                 compat_str)
556             if not next_url_path:
557                 break
558             data = self._call_api(
559                 next_url_path, display_id,
560                 note='Downloading %s JSON page %d' % (assets_key, page_num),
561                 fatal=False)
562             if not data:
563                 break
564
565
566 class NRKTVSeasonIE(NRKTVSerieBaseIE):
567     _VALID_URL = r'https?://(?P<domain>tv|radio)\.nrk\.no/serie/(?P<serie>[^/]+)/(?:sesong/)?(?P<id>\d+)'
568     _TESTS = [{
569         'url': 'https://tv.nrk.no/serie/backstage/sesong/1',
570         'info_dict': {
571             'id': 'backstage/1',
572             'title': 'Sesong 1',
573         },
574         'playlist_mincount': 30,
575     }, {
576         # no /sesong/ in path
577         'url': 'https://tv.nrk.no/serie/lindmo/2016',
578         'info_dict': {
579             'id': 'lindmo/2016',
580             'title': '2016',
581         },
582         'playlist_mincount': 29,
583     }, {
584         # weird nested _embedded in catalog JSON response
585         'url': 'https://radio.nrk.no/serie/dickie-dick-dickens/sesong/1',
586         'info_dict': {
587             'id': 'dickie-dick-dickens/1',
588             'title': 'Sesong 1',
589         },
590         'playlist_mincount': 11,
591     }, {
592         # 841 entries, multi page
593         'url': 'https://radio.nrk.no/serie/dagsnytt/sesong/201509',
594         'info_dict': {
595             'id': 'dagsnytt/201509',
596             'title': 'September 2015',
597         },
598         'playlist_mincount': 841,
599     }, {
600         # 180 entries, single page
601         'url': 'https://tv.nrk.no/serie/spangas/sesong/1',
602         'only_matching': True,
603     }]
604
605     @classmethod
606     def suitable(cls, url):
607         return (False if NRKTVIE.suitable(url) or NRKTVEpisodeIE.suitable(url)
608                 else super(NRKTVSeasonIE, cls).suitable(url))
609
610     def _real_extract(self, url):
611         domain, serie, season_id = re.match(self._VALID_URL, url).groups()
612         display_id = '%s/%s' % (serie, season_id)
613
614         data = self._call_api(
615             '%s/catalog/series/%s/seasons/%s' % (domain, serie, season_id),
616             display_id, 'season', query={'pageSize': 50})
617
618         title = try_get(data, lambda x: x['titles']['title'], compat_str) or display_id
619         return self.playlist_result(
620             self._entries(data, display_id),
621             display_id, title)
622
623
624 class NRKTVSeriesIE(NRKTVSerieBaseIE):
625     _VALID_URL = r'https?://(?P<domain>(?:tv|radio)\.nrk|(?:tv\.)?nrksuper)\.no/serie/(?P<id>[^/]+)'
626     _TESTS = [{
627         # new layout, instalments
628         'url': 'https://tv.nrk.no/serie/groenn-glede',
629         'info_dict': {
630             'id': 'groenn-glede',
631             'title': 'Grønn glede',
632             'description': 'md5:7576e92ae7f65da6993cf90ee29e4608',
633         },
634         'playlist_mincount': 90,
635     }, {
636         # new layout, instalments, more entries
637         'url': 'https://tv.nrk.no/serie/lindmo',
638         'only_matching': True,
639     }, {
640         'url': 'https://tv.nrk.no/serie/blank',
641         'info_dict': {
642             'id': 'blank',
643             'title': 'Blank',
644             'description': 'md5:7664b4e7e77dc6810cd3bca367c25b6e',
645         },
646         'playlist_mincount': 30,
647     }, {
648         # new layout, seasons
649         'url': 'https://tv.nrk.no/serie/backstage',
650         'info_dict': {
651             'id': 'backstage',
652             'title': 'Backstage',
653             'description': 'md5:63692ceb96813d9a207e9910483d948b',
654         },
655         'playlist_mincount': 60,
656     }, {
657         # old layout
658         'url': 'https://tv.nrksuper.no/serie/labyrint',
659         'info_dict': {
660             'id': 'labyrint',
661             'title': 'Labyrint',
662             'description': 'I Daidalos sin undersjøiske Labyrint venter spennende oppgaver, skumle robotskapninger og slim.',
663         },
664         'playlist_mincount': 3,
665     }, {
666         'url': 'https://tv.nrk.no/serie/broedrene-dal-og-spektralsteinene',
667         'only_matching': True,
668     }, {
669         'url': 'https://tv.nrk.no/serie/saving-the-human-race',
670         'only_matching': True,
671     }, {
672         'url': 'https://tv.nrk.no/serie/postmann-pat',
673         'only_matching': True,
674     }, {
675         'url': 'https://radio.nrk.no/serie/dickie-dick-dickens',
676         'info_dict': {
677             'id': 'dickie-dick-dickens',
678             'title': 'Dickie Dick Dickens',
679             'description': 'md5:19e67411ffe57f7dce08a943d7a0b91f',
680         },
681         'playlist_mincount': 8,
682     }, {
683         'url': 'https://nrksuper.no/serie/labyrint',
684         'only_matching': True,
685     }]
686
687     @classmethod
688     def suitable(cls, url):
689         return (
690             False if any(ie.suitable(url)
691                          for ie in (NRKTVIE, NRKTVEpisodeIE, NRKTVSeasonIE))
692             else super(NRKTVSeriesIE, cls).suitable(url))
693
694     def _real_extract(self, url):
695         site, series_id = re.match(self._VALID_URL, url).groups()
696         domain = 'radio' if site == 'radio.nrk' else 'tv'
697
698         series = self._call_api(
699             '%s/catalog/series/%s' % (domain, series_id), series_id, 'serie')
700         titles = try_get(series, [
701             lambda x: x['titles'],
702             lambda x: x[x['type']]['titles'],
703             lambda x: x[x['seriesType']]['titles'],
704         ]) or {}
705
706         entries = []
707         entries.extend(self._entries(series, series_id))
708         embedded = series.get('_embedded') or {}
709         linked_seasons = try_get(series, lambda x: x['_links']['seasons']) or []
710         embedded_seasons = embedded.get('seasons') or []
711         if len(linked_seasons) > len(embedded_seasons):
712             for season in linked_seasons:
713                 season_name = season.get('name')
714                 if season_name and isinstance(season_name, compat_str):
715                     entries.append(self.url_result(
716                         'https://%s.nrk.no/serie/%s/sesong/%s'
717                         % (domain, series_id, season_name),
718                         ie=NRKTVSeasonIE.ie_key(),
719                         video_title=season.get('title')))
720         else:
721             for season in embedded_seasons:
722                 entries.extend(self._entries(season, series_id))
723         entries.extend(self._entries(
724             embedded.get('extraMaterial') or {}, series_id))
725
726         return self.playlist_result(
727             entries, series_id, titles.get('title'), titles.get('subtitle'))
728
729
730 class NRKTVDirekteIE(NRKTVIE):
731     IE_DESC = 'NRK TV Direkte and NRK Radio Direkte'
732     _VALID_URL = r'https?://(?:tv|radio)\.nrk\.no/direkte/(?P<id>[^/?#&]+)'
733
734     _TESTS = [{
735         'url': 'https://tv.nrk.no/direkte/nrk1',
736         'only_matching': True,
737     }, {
738         'url': 'https://radio.nrk.no/direkte/p1_oslo_akershus',
739         'only_matching': True,
740     }]
741
742
743 class NRKPlaylistBaseIE(InfoExtractor):
744     def _extract_description(self, webpage):
745         pass
746
747     def _real_extract(self, url):
748         playlist_id = self._match_id(url)
749
750         webpage = self._download_webpage(url, playlist_id)
751
752         entries = [
753             self.url_result('nrk:%s' % video_id, NRKIE.ie_key())
754             for video_id in re.findall(self._ITEM_RE, webpage)
755         ]
756
757         playlist_title = self. _extract_title(webpage)
758         playlist_description = self._extract_description(webpage)
759
760         return self.playlist_result(
761             entries, playlist_id, playlist_title, playlist_description)
762
763
764 class NRKPlaylistIE(NRKPlaylistBaseIE):
765     _VALID_URL = r'https?://(?:www\.)?nrk\.no/(?!video|skole)(?:[^/]+/)+(?P<id>[^/]+)'
766     _ITEM_RE = r'class="[^"]*\brich\b[^"]*"[^>]+data-video-id="([^"]+)"'
767     _TESTS = [{
768         'url': 'http://www.nrk.no/troms/gjenopplev-den-historiske-solformorkelsen-1.12270763',
769         'info_dict': {
770             'id': 'gjenopplev-den-historiske-solformorkelsen-1.12270763',
771             'title': 'Gjenopplev den historiske solformørkelsen',
772             'description': 'md5:c2df8ea3bac5654a26fc2834a542feed',
773         },
774         'playlist_count': 2,
775     }, {
776         'url': 'http://www.nrk.no/kultur/bok/rivertonprisen-til-karin-fossum-1.12266449',
777         'info_dict': {
778             'id': 'rivertonprisen-til-karin-fossum-1.12266449',
779             'title': 'Rivertonprisen til Karin Fossum',
780             'description': 'Første kvinne på 15 år til å vinne krimlitteraturprisen.',
781         },
782         'playlist_count': 2,
783     }]
784
785     def _extract_title(self, webpage):
786         return self._og_search_title(webpage, fatal=False)
787
788     def _extract_description(self, webpage):
789         return self._og_search_description(webpage)
790
791
792 class NRKTVEpisodesIE(NRKPlaylistBaseIE):
793     _VALID_URL = r'https?://tv\.nrk\.no/program/[Ee]pisodes/[^/]+/(?P<id>\d+)'
794     _ITEM_RE = r'data-episode=["\']%s' % NRKTVIE._EPISODE_RE
795     _TESTS = [{
796         'url': 'https://tv.nrk.no/program/episodes/nytt-paa-nytt/69031',
797         'info_dict': {
798             'id': '69031',
799             'title': 'Nytt på nytt, sesong: 201210',
800         },
801         'playlist_count': 4,
802     }]
803
804     def _extract_title(self, webpage):
805         return self._html_search_regex(
806             r'<h1>([^<]+)</h1>', webpage, 'title', fatal=False)
807
808
809 class NRKSkoleIE(InfoExtractor):
810     IE_DESC = 'NRK Skole'
811     _VALID_URL = r'https?://(?:www\.)?nrk\.no/skole/?\?.*\bmediaId=(?P<id>\d+)'
812
813     _TESTS = [{
814         'url': 'https://www.nrk.no/skole/?page=search&q=&mediaId=14099',
815         'md5': '18c12c3d071953c3bf8d54ef6b2587b7',
816         'info_dict': {
817             'id': '6021',
818             'ext': 'mp4',
819             'title': 'Genetikk og eneggede tvillinger',
820             'description': 'md5:3aca25dcf38ec30f0363428d2b265f8d',
821             'duration': 399,
822         },
823     }, {
824         'url': 'https://www.nrk.no/skole/?page=objectives&subject=naturfag&objective=K15114&mediaId=19355',
825         'only_matching': True,
826     }]
827
828     def _real_extract(self, url):
829         video_id = self._match_id(url)
830
831         nrk_id = self._download_json(
832             'https://nrkno-skole-prod.kube.nrk.no/skole/api/media/%s' % video_id,
833             video_id)['psId']
834
835         return self.url_result('nrk:%s' % nrk_id)