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