abdelrahman / YouTube Qualities Size

// ==UserScript==
// @name         YouTube Qualities Size
// @namespace    ytqz.smz.k
// @version      1.2.0
// @description  Shows file size for each quality in YouTube
// @author       Abdelrahman Khalil
// @match        https://www.youtube.com/*
// @license      MIT
// ==/UserScript==

;(() => {
    const DEFAULT_CODEC = 'vp9'
    const ALT_CODEC = 'avc1'
    const CACHE = {}

    // --------------------------------
    // API Stuff.
    // --------------------------------
    const fetchInfo = (videoId, detailpage = false) => {
        let url = `https://www.youtube.com/get_video_info?video_id=${videoId}`

        // Retrive full info when video is copyrighted.
        if (detailpage) url += '&el=detailpage'

        return fetch(url)
    }

    // Youtube sends data as ugly ass url queries.
    // Parse it using URLSearchParams then get value of player_response which is stringified JSON.
    const parseInfo = uglyInfo =>
        JSON.parse(getQuery(uglyInfo, 'player_response')).streamingData
            .adaptiveFormats

    const getFormats = async videoId => {
        let info = await (await fetchInfo(videoId)).text()

        if (!info.includes('adaptiveFormats'))
            info = await (await fetchInfo(videoId, true)).text()

        return parseInfo(info)
    }

    // YouTube separates audio and video.
    const getAudioContentLength = formats =>
        formats.find(
            f =>
                f.audioQuality === 'AUDIO_QUALITY_MEDIUM' &&
                f.mimeType.includes('opus')
        ).contentLength || 0

    // Filter formats per quality.
    // Returns video content Length for all codecs summed by opus medium-quality audio content length.
    const mapFormats = (formats, qualityLabel, audioCL) =>
        formats
            .filter(f => f.qualityLabel === qualityLabel && f.contentLength)
            .map(vf => ({
                [matchCodec(vf.mimeType)]: toMBytes(vf.contentLength, audioCL)
            }))
            .reduce((r, c) => Object.assign(r, c), {})

    // --------------------------------
    // DOM Stuff.
    // --------------------------------
    const createYQSNode = mappedFormats => {
        let YQSElement = document.createElement('yt-quality-size')
        let isRTL = document.body.getAttribute('dir') === 'rtl'

        YQSElement.style.float = isRTL ? 'left' : 'right'
        YQSElement.style.textAlign = 'right'
        YQSElement.style.marginRight = '8px'
        YQSElement.setAttribute('dir', 'ltr')

        YQSElement.setAttribute('title', objectStringify(mappedFormats))

        let textNode = document.createTextNode(
            mappedFormats[DEFAULT_CODEC] || mappedFormats[ALT_CODEC]
        )

        YQSElement.appendChild(textNode)

        return YQSElement
    }

    // Hook YQS element to each quality.
    const hookYQS = async addedNode => {
        let doesYQMExist =
                addedNode && addedNode.classList.contains('ytp-quality-menu'),
            doesYQSNotExist = !document.querySelector('yt-quality-size')

        if (doesYQMExist && doesYQSNotExist) {
            let YQM = addedNode,
                videoId = getQuery(location.search, 'v')

            if (!CACHE[videoId]) CACHE[videoId] = await getFormats(videoId)

            let formats = CACHE[videoId],
                qualitiesNode = YQM.querySelectorAll('span'),
                audioCL = getAudioContentLength(formats)

            qualitiesNode.forEach((qualityNode, index) => {
                if (index === qualitiesNode.length - 1) return

                let qualityLabel = matchQLabel(qualityNode.textContent),
                    mappedFormats = mapFormats(formats, qualityLabel, audioCL),
                    YQSNode = createYQSNode(mappedFormats)

                qualityNode.appendChild(YQSNode)
            })
        }
    }

    // Listen to page navigation and observe settings-menu if it's /watch.
    const onPageUpdate = () => {
        let SettingsMenuElement = document.querySelector('.ytp-settings-menu')

        if (SettingsMenuElement) {
            removeEventListener('yt-page-data-updated', onPageUpdate)
            new MutationObserver(([{ addedNodes }]) => {
                hookYQS(addedNodes[0])
            }).observe(SettingsMenuElement, { childList: true })
        }
    }

    addEventListener('yt-page-data-updated', onPageUpdate)

    // --------------------------------
    // Utils
    // --------------------------------
    const getQuery = (string, query) => new URLSearchParams(string).get(query)

    const matchCodec = mimeType => mimeType.replace(/(?!=").+="|\..+|"/g, '')
    const matchQLabel = qLabel => qLabel.replace(/\s.+/, '')

    const toMBytes = (vCL, aCL) =>
        Math.round((parseInt(vCL) + parseInt(aCL)) / 1048576) + ' MB'

    const objectStringify = obj =>
        JSON.stringify(obj, null, '‎')
            .replace(/{|}|"|,/g, '')
            .trim()
})()