# -*- coding: utf-8 -*-
"""

    Copyright (C) 2014-2016 bromix (plugin.video.youtube)
    Copyright (C) 2016-2025 plugin.video.youtube

    SPDX-License-Identifier: GPL-2.0-only
    See LICENSES/GPL-2.0-only for more information.
"""

from __future__ import absolute_import, division, unicode_literals

from base64 import urlsafe_b64encode
from json import dumps as json_dumps, loads as json_loads
from os import path as os_path
from random import choice as random_choice
from re import compile as re_compile, sub as re_sub

from .data_client import YouTubeDataClient
from .subtitles import SUBTITLE_SELECTIONS, Subtitles
from ..helper.ratebypass import ratebypass
from ..helper.signature.cipher import Cipher
from ..helper.utils import THUMB_TYPES, THUMB_URL
from ..youtube_exceptions import YouTubeException
from ...kodion import logging
from ...kodion.compatibility import (
    entity_escape,
    parse_qs,
    quote,
    unescape,
    unquote,
    urlencode,
    urlsplit,
    urlunsplit,
    xbmcvfs,
)
from ...kodion.constants import INCOGNITO, PATHS, TEMP_PATH, VALUE_TO_STR
from ...kodion.network import get_connect_address
from ...kodion.utils.datetime import fromtimestamp
from ...kodion.utils.file_system import make_dirs
from ...kodion.utils.methods import merge_dicts
from ...kodion.utils.redact import redact_ip_in_uri


