YouTube Cobalt Tools Download Button

Adds a download button to YouTube videos using Cobalt API for downloading videos or audio.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         YouTube Cobalt Tools Download Button
// @namespace    http://tampermonkey.net/
// @version      1.5.2
// @description  Adds a download button to YouTube videos using Cobalt API for downloading videos or audio.
// @author       yodaluca23
// @license      GNU GPLv3
// @match        *://*.youtube.com/*
// @grant        GM.xmlHttpRequest
// @grant        GM_notification
// ==/UserScript==

(function() {
    'use strict';
    let cobaltListAPI = "http://instances.cobalt.best/api/instances.json" // Change this URL to change what instance list is used.
    let isYTError =  false;
    let currentPageUrl = window.location.href;
    let initialInjectDelay = 2000; // Initial delay in milliseconds
    let navigationInjectDelay = 1000; // Delay on navigation in milliseconds

    // Check if currentPageUrl is YouTube video
    function isYouTubeWatchURL() {
    return window.location.href.includes("youtube.com/watch?");
    }

  function removeElement(elementToRemove) {
    var element = document.querySelector(elementToRemove);
    if (element) {
        element.remove();
    }

  }

  function findInstance() {
    return new Promise((resolve, reject) => {
        GM.xmlHttpRequest({
            method: 'GET',
            url: cobaltListAPI,
            onload: function(response) {
                try {
                    const instances = JSON.parse(response.responseText);
                    console.log(instances);

                    // Function to check each instance's JSON response for the turnstileSitekey
                    const checkInstance = (instance) => {
                        return new Promise((resolve, reject) => {
                            GM.xmlHttpRequest({
                                method: 'GET',
                                url: `${instance.protocol}://${instance.api}`,
                                onload: function(apiResponse) {
                                    try {
                                        const apiData = JSON.parse(apiResponse.responseText);
                                        // Check if 'cobalt.turnstileSitekey' does not exist
                                        if (!apiData.cobalt || !apiData.cobalt.turnstileSitekey) {
                                            resolve(`${instance.protocol}://${instance.api}`);
                                        } else {
                                            resolve(null);  // Continue searching if turnstileSitekey exists
                                        }
                                    } catch (error) {
                                        console.error('Error parsing instance API response:', error);
                                        resolve(null);
                                    }
                                },
                                onerror: function(error) {
                                    console.error('Error fetching instance API response:', error);
                                    resolve(null);
                                }
                            });
                        });
                    };

                    // Loop through the instances to find the required one
                    (async () => {
                        for (const instance of instances) {
                            // Check initial conditions for the instance
                            if (instance.services.youtube === true && parseFloat(instance.trust) > -0.5 &&
                                instance.protocol === 'https' && parseFloat(instance.version) > 8) {

                                const validInstanceUrl = await checkInstance(instance);
                                if (validInstanceUrl) {
                                    resolve(validInstanceUrl);
                                    return;
                                }
                            }
                        }
                        // No matching instance found
                        resolve(null);
                    })();

                } catch (error) {
                    console.error('Error parsing instances:', error);
                    resolve(null);
                }
            },
            onerror: function(error) {
                console.error('Error fetching instances:', error);
                resolve(null);
            }
        });
    });
  }


    // Function to initiate download using Cobalt API
    async function Cobalt(videoUrl, audioOnly = false, quality = '1080', format = 'webm') {
      let codec = 'h264';
      if (format === 'webm') {
          codec = 'vp9';
      }

      console.log(`Sending request to Cobalt API: URL=${videoUrl}, AudioOnly=${audioOnly}, Quality=${quality}, Format=${format}, Codec=${codec}`);

      let apiUrl = await findInstance();
      if (!apiUrl) {
          console.log('No matching instance found.');
          removeElement('#cobalt-quality-picker');
          return null;
      }
      console.log('Found API URL:', apiUrl);

      try {
          const requestBody = {
              url: videoUrl,
              videoQuality: quality.replace('p', ''),
              youtubeVideoCodec: codec,
              filenameStyle: 'pretty',
              downloadMode: audioOnly ? 'audio' : 'auto',
          };

          return new Promise((resolve, reject) => {
              GM.xmlHttpRequest({
                  method: 'POST',
                  url: `${apiUrl}/`,
                  headers: {
                      'Accept': 'application/json',
                      'Content-Type': 'application/json'
                  },
                  data: JSON.stringify(requestBody),
                  onload: (response) => {
                      try {
                          const data = JSON.parse(response.responseText);
                          if (data.status === 'error') {
                              isYTError = true;
                              console.error('Error fetching from Cobalt API:', data.error?.code, data.error?.context);
                              if ((data.error.code + "").includes("no_matching_format")) {
                                  GM_notification("This format is unavailable; please try a different one or lower the download quality.");
                              } else {
                                  GM_notification("Cobalt Error: " + data.error);
                              }
                              removeElement('#cobalt-quality-picker');
                              resolve(null);
                          } else if (data.status === 'tunnel' || data.status === 'redirect') {
                              console.log('Download URL:', data.url);
                              resolve(data.url);
                          } else {
                              reject(new Error('No valid status from API response'));
                          }
                      } catch (error) {
                          console.error('Error parsing response from Cobalt API:', error);
                          reject(null);
                      }
                  },
                  onerror: (error) => {
                      console.error('Error making request to Cobalt API:', error);
                      reject(null);
                  }
              });
          });
      } catch (error) {
          console.error('Error fetching from Cobalt API:', error);
          return null;
      }
    }

    async function getUniqueQualityLabels() {

      const qualityLabels = new Set();  // Declare a Set to store unique qualities

      if (window.location.href.includes(ytplayer.config.args.raw_player_response.videoDetails.videoId)) {
          // If player variables are in sync we can just use those
          const fetchQualityLabels = new Set(
              unsafeWindow.ytInitialPlayerResponse.streamingData.adaptiveFormats
                  .filter(format => format.qualityLabel)
                  .map(format => format.qualityLabel)
          );

          // Use regex to extract the first number followed by the first non-numeric character
          const extractedQualities = [...fetchQualityLabels].map(label => {
              const match = label.match(/^(\d+)/);
              return match ? match[0] : null;
          }).filter(Boolean);  // Filter out any null values

          extractedQualities.forEach(quality => {
            if (!isNaN(quality)) {  // Check if it's a valid number
                qualityLabels.add(quality);
            }
          });

      } else {
          // If player variables are not in sync then we have to fetch using regex on the raw HTML
          const response = await fetch(window.location.href);
          const pageSource = await response.text();
          const regex = /"qualityLabel":"(\d+)p\d*"/g;
          let match;
          while ((match = regex.exec(pageSource)) !== null) {
              qualityLabels.add(match[1]);
          }
      }

      // Sort the quality labels in descending order
      const sorted = Array.from(qualityLabels)
          .map(Number)  // Convert to numbers
          .sort((a, b) => b - a);  // Sort in descending order

      console.log('Video Qualities:', sorted);  // Log the sorted qualities
      return sorted;  // Return the sorted array
    }


    async function getMimeTypes() {

        const mimeTypes = new Set();

        if (window.location.href.includes(ytplayer.config.args.raw_player_response.videoDetails.videoId)) {
          // If player variables are in sync we can just use those
          var formats = unsafeWindow.ytInitialPlayerResponse.streamingData.adaptiveFormats
              .filter(format => format.mimeType)
              .map(format => format.mimeType);

          // Regex to extract format
          const formatRegex = /\/([^;]+)/;
          var extractedFormats = formats
              .map(mimeType => {
                  const match = mimeType.match(formatRegex);
                  return match ? match[1] : null;
              })
              .filter(format => format); // Filter out null values

          extractedFormats.forEach(format => mimeTypes.add(format));

        } else {
          // If player variables are not in sync then we extract it from raw HTML
          const response = await fetch(window.location.href);
          const pageSource = await response.text();

          const regex = /"mimeType":"video\/([^;]+);/g;
          let match;

          while ((match = regex.exec(pageSource)) !== null) {
              mimeTypes.add(match[1]);
          }
        }

        // Always add mp3, because Cobalt can convert to it.
        mimeTypes.add("mp3");

        const mimeArray = Array.from(mimeTypes);

        // Move webm to top.
        const webmIndex = mimeArray.indexOf("webm");
        if (webmIndex !== -1) {
            mimeArray.splice(webmIndex, 1);
            mimeArray.unshift("webm");
        }
        console.log('Video Formats:', mimeArray);
        return mimeArray;
    }

    // Helper function to check if two arrays are equal (for detecting changes)
    function arraysEqual(arr1, arr2) {
        if (arr1.length !== arr2.length) return false;
        for (let i = 0; i < arr1.length; i++) {
            if (arr1[i] !== arr2[i]) return false;
        }
        return true;
    }

    // Function to inject download button on the page
    function injectDownloadButton() {
        setTimeout(() => {
            // Remove existing download button if present
            removeElement('#cobalt-download-btn');

            const downloadButton = document.createElement('button');
            downloadButton.id = 'cobalt-download-btn';
            downloadButton.className = 'yt-spec-button-shape-next yt-spec-button-shape-next--tonal yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-leading';
            downloadButton.setAttribute('aria-label', 'Download');
            downloadButton.setAttribute('title', 'Download');
            downloadButton.innerHTML = `
                <div class="yt-spec-button-shape-next__icon">
                    <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" focusable="false" style="pointer-events: none; display: inline-block; width: 24px; height: 24px; vertical-align: middle;">
                        <path fill="currentColor" d="M17 18v1H6v-1h11zm-.5-6.6-.7-.7-3.8 3.7V4h-1v10.4l-3.8-3.8-.7.7 5 5 5-4.9z"></path>
                    </svg>
                </div>
                <div class="yt-spec-button-shape-next__button-text-content">Download</div>
            `;
            downloadButton.style.borderRadius = '30px';
            downloadButton.style.fontSize = '14px';
            downloadButton.style.padding = '8px 16px';
            downloadButton.style.cursor = 'pointer';
            downloadButton.style.marginLeft = '8px';
            downloadButton.style.marginRight = '0px';

            downloadButton.onclick = () => showQualityPopup(currentPageUrl);

            const actionMenu = document.querySelector('.top-level-buttons');
            actionMenu.appendChild(downloadButton);
        }, initialInjectDelay);
    }

    // Function to remove native YouTube download button
    function removeNativeDownloadButton() {
        setTimeout(() => {
            // Remove download button from overflow menu
            removeElement('ytd-menu-service-item-download-renderer');
            // Remove download button next to like/dislike buttons
            var overFlowButton = document.querySelector('button[aria-label="More actions"]');
            overFlowButton.click();
            removeElement('ytd-download-button-renderer');
            overFlowButton.click();
        }, initialInjectDelay);
    }

    // Function to display quality selection popup
      function showQualityPopup(videoUrl) {
        const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;

        const qualityPrompt = `
          <div id="cobalt-quality-picker"
               style="background: ${isDarkMode ? '#181a1b' : '#fff'};
                      color: ${isDarkMode ? '#ddd' : '#000'};
                      padding: 20px;
                      border: 1px solid ${isDarkMode ? '#555' : '#ccc'};
                      border-radius: 10px;
                      position: fixed;
                      top: 50%;
                      left: 50%;
                      transform: translate(-50%, -50%);
                      z-index: 9999;
                      max-width: 75px;
                      width: 100%;
                      max-height: 400px;
                      overflow-y: auto;">
            <label for="cobalt-format" style="display: block; margin-bottom: 10px;">Format:</label>
            <select id="cobalt-format" style="margin-bottom: 10px; width: 100%;
                      background: ${isDarkMode ? '#181a1b' : '#fff'};
                      color: ${isDarkMode ? '#ddd' : '#000'};
                      border-radius: 3px;
                      border: 1px solid ${isDarkMode ? '#666' : '#ccc'};">
                      <option>Loading</option>
            </select>
            <label id="quality-label" for="cobalt-quality" style="display: block; margin-bottom: 10px;">Quality:</label>
            <select id="cobalt-quality" style="margin-bottom: 10px; width: 100%;
                      background: ${isDarkMode ? '#181a1b' : '#fff'};
                      color: ${isDarkMode ? '#ddd' : '#000'};
                      border-radius: 3px;
                      border: 1px solid ${isDarkMode ? '#666' : '#ccc'};">
                      <option>Loading</option>
            </select>
            <div id="cobalt-loading" style="display: none; margin-bottom: 10px; text-align: center;">Loading...</div>
            <button id="cobalt-start-download" style="display: block; margin-top: 10px; width: 100%;
                      background: ${isDarkMode ? '#222426' : '#eee'};
                      color: ${isDarkMode ? '#ddd' : '#000'};
                      border-radius: 3px;
                      border: 1px solid ${isDarkMode ? '#666' : '#ccc'};" disabled>Loading...</button>
          </div>
        `;

      const cobaltToolsPopupContainer = document.createElement('div');
      cobaltToolsPopupContainer.innerHTML = qualityPrompt;
      document.body.appendChild(cobaltToolsPopupContainer);

      // if clicked outside of popup then close the popup
      const clickHandler = (event) => {
        if (!cobaltToolsPopupContainer.contains(event.target)) {
            removeElement('#cobalt-quality-picker');
            document.removeEventListener('click', clickHandler);
        }
      };
      setTimeout(() => {
          document.addEventListener('click', clickHandler);
      }, 300);

      const startDownloadBtn = document.getElementById('cobalt-start-download');
      getUniqueQualityLabels().then(qualities => {
        qualityDropdown.innerHTML = qualities.map(q => `<option value="${q}">${q}p</option>`).join('');
      });
      getMimeTypes().then(formatOptions => {
        formatDropdown.innerHTML = formatOptions.map(format => `<option value="${format}">${format}</option>`).join('');
        startDownloadBtn.disabled = false;
        startDownloadBtn.textContent = "Download";
      });

      const qualityDropdown = document.getElementById('cobalt-quality');
      const loadingIndicator = document.getElementById('cobalt-loading');
      const formatDropdown = document.getElementById('cobalt-format');

      formatDropdown.addEventListener('change', () => {
          const isAudioFormat = formatDropdown.value === 'mp3' || formatDropdown.value === 'opus' || formatDropdown.value === 'wav';
          const qualityLabel = document.getElementById('quality-label');
          if (isAudioFormat) {
              qualityLabel.style.display = 'none';
              qualityDropdown.style.display = 'none';
          } else {
              qualityLabel.style.display = 'block';
              qualityDropdown.style.display = 'block';
          }
      });

      startDownloadBtn.addEventListener('click', async () => {
        // Remove the close popup click event listener
        document.removeEventListener('click', clickHandler);
        loadingIndicator.style.display = 'block';

        // Disable changes after initiating download
        startDownloadBtn.disabled = true;
        formatDropdown.disabled = true;
        qualityDropdown.disabled = true;

        const format = formatDropdown.value;
        const quality = qualityDropdown.value;

        let videoUrl = await Cobalt(window.location.href, format === 'mp3' || format === 'opus' || format === 'wav', quality, format);

        if (!isYTError && !videoUrl) {
            GM_notification('Failed to fetch download URL. Likely their are no instances with free open access. Navigating to Cobalt site.');
            window.open("https://cobalt.tools/#" + window.location.href, '_blank', 'noopener,noreferrer');
            loadingIndicator.style.display = 'none';
            startDownloadBtn.disabled = false;
            return;
        } else if (isYTError) {
          isYTError = false;
          return;
        }

        console.log(`Downloading ${format} ${quality}`);

        // Create and trigger download link
        window.open(videoUrl, '_blank', 'noopener,noreferrer');

        // Clean up
        loadingIndicator.style.display = 'none';
        startDownloadBtn.disabled = false;
        removeElement('#cobalt-quality-picker');
      });
    }

    // Function to initialize download button on YouTube video page
    function initializeDownloadButton() {
        injectDownloadButton();
        removeNativeDownloadButton();
    }

    // Initialize on page load
    if (isYouTubeWatchURL()) {
      setTimeout(() => {
          initializeDownloadButton();
      }, initialInjectDelay);
    }

    // Monitor URL changes using history API
    window.onpopstate = function(event) {
        setTimeout(() => {
            if (currentPageUrl !== window.location.href) {
                currentPageUrl = window.location.href;
                console.log('URL changed:', currentPageUrl);
                if (isYouTubeWatchURL()) {
                  initializeDownloadButton(); // Reinitialize download button on URL change
                }

                // Close the format/quality picker menu if a new video is clicked
                removeElement('#cobalt-quality-picker');
            }
        }, navigationInjectDelay);
    };

    // Monitor DOM changes using MutationObserver
    const observer = new MutationObserver(mutations => {
        for (let mutation of mutations) {
            if (mutation.type === 'childList' && mutation.target.classList.contains('html5-video-player')) {
                console.log('Video player changed');
                setTimeout(() => {
                    currentPageUrl = window.location.href;
                    if (isYouTubeWatchURL()) {
                      initializeDownloadButton(); // Reinitialize download button if video player changes
                    }
                }, navigationInjectDelay);

                // Close the format/quality picker menu if a new video is clicked
                removeElement('#cobalt-quality-picker');
                break;
            }
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true,
    });

})();