Kits.AI Universal Downloader

Adds download buttons to the Studio page and to shared conversion links. Works on any plan.

// ==UserScript==
// @name         Kits.AI Universal Downloader
// @namespace    https://greasyfork.org/en/users/12345-YOUR-USER-ID
// @version      2.1.1
// @description  Adds download buttons to the Studio page and to shared conversion links. Works on any plan.
// @author       YourUsername
// @match        https://app.kits.ai/studio*
// @match        https://app.kits.ai/conversions/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=kits.ai
// @grant        GM_download
// @license      MIT
// @supportURL   https://greasyfork.org/en/scripts/541921-kits-ai-universal-downloader/feedback
// ==/UserScript==

/* jshint esversion: 8 */
/* globals GM_download */

(function() {
    'use strict';

    const capturedAudioUrls = {};
    let lastInteractedModel = null;
    let notificationTimeout;

    const DOWNLOAD_ICON_SVG = `
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 4px;">
            <path d="M12 3V15M12 15L16 11M12 15L8 11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
            <path d="M21 15V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
        </svg>
    `;

    function createNotificationArea() {
        if (document.getElementById('kits-dl-notifications')) return;
        const area = document.createElement('div');
        area.id = 'kits-dl-notifications';
        Object.assign(area.style, {
            position: 'fixed', bottom: '20px', right: '20px',
            backgroundColor: 'rgba(40, 40, 40, 0.9)', color: 'white',
            padding: '10px 15px', borderRadius: '8px', zIndex: '99999',
            fontSize: '14px', fontFamily: 'sans-serif', border: '1px solid #444',
            boxShadow: '0 4px 12px rgba(0,0,0,0.5)', transition: 'opacity 0.5s, transform 0.5s',
            opacity: '0', transform: 'translateY(20px)', pointerEvents: 'none'
        });
        document.body.appendChild(area);
    }

    function showNotification(message, duration = 4000) {
        const area = document.getElementById('kits-dl-notifications');
        if (!area) return;
        area.textContent = `Kits-DL: ${message}`;
        area.style.opacity = '1';
        area.style.transform = 'translateY(0)';
        clearTimeout(notificationTimeout);
        notificationTimeout = setTimeout(() => {
            area.style.opacity = '0';
            area.style.transform = 'translateY(20px)';
        }, duration);
    }

    function triggerDownload(url, filename) {
        if (!url || !filename) {
            showNotification("Download failed: No URL or filename.", 5000);
            return;
        }
        showNotification(`Starting download: ${filename}`);
        GM_download(url, filename);
    }

    function processUrl(url) {
        if (!lastInteractedModel || typeof url !== 'string' || !url.startsWith('http') || !url.includes('r2.cloudflarestorage.com') || !url.includes('/projects/output_audio/')) {
            return;
        }
        const modelName = lastInteractedModel;
        capturedAudioUrls[modelName] = capturedAudioUrls[modelName] || {};
        let type = 'Track';
        const cleanUrl = url.split('?')[0];

        if (cleanUrl.endsWith('_recombined.mp3')) {
            capturedAudioUrls[modelName].instrumentalUrl = url;
            type = 'Instrumental';
        } else if (cleanUrl.endsWith('.mp3')) {
            capturedAudioUrls[modelName].acapellaUrl = url;
            type = 'Acapella';
        }

        capturedAudioUrls[modelName].url = url; // Also store generically for conversion page
        showNotification(`${type} URL for ${modelName} captured!`);
        setTimeout(updateActivePageButtons, 50);
    }


    function updateStudioPageButtons() {
        const acapellaBtn = document.getElementById('kits-dl-acapella-btn');
        const instrumentalBtn = document.getElementById('kits-dl-instrumental-btn');
        if (!acapellaBtn || !instrumentalBtn) return;

        const modelName = lastInteractedModel;
        const urls = modelName ? capturedAudioUrls[modelName] : null;

        const setButtonState = (btn, type) => {
            const urlKey = `${type}Url`;
            const capitalizedType = type.charAt(0).toUpperCase() + type.slice(1);
            if (urls && urls[urlKey]) {
                btn.disabled = false;
                btn.title = `Download ${modelName} (${capitalizedType})`;
                btn.dataset.url = urls[urlKey];
                btn.dataset.filename = `${modelName} (${capitalizedType}).mp3`;
            } else {
                btn.disabled = true;
                btn.title = `Play the ${type} track of a model to enable download.`;
                btn.dataset.url = '';
                btn.dataset.filename = '';
            }
        };

        if (modelName) {
            setButtonState(acapellaBtn, 'acapella');
            setButtonState(instrumentalBtn, 'instrumental');
        } else {
            [acapellaBtn, instrumentalBtn].forEach(btn => {
                btn.disabled = true;
                btn.title = 'Play a voice model to enable downloads.';
            });
        }
    }

    function updateConversionPageButton() {
        const btn = document.getElementById('kits-dl-shared-btn');
        if (!btn) return;
        const modelName = lastInteractedModel;
        const urls = modelName ? capturedAudioUrls[modelName] : null;

        if (urls && urls.url) {
            btn.disabled = false;
            btn.title = `Download ${modelName}`;
            btn.dataset.url = urls.url;
            btn.dataset.filename = `${modelName}.mp3`;
        } else {
            btn.disabled = true;
            btn.title = 'Play the track to enable download.';
        }
    }

    function injectStudioButtons(container) {
        if (document.getElementById('kits-dl-acapella-btn')) return;
        const acapellaToggle = container.querySelector('[data-cy="acapella-toggle-acapella"]');
        const instrumentsToggle = container.querySelector('[data-cy="acapella-toggle-with-instruments"]');
        if (!acapellaToggle || !instrumentsToggle) return;

        const btnClasses = 'kits-ui-c-dbCYuz kits-ui-c-dbCYuz-bICGYT-justify-center kits-ui-c-dbCYuz-jjTuOt-gap-4 kits-ui-c-dbCYuz-ermKEJ-variant-secondary kits-ui-c-dbCYuz-ecsZqb-size-sm';
        const dlAcapellaBtn = document.createElement('button');
        dlAcapellaBtn.id = 'kits-dl-acapella-btn';
        dlAcapellaBtn.className = btnClasses;
        dlAcapellaBtn.innerHTML = DOWNLOAD_ICON_SVG + '<span>Acapella</span>';
        dlAcapellaBtn.style.marginRight = '8px';

        const dlInstrumentalBtn = document.createElement('button');
        dlInstrumentalBtn.id = 'kits-dl-instrumental-btn';
        dlInstrumentalBtn.className = btnClasses;
        dlInstrumentalBtn.innerHTML = DOWNLOAD_ICON_SVG + '<span>Instrumental</span>';
        dlInstrumentalBtn.style.marginLeft = '8px';

        container.insertBefore(dlAcapellaBtn, acapellaToggle);
        instrumentsToggle.after(dlInstrumentalBtn);
        updateStudioPageButtons();
    }

    function injectConversionButton(anchorElement) {
        if (document.getElementById('kits-dl-shared-btn')) return;

        const titleElement = document.querySelector('.kits-ui-c-PJLV-ewMxDZ-variant-heading-1');
        if (!titleElement) return;

        lastInteractedModel = titleElement.textContent.trim();

        const btn = document.createElement('button');
        btn.id = 'kits-dl-shared-btn';
        btn.innerHTML = DOWNLOAD_ICON_SVG + 'Download Audio';
        const btnClasses = 'kits-ui-c-dbCYuz kits-ui-c-dbCYuz-bICGYT-justify-center kits-ui-c-dbCYuz-jjTuOt-gap-4 kits-ui-c-dbCYuz-ilUpLE-variant-primary kits-ui-c-dbCYuz-ghJGrS-size-lg';
        btn.className = btnClasses;

        const btnContainer = document.createElement('div');
        Object.assign(btnContainer.style, {
            width: '100%',
            display: 'flex',
            justifyContent: 'center',
            padding: '20px 0'
        });
        btnContainer.appendChild(btn);

        anchorElement.before(btnContainer);
        updateConversionPageButton();
    }



    function initializeAudioInterceptor() {
        const srcDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'src');
        if (!srcDescriptor) return;
        Object.defineProperty(HTMLMediaElement.prototype, 'src', {
            set: function(url) {
                processUrl(url);
                return srcDescriptor.set.call(this, url);
            }
        });
    }

    function initializeClickListener() {
        document.body.addEventListener('click', (e) => {
            const modelCard = e.target.closest('div[data-cy="model-card"][data-title]');
            if (modelCard) {
                const playButton = e.target.closest('button');
                const isPlayButtonClick = playButton && (playButton.querySelector('svg path[d*="M5.1869"]') || playButton.querySelector('svg rect'));
                if (isPlayButtonClick) {
                    const newModelName = modelCard.getAttribute('data-title');
                    if (newModelName && newModelName !== lastInteractedModel) {
                        lastInteractedModel = newModelName;
                        showNotification(`Selected model: ${newModelName}`);
                        updateStudioPageButtons();
                    }
                }
            }
            const dlButton = e.target.closest('#kits-dl-acapella-btn, #kits-dl-instrumental-btn, #kits-dl-shared-btn');
            if (dlButton && !dlButton.disabled) {
                triggerDownload(dlButton.dataset.url, dlButton.dataset.filename);
            }
        }, true);
    }

    function updateActivePageButtons() {
        if (window.location.pathname.startsWith('/studio')) {
            updateStudioPageButtons();
        } else if (window.location.pathname.startsWith('/conversions')) {
            updateConversionPageButton();
        }
    }

    function initializeObserver() {
        const observer = new MutationObserver((mutations, obs) => {
            if (window.location.pathname.startsWith('/studio')) {
                const studioContainer = document.querySelector('.kits-ui-c-jxLAvb');
                if (studioContainer) {
                    injectStudioButtons(studioContainer);
                }
            } else if (window.location.pathname.startsWith('/conversions')) {
                const anchorElement = document.querySelector('.kits-ui-c-cmGVBt');
                if (anchorElement) {
                    injectConversionButton(anchorElement);
                    obs.disconnect();
                }
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            createNotificationArea();
            initializeObserver();
        });
    } else {
        createNotificationArea();
        initializeObserver();
    }

    initializeAudioInterceptor();
    initializeClickListener();

})();