YouTube Cobalt Tools Download Button

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

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

})();