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      0.4
// @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 lastFetchedQualities = [];
    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 to initiate download using Cobalt API
    function Cobalt(videoUrl, audioOnly = false, quality = '1080', format = 'mp4') {
        let codec = 'avc1';
        if (format === 'mp4' && parseInt(quality.replace('p', '')) > 1080) {
            codec = 'av1';
        } else if (format === 'webm') {
            codec = 'vp9';
        }

        console.log(`Sending request to Cobalt API: URL=${videoUrl}, AudioOnly=${audioOnly}, Quality=${quality}, Format=${format}, Codec=${codec}`);
        return new Promise((resolve, reject) => {
            GM.xmlHttpRequest({
                method: 'POST',
                url: 'https://api.cobalt.tools/api/json',
                headers: {
                    'Cache-Control': 'no-cache',
                    Accept: 'application/json',
                    'Content-Type': 'application/json',
                },
                data: JSON.stringify({
                    url: encodeURI(videoUrl),
                    vQuality: audioOnly ? parseInt(quality.replace(/\D/g, '')) : quality.replace('p', ''), // Strip units for audio formats
                    codec: codec,
                    filenamePattern: 'basic',
                    isAudioOnly: audioOnly,
                    disableMetadata: true,
                }),
                onload: (response) => {
                    const data = JSON.parse(response.responseText);
                    if (data?.url) resolve(data.url);
                    else reject(data);
                },
                onerror: (err) => reject(err),
            });
        });
    }

    // Function to fetch video qualities
    function fetchVideoQualities(callback) {
        GM.xmlHttpRequest({
            method: 'GET',
            url: window.location.href,
            headers: {
                'User-Agent': navigator.userAgent,  // Use the same user agent as the user's browser
                'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
            },
            onload: function(response) {
                if (response.status === 200) {
                    // Extract video qualities using regular expressions
                    const videoQualities = extractQualities(response.responseText);
                    const strippedQualities = stripQualityLabels(videoQualities);
                    const filteredQualities = filterAndRemoveDuplicates(strippedQualities);
                    console.log('Video Qualities:', filteredQualities);

                    // Update last fetched qualities
                    lastFetchedQualities = filteredQualities;

                    // Execute callback with fetched qualities
                    callback(filteredQualities);
                } else {
                    console.error('Failed to fetch video qualities. Status:', response.status);
                    callback([]); // Empty array on failure
                }
            },
            onerror: function(err) {
                console.error('Error fetching YouTube video page:', err);
                callback([]); // Empty array on error
            }
        });
    }

    // Function to extract video qualities from the HTML response
    function extractQualities(html) {
        // Example regex to extract video qualities (modify as per actual YouTube DOM structure)
        const regex = /"(qualityLabel|width)":"([^"]+)"/g;
        const qualities = [];
        let match;

        while ((match = regex.exec(html)) !== null) {
            if (match[1] === 'qualityLabel') {
                qualities.push(match[2]);
            }
        }

        return qualities;
    }

    // Function to strip everything after the first "p" in each quality label
    function stripQualityLabels(qualities) {
        return qualities.map(quality => {
            const index = quality.indexOf('p');
            return index !== -1 ? quality.substring(0, index + 1) : quality;
        });
    }

    // Function to filter out premium formats, remove duplicates, and order from greatest to least
    function filterAndRemoveDuplicates(qualities) {
        const filteredQualities = [];
        const seenQualities = new Set();

        for (let quality of qualities) {
            if (!quality.includes('Premium') && !seenQualities.has(quality)) {
                filteredQualities.push(quality);
                seenQualities.add(quality);
            }
        }

        // Sort filtered qualities from greatest to least
        filteredQualities.sort((a, b) => compareQuality(a, b));

        return filteredQualities;
    }

    // Helper function to compare video quality labels (e.g., "1080p" > "720p")
    function compareQuality(a, b) {
        // Extract resolution (assuming format like "1080p")
        const regex = /(\d+)p/;
        const resA = parseInt(a.match(regex)[1]);
        const resB = parseInt(b.match(regex)[1]);

        // Compare resolutions descending
        return resB - resA;
    }

    // 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
            const existingButton = document.getElementById('cobalt-download-btn');
            if (existingButton) {
                existingButton.remove();
            }

            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.backgroundColor = 'rgb(44, 44, 44)';
            downloadButton.style.border = '0px solid rgb(204, 204, 204)';
            downloadButton.style.borderRadius = '30px';
            downloadButton.style.fontSize = '14px';
            downloadButton.style.padding = '8px 16px';
            downloadButton.style.cursor = 'pointer';
            downloadButton.style.marginLeft = '8px'; // Add spacing to the left
            downloadButton.style.marginRight = '0px'; // No spacing on the right

            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
            const nativeDownloadButtonInOverflow = document.querySelector('ytd-menu-service-item-download-renderer');
            if (nativeDownloadButtonInOverflow) {
                nativeDownloadButtonInOverflow.remove();
            }

            // Remove download button next to like/dislike buttons
            const nativeDownloadButton = document.querySelector('ytd-download-button-renderer');
            if (nativeDownloadButton) {
                nativeDownloadButton.remove();
            }
        }, initialInjectDelay);
    }

    // Function to display quality selection popup
    function showQualityPopup(videoUrl) {
        fetchVideoQualities((qualities) => {
            const formatOptions = ['mp4', 'webm', 'ogg', 'mp3', 'opus', 'wav']; // Adjust based on Cobalt API support

            const qualityPrompt = `
                <div id="cobalt-quality-picker" style="background: #fff; padding: 20px; border: 1px solid #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%;">
                        ${formatOptions.map(format => `<option value="${format}">${format}</option>`).join('')}
                    </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%;">
                        ${qualities.map(q => `<option value="${q}">${q}</option>`).join('')}
                    </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;">Download</button>
                </div>
            `;

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

            // Add click listener to close the popup when clicking outside of it
            document.addEventListener('click', (event) => {
                if (!popupContainer.contains(event.target)) {
                    document.body.removeChild(popupContainer);
                }
            }, { once: true });

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

            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 () => {
                try {
                    loadingIndicator.style.display = 'block';
                    startDownloadBtn.disabled = true;
                    startDownloadBtn.style.cursor = 'not-allowed';

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

                    let videoUrl = await Cobalt(currentPageUrl, format === 'mp3' || format === 'opus' || format === 'wav', quality, format);
                    console.log(`Downloading ${format} ${quality} with codec ${format === 'mp4' && parseInt(quality.replace('p', '')) > 1080 ? 'av1' : (format === 'webm' ? 'vp9' : 'avc1')}`);

                    // Simulate download link click
                    let link = document.createElement('a');
                    link.href = videoUrl;
                    link.setAttribute('download', '');
                    document.body.appendChild(link);
                    link.click();
                } catch (err) {
                    console.error('Error fetching download URL:', err);
                    GM_notification('Failed to fetch download link. Please try again.', 'Error');
                } finally {
                    // Hide loading indicator and enable button
                    loadingIndicator.style.display = 'none';
                    startDownloadBtn.disabled = false;
                    startDownloadBtn.style.cursor = 'pointer';
                }

                // Close the popup after initiating download
                document.body.removeChild(popupContainer);
            });
        });
    }

    // 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
                const existingPopup = document.querySelector('#cobalt-quality-picker');
                if (existingPopup) {
                    existingPopup.remove();
                }
            }
        }, 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
                const existingPopup = document.querySelector('#cobalt-quality-picker');
                if (existingPopup) {
                    existingPopup.remove();
                }
                break;
            }
        }
    });

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

})();