Youtube Downloader Including Video, Audio, Subtitles

Download Video, Audio, Subtitles

// ==UserScript==
// @name           Youtube Downloader Including Video, Audio, Subtitles
// @include        https://*youtube.com/*
// @author         Jone
// @require        https://code.jquery.com/jquery-1.12.4.min.js
// @require        https://cdn.jsdelivr.net/npm/[email protected]/StreamSaver.js
// @require        https://cdn.jsdelivr.net/npm/[email protected]/dist/ponyfill.min.js
// @version        1.3
// @license MIT
// @grant GM_xmlhttpRequest
// @description   Download Video, Audio, Subtitles
// @namespace https://greasyfork.org/users/889856
// ==/UserScript==

/*
  [What is this?]
    This Tampermonkey script allows you to download Youtube Video,Audio and subtitle incluing "Automatic subtitle" and "closed subtitle".

  [Who built this?]
    Author :  Jone

  [Developed based on the author?]
    Author :  Cheng Zheng
    Email  :  [email protected]
    Github :  https://github.com/1c7/Youtube-Auto-Subtitle-Download
    If you want to improve the script, Github pull requests are welcome.


  [Version of decoding signature function]
    verson :  534c466c
    package:  youtube's base.js


  [Test Video]
    https://www.youtube.com/watch?v=bkVsus8Ehxs
    This videos only has a closed English subtitle, with no auto subtitles.

    https://www.youtube.com/watch?v=-WEqFzyrbbs
    no subtitle at all

    https://www.youtube.com/watch?v=9AzNEG1GB-k
    have a lot of subtitles

    https://www.youtube.com/watch?v=tqGkOvrKGfY
    1:36:33  super long subtitle

  [How does it work?]
    The code can be roughly divided into three parts:
      1. Add a button on the page. (UI)
      2. Detect if subtitle exists.
      3. Convert subtitle format, then download.

  [Test Enviroment]
    Works best on Chrome + Tampermonkey.
    There are plenty Chromium-based Browser, I do not guarantee this work on all of them;
*/