class YouTubePlayerClient(YouTubeDataClient):
    log = logging.getLogger(__name__)

    BASE_PATH = make_dirs(TEMP_PATH)

    FORMAT = {
        # === Non-DASH ===
        '5': {'container': 'flv',
              'title': '240p',
              'sort': [240, 0],
              'video': {'height': 240, 'codec': 'h.263'},
              'audio': {'bitrate': 64, 'codec': 'mp3'}},
        '6': {'container': 'flv',  # Discontinued
              'discontinued': True,
              'video': {'height': 270, 'codec': 'h.263'},
              'audio': {'bitrate': 64, 'codec': 'mp3'}},
        '13': {'container': '3gp',  # Discontinued
               'discontinued': True,
               'video': {'codec': 'h.264'},
               'audio': {'codec': 'aac'}},
        '17': {'container': '3gp',
               'title': '144p',
               'video': {'height': 144, 'codec': 'h.264'},
               'audio': {'bitrate': 24, 'codec': 'aac'}},
        '18': {'container': 'mp4',
               'title': '360p',
               'video': {'height': 360, 'codec': 'h.264'},
               'audio': {'bitrate': 96, 'codec': 'aac'}},
        '22': {'container': 'mp4',
               'title': '720p',
               'video': {'height': 720, 'codec': 'h.264'},
               'audio': {'bitrate': 192, 'codec': 'aac'}},
        '34': {'container': 'flv',  # Discontinued
               'discontinued': True,
               'video': {'height': 360, 'codec': 'h.264'},
               'audio': {'bitrate': 128, 'codec': 'aac'}},
        '35': {'container': 'flv',  # Discontinued
               'discontinued': True,
               'video': {'height': 480, 'codec': 'h.264'},
               'audio': {'bitrate': 128, 'codec': 'aac'}},
        '36': {'container': '3gp',
               'title': '240p',
               'video': {'height': 240, 'codec': 'h.264'},
               'audio': {'bitrate': 32, 'codec': 'aac'}},
        '37': {'container': 'mp4',
               'title': '1080p',
               'video': {'height': 1080, 'codec': 'h.264'},
               'audio': {'bitrate': 192, 'codec': 'aac'}},
        '38': {'container': 'mp4',
               'title': '3072p',
               'video': {'height': 3072, 'codec': 'h.264'},
               'audio': {'bitrate': 192, 'codec': 'aac'}},
        '43': {'container': 'webm',
               'title': '360p',
               'video': {'height': 360, 'codec': 'vp8'},
               'audio': {'bitrate': 128, 'codec': 'vorbis'}},
        '44': {'container': 'webm',  # Discontinued
               'discontinued': True,
               'video': {'height': 480, 'codec': 'vp8'},
               'audio': {'bitrate': 128, 'codec': 'vorbis'}},
        '45': {'container': 'webm',  # Discontinued
               'discontinued': True,
               'video': {'height': 720, 'codec': 'vp8'},
               'audio': {'bitrate': 192, 'codec': 'vorbis'}},
        '46': {'container': 'webm',  # Discontinued
               'discontinued': True,
               'video': {'height': 1080, 'codec': 'vp8'},
               'audio': {'bitrate': 192, 'codec': 'vorbis'}},
        '59': {'container': 'mp4',
               'title': '480p',
               'video': {'height': 480, 'codec': 'h.264'},
               'audio': {'bitrate': 96, 'codec': 'aac'}},
        '78': {'container': 'mp4',
               'title': '360p',
               'video': {'height': 360, 'codec': 'h.264'},
               'audio': {'bitrate': 96, 'codec': 'aac'}},
        # === 3D ===
        '82': {'container': 'mp4',
               '3D': True,
               'title': '3D 360p',
               'video': {'height': 360, 'codec': 'h.264'},
               'audio': {'bitrate': 96, 'codec': 'aac'}},
        '83': {'container': 'mp4',
               '3D': True,
               'title': '3D 240p',
               'video': {'height': 240, 'codec': 'h.264'},
               'audio': {'bitrate': 96, 'codec': 'aac'}},
        '84': {'container': 'mp4',
               '3D': True,
               'title': '3D 720p',
               'video': {'height': 720, 'codec': 'h.264'},
               'audio': {'bitrate': 192, 'codec': 'aac'}},
        '85': {'container': 'mp4',
               '3D': True,
               'title': '3D 1080p',
               'video': {'height': 1080, 'codec': 'h.264'},
               'audio': {'bitrate': 192, 'codec': 'aac'}},
        '100': {'container': 'webm',
                '3D': True,
                'title': '3D 360p',
                'video': {'height': 360, 'codec': 'vp8'},
                'audio': {'bitrate': 128, 'codec': 'vorbis'}},
        '101': {'container': 'webm',  # Discontinued
                'discontinued': True,
                '3D': True,
                'title': '3D 360p',
                'video': {'height': 360, 'codec': 'vp8'},
                'audio': {'bitrate': 192, 'codec': 'vorbis'}},
        '102': {'container': 'webm',  # Discontinued
                'discontinued': True,
                '3D': True,
                'video': {'height': 720, 'codec': 'vp8'},
                'audio': {'bitrate': 192, 'codec': 'vorbis'}},
        # === Live Streams ===
        '91': {'container': 'ts',
               'title': '144p',
               'video': {'height': 144, 'codec': 'h.264'},
               'audio': {'bitrate': 48, 'codec': 'aac'}},
        '92': {'container': 'ts',
               'title': '240p',
               'video': {'height': 240, 'codec': 'h.264'},
               'audio': {'bitrate': 48, 'codec': 'aac'}},
        '93': {'container': 'ts',
               'title': '360p',
               'video': {'height': 360, 'codec': 'h.264'},
               'audio': {'bitrate': 128, 'codec': 'aac'}},
        '94': {'container': 'ts',
               'title': '480p',
               'video': {'height': 480, 'codec': 'h.264'},
               'audio': {'bitrate': 128, 'codec': 'aac'}},
        '95': {'container': 'ts',
               'title': '720p',
               'video': {'height': 720, 'codec': 'h.264'},
               'audio': {'bitrate': 256, 'codec': 'aac'}},
        '96': {'container': 'ts',
               'title': '1080p',
               'video': {'height': 1080, 'codec': 'h.264'},
               'audio': {'bitrate': 256, 'codec': 'aac'}},
        '120': {'container': 'flv',  # Discontinued
                'discontinued': True,
                'live': True,
                'title': 'Live 720p',
                'video': {'height': 720, 'codec': 'h.264'},
                'audio': {'bitrate': 128, 'codec': 'aac'}},
        '127': {'container': 'ts',
                'live': True,
                'audio': {'bitrate': 96, 'codec': 'aac'}},
        '128': {'container': 'ts',
                'live': True,
                'audio': {'bitrate': 96, 'codec': 'aac'}},
        '132': {'container': 'ts',
                'title': '240p',
                'video': {'height': 240, 'codec': 'h.264'},
                'audio': {'bitrate': 48, 'codec': 'aac'}},
        '151': {'container': 'ts',
                'live': True,
                'unsupported': True,
                'title': 'Live 72p',
                'video': {'height': 72, 'codec': 'h.264'},
                'audio': {'bitrate': 24, 'codec': 'aac'}},
        '300': {'container': 'ts',
                'title': '720p',
                'video': {'height': 720, 'codec': 'h.264'},
                'audio': {'bitrate': 128, 'codec': 'aac'}},
        '301': {'container': 'ts',
                'title': '1080p',
                'video': {'height': 1080, 'codec': 'h.264'},
                'audio': {'bitrate': 128, 'codec': 'aac'}},
        # === DASH (video only)
        '597': {'container': 'mp4',
                'dash/video': True,
                'fps': 12,
                'video': {'height': 144, 'codec': 'h.264'}},
        '133': {'container': 'mp4',
                'dash/video': True,
                'video': {'height': 240, 'codec': 'h.264'}},
        '134': {'container': 'mp4',
                'dash/video': True,
                'video': {'height': 360, 'codec': 'h.264'}},
        '135': {'container': 'mp4',
                'dash/video': True,
                'video': {'height': 480, 'codec': 'h.264'}},
        '136': {'container': 'mp4',
                'dash/video': True,
                'video': {'height': 720, 'codec': 'h.264'}},
        '214': {'container': 'mp4',
                'dash/video': True,
                'video': {'height': 720, 'codec': 'h.264'}},
        '137': {'container': 'mp4',
                'dash/video': True,
                'video': {'height': 1080, 'codec': 'h.264'}},
        '216': {'container': 'mp4',
                'dash/video': True,
                'video': {'height': 1080, 'codec': 'h.264'}},
        '138': {'container': 'mp4',  # Discontinued
                'discontinued': True,
                'dash/video': True,
                'video': {'height': 2160, 'codec': 'h.264'}},
        '160': {'container': 'mp4',
                'dash/video': True,
                'video': {'height': 144, 'codec': 'h.264'}},
        '167': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 360, 'codec': 'vp8'}},
        '168': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 480, 'codec': 'vp8'}},
        '169': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 720, 'codec': 'vp8'}},
        '170': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 1080, 'codec': 'vp8'}},
        '218': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 480, 'codec': 'vp8'}},
        '219': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 480, 'codec': 'vp8'}},
        '242': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 240, 'codec': 'vp9'}},
        '243': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 360, 'codec': 'vp9'}},
        '244': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 480, 'codec': 'vp9'}},
        '247': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 720, 'codec': 'vp9'}},
        '248': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 1080, 'codec': 'vp9'}},
        '356': {'container': 'webm',
                'title': 'Premium 1080p',
                'dash/video': True,
                'video': {'height': 1080, 'codec': 'vp9'}},
        '779': {'container': 'webm',
                'title': '1080p vertical',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 480, 'width': 1080, 'codec': 'vp9'}},
        '780': {'container': 'webm',
                'title': 'Premium 1080p vertical',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 480, 'width': 1080, 'codec': 'vp9'}},
        '788': {'container': 'mp4',
                'title': '608p',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 608, 'width': 1080, 'codec': 'av1'}},
        '264': {'container': 'mp4',
                'dash/video': True,
                'video': {'height': 1440, 'codec': 'h.264'}},
        '266': {'container': 'mp4',
                'dash/video': True,
                'video': {'height': 2160, 'codec': 'h.264'}},
        '271': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 1440, 'codec': 'vp9'}},
        '272': {'container': 'webm',  # was VP9 2160p30
                'dash/video': True,
                'fps': 60,
                'video': {'height': 4320, 'codec': 'vp9'}},
        '598': {'container': 'webm',
                'dash/video': True,
                'fps': 12,
                'video': {'height': 144, 'codec': 'vp9'}},
        '278': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 144, 'codec': 'vp9'}},
        '298': {'container': 'mp4',
                'dash/video': True,
                'fps': 60,
                'video': {'height': 720, 'codec': 'h.264'}},
        '299': {'container': 'mp4',
                'dash/video': True,
                'fps': 60,
                'video': {'height': 1080, 'codec': 'h.264'}},
        '302': {'container': 'webm',
                'dash/video': True,
                'fps': 60,
                'video': {'height': 720, 'codec': 'vp9'}},
        '303': {'container': 'webm',
                'dash/video': True,
                'fps': 60,
                'video': {'height': 1080, 'codec': 'vp9'}},
        '308': {'container': 'webm',
                'dash/video': True,
                'fps': 60,
                'video': {'height': 1440, 'codec': 'vp9'}},
        '313': {'container': 'webm',
                'dash/video': True,
                'video': {'height': 2160, 'codec': 'vp9'}},
        '315': {'container': 'webm',
                'dash/video': True,
                'fps': 60,
                'video': {'height': 2160, 'codec': 'vp9'}},
        '330': {'container': 'webm',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 144, 'codec': 'vp9.2'}},
        '331': {'container': 'webm',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 240, 'codec': 'vp9.2'}},
        '332': {'container': 'webm',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 360, 'codec': 'vp9.2'}},
        '333': {'container': 'webm',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 480, 'codec': 'vp9.2'}},
        '334': {'container': 'webm',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 720, 'codec': 'vp9.2'}},
        '335': {'container': 'webm',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 1080, 'codec': 'vp9.2'}},
        '336': {'container': 'webm',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 1440, 'codec': 'vp9.2'}},
        '337': {'container': 'webm',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 2160, 'codec': 'vp9.2'}},
        '394': {'container': 'mp4',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 144, 'codec': 'av1'}},
        '395': {'container': 'mp4',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 240, 'codec': 'av1'}},
        '396': {'container': 'mp4',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 360, 'codec': 'av1'}},
        '397': {'container': 'mp4',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 480, 'codec': 'av1'}},
        '398': {'container': 'mp4',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 720, 'codec': 'av1'}},
        '399': {'container': 'mp4',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 1080, 'codec': 'av1'}},
        '400': {'container': 'mp4',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 1440, 'codec': 'av1'}},
        '401': {'container': 'mp4',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 2160, 'codec': 'av1'}},
        '402': {'container': 'mp4',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 4320, 'codec': 'av1'}},
        '571': {'container': 'mp4',
                'title': 'Premium 4k',
                'dash/video': True,
                'fps': 30,
                'video': {'height': 4320, 'codec': 'av1'}},
        '694': {'container': 'mp4',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 144, 'codec': 'av1'}},
        '695': {'container': 'mp4',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 240, 'codec': 'av1'}},
        '696': {'container': 'mp4',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 360, 'codec': 'av1'}},
        '697': {'container': 'mp4',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 480, 'codec': 'av1'}},
        '698': {'container': 'mp4',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 720, 'codec': 'av1'}},
        '699': {'container': 'mp4',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 1080, 'codec': 'av1'}},
        '700': {'container': 'mp4',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 1440, 'codec': 'av1'}},
        '701': {'container': 'mp4',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 2160, 'codec': 'av1'}},
        '702': {'container': 'mp4',
                'dash/video': True,
                'fps': 60,
                'hdr': True,
                'video': {'height': 4320, 'codec': 'av1'}},
        # === Dash (audio only)
        '599': {'container': 'mp4',
                'title': 'he-aac@32',
                'dash/audio': True,
                'audio': {'bitrate': 32, 'codec': 'aac'}},
        '139': {'container': 'mp4',
                'title': 'he-aac@48',
                'dash/audio': True,
                'audio': {'bitrate': 48, 'codec': 'aac'}},
        '140': {'container': 'mp4',
                'title': 'aac-lc@128',
                'dash/audio': True,
                'audio': {'bitrate': 128, 'codec': 'aac'}},
        '141': {'container': 'mp4',
                'title': 'aac-lc@256',
                'dash/audio': True,
                'audio': {'bitrate': 256, 'codec': 'aac'}},
        '256': {'container': 'mp4',
                'title': 'he-aac@192',
                'dash/audio': True,
                'audio': {'bitrate': 192, 'codec': 'aac'}},
        '258': {'container': 'mp4',
                'title': 'aac-lc@384',
                'dash/audio': True,
                'audio': {'bitrate': 384, 'codec': 'aac'}},
        '325': {'container': 'mp4',
                'title': 'dtse@384',
                'dash/audio': True,
                'audio': {'bitrate': 384, 'codec': 'dtse'}},
        '327': {'container': 'mp4',
                'title': 'aac-lc@256',
                'dash/audio': True,
                'audio': {'bitrate': 256, 'codec': 'aac'}},
        '328': {'container': 'mp4',
                'title': 'ec-3@384',
                'dash/audio': True,
                'audio': {'bitrate': 384, 'codec': 'ec-3'}},
        '171': {'container': 'webm',
                'title': 'vorbis@128',
                'dash/audio': True,
                'audio': {'bitrate': 128, 'codec': 'vorbis'}},
        '172': {'container': 'webm',
                'title': 'vorbis@192',
                'dash/audio': True,
                'audio': {'bitrate': 192, 'codec': 'vorbis'}},
        '600': {'container': 'webm',
                'title': 'opus@40',
                'dash/audio': True,
                'audio': {'bitrate': 40, 'codec': 'opus'}},
        '249': {'container': 'webm',
                'title': 'opus@50',
                'dash/audio': True,
                'audio': {'bitrate': 50, 'codec': 'opus'}},
        '250': {'container': 'webm',
                'title': 'opus@70',
                'dash/audio': True,
                'audio': {'bitrate': 70, 'codec': 'opus'}},
        '251': {'container': 'webm',
                'title': 'opus@160',
                'dash/audio': True,
                'audio': {'bitrate': 160, 'codec': 'opus'}},
        '338': {'container': 'webm',
                'title': 'opus@480',
                'dash/audio': True,
                'audio': {'bitrate': 480, 'codec': 'opus'}},
        '380': {'container': 'mp4',
                'title': 'ac-3@384',
                'dash/audio': True,
                'audio': {'bitrate': 384, 'codec': 'ac-3'}},
        # === HLS
        '229': {'container': 'hls',
                'title': '240p',
                'hls/video': True,
                'video': {'height': 240, 'codec': 'h.264'}},
        '230': {'container': 'hls',
                'title': '360p',
                'hls/video': True,
                'video': {'height': 360, 'codec': 'h.264'}},
        '231': {'container': 'hls',
                'title': '480p',
                'hls/video': True,
                'video': {'height': 480, 'codec': 'h.264'}},
        '232': {'container': 'hls',
                'title': '720p',
                'hls/video': True,
                'video': {'height': 720, 'codec': 'h.264'}},
        '379': {'container': 'hls',
                'title': 'Premium 720p',
                'hls/video': True,
                'video': {'height': 720, 'codec': 'h.264'}},
        '269': {'container': 'hls',
                'title': '144p',
                'hls/video': True,
                'video': {'height': 144, 'codec': 'h.264'}},
        '270': {'container': 'hls',
                'title': 'Premium 1080p',
                'hls/video': True,
                'video': {'height': 1080, 'codec': 'h.264'}},
        '311': {'container': 'hls',
                'title': '720p60',
                'hls/video': True,
                'fps': 60,
                'video': {'height': 720, 'codec': 'h.264'}},
        '312': {'container': 'hls',
                'title': 'Premium 1080p60',
                'hls/video': True,
                'fps': 60,
                'video': {'height': 1080, 'codec': 'h.264'}},
        '602': {'container': 'hls',
                'title': '144p15',
                'hls/video': True,
                'fps': 15,
                'video': {'height': 144, 'codec': 'vp9'}},
        '603': {'container': 'hls',
                'title': '144p',
                'hls/video': True,
                'video': {'height': 144, 'codec': 'vp9'}},
        '604': {'container': 'hls',
                'title': '240p',
                'hls/video': True,
                'video': {'height': 240, 'codec': 'vp9'}},
        '605': {'container': 'hls',
                'title': '360p',
                'hls/video': True,
                'video': {'height': 360, 'codec': 'vp9'}},
        '606': {'container': 'hls',
                'title': '480p',
                'hls/video': True,
                'video': {'height': 480, 'codec': 'vp9'}},
        '609': {'container': 'hls',
                'title': '720p',
                'hls/video': True,
                'video': {'height': 720, 'codec': 'vp9'}},
        '612': {'container': 'hls',
                'title': '720p60',
                'hls/video': True,
                'fps': 60,
                'video': {'height': 720, 'codec': 'vp9'}},
        '614': {'container': 'hls',
                'title': '1080p',
                'hls/video': True,
                'video': {'height': 1080, 'codec': 'vp9'}},
        '616': {'container': 'hls',
                'title': 'Premium 1080p',
                'hls/video': True,
                'video': {'height': 1080, 'codec': 'vp9'}},
        '617': {'container': 'hls',
                'title': 'Premium 1080p60',
                'hls/video': True,
                'fps': 60,
                'video': {'height': 1080, 'codec': 'vp9'}},
        '620': {'container': 'hls',
                'title': '1440p',
                'hls/video': True,
                'video': {'height': 1440, 'codec': 'vp9'}},
        '623': {'container': 'hls',
                'title': '1440p@60',
                'hls/video': True,
                'fps': 60,
                'video': {'height': 1440, 'codec': 'vp9'}},
        '625': {'container': 'hls',
                'title': '4k',
                'hls/video': True,
                'video': {'height': 2160, 'codec': 'vp9'}},
        '628': {'container': 'hls',
                'title': '4k@60',
                'hls/video': True,
                'fps': 60,
                'video': {'height': 2160, 'codec': 'vp9'}},
        '631': {'container': 'hls',
                'title': '144p',
                'hls/video': True,
                'hdr': True,
                'video': {'height': 144, 'codec': 'vp9.2'}},
        '632': {'container': 'hls',
                'title': '240p',
                'hls/video': True,
                'hdr': True,
                'video': {'height': 240, 'codec': 'vp9.2'}},
        '633': {'container': 'hls',
                'title': '360p',
                'hls/video': True,
                'hdr': True,
                'video': {'height': 360, 'codec': 'vp9.2'}},
        '634': {'container': 'hls',
                'title': '480p',
                'hls/video': True,
                'hdr': True,
                'video': {'height': 480, 'codec': 'vp9.2'}},
        '635': {'container': 'hls',
                'title': '720p',
                'hls/video': True,
                'hdr': True,
                'video': {'height': 720, 'codec': 'vp9.2'}},
        '636': {'container': 'hls',
                'title': '1080p',
                'hls/video': True,
                'hdr': True,
                'video': {'height': 1080, 'codec': 'vp9.2'}},
        '639': {'container': 'hls',
                'title': '1440p',
                'hls/video': True,
                'hdr': True,
                'video': {'height': 1440, 'codec': 'vp9.2'}},
        '642': {'container': 'hls',
                'title': '4k',
                'hls/video': True,
                'hdr': True,
                'video': {'height': 2160, 'codec': 'vp9.2'}},
        '9993': {'container': 'hls',
                 'title': 'HLS',
                 'hls/audio': True,
                 'hls/video': True,
                 'sort': 9993,
                 'audio': {'bitrate': 0, 'codec': ''},
                 'video': {'height': 0, 'codec': ''}},
        '9994': {'container': 'hls',
                 'title': 'Adaptive HLS',
                 'hls/audio': True,
                 'hls/video': True,
                 'adaptive': True,
                 'sort': 9994,
                 'audio': {'bitrate': 0, 'codec': ''},
                 'video': {'height': 0, 'codec': ''}},
        # === Live HLS
        '9995': {'container': 'hls',
                 'live': True,
                 'title': 'Live HLS',
                 'hls/audio': True,
                 'hls/video': True,
                 'sort': 9995,
                 'audio': {'bitrate': 0, 'codec': ''},
                 'video': {'height': 0, 'codec': ''}},
        # === Live HLS adaptive
        '9996': {'container': 'hls',
                 'live': True,
                 'title': 'Adaptive Live HLS',
                 'hls/audio': True,
                 'hls/video': True,
                 'adaptive': True,
                 'sort': 9996,
                 'audio': {'bitrate': 0, 'codec': ''},
                 'video': {'height': 0, 'codec': ''}},
        # === DASH adaptive audio only
        '9997': {'container': 'mpd',
                 'title': 'DASH Audio',
                 'dash/audio': True,
                 'adaptive': True,
                 'sort': 9997,
                 'audio': {'bitrate': 0, 'codec': ''}},
        # === Live DASH adaptive
        '9998': {'container': 'mpd',
                 'live': True,
                 'title': 'Live DASH',
                 'dash/audio': True,
                 'dash/video': True,
                 'adaptive': True,
                 'sort': 9998,
                 'audio': {'bitrate': 0, 'codec': ''},
                 'video': {'height': 0, 'codec': ''}},
        # === DASH adaptive
        '9999': {'container': 'mpd',
                 'title': 'DASH',
                 'dash/audio': True,
                 'dash/video': True,
                 'adaptive': True,
                 'sort': 9999,
                 'audio': {'bitrate': 0, 'codec': ''},
                 'video': {'height': 0, 'codec': ''}}
    }

    INTEGER_FPS_SCALE = {
        0: '{0}000/1000',  # --.00 fps
        24: '24000/1000',  # 24.00 fps
        25: '25000/1000',  # 25.00 fps
        30: '30000/1000',  # 30.00 fps
        48: '48000/1000',  # 48.00 fps
        50: '50000/1000',  # 50.00 fps
        60: '60000/1000',  # 60.00 fps
    }
    FRACTIONAL_FPS_SCALE = {
        0: '{0}000/1000',  # --.00 fps
        24: '24000/1001',  # 23.976 fps
        25: '25000/1000',  # 25.00 fps
        30: '30000/1001',  # 29.97 fps
        48: '48000/1000',  # 48.00 fps
        50: '50000/1000',  # 50.00 fps
        60: '60000/1001',  # 59.94 fps
    }

    QUALITY_FACTOR = {
        # video - order based on comparative compression ratio
        'av01': 1,
        'vp9.2': 0.75,
        'vp9': 0.75,
        'vp8': 0.55,
        'vp08': 0.55,
        'avc1': 0.5,
        'h.264': 0.5,
        'h.263': 0.4,
        # audio - order based on preference
        'mp3': 0.5,
        'vorbis': 0.75,
        'aac': 0.9,
        'mp4a': 0.9,
        'opus': 1,
        'ac-3': 1.1,
        'ec-3': 1.2,
        'dts': 1.3,
        'dtse': 1.3,
    }

    LANG_ROLE_DETAILS = {
        '4':  ('original', 'main', -1),
        '3':  ('dub', 'dub', -2),
        '6':  ('secondary', 'alternate', -3),
        '10': ('dub.auto', 'dub', -4),
        '2':  ('descriptive', 'description', -5),
        '0':  ('alt', 'alternate', -6),
        '-1': ('original', 'main', -6),
    }

    FAILURE_REASONS = {
        'abort': frozenset((
            'country',
            'not available',
        )),
        'auth': frozenset((
            'not a bot',
            'please sign in',
        )),
        'reauth': frozenset((
            'confirm your age',
            'inappropriate',
            'member',
        )),
        'retry': frozenset((
            'try again later',
            'unavailable',
            'unknown',
        )),
        'skip': frozenset((
            'error code: 6',
            'latest version',
        )),
    }

    def __init__(self,
                 context,
                 clients=None,
                 **kwargs):
        self.video_id = None
        self.yt_item = None

        settings = context.get_settings()
        self._ask_for_quality = settings.ask_for_video_quality()
        self._audio_only = settings.audio_only()
        self._use_mpd = settings.use_mpd_videos()

        audio_language, prefer_default = context.get_player_language()
        if audio_language == 'mediadefault':
            self._language_base = settings.get_language()[0:2]
        elif audio_language == 'original':
            self._language_base = ''
        else:
            self._language_base = audio_language
        self._language_prefer_default = prefer_default

        self._player_js = None
        # signatureCipher and nsig handling currently broken and disabled
        # self._calculate_n = True
        # self._cipher = None
        self._calculate_n = False
        self._cipher = False

        self._visitor_data = {
            'current': None,
            INCOGNITO: None,
        }
        self._visitor_data_key = 'current'
        self._auth_client = {}
        self._client_groups = (
            ('custom', clients if clients else ()),
            ('auth_enabled|initial_request|no_playable_streams', (
                'tv_unplugged',
                'tv',
            )),
            ('auth_disabled|kids|av1|vp9|vp9.2|avc1|stereo_sound|multi_audio', (
                'ios_testsuite_params',
            )),
            ('auth_disabled|kids|av1|vp9.2|avc1|surround_sound|multi_audio', (
                'android_testsuite_params',
            )),
            ('auth_enabled|no_kids|av1|vp9.2|avc1|surround_sound', (
                'android_vr',
            )),
            ('mpd', (
            )),
            ('ask', (
            )),
        )

        super(YouTubePlayerClient, self).__init__(context=context, **kwargs)

    @staticmethod
    def _player_error_hook(**kwargs):
        exc = kwargs.pop('exc')
        json_data = getattr(exc, 'json_data', None)
        if getattr(exc, 'pass_data', False):
            data = json_data
        else:
            data = None
        if getattr(exc, 'raise_exc', False):
            exception = YouTubeException
        else:
            exception = None

        if not json_data or 'error' not in json_data:
            info = (
                'video_id: {video_id!r}',
                'Client:   {client_name!r}',
                'Auth:     {has_auth!r}',
            )
            return None, info, None, data, exception

        info = (
            'Reason:   {error_reason}',
            'Message:  {error_message}',
            'video_id: {video_id!r}',
            'Client:   {client_name!r}',
            'Auth:     {has_auth!r}',
        )
        details = json_data['error']
        details = {
            'error_reason': (
                    (details.get('errors') or [{}])[0].get('reason')
                    or 'Unknown'
            ),
            'error_message': details.get('message') or 'Unknown error',
        }
        return None, info, details, data, exception

    @staticmethod
    def _generate_cpn(_alphabet=('abcdefghijklmnopqrstuvwxyz'
                                 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
                                 '0123456789-_')):
        # https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L1381
        # LICENSE: The Unlicense
        # cpn generation algorithm is reverse engineered from base.js.
        # In fact it works even with dummy cpn.
        return ''.join([random_choice(_alphabet) for _ in range(16)])

    def _get_stream_format(self, itag, info=None, max_height=None, **kwargs):
        yt_format = self.FORMAT.get(itag)
        if not yt_format:
            return None
        if yt_format.get('discontinued') or yt_format.get('unsupported'):
            return False

        yt_format = yt_format.copy()
        manual_sort = yt_format.get('sort', 0)
        av_label = [yt_format['container']]

        if info:
            if 'video' in yt_format:
                if self._audio_only:
                    del yt_format['video']
                else:
                    video_info = info['video'] or {}
                    yt_format['title'] = video_info.get('label', '')
                    yt_format['video']['codec'] = video_info.get('codec', '')
                    yt_format['video']['height'] = video_info.get('height', 0)

            audio_info = info.get('audio') or {}
            yt_format['audio']['codec'] = audio_info.get('codec', '')
            yt_format['audio']['bitrate'] = audio_info.get('bitrate', 0) // 1000

        video_info = yt_format.get('video')
        if video_info:
            video_height = video_info.get('height', 0)
            if max_height and video_height > max_height:
                return False
            codec = video_info.get('codec')
            if codec:
                video_sort = video_height * self.QUALITY_FACTOR.get(codec, 1)
                av_label.append(codec)
            else:
                video_sort = video_height
        else:
            video_sort = -1

        audio_info = yt_format.get('audio')
        if audio_info:
            codec = audio_info.get('codec')
            bitrate = audio_info.get('bitrate', 0)
            audio_sort = bitrate * self.QUALITY_FACTOR.get(codec, 1)
            if bitrate:
                av_label.append('@'.join((codec, str(bitrate))))
            elif codec:
                av_label.append(codec)
        else:
            audio_sort = 0

        yt_format['sort'] = [
            manual_sort,
            video_sort,
            audio_sort,
        ]

        if kwargs:
            kwargs.update(yt_format)
            yt_format = kwargs

        yt_format['title'] = ''.join((
            self._context.get_ui().bold(yt_format['title']),
            ' (',
            ' / '.join(av_label),
            ')'
        ))
        return yt_format

    def _get_player_config(self, client_name='web', embed=False):
        video_id = self.video_id
        if embed:
            url = self.BASE_URL + '/embed/%s' % video_id
        else:
            url = self.WATCH_URL.format(_video_id=video_id)
        # Manually configured cookies to avoid cookie consent redirect
        cookies = {'SOCS': 'CAISAiAD'}

        client_data = {'json': {'videoId': self.video_id}}
        client = self.build_client(client_name, client_data)

        result = self.request(
            url,
            cookies=cookies,
            headers=client['headers'],
            response_hook=self._response_hook_text,
            error_title='Failed to get player html',
            video_id=self.video_id,
            error_hook=self._player_error_hook,
            client_name=client_name,
            has_auth=False,
            cache=False,
        )
        if not result:
            return None

        # pattern source is from youtube-dl
        # https://github.com/ytdl-org/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L313
        # LICENSE: The Unlicense
        match = re_compile(r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;').search(result)
        if match:
            return json_loads(match.group(1))
        return None

    @staticmethod
    def _get_player_client(config):
        return config.get('INNERTUBE_CONTEXT', {}).get('client', {})

    def _get_player_key(self, html):
        if not html:
            return None

        pattern = 'INNERTUBE_API_KEY":"'
        start_index = html.find(pattern)
        if start_index != -1:
            start_index += len(pattern)
            end_index = html.find('"', start_index)
            player_key = html[start_index:end_index]
            self.log.debug('Player key found: %r', player_key)
            return player_key
        return None

    def _get_player_js(self):
        data_cache = self._context.get_data_cache()
        cached = data_cache.get_item('player_js_url', data_cache.ONE_HOUR * 4)
        cached = cached and cached.get('url', '')
        js_url = cached if cached not in {'', 'http://', 'https://'} else None

        if not js_url:
            player_config = self._get_player_config()
            if not player_config:
                return ''
            js_url = player_config.get('PLAYER_JS_URL')

        if not js_url:
            context = player_config.get('WEB_PLAYER_CONTEXT_CONFIGS', {})
            for configs in context.values():
                if 'jsUrl' in configs:
                    js_url = configs['jsUrl']
                    break

        if not js_url:
            return ''

        js_url = self._normalize_url(js_url)
        data_cache.set_item('player_js_url', {'url': js_url})

        js_cache_key = quote(js_url)
        cached = data_cache.get_item(js_cache_key, data_cache.ONE_HOUR * 4)
        cached = cached and cached.get('js')
        if cached:
            return cached

        client_name = 'web'
        client_data = {'json': {'videoId': self.video_id}}
        client = self.build_client(client_name, client_data)

        result = self.request(
            js_url,
            headers=client['headers'],
            response_hook=self._response_hook_text,
            error_title='Failed to get player JavaScript',
            error_hook=self._player_error_hook,
            video_id=self.video_id,
            client_name=client_name,
            has_auth=False,
            cache=False,
        )
        if not result:
            return ''

        data_cache.set_item(js_cache_key, {'js': result})
        return result

    @staticmethod
    def _prepare_headers(headers, cookies=None, new_headers=None):
        if cookies or new_headers:
            headers = headers.copy()
        if cookies:
            headers['Cookie'] = '; '.join([
                '='.join((cookie.name, cookie.value)) for cookie in cookies
            ])
        if new_headers:
            headers.update(new_headers)
        return headers

    def _process_mpd(self,
                     stream_list,
                     responses,
                     meta_info=None,
                     playback_stats=None):
        if not responses:
            return

        if meta_info is None:
            meta_info = {'video': {},
                         'channel': {},
                         'thumbnails': {},
                         'subtitles': []}

        if playback_stats is None:
            playback_stats = {}

        itag = '9998'

        for client_name, response in responses.items():
            if itag in stream_list:
                break

            url = response['mpd_manifest']
            if not url:
                continue

            headers = response['client']['headers']

            url_components = urlsplit(url)
            if url_components.query:
                params = dict(parse_qs(url_components.query))
                params['mpd_version'] = ['7']
                url = url_components._replace(
                    query=urlencode(params, doseq=True),
                ).geturl()
            else:
                path = re_sub(
                    r'/mpd_version/\d+|/?$',
                    '/mpd_version/7',
                    url_components.path,
                )
                url = url_components._replace(
                    path=path,
                ).geturl()

            stream_list[itag] = self._get_stream_format(
                itag=itag,
                title='',
                url=url,
                meta=meta_info,
                headers=headers,
                playback_stats=playback_stats,
            )

    def _process_hls(self,
                     stream_list,
                     responses,
                     is_live=False,
                     meta_info=None,
                     playback_stats=None):
        if not responses:
            return

        if meta_info is None:
            meta_info = {'video': {},
                         'channel': {},
                         'thumbnails': {},
                         'subtitles': []}

        if playback_stats is None:
            playback_stats = {}

        context = self._context
        settings = context.get_settings()
        if self._use_mpd:
            qualities = settings.mpd_video_qualities()
            selected_height = qualities[0]['nom_height']
        else:
            selected_height = settings.fixed_video_quality()

        # Regular expression used to capture the URL of a HLS m3u8 playlist and
        # the itag from that URL.
        # The playlist might include a #EXT-X-MEDIA entry, but it's usually
        # for a default stream with itag 133 (240p) and can be ignored.
        re_playlist_data = re_compile(
            r'#EXT-X-STREAM-INF[^#]+'
            r'(?P<url>http\S+/itag/(?P<itag>\d+)\S+)'
        )
        itags = ('9995', '9996') if is_live else ('9993', '9994')

        for client_name, response in responses.items():
            url = response['hls_manifest']
            if not url:
                continue

            headers = response['client']['headers']

            result = self.request(
                url,
                headers=headers,
                response_hook=self._response_hook_text,
                error_title='Failed to get HLS manifest',
                error_hook=self._player_error_hook,
                video_id=self.video_id,
                client_name=client_name,
                has_auth=False,
                cache=False,
            )
            if not result:
                continue

            for itag in itags:
                if itag in stream_list:
                    continue

                stream_list[itag] = self._get_stream_format(
                    itag=itag,
                    title='',
                    url=url,
                    meta=meta_info,
                    headers=headers,
                    playback_stats=playback_stats,
                )

            for match in re_playlist_data.finditer(result):
                itag = match.group('itag')
                if itag in stream_list:
                    continue

                yt_format = self._get_stream_format(
                    itag=itag,
                    max_height=selected_height,
                    title='',
                    url=match.group('url'),
                    meta=meta_info,
                    headers=headers,
                    playback_stats=playback_stats,
                )
                if yt_format is None:
                    stream_info = redact_ip_in_uri(match.group(1))
                    self.log.debug(('Unknown itag - {itag}',
                                    '{stream}'),
                                   itag=itag,
                                   stream=stream_info)
                if (not yt_format
                        or (yt_format.get('hls/video')
                            and not yt_format.get('hls/audio'))):
                    continue

                if is_live:
                    yt_format['live'] = True
                    yt_format['title'] = 'Live ' + yt_format['title']

                stream_list[itag] = yt_format

    def _process_progressive_streams(self,
                                     stream_list,
                                     responses,
                                     is_live=False,
                                     use_adaptive=False,
                                     meta_info=None,
                                     playback_stats=None):
        if not responses:
            return

        if meta_info is None:
            meta_info = {'video': {},
                         'channel': {},
                         'thumbnails': {},
                         'subtitles': []}

        if playback_stats is None:
            playback_stats = {}

        context = self._context
        settings = context.get_settings()
        if self._use_mpd:
            qualities = settings.mpd_video_qualities()
            selected_height = qualities[0]['nom_height']
        else:
            selected_height = settings.fixed_video_quality()

        for client_name, response in responses.items():
            streams = response['progressive_fmts']
            if use_adaptive:
                _streams = response['adaptive_fmts']
                if _streams:
                    if streams:
                        streams += _streams
                    else:
                        streams = _streams
            if not streams:
                continue

            headers = response['client']['headers']

            for stream_map in streams:
                itag = str(stream_map['itag'])
                if itag in stream_list:
                    continue

                url = stream_map.get('url')
                conn = stream_map.get('conn')
                stream = stream_map.get('stream')

                if not url and conn and stream:
                    new_url = '%s?%s' % (conn, unquote(stream))
                elif not url and 'signatureCipher' in stream_map:
                    new_url = self._process_signature_cipher(stream_map)
                else:
                    new_url = url

                new_url = self._process_url_params(new_url,
                                                   mpd=False,
                                                   headers=headers,
                                                   referrer=None,
                                                   visitor_data=None)
                if not new_url:
                    continue

                stream_map['itag'] = itag
                yt_format = self._get_stream_format(
                    itag=itag,
                    max_height=selected_height,
                    title='',
                    url=new_url,
                    meta=meta_info,
                    headers=headers,
                    playback_stats=playback_stats,
                )
                if yt_format is None:
                    if url:
                        stream_map['url'] = redact_ip_in_uri(url)
                    if conn:
                        stream_map['conn'] = redact_ip_in_uri(conn)
                    if stream:
                        stream_map['stream'] = redact_ip_in_uri(stream)
                    self.log.debug(('Unknown itag - {itag}',
                                    '{stream}'),
                                   itag=itag,
                                   stream=stream_map)
                if (not yt_format
                        or (yt_format.get('dash/video')
                            and not yt_format.get('dash/audio'))):
                    continue

                if is_live:
                    yt_format['live'] = True
                    yt_format['title'] = 'Live ' + yt_format['title']

                audio_track = stream_map.get('audioTrack')
                if audio_track:
                    track_id = audio_track['id']
                    track_name = audio_track['displayName']
                    is_default = audio_track['audioIsDefault']
                    itag = '.'.join((
                        itag,
                        track_id,
                    ))
                    yt_format['title'] = ' '.join((
                        yt_format['title'],
                        track_name,
                    )).strip()
                    yt_format['sort'].extend((
                        track_id.startswith(self._language_base),
                        is_default or 'original' in track_name,
                        track_name,
                    ))

                stream_list[itag] = yt_format

    def _process_signature_cipher(self, stream_map):
        if self._cipher is None:
            self.log.debug('signatureCipher detected')
            if self._player_js is None:
                self._player_js = self._get_player_js()
            self._cipher = Cipher(self._context, javascript=self._player_js)
        if not self._cipher:
            self.log.warning('signatureCipher handling disabled')
            return None

        signature_cipher = parse_qs(stream_map['signatureCipher'])
        url = signature_cipher.get('url', [None])[0]
        encrypted_signature = signature_cipher.get('s', [None])[0]
        query_var = signature_cipher.get('sp', ['signature'])[0]

        if not url or not encrypted_signature:
            return None

        data_cache = self._context.get_data_cache()
        signature = data_cache.get_item(encrypted_signature,
                                        data_cache.ONE_HOUR * 4)
        signature = signature and signature.get('sig')
        if not signature:
            try:
                signature = self._cipher.get_signature(encrypted_signature)
            except Exception:
                self.log.exception(('Failed to extract URL', 'Signature: %r'),
                                   encrypted_signature)
                self._cipher = False
                return None
            data_cache.set_item(encrypted_signature, {'sig': signature})

        if signature:
            url = ''.join((url, '&', query_var, '=', signature))
            return url
        return None

    def _process_url_params(self,
                            url,
                            mpd=True,
                            headers=None,
                            cpn=False,
                            referrer=False,
                            visitor_data=False,
                            method='POST',
                            digits_re=re_compile(r'\d+')):
        if not url:
            return url

        parts = urlsplit(url)
        params = parse_qs(parts.query)
        new_params = {}

        if 'n' not in params:
            pass
        elif not self._calculate_n:
            self.log.debug('Decoding of nsig value disabled')
            return None
        else:
            if self._player_js is None:
                self._player_js = self._get_player_js()
            if self._calculate_n is True:
                self.log.debug('Detected nsig in stream url')
                self._calculate_n = ratebypass.CalculateN(self._player_js)

            # Cipher n to get the updated value
            new_n = self._calculate_n.calculate_n(params['n'][0])
            if new_n:
                new_params['n'] = new_n
                new_params['ratebypass'] = ['yes']
            else:
                self.log.error('nsig handling failed')
                self._calculate_n = False

        if 'lmt' in params:
            snippet = (self.yt_item or {}).get('snippet')
            if snippet and 'publishedAt' not in snippet:
                try:
                    modified = fromtimestamp(int(params['lmt'][0]) // 1000000)
                except (OSError, OverflowError, ValueError):
                    modified = None
                snippet['publishedAt'] = modified

        if headers:
            if visitor_data is not False:
                headers.setdefault(
                    'X-Goog-Visitor-Id',
                    visitor_data or self._visitor_data[self._visitor_data_key],
                )
            if referrer is not False:
                headers.setdefault(
                    'Referer',
                    referrer
                    or 'https://www.youtube.com/watch?v=%s' % self.video_id,
                )

        if mpd:
            new_params['__id'] = self.video_id
            new_params['__method'] = method
            new_params['__host'] = [parts.hostname]
            new_params['__path'] = parts.path
            new_params['__headers'] = urlsafe_b64encode(
                json_dumps(headers or {}).encode('utf-8')
            )

            if 'mn' in params and 'fvip' in params:
                fvip = params['fvip'][0]
                primary, _, secondary = params['mn'][0].partition(',')
                prefix, separator, server = parts.hostname.partition('---')
                if primary and secondary:
                    new_params['__host'].append(separator.join((
                        digits_re.sub(fvip, prefix),
                        server.replace(primary, secondary),
                    )))

            if cpn is not False:
                new_params['cpn'] = cpn or self._generate_cpn()

            params.update(new_params)
            query_str = urlencode(params, doseq=True)

            return parts._replace(
                scheme='http',
                netloc=get_connect_address(self._context, as_netloc=True),
                path=PATHS.STREAM_PROXY,
                query=query_str,
            ).geturl()

        elif 'ratebypass' not in params and 'range' not in params:
            content_length = params.get('clen', [''])[0]
            new_params['range'] = '0-{0}'.format(content_length)

        if new_params:
            params.update(new_params)
            query_str = urlencode(params, doseq=True)
            return parts._replace(query=query_str).geturl()

        return parts.geturl()

    def _process_captions(self, subtitles, responses):
        all_subs = SUBTITLE_SELECTIONS['all']
        default_lang = None
        subs_data = None

        for client_name, response in responses.items():
            captions = response['captions']
            client = response['client']
            use_subtitles = client.get('_use_subtitles')

            if (not captions
                    or not use_subtitles
                    or (use_subtitles is not True
                        and subtitles.sub_selection == all_subs)):
                continue

            subtitles.load(captions, client['headers'].copy())
            default_lang = subtitles.get_lang_details()
            subs_data = subtitles.get_subtitles()
            if subs_data or subs_data is False:
                return default_lang, subs_data

        video_id = self.video_id
        client_data = {
            'json': {
                'videoId': video_id,
            },
            'url': self.V1_API_URL,
            'method': 'POST',
            '_endpoint': 'player',
            '_visitor_data': self._visitor_data[self._visitor_data_key],
        }

        for client_name in ('tv_unplugged', 'web'):
            client = self.build_client(client_name, client_data)
            if not client:
                continue
            result = self.request(
                response_hook=self._response_hook_json,
                error_title='Caption player request failed',
                error_hook=self._player_error_hook,
                video_id=video_id,
                client_name=client_name,
                has_auth=client.get('_has_auth'),
                cache=False,
                **client
            )

            if result is None:
                continue
            captions = result.get('captions')
            if captions:
                subtitles.load(captions, client['headers'])
                default_lang = subtitles.get_lang_details()
                subs_data = subtitles.get_subtitles()
                if subs_data or subs_data is False:
                    return default_lang, subs_data

        return default_lang, subs_data

    def _get_error_details(self,
                           playability_status,
                           details=('errorScreen', (
                                   ('playerErrorMessageRenderer',
                                    'reason'),
                                   ('confirmDialogRenderer',
                                    'title'),
                                   ('playerCaptchaViewModel',
                                    'accessibility',
                                    'accessibilityData',
                                    'label'),
                           ))):
        if not playability_status:
            return None

        result = self.json_traverse(playability_status, details)
        if not result or not isinstance(result, dict) or 'runs' not in result:
            return result

        detail_texts = [
            text['text']
            for text in result['runs']
            if text and 'text' in text and text['text']
        ]
        if detail_texts:
            return ''.join(detail_texts)
        if 'simpleText' in result:
            return result['simpleText']
        return None

    def load_stream_info(self,
                         video_id,
                         ask_for_quality=None,
                         audio_only=None,
                         incognito=None,
                         use_mpd=None):
        self.video_id = video_id

        if ask_for_quality is None:
            ask_for_quality = self._ask_for_quality
        else:
            self._ask_for_quality = ask_for_quality

        if audio_only is None:
            audio_only = self._audio_only
        else:
            self._audio_only = audio_only

        if incognito is None:
            incognito = self._context.get_param(INCOGNITO, False)
        if incognito:
            visitor_data_key = self._visitor_data_key = INCOGNITO
            self._visitor_data[visitor_data_key] = None
        else:
            visitor_data_key = self._visitor_data_key = 'current'

        if use_mpd is None:
            use_mpd = self._use_mpd
        else:
            self._use_mpd = use_mpd

        context = self._context
        settings = context.get_settings()
        age_gate_enabled = settings.age_gate()
        use_remote_history = settings.use_remote_history()

        _client_name = None
        _client = None
        _has_auth = None
        _result = None
        _video_details = None
        _microformat = None
        _streaming_data = None
        _playability = None
        _status = None
        _reason = None

        visitor_data = self._visitor_data[visitor_data_key]
        video_details = {}
        microformat = {}
        responses = {}
        stream_list = {}

        fail = self.FAILURE_REASONS
        abort = False

        logged_in = self.logged_in
        client_data = {
            'json': {
                'videoId': video_id,
            },
            'url': self.V1_API_URL,
            'method': 'POST',
            '_access_tokens': {
                'user': (self._access_tokens.get('user')
                         if (self._configs.get('user', {})
                             .get('token-allowed', True)) else
                         None),
                'tv': self._access_tokens.get('tv'),
                'vr': self._access_tokens.get('vr'),
            },
            '_endpoint': 'player',
            '_cpn': None,
            '_visitor_data': visitor_data,
        }
        if use_remote_history:
            client_data['_auth_type'] = 'user'
            client_data['_auth_requested'] = True

        for name, clients in self._client_groups:
            if not clients:
                continue
            if name == 'mpd' and not use_mpd:
                continue
            if name == 'ask' and use_mpd and not ask_for_quality:
                continue
            if name.startswith('auth_enabled|initial_request'):
                if visitor_data and not logged_in:
                    continue
                allow_skip = False
                client_data['_auth_requested'] = True
            else:
                allow_skip = True

            exclude_retry = set()
            restart = None
            while 1:
                for _client_name in clients:
                    if _client_name in exclude_retry:
                        continue
                    client_data['_cpn'] = self._generate_cpn()
                    _client = self.build_client(_client_name, client_data)
                    if _client:
                        _has_auth = _client.get('_has_auth')
                        if _has_auth or _has_auth is False:
                            exclude_retry.add(_client_name)
                    else:
                        _has_auth = None
                        _result = None
                        _video_details = None
                        _microformat = None
                        _streaming_data = None
                        _playability = None
                        _status = None
                        _reason = None
                        continue

                    _result = self.request(
                        response_hook=self._response_hook_json,
                        error_title='Player request failed',
                        error_hook=self._player_error_hook,
                        video_id=video_id,
                        client_name=_client_name,
                        has_auth=_has_auth,
                        cache=False,
                        pass_data=True,
                        raise_exc=False,
                        **_client
                    ) or {}

                    if not visitor_data:
                        visitor_data = self.json_traverse(
                            _result,
                            (
                                'responseContext',
                                (
                                    (
                                        'visitorData',
                                    ),
                                    (
                                        'serviceTrackingParams',
                                        0,
                                        'params',
                                        {
                                            'name': 'key',
                                            'match': ('visitor_data',
                                                      'visitorData'),
                                            'out': 'value',
                                        },
                                    ),
                                ),
                            )
                        )
                        if visitor_data:
                            client_data['_visitor_data'] = visitor_data
                            self._visitor_data[visitor_data_key] = visitor_data
                    _video_details = _result.get('videoDetails', {})
                    _microformat = (_result
                                    .get('microformat', {})
                                    .get('playerMicroformatRenderer'))
                    _streaming_data = _result.get('streamingData', {})
                    _playability = _result.get('playabilityStatus', {})
                    if _playability:
                        _status = _playability.get('status', 'ERROR').upper()
                        _reason = _playability.get('reason', 'UNKNOWN')
                    else:
                        _error = _result.get('error', {})
                        _status = _error.get('status', 'ERROR').upper()
                        _reason = _error.get('message', 'UNKNOWN')

                    if (_video_details
                            and video_id != _video_details.get('videoId')):
                        _status = 'CONTENT_NOT_AVAILABLE_IN_THIS_APP'
                        _reason = 'Watch on the latest version of YouTube'

                    if (age_gate_enabled
                            and _playability.get('desktopLegacyAgeGateReason')):
                        abort = True
                        break
                    elif _status == 'LIVE_STREAM_OFFLINE':
                        abort = True
                        break
                    elif _status == 'OK':
                        break
                    elif not _playability or _status in {
                        'AGE_CHECK_REQUIRED',
                        'AGE_VERIFICATION_REQUIRED',
                        'CONTENT_CHECK_REQUIRED',
                        'LOGIN_REQUIRED',
                        'CONTENT_NOT_AVAILABLE_IN_THIS_APP',
                        'ERROR',
                        'UNPLAYABLE',
                    }:
                        self.log.warning(('Failed to retrieve video info',
                                          'Status:   {status}',
                                          'Reason:   {reason}',
                                          'video_id: {video_id!r}',
                                          'Client:   {client!r}',
                                          'Auth:     {has_auth!r}'),
                                         status=_status,
                                         reason=_reason or 'UNKNOWN',
                                         video_id=video_id,
                                         client=_client_name,
                                         has_auth=_has_auth)
                        fail_reason = _reason.lower()
                        if any(why in fail_reason for why in fail['auth']):
                            if _has_auth:
                                restart = False
                            elif restart is None and logged_in:
                                client_data['_auth_requested'] = True
                                restart = True
                            else:
                                continue
                            break
                        elif any(why in fail_reason for why in fail['reauth']):
                            if _client.get('_auth_required') == 'ignore_fail':
                                continue
                            elif client_data.get('_auth_required'):
                                restart = False
                                abort = True
                            elif restart is None and logged_in:
                                client_data['_auth_required'] = True
                                restart = True
                            break
                        elif any(why in fail_reason for why in fail['abort']):
                            abort = True
                            break
                        elif any(why in fail_reason for why in fail['skip']):
                            if allow_skip:
                                break
                        elif any(why in fail_reason for why in fail['retry']):
                            continue
                    else:
                        self.log.warning('Unknown playabilityStatus: {status!r}',
                                         status=_playability)
                else:
                    break
                if not restart:
                    break
                restart = False

            if abort:
                break

            if _status == 'OK':
                self.log.debug(('Retrieved video info:',
                                'video_id: {video_id!r}',
                                'Client:   {client!r}',
                                'Auth:     {has_auth!r}'),
                               video_id=video_id,
                               client=_client_name,
                               has_auth=_has_auth)

                video_details = merge_dicts(
                    _video_details,
                    video_details,
                    compare_str=True,
                )

                microformat = merge_dicts(
                    _microformat,
                    microformat,
                    compare_str=True,
                )

                if not self._auth_client and _has_auth:
                    self._auth_client = {
                        'client': _client.copy(),
                        'result': _result,
                    }
                    client_data['_auth_requested'] = False

                responses[_client_name] = {
                    'client': _client,
                    'progressive_fmts': _streaming_data.get('formats'),
                    'adaptive_fmts': _streaming_data.get('adaptiveFormats'),
                    'mpd_manifest': _streaming_data.get('dashManifestUrl'),
                    'hls_manifest': _streaming_data.get('hlsManifestUrl'),
                    'captions': _result.get('captions'),
                }

                if (not client_data.get('_auth_required')
                        and video_details.get('isPrivate')):
                    client_data['_auth_required'] = True

        if not responses:
            if _status == 'LIVE_STREAM_OFFLINE':
                if not _reason:
                    _reason = self._get_error_details(
                        _playability,
                        details=(
                            'liveStreamability',
                            'liveStreamabilityRenderer',
                            'offlineSlate',
                            'liveStreamOfflineSlateRenderer',
                            'mainText'
                        )
                    )
            elif not _reason:
                _reason = self._get_error_details(_playability)
            raise YouTubeException(_reason or 'UNKNOWN')

        self.yt_item = yt_item = {
            'id': video_id,
            'snippet': {
                'title': video_details.get('title'),
                'description': video_details.get('shortDescription'),
                'channelId': video_details.get('channelId'),
                'channelTitle': video_details.get('author'),
                'thumbnails': (video_details
                               .get('thumbnail', {})
                               .get('thumbnails', [])),
            },
            'contentDetails': {
                'duration': 'P' + video_details.get('lengthSeconds', '0') + 'S',
            },
            'statistics': {
                'viewCount': video_details.get('viewCount', ''),
            },
            '_partial': True,
        }
        is_live = video_details.get('isLiveContent') or video_details.get('hasLiveStreamingData')
        if is_live:
            is_live = video_details.get('isLive', False)
            live_dvr = video_details.get('isLiveDvrEnabled', False)
            thumb_suffix = '_live' if is_live else ''
        else:
            live_dvr = False
            thumb_suffix = ''

        meta_info = {
            'id': video_id,
            'title': unescape(video_details.get('title', '')
                              .encode('raw_unicode_escape')
                              .decode('raw_unicode_escape')),
            'status': {
                'unlisted': microformat.get('isUnlisted', False),
                'private': video_details.get('isPrivate', False),
                'crawlable': video_details.get('isCrawlable', False),
                'family_safe': microformat.get('isFamilySafe', False),
                'live': is_live,
            },
            'channel': {
                'id': video_details.get('channelId', ''),
                'author': unescape(video_details.get('author', '')
                                   .encode('raw_unicode_escape')
                                   .decode('raw_unicode_escape')),
            },
            'thumbnails': {
                thumb_type: {
                    'url': THUMB_URL.format(
                        video_id, thumb['name'], thumb_suffix
                    ),
                    'size': thumb['size'],
                    'ratio': thumb['ratio'],
                    'unverified': True,
                }
                for thumb_type, thumb in THUMB_TYPES.items()
            },
            'subtitles': None,
        }

        if use_remote_history and self._auth_client:
            playback_stats = {
                'playback_url': 'videostatsPlaybackUrl',
                'watchtime_url': 'videostatsWatchtimeUrl',
            }
            playback_tracking = (self._auth_client
                                 .get('result', {})
                                 .get('playbackTracking', {}))
            cpn = self._auth_client.get('_cpn') or self._generate_cpn()

            for key, url_key in playback_stats.items():
                url = playback_tracking.get(url_key, {}).get('baseUrl')
                if url and url.startswith('http'):
                    playback_stats[key] = '&cpn='.join((url, cpn))
                else:
                    playback_stats[key] = ''
        else:
            playback_stats = {
                'playback_url': '',
                'watchtime_url': '',
            }

        if is_live or live_dvr or ask_for_quality or not use_mpd:
            self._process_hls(
                stream_list=stream_list,
                responses=responses,
                is_live=is_live,
                meta_info=meta_info,
                playback_stats=playback_stats,
            )

        if not is_live or live_dvr:
            subtitles = Subtitles(context, video_id, use_mpd=use_mpd)
            default_lang, subs_data = self._process_captions(
                subtitles=subtitles,
                responses=responses,
            )
            if subs_data and not subtitles.use_isa:
                meta_info['subtitles'] = [
                    subtitle['url'] for subtitle in subs_data.values()
                    if 'url' in subtitle
                ]
                subs_data = None
        else:
            default_lang = None
            subs_data = None

        if not default_lang:
            default_lang = {
                'default': 'und',
                'original': 'und',
                'is_asr': False,
            }

        # extract adaptive streams and create MPEG-DASH manifest
        if use_mpd and not audio_only:
            self._process_mpd(
                stream_list=stream_list,
                responses=responses,
                meta_info=meta_info,
                playback_stats=playback_stats,
            )
            video_data, audio_data = self._process_adaptive_streams(
                responses=responses,
                default_lang_code=(default_lang['default']
                                   if default_lang['original'] == 'und' else
                                   default_lang['original']),
            )
            manifest_url, main_stream = self._generate_mpd_manifest(
                video_data, audio_data, subs_data,
            )

            if main_stream:
                yt_format = self._get_stream_format(
                    itag='9999',
                    info=main_stream,
                    title='',
                    url=manifest_url,
                    meta=meta_info,
                    headers={
                        'User-Agent': 'youtube/0.1 ({0})'.format(self.video_id),
                    },
                    playback_stats=playback_stats,
                )

                title = [yt_format['title']]

                audio_info = main_stream.get('audio') or {}
                if audio_info.get('langCode', '') not in {'', 'und'}:
                    title.extend((' ', audio_info.get('langName', '')))

                if default_lang['default'] != 'und':
                    title.extend((' [', default_lang['default'], ']'))
                elif default_lang['is_asr']:
                    title.append(' [ASR]')

                localize = context.localize
                for _prop in ('multi_language', 'multi_audio'):
                    if not main_stream.get(_prop):
                        continue
                    _prop = 'stream.' + _prop
                    title.extend((' [', localize(_prop), ']'))

                if len(title) > 1:
                    yt_format['title'] = ''.join(title)

                stream_list['9999'] = yt_format

        # extract non-adaptive streams
        if audio_only or ask_for_quality or not use_mpd:
            self._process_progressive_streams(
                stream_list=stream_list,
                responses=responses,
                is_live=is_live,
                use_adaptive=use_mpd,
                meta_info=meta_info,
                playback_stats=playback_stats,
            )

        if stream_list:
            self.log.debug(('Media details:',
                            'Status: {status!r}',
                            'Item:   {item!r}'),
                           status=meta_info['status'],
                           item=yt_item)
        else:
            raise YouTubeException('No streams found')

        return stream_list.values(), yt_item

    def _process_adaptive_streams(self,
                                  responses,
                                  default_lang_code='und',
                                  codec_re=re_compile(
                                      r'codecs='
                                      r'"((?P<codec>.+?)\.(?P<props>.+))"'
                                  )):
        context = self._context
        settings = context.get_settings()
        audio_only = self._audio_only
        qualities = settings.mpd_video_qualities()
        isa_capabilities = context.inputstream_adaptive_capabilities()
        stream_features = settings.stream_features()
        allow_3d = '3d' in stream_features
        allow_hdr = 'hdr' in stream_features
        allow_hfr = 'hfr' in stream_features
        disable_hfr_max = 'no_hfr_max' in stream_features
        allow_spa = 'spa' in stream_features
        allow_ssa = 'ssa' in stream_features
        allow_vr = 'vr' in stream_features
        prefer_dub = 'prefer_dub' in stream_features
        prefer_auto_dub = 'prefer_auto_dub' in stream_features
        fps_map = (self.INTEGER_FPS_SCALE
                   if 'no_frac_fr_hint' in stream_features else
                   self.FRACTIONAL_FPS_SCALE)
        quality_factor_map = self.QUALITY_FACTOR
        stream_select = settings.stream_select()
        localize = context.localize

        debugging = self.log.debugging

        audio_data = {}
        video_data = {}
        preferred_audio = {
            'language_code': None,
            'role_id': None,
            'role_order': None,
            'fallback': True,
        }
        default_lang = self._language_base
        prefer_default_lang = self._language_prefer_default
        lang_role_details = self.LANG_ROLE_DETAILS

        for client_name, response in responses.items():
            client = response['client']
            if not client.get('_use_adaptive', True):
                continue

            stream_data = response['adaptive_fmts']
            if not stream_data:
                continue

            log_client = debugging
            log_audio_header = None
            log_video_header = None

            for stream in stream_data:
                mime_type = stream.get('mimeType')
                if not mime_type:
                    continue

                itag_id = itag = str(stream.get('itag'))
                if not itag:
                    continue

                index_range = stream.get('indexRange')
                if not index_range:
                    continue

                init_range = stream.get('initRange')
                if not init_range:
                    continue

                url = stream.get('url')
                if not url and 'signatureCipher' in stream:
                    url = self._process_signature_cipher(stream)
                if not url:
                    continue

                mime_type, codecs = unquote(mime_type).split('; ')
                codecs = codec_re.match(codecs)
                if codecs:
                    codec = codecs.group('codec')
                    codec_properties = codecs.group('props')
                    codecs = codecs.group(1)
                    if codec.startswith(('vp9', 'vp09')):
                        codec = 'vp9'
                        preferred_codec = codec in stream_features
                        if codec_properties.startswith(('2', '02.')):
                            codec = 'vp9.2'
                    else:
                        if codec.startswith('dts'):
                            codec = 'dts'
                        preferred_codec = codec in stream_features
                    if codec not in isa_capabilities:
                        continue
                else:
                    continue
                media_type, container = mime_type.split('/')
                bitrate = stream.get('bitrate', 0)

                if media_type == 'audio':
                    data = audio_data
                    channels = stream.get('audioChannels', 2)
                    if channels > 2 and not allow_ssa:
                        continue

                    is_spa = stream.get('spatialAudioType', '')
                    if is_spa and not allow_spa:
                        continue

                    if 'audioTrack' in stream:
                        audio_track = stream['audioTrack']

                        language = audio_track.get('id', default_lang_code)
                        if '.' in language:
                            language_code, role_id = language.split('.')
                        else:
                            language_code = language
                            role_id = '4'
                    else:
                        language_code = default_lang_code
                        role_id = '-1'

                    role_details = lang_role_details.get(role_id)
                    # Unsure of what other audio types are available
                    # Role set to "alternate" as default fallback
                    if not role_details:
                        role_details = lang_role_details[0]
                    role_type, role, role_order = role_details

                    preferred_order = preferred_audio['role_order']
                    language_fallback = preferred_audio['fallback']

                    if (default_lang
                            and language_code.startswith(default_lang)):
                        is_fallback = role != 'main'
                        if role_type == 'dub.auto':
                            if prefer_auto_dub:
                                role = 'main'
                                role_order = 0
                        elif role_type == 'dub':
                            if prefer_dub:
                                role = 'main'
                                role_order = 0
                        elif prefer_default_lang:
                            role = 'main'
                            role_order = 0
                        lang_match = (
                                (language_fallback and not is_fallback)
                                or preferred_order is None
                                or role_order > preferred_order
                        )
                        language_fallback = is_fallback
                    else:
                        lang_match = (
                                language_fallback
                                and (preferred_order is None
                                     or role_order > preferred_order)
                        )
                        language_fallback = True

                    if lang_match:
                        preferred_audio = {
                            'language_code': language_code,
                            'role_id': role_id,
                            'role_order': role_order,
                            'fallback': language_fallback,
                        }
                    language = context.get_language_name(language_code)

                    sample_rate = int(stream.get('audioSampleRate', '0'), 10)

                    is_drc = stream.get('isDrc', False)
                    if is_drc:
                        itag += '.drc'

                    mime_group = (
                        mime_type,
                        language_code,
                        role_id,
                    )

                    label = '{0} ({1} kbps)'.format(
                        localize('stream.{0}'.format(role_type)),
                        bitrate // 1000,
                    )
                    if channels > 2 or 'auto' not in stream_select:
                        quality_group = (
                            container,
                            codec,
                            language_code,
                            role_id,
                        )
                    else:
                        quality_group = mime_group

                    height = width = fps = frame_rate = None
                    is_hdr = is_vr = is_3d = None

                    log_audio = debugging
                    log_video = False
                    if log_audio_header is None:
                        log_audio_header = debugging
                elif audio_only:
                    continue
                else:
                    data = video_data
                    # Could use "zxx" language code for
                    # "Non-Linguistic, Not Applicable" but that is too verbose
                    language_code = ''

                    fps = stream.get('fps', 0)
                    if fps > 30 and not allow_hfr:
                        continue

                    if 'colorInfo' in stream:
                        is_hdr = not any(
                            value.endswith('BT709')
                            for value in stream['colorInfo'].values()
                        )
                    else:
                        is_hdr = 'HDR' in stream.get('qualityLabel', '')
                    if is_hdr and not allow_hdr:
                        continue

                    is_3d = stream.get('stereoLayout', '')
                    if is_3d and not allow_3d:
                        continue

                    is_vr = stream.get('projectionType', '')
                    if is_vr:
                        if is_vr == 'RECTANGULAR':
                            is_vr = ''
                        elif not allow_vr:
                            continue

                    height = stream.get('height')
                    width = stream.get('width')
                    if height > width:
                        compare_width = height
                        compare_height = width
                    else:
                        compare_width = width
                        compare_height = height
                    # Compare video stream width against pre-computed quality
                    # selection width based on approximate aspect ratio.
                    # 1.69 ~= 0.95 * 16 / 9
                    if width / height > 1.69:
                        nom_width = 'width_16:9'
                    else:
                        nom_width = 'width_4:3'

                    bound = None
                    _disable_hfr_max = disable_hfr_max
                    for quality in qualities:
                        if compare_width > quality[nom_width]:
                            if bound:
                                if compare_height >= bound['min_height']:
                                    quality = bound
                                elif compare_height < quality['min_height']:
                                    quality = qualities[-1]
                                if fps > 30 and _disable_hfr_max:
                                    bound = None
                            break
                        _disable_hfr_max = _disable_hfr_max and not bound
                        bound = quality
                    if not bound:
                        continue

                    # map frame rates to a more common representation to lessen
                    # the chance of double refresh changes
                    if fps:
                        frame_rate = fps_map.get(fps) or fps_map[0].format(fps)
                    else:
                        frame_rate = None

                    mime_group = (
                        mime_type,
                        codec,
                        is_hdr,
                        is_vr,
                    )

                    label = quality['label'].format(
                        quality['nom_height'] or compare_height,
                        fps if fps > 30 else '',
                        ' HDR' if is_hdr else '',
                        ' 3D' if is_3d else '',
                        ' VR' if is_vr else '',
                    )
                    quality_group = (
                        container,
                        codec,
                        label,
                    )

                    channels = sample_rate = is_drc = is_spa = None
                    language = role = role_order = role_type = None

                    log_audio = False
                    log_video = debugging
                    if log_video_header is None:
                        log_video_header = debugging

                urls = self._process_url_params(
                    unquote(url),
                    headers=client['headers'],
                    cpn=client.get('_cpn'),
                )
                if not urls:
                    continue

                details = {
                    'mimeType': mime_type,
                    'baseUrl': entity_escape(urls),
                    'mediaType': media_type,
                    'container': container,
                    'codecs': codecs,
                    'codec': codec,
                    'preferred_codec': preferred_codec,
                    'id': itag,
                    'width': width,
                    'height': height,
                    'label': label,
                    'bitrate': bitrate,
                    'biasedBitrate': bitrate * quality_factor_map.get(codec, 1),
                    # integer round up
                    'duration': -(-int(stream.get('approxDurationMs', 0))
                                  // 1000),
                    'fps': fps,
                    'frameRate': frame_rate,
                    'hdr': is_hdr,
                    'projection': is_vr,
                    'stereoLayout': is_3d,
                    'indexRange': '{start}-{end}'.format(**index_range),
                    'initRange': '{start}-{end}'.format(**init_range),
                    'langCode': language_code,
                    'langName': language,
                    'role': role,
                    'roleOrder': role_order,
                    'sampleRate': sample_rate,
                    'channels': channels,
                    'drc': is_drc,
                    'spatial': is_spa,
                }
                mime_group = data.setdefault(mime_group, {})
                quality_group = data.setdefault(quality_group, {})
                mime_group[itag] = quality_group[itag] = details

                if log_client:
                    self.log.debug('{_:{_}^100}', _='=')
                    self.log.debug('Streams found for %r client:', client_name)
                    log_client = False
                if log_audio:
                    if log_audio_header:
                        self.log.debug('{_:{_}^100}', _='-')
                        self.log.debug('{itag:^3}'
                                       ' | {container:^4}'
                                       ' | {channels:^5}'
                                       ' | {bitrate:^8}'
                                       ' | {sample_rate:^9}'
                                       ' | {drc:^3}'
                                       ' | {codecs:^19}'
                                       ' | {info}',
                                       itag='ID',
                                       container='TYPE',
                                       channels='CH',
                                       bitrate='ABR',
                                       sample_rate='ASR',
                                       drc='DRC',
                                       codecs='CODECS',
                                       info='INFO')
                        self.log.debug('{_:{_}^100}', _='-')
                        log_audio_header = False
                    self.log.debug('{itag:3}'
                                   ' | {container:4}'
                                   ' | {channels:2} ch'
                                   ' | {bitrate:3} kbps'
                                   ' | {sample_rate:<5.2f} kHz'
                                   ' | {drc:^3}'
                                   ' | {codecs:19}'
                                   ' | {language}'
                                   ' {role_type}',
                                   itag=itag_id,
                                   container=container,
                                   channels=channels,
                                   bitrate=bitrate // 1000,
                                   sample_rate=sample_rate / 1000,
                                   drc='Y' if is_drc else '-',
                                   codecs='%s (%s)' % (codec, codecs),
                                   language=language,
                                   role_type=role_type)
                elif log_video:
                    if log_video_header:
                        self.log.debug('{_:{_}^100}', _='-')
                        self.log.debug('{itag:^3}'
                                       ' | {container:^4}'
                                       ' | {width:>4} x {height:<4}'
                                       ' | {fps:^6}'
                                       ' | {hdr:^3}'
                                       ' | {s3d:^3}'
                                       ' | {vr:^3}'
                                       ' | {bitrate:^11}'
                                       ' | {codecs}',
                                       itag='ID',
                                       container='TYPE',
                                       width='W',
                                       height='H',
                                       fps='FPS',
                                       hdr='HDR',
                                       s3d='3D',
                                       vr='VR',
                                       bitrate='VBR',
                                       codecs='CODECS')
                        self.log.debug('{_:{_}^100}', _='-')
                        log_video_header = False
                    self.log.debug('{itag:3}'
                                   ' | {container:4}'
                                   ' | {width:>4} x {height:<4}'
                                   ' | {fps:2} fps'
                                   ' | {hdr:^3}'
                                   ' | {s3d:^3}'
                                   ' | {vr:^3}'
                                   ' | {bitrate:6,} kbps'
                                   ' | {codecs}',
                                   itag=itag_id,
                                   container=container,
                                   width=width,
                                   height=height,
                                   fps=fps,
                                   hdr='Y' if is_hdr else '-',
                                   s3d='Y' if is_3d else '-',
                                   vr='Y' if is_vr else '-',
                                   bitrate=bitrate // 1000,
                                   codecs='%s (%s)' % (codec, codecs))

        if not video_data and not audio_only:
            self.log.debug('No video mime-types found')
            return None, None

        def _stream_sort(stream, alt_sort=('alt_sort' in stream_features)):
            if not stream:
                return (1,)

            preferred = stream['preferred_codec']
            return (
                - preferred,
                - stream['height']
                if preferred or not alt_sort else
                stream['height'],
                not stream['projection'],
                not stream['stereoLayout'],
                - stream['fps'],
                - stream['hdr'],
                - stream['biasedBitrate'],
            ) if stream['mediaType'] == 'video' else (
                - preferred,
                not stream['spatial'],
                - stream['channels'],
                - stream['biasedBitrate'],
                stream['drc'],
            )

        def _group_sort(item):
            group, streams = item
            main_stream = streams[0]

            key = (
                group[0] != main_stream['mimeType'],
            ) if main_stream['mediaType'] == 'video' else (
                group[0] != main_stream['mimeType'],
                group[-2] != preferred_audio['language_code'],
                group[-1] != preferred_audio['role_id'],
                main_stream['langName'],
                - main_stream['roleOrder'],
            )
            return key + _stream_sort(main_stream)

        video_data = sorted((
            (group, sorted(streams.values(), key=_stream_sort))
            for group, streams in video_data.items()
        ), key=_group_sort)

        audio_data = sorted((
            (group, sorted(streams.values(), key=_stream_sort))
            for group, streams in audio_data.items()
        ), key=_group_sort)

        return video_data, audio_data

    def _generate_mpd_manifest(self,
                               video_data,
                               audio_data,
                               subs_data):
        # Following line can be uncommented if needed to use mpd for audio only
        # if (not video_data and not self._audio_only) or not audio_data:
        if not video_data or not audio_data:
            return None, None

        if not self.BASE_PATH:
            self.log.error_trace('Unable to access temp directory')
            return None, None

        def _filter_group(previous_group, previous_stream, item):
            skip_group = True
            if not item:
                return skip_group
            if not previous_group or not previous_stream:
                return not skip_group

            new_group = item[0]
            new_stream = item[1][0]

            media_type = new_stream['mediaType']
            if media_type != previous_stream['mediaType']:
                return not skip_group

            if previous_group[0] == previous_stream['mimeType']:
                if new_group[0] == new_stream['container']:
                    return not skip_group

                skip_group = (
                    new_stream['height'] <= previous_stream['height']
                    if media_type == 'video' else
                    new_stream['channels'] <= previous_stream['channels']
                )
            else:
                if new_group[0] == new_stream['mimeType']:
                    return not skip_group

                skip_group = (
                    new_stream['height'] == previous_stream['height']
                    if media_type == 'video' else
                    2 == new_stream['channels'] == previous_stream['channels']
                )

            skip_group = (
                skip_group
                and new_stream['fps'] == previous_stream['fps']
                and new_stream['hdr'] == previous_stream['hdr']
                if media_type == 'video' else
                skip_group
                and new_stream['langCode'] == previous_stream['langCode']
                and new_stream['role'] == previous_stream['role']
            )
            return skip_group

        context = self._context
        settings = context.get_settings()
        stream_features = settings.stream_features()
        do_filter = 'filter' in stream_features
        frame_rate_hint = 'no_fr_hint' not in stream_features
        stream_select = settings.stream_select()
        localize = context.localize

        main_stream = {
            'audio': audio_data[0][1][0],
            'multi_audio': False,
            'multi_language': False,
        }
        if video_data:
            main_stream['video'] = video_data[0][1][0]
            duration = main_stream['video']['duration']
        else:
            duration = main_stream['audio']['duration']

        output = [
            '<?xml version="1.0" encoding="UTF-8"?>\n'
            '<MPD xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
                ' xmlns="urn:mpeg:dash:schema:mpd:2011"'
                ' xmlns:xlink="http://www.w3.org/1999/xlink"'
                ' xsi:schemaLocation="urn:mpeg:dash:schema:mpd:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd"'
                ' minBufferTime="PT1.5S"'
                ' mediaPresentationDuration="PT', str(duration), 'S"'
                ' type="static"'
                ' profiles="urn:mpeg:dash:profile:isoff-main:2011"'
                '>\n'
            '\t<Period>\n'
        ]

        set_id = 0
        group = stream = None
        languages = set()
        roles = set()
        for item in (video_data + audio_data):
            default = original = impaired = False

            if do_filter and _filter_group(group, stream, item):
                continue
            group, streams = item
            stream = streams[0]
            container = stream['container']
            media_type = stream['mediaType']
            mime_type = stream['mimeType']
            language = stream['langCode']
            role = stream['role'] or ''

            if group[0] == mime_type and 'auto' in stream_select:
                label = '{0} [{1}]'.format(
                    stream['langName']
                    or localize('stream.automatic'),
                    stream['label']
                )
                if stream == main_stream[media_type]:
                    default = True
                    role = 'main'
            elif group[0] == container and 'list' in stream_select:
                if 'auto' in stream_select or media_type == 'video':
                    label = stream['label']
                else:
                    label = ' '.join((
                        stream['langName'],
                        stream['label'],
                    )).strip()
                    if stream == main_stream[media_type]:
                        default = True
                        role = 'main'
            else:
                continue

            if role == 'main':
                if not default:
                    role = 'alternate'
                if media_type == 'audio':
                    original = stream['role'] == 'main'
                else:
                    original = True
            elif role == 'description':
                impaired = True

            languages.add(language)
            roles.add(role)

            output.extend((
                '\t\t<AdaptationSet'
                    ' subsegmentAlignment="true"'
                    ' subsegmentStartsWithSAP="1"'
                    ' bitstreamSwitching="true"'
                    ' id="', str(set_id), '"'
                    ' contentType="', media_type, '"'
                    ' mimeType="', mime_type, '"'
                    ' lang="', language, '"'
                    # name attribute is ISA specific and does not exist in the
                    # MPD spec. Should be a child Label element instead
                    ' name="[B]', label, '[/B]"'
                    # original / default / impaired are ISA specific attributes
                    ' original="', VALUE_TO_STR[original], '"'
                    ' default="', VALUE_TO_STR[default], '"'
                    ' impaired="', VALUE_TO_STR[impaired], '"'
                    '>\n'
                # AdaptationSet Label element not currently used by ISA
                '\t\t\t<Label>', label, '</Label>\n'
                '\t\t\t<Role'
                    ' schemeIdUri="urn:mpeg:dash:role:2011"'
                    ' value="', role, '"'
                    '/>\n'
            ))

            num_streams = len(streams)
            if media_type == 'audio':
                output.extend([(
                    '\t\t\t<Representation'
                        ' id="{id}"'
                        ' codecs="{codecs}"'
                        ' mimeType="{mimeType}"'
                        ' bandwidth="{bitrate}"'
                        ' sampleRate="{sampleRate}"'
                        ' numChannels="{channels}"'
                        # quality and priority attributes are not used by ISA
                        ' qualityRanking="{quality}"'
                        ' selectionPriority="{priority}"'
                        '>\n'
                    '\t\t\t\t<AudioChannelConfiguration'
                        ' schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"'
                        ' value="{channels}"'
                        '/>\n'
                    # Representation Label element is not used by ISA
                    '\t\t\t\t<Label>{label}</Label>\n'
                    '\t\t\t\t<BaseURL>{baseUrl}</BaseURL>\n'
                    # '\t\t\t\t<BaseURL>{url}</BaseURL>\n'
                    # + ''.join([''.join([
                    # '\t\t\t\t<BaseURL>', entity_escape(url), '</BaseURL>\n',
                    # ]) for url in stream['baseUrl'] if url]) +
                    '\t\t\t\t<SegmentBase indexRange="{indexRange}">\n'
                    '\t\t\t\t\t<Initialization range="{initRange}"/>\n'
                    '\t\t\t\t</SegmentBase>\n'
                    '\t\t\t</Representation>\n'
                ).format(
                    quality=(idx + 1),
                    priority=(num_streams - idx),
                    # url=entity_escape(url),
                    **stream
                )
                    for idx, stream in enumerate(streams)
                    # for url in stream['baseUrl']
                    # if url
                ])

            elif media_type == 'video':
                output.extend([(
                    '\t\t\t<Representation'
                        ' id="{id}"'
                        ' codecs="{codecs}"'
                        ' mimeType="{mimeType}"'
                        ' bandwidth="{bitrate}"'
                        ' width="{width}"'
                        ' height="{height}"' +
                        (' frameRate="{frameRate}"' if frame_rate_hint else '') +
                        # quality and priority attributes are not used by ISA
                        ' qualityRanking="{quality}"'
                        ' selectionPriority="{priority}"'
                        '>\n'
                    # Representation Label element is not used by ISA
                    '\t\t\t\t<Label>{label}</Label>\n'
                    '\t\t\t\t<BaseURL>{baseUrl}</BaseURL>\n'
                    # '\t\t\t\t<BaseURL>{url}</BaseURL>\n'
                    # + ''.join([''.join([
                    # '\t\t\t\t<BaseURL>', entity_escape(url), '</BaseURL>\n',
                    # ]) for url in stream['baseUrl'] if url]) +
                    '\t\t\t\t<SegmentBase indexRange="{indexRange}">\n'
                    '\t\t\t\t\t<Initialization range="{initRange}"/>\n'
                    '\t\t\t\t</SegmentBase>\n'
                    '\t\t\t</Representation>\n'
                ).format(
                    quality=(idx + 1),
                    priority=(num_streams - idx),
                    # url=entity_escape(url),
                    **stream
                )
                    for idx, stream in enumerate(streams)
                    # for url in stream['baseUrl']
                    # if url
                ])

            output.append('\t\t</AdaptationSet>\n')
            set_id += 1

        if subs_data:
            headers = subs_data.pop('_headers', None)
            for lang_id, subtitle in subs_data.items():
                lang_code = subtitle['lang']
                label = language = subtitle['language']
                kind = subtitle['kind']
                if kind == 'translation':
                    label = localize('subtitles.translation.x', language)
                    kind = '_'.join((lang_code, kind))
                else:
                    kind = lang_id

                url = entity_escape(unquote(self._process_url_params(
                    subtitle['url'],
                    headers=headers,
                    referrer=None,
                    visitor_data=None,
                )))
                if not url:
                    continue

                output.extend((
                    '\t\t<AdaptationSet'
                        ' id="', str(set_id), '"'
                        ' contentType="text"'
                        ' mimeType="', subtitle['mime_type'], '"'
                        ' lang="', lang_code, '"'
                        # name attribute is ISA specific and does not exist in
                        # the MPD spec. Should be a child Label element instead
                        ' name="[B]', label, '[/B]"'
                        # original / default are ISA specific attributes
                        ' original="', VALUE_TO_STR[subtitle['original']], '"'
                        ' default="', VALUE_TO_STR[subtitle['default']], '"'
                        '>\n'
                    # AdaptationSet Label element not currently used by ISA
                    '\t\t\t<Label>', label, '</Label>\n'
                    '\t\t\t<Role'
                        ' schemeIdUri="urn:mpeg:dash:role:2011"'
                        ' value="subtitle"'
                        '/>\n'
                    '\t\t\t<Representation'
                        ' id="subs_', kind, '"'
                        ' codecs="', subtitle['codec'], '"'
                        ' mimeType="', subtitle['mime_type'], '"'
                        # unsure about what value to use for bandwidth
                        # ' bandwidth="0"'
                        '>\n'
                    '\t\t\t\t<BaseURL>', url, '</BaseURL>\n'
                    '\t\t\t</Representation>\n'
                    '\t\t</AdaptationSet>\n'
                ))
                set_id += 1

        output.append('\t</Period>\n'
                      '</MPD>\n')
        output = ''.join(output)

        if len(languages.difference({'', 'und'})) > 1:
            main_stream['multi_language'] = True
        if roles.difference({'', 'main', 'dub'}):
            main_stream['multi_audio'] = True

        filename = '.'.join((self.video_id, 'mpd'))
        filepath = os_path.join(self.BASE_PATH, filename)
        try:
            with xbmcvfs.File(filepath, 'w') as mpd_file:
                success = mpd_file.write(output)
        except (IOError, OSError):
            self.log.exception(('File write failed', 'File: %s'), filepath)
            success = False
        if success:
            return urlunsplit((
                'http',
                get_connect_address(context, as_netloc=True),
                PATHS.MPD,
                urlencode({'file': filename}),
                '',
            )), main_stream
        return None, None
