Youtube Downloader Including Video, Audio, Subtitles

Download Video, Audio, Subtitles

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
}
})();