(function () {

// Config
var NO_CAPTION = 'No Subtitle';
var HAVE_CAPTION = 'Download Subtitles';
var NO_VIDEO = 'No Video';
var HAVE_VIDEO = 'Download Video';
var NO_AUDIO = 'No Audio';
var HAVE_AUDIO = 'Download Audio';
var NO_VI_AU = 'No VidelAndAudio';
var HAVE_VI_AU = 'Download VidelAndAudio';
var TEXT_LOADING = 'Loading...';

const BUTTON_ID = 'youtube-parent-downloader-by-1c7-last-update-2021-2-21';
const CAPTION_BUTTON_ID = 'youtube-caption-downloader-by-1c7-last-update-2021-2-21';
const VIDEO_BUTTON_ID = 'youtube-video-downloader-by-1c7-last-update-2021-2-21';
const AUDIO_BUTTON_ID = 'youtube-audio-downloader-by-1c7-last-update-2021-2-21';
const VI_AU_BUTTON_ID = 'youtube-videoandaudio-downloader-by-1c7-last-update-2021-2-21';

// Config
var HASH_BUTTON_ID = `#${BUTTON_ID}`
var HASH_CAPTION_BUTTON_ID = `#${CAPTION_BUTTON_ID}`
var HASH_VIDEO_BUTTON_ID = `#${VIDEO_BUTTON_ID}`
var HASH_AUDIO_BUTTON_ID = `#${AUDIO_BUTTON_ID}`
var HASH_VI_AU_BUTTON_ID = `#${VI_AU_BUTTON_ID}`

//config
let VIDEO_FORMAT = 'mp4';
let AUDIO_FORMAT =  'mp3';
let CAPTION_FORMAT = 'srt'

// config
window.caption_array = null;
window.video_array = null;
window.audio_array = null;
window.vi_au_array = null;

//config
const CERTAIN_TYPE_INFO = {
    "VIDEO": "video",
    "AUDIO": "audio",
    "VI_AU": "vi_au",
    "CAPTION": "caption"
}

// initialize
var first_load = true;
// indicate if first load this webpage or not
var youtube_playerResponse_1c7 = null;

  // trigger when first load
$(document).ready(function () {
    start();
});

// Explain this function: we repeatly try if certain HTML element exist,
// if it does, we call init()
// if it doesn't, stop trying after certain time
function start() {

    var retry_count = 0;
    var RETRY_LIMIT = 30;
    // use "setInterval" is because "$(document).ready()" still not enough, still too early
    // 330 work for me.
    if (new_material_design_version()) {
        var material_checkExist = setInterval(function() {
            if (document.querySelectorAll('.title.style-scope.ytd-video-primary-info-renderer').length) {
                init();
                clearInterval(material_checkExist);
            }
            retry_count = retry_count + 1;
            if (retry_count > RETRY_LIMIT) {
                clearInterval(material_checkExist);
            }
        }, 330);
    } else {
        var checkExist = setInterval(function() {
            if ($('#watch7-headline').length) {
                init();
                clearInterval(checkExist);
            }
            retry_count = retry_count + 1;
            if (retry_count > RETRY_LIMIT) {
                clearInterval(checkExist);
            }
        }, 330);
    }
}

// trigger when loading new page
// (actually this would also trigger when first loading, that's not what we want, that's why we need to use firsr_load === false)
// (new Material design version would trigger this "yt-navigate-finish" event. old version would not.)
  var body = document.getElementsByTagName("body")[0];
  body.addEventListener("yt-navigate-finish", function (event) {
    // 2021-8-9 测试结果:yt-navigate-finish 可以正常触发
    if (current_page_is_video_page() === false) {
      return;
    }
    youtube_playerResponse_1c7 = event.detail.response.playerResponse; // for auto subtitle
    unsafeWindow.caption_array = []; // clean up (important, otherwise would have more and more item and cause error)

    // if use click to another page, init again to get correct subtitle
    if (first_load === false) {
      remove_subtitle_download_button();
      init();
    }
  });
// return true / false
// Detect [new version UI(material design)] OR [old version UI]
// I tested this, accurated.
function new_material_design_version() {
    var old_title_element = document.getElementById('watch7-headline');
    if (old_title_element) {
        return false;
    } else {
        return true;
    }
}

// return true / false
function current_page_is_video_page() {
    return get_url_video_id() !== null;
}

// return string like "RW1ChiWyiZQ",  from "https://www.youtube.com/watch?v=RW1ChiWyiZQ"
// or null
function get_url_video_id() {
    return getURLParameter('v');
}

//https://stackoverflow.com/questions/11582512/how-to-get-url-parameters-with-javascript/11582513#11582513
function getURLParameter(name) {
    return decodeURIComponent((new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search) || [null, ''])[1].replace(/\+/g, '%20')) || null;
}

// finish
function remove_subtitle_download_button() {
    $(HASH_BUTTON_ID).remove();
}

function init() {
    inject_our_script();
    first_load = false;
}

// inject init button
function inject_our_script() {
    var div = document.createElement('div')
      , div_video = document.createElement('div')
      , div_audio = document.createElement('div')
      , div_vi_au = document.createElement('div')
      , div_caption = document.createElement('div')
      , controls = document.getElementById('watch7-headline');

    var css_div = `display: table;
    margin-top:4px;
    margin-right:4px;
    border: 1px solid rgb(0, 183, 90);
    cursor: pointer; color: rgb(255, 255, 255);
    border-top-left-radius: 3px;
    border-top-right-radius: 3px;
    border-bottom-right-radius: 3px;
    border-bottom-left-radius: 3px;
    background-color: #00B75A;
    display:inline-block;
    `;
    div_video.setAttribute('style', css_div);
    div_video.id = VIDEO_BUTTON_ID;

    div_audio.setAttribute('style', css_div);
    div_audio.id = AUDIO_BUTTON_ID;

    div_vi_au.setAttribute('style', css_div);
    div_vi_au.id = VI_AU_BUTTON_ID;

    div_caption.setAttribute('style', css_div);
    div_caption.id = CAPTION_BUTTON_ID;

    div.id = BUTTON_ID

    // generate selector
    div_video.appendChild(generate_selector(CERTAIN_TYPE_INFO.VIDEO));
    div_audio.appendChild(generate_selector(CERTAIN_TYPE_INFO.AUDIO));
    div_vi_au.appendChild(generate_selector(CERTAIN_TYPE_INFO.VI_AU));
    div_caption.appendChild(generate_selector(CERTAIN_TYPE_INFO.CAPTION));

    // inject select to div element
    div.appendChild(div_video)
    div.appendChild(div_audio)
    div.appendChild(div_vi_au)
    div.appendChild(div_caption)

    // put the div into page: new material design
    var title_element = document.querySelectorAll('.title.style-scope.ytd-video-primary-info-renderer');
    if (title_element) {
        $(title_element[0]).after(div);
    }
    // put the div into page: old version
    if (controls) {
        controls.appendChild(div);
    }

}

// generate selector by type(type: video,audio,subtitle)
function generate_selector(type) {

    let select = document.createElement('select')
    let option = document.createElement('option')
    select.id = type + 's_selector';
    select.disabled = true;
    let css_select = `display:block;
    border: 1px solid rgb(0, 183, 90);
    cursor: pointer;
    color: rgb(255, 255, 255);
    background-color: #00B75A;
    padding: 4px;
    `;
    select.setAttribute('style', css_select);

    option.textContent = TEXT_LOADING;
    option.selected = true;
    select.appendChild(option);


    select.addEventListener('change', function() {
	    // downloading the data by type
        download_mime_type(this, type);
    }, false);

    get_options_list(select, type);

    return select;

}

//get options by type
async function get_options_list(select, type) {

    //get video information
    let video_data = get_youtube_data();

    //select the data by type
    let filter_list = getVideoInfoByType(video_data, type);

    let HAVE_NAME = null;
    let NO_NAME = null;
    let HASH_BUTTON_ID = null;
    switch (type) {
    case CERTAIN_TYPE_INFO.VIDEO:
        window.video_array = filter_list;
        HAVE_NAME = HAVE_VIDEO;
        NO_NAME = NO_VIDEO;
        HASH_BUTTON_ID = HASH_VIDEO_BUTTON_ID;
        break;
    case CERTAIN_TYPE_INFO.AUDIO:
        window.audio_array = filter_list;
        HAVE_NAME = HAVE_AUDIO;
        NO_NAME = NO_AUDIO;
        HASH_BUTTON_ID = HASH_AUDIO_BUTTON_ID;
        break;
    case CERTAIN_TYPE_INFO.VI_AU:
        window.vi_au_array = filter_list;
        HAVE_NAME = HAVE_VI_AU;
        NO_NAME = NO_VI_AU;
        HASH_BUTTON_ID = HASH_VI_AU_BUTTON_ID;
        break;
    case CERTAIN_TYPE_INFO.CAPTION:
        window.caption_array = filter_list;
        HAVE_NAME = HAVE_CAPTION;
        NO_NAME = NO_CAPTION;
        HASH_BUTTON_ID = HASH_CAPTION_BUTTON_ID;
        break;
    default:
        console.log("no match type")
    }

    // if no data at all, just say no and stop
    if (filter_list == null || filter_list.length == 0) {
        select.options[0].textContent = NO_NAME;
        disable_download_button(HASH_BUTTON_ID);
        return false;
    }

    // if at least one type of data exist
    select.options[0].textContent = HAVE_NAME;
    select.disabled = false;
    let option = null;
    filter_list.forEach(item=>{
        option = document.createElement('option');
        option.textContent = item.name;
        select.appendChild(option);
    }
    )

}

// downloading the data by type
async function download_mime_type(selector, type) {

    // if user select first <option>, we just return, do nothing.
    if (selector.selectedIndex == 0) {
        return;
    }

    // video_title
    let name = get_title();
    let selected = null;
    let selected_name = selector.options[selector.selectedIndex].textContent;
	let type_format = null;
    switch (type) {
    case CERTAIN_TYPE_INFO.VIDEO:
        selected = window.video_array.filter(format=>format.name === selected_name);
		type_format = VIDEO_FORMAT;
        break;
    case CERTAIN_TYPE_INFO.AUDIO:
        selected = window.audio_array.filter(format=>format.name === selected_name);
		type_format = AUDIO_FORMAT;
        break;
    case CERTAIN_TYPE_INFO.VI_AU:
        selected = window.vi_au_array.filter(format=>format.name === selected_name);
		type_format = VIDEO_FORMAT;
        break;
    case CERTAIN_TYPE_INFO.CAPTION:
        selected = window.caption_array.filter(format=>format.name === selected_name);
		type_format = CAPTION_FORMAT;
        break;
    default:
        console.log("no match type")
    }

    console.log("selected url :", selected)
    selected != null && selected.length != 0 && (CERTAIN_TYPE_INFO.CAPTION === type && await download_subtitle(name, selected[0].url, type_format) || judeSigcipher(selected[0])  && await fetch_mime_type(name, selected[0].url, type_format));
    selector.options[0].selected = true;
}

//fetching the data by type using the streamSaver
async function fetch_mime_type(name, url, type) {

    const fileStream = streamSaver.createWriteStream(`${name}.${type}`, {
        size: 500,
        // (optional filesize) Will show progress
        writableStrategy: 500,
        // (optional)
        readableStrategy: undefined // (optional)
    })

    try {
        let controller = new AbortController();
        let signal = controller.signal;
        const res = await fetch(url, {
            signal: controller.signal
        });

        if (!res.ok) {
            console.log("fetch failse:", res.ok)
            return;
        }
        // abort so it dose not look stuck

        const readableStream = res.body
        if (window.WritableStream && readableStream.pipeTo) {
            console.log("pipe Stream")
            return readableStream.pipeTo(fileStream).then(()=>console.log('done writing'))
        } else {
            window.wirter = fileStream.getWriter()
            const reader = res.body.getReader()
            const pump = ()=>reader.read().then(res=>res.done ? writer.close() : writer.write(res.value).then(pump))
            pump()
        }

        window.onunload = ()=>{
            fileStream.abort()
        }

        window.onbeforeunload = evt=>{
            if (!done) {
                evt.returnValue = `Are you sure you want to leave?`;
            }
        }

        signal.addEventListener('abort', ()=>{
            console.log('abort!')
            fileStream.abort()
        }
        );

    } catch (e) {
        console.info("fetch failse:", e)
        return;
    }
}

//get youtube information by type
function getVideoInfoByType(video_data, type) {

    if (!video_data || video_data instanceof Array)
        throw new Error(`video_data'Desktop is false type`);

    //判断视频是否可播放
    if (video_data.playabilityStatus.status != 'OK')
        throw new Error('video is not playability');

    let format_list = [];
    try {
        switch (type) {
        case CERTAIN_TYPE_INFO.VIDEO:
            format_list = format_list.concat(video_data.streamingData.formats.filter((format)=>format.qualityLabel && !(format.audioQuality || format.audioBitrate)) || []).concat(video_data.streamingData.adaptiveFormats.filter((format)=>format.qualityLabel && !(format.audioQuality || format.audioBitrate)) || []);
            format_list = format_list.filter((format)=>format.mimeType.indexOf("mp4") != -1 && format.mimeType.indexOf("avc1") != -1);
            format_list = format_list.map((data)=>{
                return {
                    'name': `${data.qualityLabel}--bitrate:${data.bitrate}`,
                    "url": data.url || data.signatureCipher,
                    "sig_cipher": data.url == undefined || data.url == null,
                    "sig_cipher_old": data.url == undefined || data.url == null
                };
            }
            );
            break;
        case CERTAIN_TYPE_INFO.AUDIO:
            format_list = format_list.concat(video_data.streamingData.formats.filter((format)=>!format.qualityLabel && (format.audioQuality || format.audioBitrate)) || []).concat(video_data.streamingData.adaptiveFormats.filter((format)=>!format.qualityLabel && (format.audioQuality || format.audioBitrate)) || []);
            format_list = format_list.filter((format)=>format.mimeType.indexOf("mp4") != -1);
            format_list = format_list.map((data)=>{
                return {
                    'name': data.audioQuality,
                    "url": data.url || data.signatureCipher,
                    "sig_cipher": data.url == undefined || data.url == null,
                    "sig_cipher_old": data.url == undefined || data.url == null
                };
            }
            );
            break;
        case CERTAIN_TYPE_INFO.VI_AU:
            format_list = format_list.concat(video_data.streamingData.formats.filter((format)=>format.qualityLabel && (format.audioQuality || format.audioBitrate)) || []).concat(video_data.streamingData.adaptiveFormats.filter((format)=>format.qualityLabel && (format.audioQuality || format.audioBitrate)) || []);
            format_list = format_list.filter((format)=>format.mimeType.indexOf("mp4") != -1);
            format_list = format_list.map((data)=>{
                return {
                    'name': data.qualityLabel + "," + data.audioQuality,
                    "url": data.url || data.signatureCipher,
                    "sig_cipher": data.url == undefined || data.url == null,
                    "sig_cipher_old": data.url == undefined || data.url == null
                };
            }
            );
            break;
        case CERTAIN_TYPE_INFO.CAPTION:
            format_list = format_list.concat(video_data.captions.playerCaptionsTracklistRenderer.captionTracks || []);
            format_list = format_list.map((data)=>{
                return {
                    'name': data.name.simpleText,
                    "url": data.baseUrl
                };
            }
            );
            break;
        default:
            throw new Error('type variable is missing or not in the range');
        }
    } catch (e) {
        console.error("error in get list in type" + type)
    }

    return format_list
}

function disable_download_button(HASH_BUTTON_ID) {
    $(HASH_BUTTON_ID).css('border', '#95a5a6').css('cursor', 'not-allowed').css('background-color', '#95a5a6');
    $('#captions_selector').css('border', '#95a5a6').css('cursor', 'not-allowed').css('background-color', '#95a5a6');

    if (new_material_design_version()) {
        $(HASH_BUTTON_ID).css('padding', '6px');
    } else {
        $(HASH_BUTTON_ID).css('padding', '5px');
    }
}

function get_youtube_data() {
    return document.getElementsByTagName("ytd-app")[0].data.playerResponse
}

// get youtube vedio title
function get_title() {
    var title_element = document.querySelector("h1.title.style-scope.ytd-video-primary-info-renderer");
    if (title_element != null) {
        var title = title_element.innerText;
        if (title != undefined && title != null && title != "") {
            return title;
        }
    }
    return ytplayer.config.args.title;
}

//判断是否需要解密
function judeSigcipher(selected) {
    if (!selected.sig_cipher)
        return true;

    try {
        let searchParams = new URLSearchParams(selected.url);
        let url = new URL(searchParams.get('url'));
		// 进行解密
        url.searchParams.set(searchParams.get('sp'), Sqa(searchParams.get('s')));
        selected.url = url.toString();
        selected.sig_cipher = false;
        return true;
    } catch (e) {
        console.error("decipher error", e)
        return false;
    }
}

//解密单位函数
var Xx = {
    kg: function(a, b) {
        a.splice(0, b)
    },
    jl: function(a) {
        a.reverse()
    },
    ti: function(a, b) {
        var c = a[0];
        a[0] = a[b % a.length];
        a[b % a.length] = c
    }
};
//解密函数
Sqa = function(a) {
    a = a.split("");
    Xx.kg(a, 2);
    Xx.ti(a, 34);
    Xx.kg(a, 2);
    Xx.ti(a, 35);
    Xx.jl(a, 74);
    return a.join("")
};


//将下载的字幕从xml格式转化为SRT格式
function parse_youtube_XML_to_SRT(youtube_xml_string) {


    let regexp = /<text start="(.*?)" dur="(.*?)">(.*?)<\/text>/g;
    let value = null;
    let index = 1;
    let output = '';
    while ((value = regexp.exec(youtube_xml_string)) !== null) {
        let start = totime(value[1]);
        let end = totime((value[1] * 10 + value[2] * 10) / 10);
        output = output +  `${index}\n${start[0]},${start[1]} --> ${end[0]},${end[1]}\n${htmlDecode(value[3])}\n\n`
        index++;
    }

    return output;
}

// 对xml中字符进行格式化输出
function htmlDecode(input) {
    var e = document.createElement('div');
    e.class = 'dummy-element-for-tampermonkey-Youtube-Subtitle-Downloader-script-to-decode-html-entity';
    e.innerHTML = input;
    return e.childNodes.length === 0 ? "" : e.childNodes[0].nodeValue.replace(/&#(\d+);/gi, function(match, numStr) {
        var num = parseInt(numStr, 10);
        return String.fromCharCode(num);
    });
}



