YTdl

download YouTube video

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==UserScript==
// @name         YTdl
// @namespace    https://tampermonkey.net/
// @version      0.2.1
// @description  download YouTube video
// @author       Shiroikoi
// @run-at       document-idle
// @match        https://www.youtube.com/watch*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.min.js
// @require      https://unpkg.com/@ffmpeg/[email protected]/dist/ffmpeg.min.js
// @grant        none
// @compatible   firefox >=79
// @compatible   chrome >=68
// @license      MIT
// ==/UserScript==
(function () {
  const ffmpeg = FFmpeg.createFFmpeg({
    corePath: "https://unpkg.com/@ffmpeg/[email protected]/dist/ffmpeg-core.js",
    log: true,
  });
  let vidseleVm,
    audseleVm,
    infoVm,
    buttVm,
    dataArray,
    controller,
    fileName,
    decryptFun,
    progressText = { totalLength: 0, receivedLength: 0, text: "" },
    videoList = {
      options: [],
    },
    audioList = {
      options: [],
    },
    style = document.createElement("style"),
    optionVideo = document.createElement("option"),
    optionAudio = document.createElement("option"),
    row1 = document.createElement("div"),
    row2 = document.createElement("div"),
    selectVideo = document.createElement("select"),
    selectAudio = document.createElement("select"),
    button = document.createElement("button"),
    spanInfo = document.createElement("span");

  style.innerText =
    "@font-face {\
      font-family: 'Quicksand';\
      font-style: normal;\
      font-weight: 400;\
      font-display: swap;\
      src: url(https://fonts.gstatic.com/s/quicksand/v21/6xK-dSZaM9iE8KbpRA_LJ3z8mH9BOJvgkP8o58a-wg.woff2) format('woff2');\
      unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\
    }";
  document.head.append(style);
  button.setAttribute("v-on:click", "click");
  button.textContent = "{{text}}";
  button.style.width = "110px";
  button.style.height = "35px";
  button.style.fontSize = "20px";
  button.style.fontFamily = "Quicksand";
  button.style.marginLeft = "20px";
  button.style.position = "absolute";
  selectVideo.style.fontSize = "20px";
  selectVideo.style.fontFamily = "Quicksand";
  selectVideo.style.height = "35px";
  selectVideo.style.width = "280px";
  selectAudio.style.fontSize = "20px";
  selectAudio.style.fontFamily = "Quicksand";
  selectAudio.style.height = "35px";
  selectAudio.style.width = "280px";
  optionVideo.setAttribute("v-for", "i in options");
  optionVideo.textContent = "{{ i }}";
  optionAudio.setAttribute("v-for", "i in options");
  optionAudio.textContent = "{{ i }}";
  spanInfo.style.fontSize = "20px";
  spanInfo.style.fontFamily = "Quicksand";
  spanInfo.textContent = "{{text}}";

  infoVm = new Vue({
    data: progressText,
  });
  vidseleVm = new Vue({
    data: videoList,
  });
  audseleVm = new Vue({
    data: audioList,
  });
  buttVm = new Vue({
    data: {
      text: "download",
      signal: false,
    },
    methods: {
      click: function () {
        if (this.signal == false) {
          infoVm.text = " parsing...";
          controller = new AbortController();
          this.stat2();
          vidseleVm.$el.selectedIndex == videoList.options.length - 1 ? this.taskAud() : this.taskVidnAud();
        } else {
          controller.abort();
          this.stat1();
          infoVm.text = " canceled!";
        }
      },
      taskVidnAud: async function () {
        let indexA = videoList.options.length - 1 + audseleVm.$el.selectedIndex,
          indexV = vidseleVm.$el.selectedIndex,
          videoUrl,
          audioUrl;
        if (dataArray[indexV].url == undefined) {
          if (decryptFun == undefined) decryptFun = await fetchCode().then(getDecryptFun);
          videoUrl = decryptUrls(decryptFun, indexV);
          audioUrl = decryptUrls(decryptFun, indexA);
        } else {
          videoUrl = dataArray[indexV].url;
          audioUrl = dataArray[indexA].url;
        }
        console.log(videoUrl) || console.log(audioUrl);
        let mediaArray = await Promise.all([
          fetchMedia(videoUrl, controller, progressText, this.errcb),
          fetchMedia(audioUrl, controller, progressText, this.errcb),
        ]);
        if (mediaArray[0] == null) {
          return;
        } else if (mediaArray[0] == null) {
          return;
        }
        ffmpeg.isLoaded() ? null : await ffmpeg.load();
        await blobToUint8Array(mediaArray[0])
          .then((result) => {
            ffmpeg.FS("writeFile", "video", result);
          })
          .catch((error) => {
            this.errcb(error);
          });
        await blobToUint8Array(mediaArray[1])
          .then((result) => {
            ffmpeg.FS("writeFile", "audio", result);
          })
          .catch((error) => {
            this.errcb(error);
          });
        await ffmpeg.run("-i", "video", "-i", "audio", "-c", "copy", "output.mp4");
        let outPut = ffmpeg.FS("readFile", "output.mp4");
        ffmpeg.FS("unlink", "output.mp4");
        blobLink(new Blob([outPut]), fileName + ".mp4");
        infoVm.text = " merged! total:" + progressText.totalLength + "MiB";
        this.stat1();
      },
      taskAud: async function () {
        let indexA = videoList.options.length - 1 + audseleVm.$el.selectedIndex,
          audioUrl,
          suff;
        if (dataArray[indexA].url == undefined) {
          if (decryptFun == undefined) decryptFun = await fetchCode().then(getDecryptFun);
          audioUrl = decryptUrls(decryptFun, indexA);
        } else {
          audioUrl = dataArray[indexA].url;
        }
        console.log(audioUrl);
        let audio = await fetchMedia(audioUrl, controller, progressText, this.errcb);
        if (audio == null) return;
        if (dataArray[indexA].mimeType.match("mp4")) {
          suff = ".m4a";
        } else if (dataArray[indexA].mimeType.match("webm")) {
          suff = ".weba";
        }
        blobLink(audio, fileName + suff);
        infoVm.text = " merged! total:" + progressText.totalLength + "MiB";
        this.stat1();
      },
      stat1: function () {
        this.signal = false;
        this.text = "download";
        this.$el.style.backgroundColor = "";
      },
      stat2: function () {
        this.signal = true;
        this.text = "cancel";
        this.$el.style.backgroundColor = "#99ccff";
      },
      errcb: function (error) {
        console.log(error.name);
        switch (error.name) {
          case "AbortError":
            this.stat1();
            break;
          default:
            infoVm.text = " error! try refresh the page";
            this.stat1();
            break;
        }
      },
    },
  });
  mountFun();

  function mountFun() {
    if (document.querySelector("#meta") == null) {
      setTimeout(mountFun, 500);
    } else {
      document.querySelector("#meta").append(row1);
      document.querySelector("#meta").append(row2);
      row1.append(selectVideo);
      row1.append(button);
      row2.append(selectAudio);
      row2.append(spanInfo);
      selectVideo.prepend(optionVideo);
      selectAudio.prepend(optionAudio);
      buttVm.$mount(button);
      audseleVm.$mount(selectAudio);
      vidseleVm.$mount(selectVideo);
      infoVm.$mount(spanInfo);
      try {
        dataArray = ytInitialPlayerResponse.streamingData.adaptiveFormats;
        fileName = ytInitialPlayerResponse.videoDetails.title;
        dataArray.forEach((item) => {
          if (item.mimeType.match(/video/)) {
            videoList.options.push(item.qualityLabel + "-" + item.mimeType);
          } else if (item.mimeType.match(/audio/)) {
            audioList.options.push(item.audioQuality + "-" + item.mimeType);
          }
        });
        videoList.options.push("none");
      } catch (error) {
        console.log(error.name);
        infoVm.text = "script currently not available";
        buttVm.$el.disabled = true;
      }
    }
  }
  async function fetchCode(errcb) {
    let code,
      script = document.querySelectorAll("script");
    try {
      for (let i = 0; i < script.length; i++) {
        if (script[i].src.match(/base\.js/)) {
          const res = await fetch(script[i].src);
          code = await res.text();
          break;
        }
      }
      return code;
    } catch (error) {
      errcb(error);
      console.log("fetchCode failed" + error.name);
      return null;
    }
  }
  function getDecryptFun(code) {
    let funName = code.match(/(?<==)[\w]+(?=\(decodeURIC)/)[0],
      funString = code
        .match(new RegExp(`(?<=${funName}=)function\\([\\s\\S]+?}`))[0]
        .replace(/^/, "(")
        .replace(/$/, ")"),
      fun = eval(funString),
      objName = funString.match(/(?<=;).+?./)[0],
      objSting = `var ${objName} =` + code.match(new RegExp(`(?<=var ${objName}=)[\\s\\S]+?}}`))[0];
    eval(objSting);
    console.log(fun) || console.log(objSting);
    return fun;
  }
  function decryptUrls(fun, index) {
    let url = dataArray[index].signatureCipher,
      sig = fun(decodeURIComponent(url.match(/(?<=s=).+?(?=&sp)/)));
    url = decodeURIComponent(url.match(/https.+/)[0].replace(/\%25/g, "%"));
    return url + "&sig=" + encodeURIComponent(sig);
  }
  async function fetchMedia(url, controller, progressText, errcb) {
    try {
      let res = await fetch(url, {
        signal: controller.signal,
      });
      const reader = res.body.getReader();
      const contentLength = res.headers.get("Content-Length");
      progressText.totalLength += parseFloat((parseInt(contentLength) / 1024 / 1024).toFixed(2));
      let chunks = [];
      while (true) {
        const { done, value } = await reader.read();
        if (done) {
          break;
        }
        chunks.push(value);
        progressText.receivedLength += value.length;
        progressText.text =
          "downloaded:" + (parseFloat(progressText.receivedLength) / 1024 / 1024).toFixed(2) + "MiB total:" + progressText.totalLength + "MiB";
      }
      return new Blob(chunks);
    } catch (error) {
      errcb(error);
      return null;
    }
  }
  function blobToUint8Array(blob) {
    return new Promise((rs, rj) => {
      let fileReader = new FileReader();
      fileReader.onload = () => {
        rs(new Uint8Array(fileReader.result));
      };
      fileReader.onerror = () => {
        rj({ name: "b28 failed" });
      };
      fileReader.readAsArrayBuffer(blob);
    });
  }
  function blobLink(blob, fileName) {
    let dl = document.createElement("a");
    dl.download = fileName;
    dl.href = URL.createObjectURL(blob);
    dl.click();
    URL.revokeObjectURL(dl.href);
    dl.remove();
  }
})();