diff --git a/resources/lib/youtube_plugin/kodion/impl/abstract_settings.py b/resources/lib/youtube_plugin/kodion/impl/abstract_settings.py index 6a2e28870..29b8a93d7 100644 --- a/resources/lib/youtube_plugin/kodion/impl/abstract_settings.py +++ b/resources/lib/youtube_plugin/kodion/impl/abstract_settings.py @@ -142,8 +142,6 @@ def use_dash_videos(self): return self.get_bool(constants.setting.DASH_VIDEOS, False) def include_hdr(self): - if self.get_mpd_quality() == 'mp4': - return False return self.get_bool(constants.setting.DASH_INCL_HDR, False) def use_dash_live_streams(self): @@ -204,44 +202,39 @@ def get_play_count_min_percent(self): def use_playback_history(self): return self.get_bool(constants.setting.USE_PLAYBACK_HISTORY, False) - @staticmethod - def __get_mpd_quality_map(): - return { - 0: 240, - 1: 360, - 2: 480, - 3: 720, - 4: 1080, - 5: 1440, - 6: 2160, - 7: 4320, - 8: 'mp4', - 9: 'webm' - } - - def get_mpd_quality(self): - quality_map = self.__get_mpd_quality_map() - quality_enum = self.get_int(constants.setting.MPD_QUALITY_SELECTION, 8) - return quality_map.get(quality_enum, 'mp4') - - def mpd_video_qualities(self): + # Selections based on max width and min height at common (utra-)wide aspect ratios + # 8K and 4K at 32:9, 2K at 8:3, remainder at 22:9 (2.444...) + # MPD_QUALITY_SELECTION value + _QUALITY_SELECTIONS = ['mp4', # 8 (default) + 'webm', # 9 + {'width': 256, 'height': 105, 'label': '144p{0}{1}'}, # No setting + {'width': 426, 'height': 175, 'label': '240p{0}{1}'}, # 0 + {'width': 640, 'height': 263, 'label': '360p{0}{1}'}, # 1 + {'width': 854, 'height': 350, 'label': '480p{0}{1}'}, # 2 + {'width': 1280, 'height': 525, 'label': '720p{0} (HD){1}'}, # 3 + {'width': 1920, 'height': 787, 'label': '1080p{0} (FHD){1}'}, # 4 + {'width': 2560, 'height': 984, 'label': '1440p{0} (2K){1}'}, # 5 + {'width': 3840, 'height': 1080, 'label': '2160p{0} (4K){1}'}, # 6 + {'width': 7680, 'height': 3148, 'label': '4320p{0} (8K){1}'}, # 7 + {'width': 0, 'height': 0, 'label': '{2}p{0}{1}'}] # Unknown quality + + def get_mpd_video_qualities(self, list_all=False): if not self.use_dash_videos(): return [] - - quality = self.get_mpd_quality() - - if not isinstance(quality, int): - return quality - - quality_map = self.__get_mpd_quality_map() - qualities = sorted([x for x in list(quality_map.values()) - if isinstance(x, int) and x <= quality], reverse=True) - + if list_all: + # to be converted to selection index 2 + selected = 7 + else: + selected = self.get_int(constants.setting.MPD_QUALITY_SELECTION, 8) + if 8 <= selected <= 9: + # converted to selection index 0 or 1 + return self._QUALITY_SELECTIONS[selected - 8] + # converted to selection index starting from 2 + qualities = self._QUALITY_SELECTIONS[2:] + del qualities[2 + selected:-1] return qualities def mpd_30fps_limit(self): - if self.include_hdr() or isinstance(self.get_mpd_quality(), str): - return False return self.get_bool(constants.setting.MPD_30FPS_LIMIT, False) def remote_friendly_search(self): diff --git a/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context.py b/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context.py index 867fb190e..17915993d 100644 --- a/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context.py +++ b/resources/lib/youtube_plugin/kodion/impl/xbmc/xbmc_context.py @@ -306,20 +306,33 @@ def inputstream_adaptive_capabilities(self, capability=None): if not use_dash or not inputstream_version: return frozenset() if capability is None else None + # Values of capability map can be any of the following: + # - required version number as string for comparison with actual installed InputStream.Adaptive version + # - any Falsey value to exclude capability regardless of version + # - True to include capability regardless of version capability_map = { 'live': '2.0.12', 'drm': '2.2.12', + # audio + 'vorbis': '2.3.14', + 'opus': '19.0.7', + 'mp4a': True, + 'ac-3': '2.1.15', + 'ec-3': '2.1.15', + 'dts': '2.1.15', + # video + 'avc1': True, + 'av01': '20.3.0', + 'vp8': False, 'vp9': '2.3.14', 'vp9.2': '2.3.14', - 'vorbis': None, - 'opus': '19.0.7', - 'av1': '20.3.0', } if capability is None: ia_loose_version = utils.loose_version(inputstream_version) capabilities = frozenset(key for key, version in capability_map.items() - if version and ia_loose_version >= utils.loose_version(version)) + if version is True + or version and ia_loose_version >= utils.loose_version(version)) return capabilities return capability_map.get(capability) diff --git a/resources/lib/youtube_plugin/youtube/helper/video_info.py b/resources/lib/youtube_plugin/youtube/helper/video_info.py index acd0545be..0b3cc836f 100644 --- a/resources/lib/youtube_plugin/youtube/helper/video_info.py +++ b/resources/lib/youtube_plugin/youtube/helper/video_info.py @@ -290,9 +290,10 @@ class VideoInfo(object): '271': {'container': 'webm', 'dash/video': True, 'video': {'height': 1440, 'encoding': 'vp9'}}, - '272': {'container': 'webm', + '272': {'container': 'webm', # was VP9 2160p30 'dash/video': True, - 'video': {'height': 2160, 'encoding': 'vp9'}}, + 'fps': 60, + 'video': {'height': 4320, 'encoding': 'vp9'}}, '278': {'container': 'webm', 'dash/video': True, 'video': {'height': 144, 'encoding': 'vp9'}}, @@ -395,6 +396,14 @@ class VideoInfo(object): 'dash/video': True, 'fps': 30, 'video': {'height': 2160, 'encoding': 'av1'}}, + '402': {'container': 'mp4', + 'dash/video': True, + 'fps': 30, + 'video': {'height': 4320, 'encoding': 'av1'}}, + '571': {'container': 'mp4', + 'dash/video': True, + 'fps': 30, + 'video': {'height': 4320, 'encoding': 'av1'}}, '694': {'container': 'mp4', 'dash/video': True, 'fps': 60, @@ -442,65 +451,80 @@ class VideoInfo(object): 'video': {'height': 4320, 'encoding': 'av1'}}, # === Dash (audio only) '139': {'container': 'mp4', - 'sort': [48, 0], - 'title': 'aac@48', + 'sort': [0, 48 * 0.9], + 'title': 'he-aac@48', 'dash/audio': True, 'audio': {'bitrate': 48, 'encoding': 'aac'}}, '140': {'container': 'mp4', - 'sort': [129, 0], - 'title': 'aac@128', + 'sort': [0, 128 * 0.9], + 'title': 'aac-lc@128', 'dash/audio': True, 'audio': {'bitrate': 128, 'encoding': 'aac'}}, '141': {'container': 'mp4', - 'sort': [143, 0], - 'title': 'aac@256', + 'sort': [0, 256 * 0.9], + 'title': 'aac-lc@256', 'dash/audio': True, 'audio': {'bitrate': 256, 'encoding': 'aac'}}, '256': {'container': 'mp4', - 'title': 'aac/itag 256', + 'sort': [0, 192 * 0.9], + 'title': 'he-aac@192', 'dash/audio': True, - 'unsupported': True, - 'audio': {'bitrate': 0, 'encoding': 'aac'}}, + 'audio': {'bitrate': 192, 'encoding': 'aac'}}, '258': {'container': 'mp4', - 'title': 'aac/itag 258', + 'sort': [0, 384 * 0.9], + 'title': 'aac-lc@384', 'dash/audio': True, - 'unsupported': True, - 'audio': {'bitrate': 0, 'encoding': 'aac'}}, + 'audio': {'bitrate': 384, 'encoding': 'aac'}}, '325': {'container': 'mp4', - 'title': 'dtse/itag 325', + 'sort': [0, 384 * 1.3], + 'title': 'dtse@384', 'dash/audio': True, - 'unsupported': True, - 'audio': {'bitrate': 0, 'encoding': 'aac'}}, + 'audio': {'bitrate': 384, 'encoding': 'dtse'}}, + '327': {'container': 'mp4', + 'sort': [0, 256 * 0.9], + 'title': 'aac-lc@256', + 'dash/audio': True, + 'audio': {'bitrate': 256, 'encoding': 'aac'}}, '328': {'container': 'mp4', - 'title': 'ec-3/itag 328', + 'sort': [0, 384 * 1.2], + 'title': 'ec-3@384', 'dash/audio': True, - 'unsupported': True, - 'audio': {'bitrate': 0, 'encoding': 'aac'}}, + 'audio': {'bitrate': 384, 'encoding': 'ec-3'}}, '171': {'container': 'webm', - 'sort': [128, 0], + 'sort': [0, 128 * 0.75], 'title': 'vorbis@128', 'dash/audio': True, 'audio': {'bitrate': 128, 'encoding': 'vorbis'}}, '172': {'container': 'webm', - 'sort': [142, 0], + 'sort': [0, 192 * 0.75], 'title': 'vorbis@192', 'dash/audio': True, 'audio': {'bitrate': 192, 'encoding': 'vorbis'}}, '249': {'container': 'webm', - 'sort': [50, 0], + 'sort': [0, 50], 'title': 'opus@50', 'dash/audio': True, 'audio': {'bitrate': 50, 'encoding': 'opus'}}, '250': {'container': 'webm', - 'sort': [70, 0], + 'sort': [0, 70], 'title': 'opus@70', 'dash/audio': True, 'audio': {'bitrate': 70, 'encoding': 'opus'}}, '251': {'container': 'webm', - 'sort': [141, 0], + 'sort': [0, 160], 'title': 'opus@160', 'dash/audio': True, 'audio': {'bitrate': 160, 'encoding': 'opus'}}, + '338': {'container': 'webm', + 'sort': [0, 480], + 'title': 'opus@480', + 'dash/audio': True, + 'audio': {'bitrate': 480, 'encoding': 'opus'}}, + '380': {'container': 'mp4', + 'sort': [0, 384 * 1.1], + 'title': 'ac-3@384', + 'dash/audio': True, + 'audio': {'bitrate': 384, 'encoding': 'ac-3'}}, # === DASH adaptive audio only '9997': {'container': 'mpd', 'sort': [-1, 0], @@ -1263,7 +1287,7 @@ def _method_get_video_info(self): stream_details['audio']['bitrate'] = bitrate if not video_info: stream_details['title'] = '{0}@{1}'.format(codec, bitrate) - if audio_info['lang']: + if audio_info['lang'] not in {'', 'und'}: stream_details['title'] += ' Multi-language' video_stream.update(stream_details) @@ -1298,6 +1322,8 @@ def parse_to_stream_list(streams): 'headers': curl_headers, 'playback_stats': playback_stats} stream.update(yt_format) + if 'audioTrack' in stream_map: + stream['title'] = '{0} {1}'.format(stream['title'], stream_map['audioTrack']['displayName']) stream_list.append(stream) # extract streams from map @@ -1319,11 +1345,18 @@ def generate_mpd(self, adaptive_fmts, duration, license_url): self._context.log_debug('Failed to create directories: %s' % basepath) return None + qualities = self._context.get_settings().get_mpd_video_qualities() + if isinstance(qualities, str): + max_quality = None + selected_container = qualities + qualities = self._context.get_settings().get_mpd_video_qualities(list_all=True) + else: + max_quality = qualities[-2] + selected_container = None + qualities = list(enumerate(qualities)) + ia_capabilities = self._context.inputstream_adaptive_capabilities() - mpd_quality = self._context.get_settings().get_mpd_quality() - quality_type = isinstance(mpd_quality, str) and mpd_quality or '' - quality_height = isinstance(mpd_quality, int) and mpd_quality or 0 - hdr = self._context.get_settings().include_hdr() and {'vp9.2', 'av1'} & ia_capabilities + include_hdr = self._context.get_settings().include_hdr() limit_30fps = self._context.get_settings().mpd_30fps_limit() ipaddress = self._context.get_settings().httpd_listen() @@ -1336,14 +1369,33 @@ def generate_mpd(self, adaptive_fmts, duration, license_url): 60: 1001 } + bitrate_bias_map = { + # video - order based on comparative compression ratio + 'av01': 1, + 'vp9': 0.75, + 'vp8': 0.55, + 'avc1': 0.5, + # audio - order based on preference + 'vorbis': 0.75, + 'mp4a': 0.9, + 'opus': 1, + 'ac-3': 1.1, + 'ec-3': 1.2, + 'dts': 1.3, + } + data = {} - preferred_audio = '' + preferred_audio = { + 'id': '', + 'language_code': None, + 'audio_type': 0, + } for stream_map in adaptive_fmts: mime_type = stream_map.get('mimeType') if not mime_type: continue - itag = str(stream_map.get('itag', '')) + itag = stream_map.get('itag') if not itag: continue @@ -1362,52 +1414,97 @@ def generate_mpd(self, adaptive_fmts, duration, license_url): continue mime_type, codecs = unquote(mime_type).split('; ') - codec = re.match(r'codecs="([a-z0-9]+)', codecs) + codec = re.match(r'codecs="([a-z0-9]+([.\-][0-9](?="))?)', codecs) if codec: codec = codec.group(1) + if codec.startswith('dts'): + codec = 'dts' media_type, container = mime_type.split('/') - if ((quality_type and container != quality_type) - or (mime_type == 'video/webm' and not {'vp9', 'vp9.2'} & ia_capabilities) - or (mime_type == 'audio/webm' and not {'vorbis', 'opus'} & ia_capabilities) - or (codec in {'av01', 'av1'} and 'av1' not in ia_capabilities)): + if ((selected_container and container != selected_container) + or codec not in ia_capabilities): continue - if 'audioTrack' in stream_map: - audio_track = stream_map['audioTrack'] - language_code = audio_track.get('id', '')[0:2] - label = audio_track.get('displayName', '') - audio_type = 'main' if audio_track.get('audioIsDefault') else 'dub' - height = None - width = None - key = '{0}_{1}'.format(mime_type, language_code) - if language_code == self._language_base: - preferred_audio = '_'+language_code - elif media_type == 'audio': - language_code = '' - label = '' - audio_type = 'main' - height = None - width = None - key = mime_type + if media_type == 'audio': + if 'audioTrack' in stream_map: + audio_track = stream_map['audioTrack'] + language = audio_track.get('id', '') + if '.' in language: + language_code, audio_type = language.split('.') + audio_type = int(audio_type) + else: + language_code = language or 'und' + audio_type = 4 + if audio_type == 4 or audio_track.get('audioIsDefault'): + role = 'main' + label = 'Original' + elif audio_type == 3: + role = 'dub' + label = 'Dubbed' + elif audio_type == 2: + role = 'description' + label = 'Descriptive' + # Unsure of what other audio types are actually available + # Role set to "alternate" as default fallback + else: + role = 'alternate' + label = 'Alternate' + label = '{0} - {1:.0f} kbps'.format(label, + stream_map.get('averageBitrate', 0) / 1000) + key = '{0}_{1}'.format(mime_type, language) + if (language_code == self._language_base and ( + not preferred_audio['id'] + or role == 'main' + or audio_type > preferred_audio['audio_type'] + )): + preferred_audio = { + 'id': '_'+language, + 'language_code': language_code, + 'audio_type': audio_type, + } + else: + language_code = 'und' + role = 'main' + label = 'Original - {0:.0f} kbps'.format(stream_map.get('averageBitrate', 0) / 1000) + key = mime_type + sample_rate = int(stream_map.get('audioSampleRate', '0'), 10) + channels = stream_map.get('audioChannels', 2) + height = width = fps = frame_rate = hdr = None else: + # Could use "zxx" language code for Non-Linguistic, Not Applicable + # but that is too verbose language_code = '' - label = stream_map.get('qualityLabel', '') - audio_type = None height = stream_map.get('height') width = stream_map.get('width') + if height > width: + compare_width = height + compare_height = width + else: + compare_width = width + compare_height = height + if max_quality and compare_width > max_quality['width']: + continue + fps = stream_map.get('fps', 0) + if limit_30fps and fps > 30: + continue + hdr = 'HDR' in stream_map.get('qualityLabel', '') + if hdr and not include_hdr: + continue + # map frame rates to a more common representation to lessen the chance of double refresh changes + # sometimes 30 fps is 30 fps, more commonly it is 29.97 fps (same for all mapped frame rates) + if fps: + frame_rate = '{0}/{1}'.format(fps * 1000, fps_scale_map.get(fps, 1000)) + else: + frame_rate = None + for idx, quality in qualities: + if compare_width <= quality['width']: + if compare_height < quality['height']: + quality = qualities[idx - 1][1] + break + label = quality['label'].format(fps if fps > 30 else '', + ' HDR' if hdr else '', + compare_height) key = mime_type - - # map frame rates to a more common representation to lessen the chance of double refresh changes - # sometimes 30 fps is 30 fps, more commonly it is 29.97 fps (same for all mapped frame rates) - fps = stream_map.get('fps', 0) - if fps: - frame_rate = '{0}/{1}'.format(fps * 1000, fps_scale_map.get(fps, 1000)) - else: - frame_rate = None - - if ((not hdr and 'HDR' in label) or (limit_30fps and fps > 30) - or (height and 0 < quality_height < height)): - continue + role = sample_rate = channels = None if key not in data: data[key] = {} @@ -1416,6 +1513,9 @@ def generate_mpd(self, adaptive_fmts, duration, license_url): url = self._process_url_params(url) url = url.replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">") + bitrate = stream_map.get('bitrate', 0) + biased_bitrate = bitrate * bitrate_bias_map.get(codec, 1) + data[key][itag] = { 'mimeType': mime_type, 'baseUrl': url, @@ -1427,15 +1527,17 @@ def generate_mpd(self, adaptive_fmts, duration, license_url): 'width': width, 'height': height, 'label': label, - 'bitrate': stream_map.get('bitrate', 0), + 'bitrate': bitrate, + 'biasedBitrate': biased_bitrate, 'fps': fps, 'frameRate': frame_rate, + 'hdr': hdr, 'indexRange': '{start}-{end}'.format(**index_range), 'initRange': '{start}-{end}'.format(**init_range), 'lang': language_code, - 'audioType': audio_type, - 'sampleRate': int(stream_map.get('audioSampleRate', '0'), 10), - 'channels': stream_map.get('audioChannels'), + 'role': role, + 'sampleRate': sample_rate, + 'channels': channels, } if 'video/mp4' not in data and 'video/webm' not in data: @@ -1448,14 +1550,12 @@ def _stream_sort(stream): return ( stream['height'], stream['fps'], - hdr and ('HDR' in stream['label']), - # Prefer lower bitrate for video streams - # Used to preference more advanced codecs - -stream['bitrate'], + stream['hdr'], + stream['biasedBitrate'], ) if stream['mediaType'] == 'video' else ( stream['channels'], stream['sampleRate'], - stream['bitrate'], + stream['biasedBitrate'], ) data = { @@ -1473,8 +1573,8 @@ def _stream_sort(stream): else: stream_info['video'] = data['video/webm'][0] - mp4_audio = data.get('audio/mp4'+preferred_audio, [None])[0] - webm_audio = data.get('audio/webm'+preferred_audio, [None])[0] + mp4_audio = data.get('audio/mp4'+preferred_audio['id'], [None])[0] + webm_audio = data.get('audio/webm'+preferred_audio['id'], [None])[0] if _stream_sort(mp4_audio) > _stream_sort(webm_audio): stream_info['audio'] = mp4_audio else: @@ -1492,21 +1592,24 @@ def _stream_sort(stream): for streams in data.values(): default = False original = False + impaired = False + label = '' main_stream = streams[0] media_type = main_stream['mediaType'] if media_type == 'video': - # InputStream.Adaptive seems to mark any video AdaptationSet as default - # regardless of Role or default attribute if stream_info[media_type] == main_stream: default = True - video_type = 'main' + role = 'main' else: - video_type = 'alternate' + role = 'alternate' original = '' elif media_type == 'audio': label = main_stream['label'] - audio_type = main_stream['audioType'] - original = audio_type == 'main' + role = main_stream['role'] + if role == 'main': + original = True + elif role == 'description': + impaired = True if stream_info[media_type] == main_stream: default = True # Use main audio stream with same container format as video stream @@ -1521,8 +1624,16 @@ def _stream_sort(stream): ' id="', str(set_id), '"' ' mimeType="', main_stream['mimeType'], '"' ' lang="', main_stream['lang'], '"' + # name attribute is ISA specific and does not exist in the MPD spec + # Should be a child Label element instead + ' name="', label, '"' + # original, default and impaired are ISA specific attributes ' original="', str(original).lower(), '"' - ' default="', str(default).lower(), '">\n' + ' default="', str(default).lower(), '"' + ' impaired="', str(impaired).lower(), '">\n' + # AdaptationSet Label element not currently used by ISA + '\t\t\t\n' + '\t\t\t\n' )) if license_url: @@ -1533,35 +1644,36 @@ def _stream_sort(stream): '\t\t\t\n' )) + num_streams = len(streams) if media_type == 'audio': - # InputStream.Adaptive seems to mark any AdaptationSet with a Role as default - # regardless of what the role is. Omit Role as a workaround - # out_list.extend(( - # '\t\t\t\n', - # '\t\t\t\n' - # )) out_list.extend((( - '\t\t\t\n' + '\t\t\t\n' '\t\t\t\t\n' + # Representation Label element not currently used by ISA + '\t\t\t\t\n' '\t\t\t\t{baseUrl}\n' '\t\t\t\t\n' '\t\t\t\t\t\n' '\t\t\t\t\n' '\t\t\t\n' - ).format(**stream) for stream in streams)) + ).format(quality=(idx + 1), priority=(num_streams - idx), **stream) for idx, stream in enumerate(streams))) elif media_type == 'video': - out_list.extend(( - '\t\t\t\n' - )) out_list.extend((( - '\t\t\t\n' + '\t\t\t\n' + # Representation Label element not currently used by ISA '\t\t\t\t\n' '\t\t\t\t{baseUrl}\n' '\t\t\t\t\n' '\t\t\t\t\t\n' '\t\t\t\t\n' '\t\t\t\n' - ).format(**stream) for stream in streams)) + ).format(quality=(idx + 1), priority=(num_streams - idx), **stream) for idx, stream in enumerate(streams))) out_list.append('\t\t\n') set_id += 1 diff --git a/resources/settings.xml b/resources/settings.xml index 14bfc4524..c13e8d5af 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -268,25 +268,17 @@ false - - true - 8 - + true - + 0 false - - true - false - 8 - 9 - + true