function totime(ms) {
    let date = new Date(ms * 1000 - 8 * 3600 * 1000);
    return [date.toString().slice(16, 16 + 8), date.getMilliseconds()]
}

//  trigger when user select <option>
async function download_subtitle(filename, url, type) {
    // if user select first <option>, we just return, do nothing.
    let response = await fetch(url);

    let body = await readAllChunks(response.body)

    let value = parse_youtube_XML_to_SRT(body);

    //dowmload  the  subtitle
    downloadString(value, "text/plain", filename,type);

    return true;
}

async function readAllChunks(readableStream) {
  const reader = readableStream.getReader();
  const chunks = [];

  let done, value;
  var output = '';
  while (!done) {
    ({ value, done } = await reader.read());
    if (done) {
      return output;
    }
    output = output + new TextDecoder().decode(value);
  }
}




// copy from: https://gist.github.com/danallison/3ec9d5314788b337b682
// Thanks! https://github.com/danallison
// work in Chrome 66
// test passed: 2018-5-19
function downloadString(text, fileType, fileName, type) {
    var blob = new Blob([text],{
        type: fileType
    });
    var a = document.createElement('a');
    a.download = `${fileName}.${type}`;
    a.href = URL.createObjectURL(blob);
    a.dataset.downloadurl = [fileType, a.download, a.href].join(':');
    a.style.display = "none";
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    setTimeout(function() {
        URL.revokeObjectURL(a.href);
    }, 1500);
}
})();