Suno/Udio Media Extractor (Enhanced UI + Downloads)

Extracts title, audio, and video links from Suno.com/song and Udio.com/songs with a Material UI, state saving, OG/Twitter meta listing, and MP3/MP4 download buttons using a Windows-safe title filename.

// ==UserScript==
// @name         Suno/Udio Media Extractor (Enhanced UI + Downloads)
// @namespace    http://tampermonkey.net/
// @version      1.1.0
// @description  Extracts title, audio, and video links from Suno.com/song and Udio.com/songs with a Material UI, state saving, OG/Twitter meta listing, and MP3/MP4 download buttons using a Windows-safe title filename.
// @author       Graph1ks + GPT
// @match        https://suno.com/song/*
// @match        https://www.udio.com/songs/*
// @match        https://www.riffusion.com/song/*
// @grant        GM_addStyle
// @grant        GM_download
// @homepageURL  https://github.com/YourUsername/SunoUdioMediaExtractor
// @supportURL   https://github.com/YourUsername/SunoUdioMediaExtractor/issues
// ==/UserScript==

(function() {
    'use strict';

    // SVG icons
    const ICON_INFO_OUTLINE = '<svg viewBox="0 0 24 24" width="24" height="24" fill="white"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>';
    const ICON_CLOSE = '<svg viewBox="0 0 24 24" width="24" height="24" fill="white"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>';

    GM_addStyle(`
        #media-extractor-ui { position: fixed; top: 100px; right: 20px; z-index: 99999; background-color: #212121; color: #e0e0e0; padding: 15px; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,.4); font-family: 'Roboto','Segoe UI',sans-serif; width: 1200px; transition: transform .3s ease-out, opacity .3s ease-out; transform: translateX(100%); opacity: 0; }
        #media-extractor-ui.open { transform: translateX(0); opacity: 1; }
        #media-extractor-toggle { position: fixed; top: 100px; right: 20px; z-index: 100000; background-color: #6200ee; color: white; border: none; border-radius: 50%; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 2px 5px rgba(0,0,0,.26); transition: background-color .3s, box-shadow .3s; }
        #media-extractor-toggle:hover { background-color: #7b1fa2; box-shadow: 0 4px 10px rgba(0,0,0,.3); }
        #media-extractor-ui h3 { margin: 0 0 10px; color: #bb86fc; font-size: 1.2em; }
        #media-extractor-ui .info-item { display: flex; align-items: flex-start; margin-bottom: 10px; gap: 10px; }
        #media-extractor-ui .info-item label { font-weight: bold; min-width: 60px; margin-right: 10px; flex-shrink: 0; padding-top: 4px; }
        #media-extractor-ui .info-item .content { flex-grow: 1; word-break: break-all; background-color: #333; padding: 4px 8px; border-radius: 4px; font-size: .9em; line-height: 1.4; user-select: text; min-height: 24px; display: flex; align-items: center; }
        #media-extractor-ui .info-item button.copy-btn { background-color: #03dac6; color: #212121; border: none; padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: .8em; text-transform: uppercase; font-weight: bold; flex-shrink: 0; }
        #media-extractor-ui button.copy-btn:disabled { background-color: #555; cursor: not-allowed; opacity: .7; }
        #media-extractor-ui button.copy-btn:hover:not(:disabled) { background-color: #00bfa5; box-shadow: 0 1px 3px rgba(0,0,0,.3); }

        /* New download buttons */
        #media-extractor-ui .info-item button.download-btn { background-color: #ffb300; color: #212121; border: none; padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: .8em; text-transform: uppercase; font-weight: bold; flex-shrink: 0; }
        #media-extractor-ui .info-item button.download-btn:hover { background-color: #ffa000; box-shadow: 0 1px 3px rgba(0,0,0,.3); }

        #media-extractor-ui .status { margin-top: 15px; font-size: .9em; font-style: italic; color: #a0a0a0; }
        #media-extractor-ui .meta-tags-section { margin-top: 15px; border-top: 1px solid #444; padding-top: 10px; display: flex; gap: 20px; }
        #media-extractor-ui .meta-column { flex: 1; min-width: 0; }
        #media-extractor-ui .meta-tags-section h4 { margin: 0 0 8px; color: #bb86fc; font-size: 1em; }
        #media-extractor-ui .meta-tag { display: flex; align-items: flex-start; margin-bottom: 5px; background-color: #333; padding: 4px 8px; border-radius: 4px; color: #ccc; line-height: 1.4; gap: 8px; }
        #media-extractor-ui .meta-tag strong { color: #03dac6; margin-right: 5px; flex-shrink: 0; font-size: .9em; padding-top: 2px; }
        #media-extractor-ui .meta-tag .meta-value { flex-grow: 1; word-break: break-all; user-select: text; font-size: .9em; padding-top: 2px; }
        #media-extractor-ui .meta-tag button.copy-btn { background-color: #03dac6; color: #212121; border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: .7em; text-transform: uppercase; font-weight: bold; flex-shrink: 0; min-width: unset; }
    `);

    const UI_CONTAINER_ID = 'media-extractor-ui';
    const UI_TOGGLE_BUTTON_ID = 'media-extractor-toggle';
    const LOCAL_STORAGE_KEY = 'mediaExtractorUIOpen';

    let lastExtractedData = null; // keep latest extracted for download naming

    const uiContainer = document.createElement('div');
    uiContainer.id = UI_CONTAINER_ID;
    document.body.appendChild(uiContainer);

    const toggleButton = document.createElement('button');
    toggleButton.id = UI_TOGGLE_BUTTON_ID;
    document.body.appendChild(toggleButton);

    let isOpen = localStorage.getItem(LOCAL_STORAGE_KEY) === 'true';
    if (isOpen) {
        uiContainer.classList.add('open');
        toggleButton.innerHTML = ICON_CLOSE;
        toggleButton.title = 'Hide Song Info';
    } else {
        uiContainer.classList.remove('open');
        toggleButton.innerHTML = ICON_INFO_OUTLINE;
        toggleButton.title = 'Show Song Info';
    }

    toggleButton.addEventListener('click', () => {
        isOpen = !isOpen;
        uiContainer.classList.toggle('open', isOpen);
        toggleButton.innerHTML = isOpen ? ICON_CLOSE : ICON_INFO_OUTLINE;
        toggleButton.title = isOpen ? 'Hide Song Info' : 'Show Song Info';
        localStorage.setItem(LOCAL_STORAGE_KEY, isOpen.toString());
        if (isOpen) {
            updateUI('Searching...', {});
            setTimeout(findMediaLinks, 100);
        }
    });

    // Windows filename sanitizer: strips illegal/reserved characters, emoji, controls, trims, avoids reserved device names
    function sanitizeForWindows(name, replacement = ' ') {
        try {
            if (!name) return 'download';
            let s = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
            // Remove emoji/pictographs (SMP), dingbats, misc symbols, variation selectors
            s = s.replace(/[\u{1F000}-\u{1FFFF}\u2600-\u26FF\u2700-\u27BF\uFE0E\uFE0F]/gu, '');
            // Remove Windows-forbidden chars and control chars 0-31
            s = s.replace(/[<>:"/\\|?*\x00-\x1F]/g, replacement);
            // Collapse whitespace
            s = s.replace(/\s+/g, ' ').trim();
            // Remove trailing spaces/dots
            s = s.replace(/[ .]+$/g, '');
            if (!s) s = 'download';
            // Length guard
            s = s.slice(0, 180);
            // Avoid reserved device names
            const reserved = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
            if (reserved.test(s)) s = '_' + s;
            return s;
        } catch {
            return 'download';
        }
    }

    function defaultFilename(ext) {
        const title = (lastExtractedData && lastExtractedData.title) || document.title || 'download';
        return `${sanitizeForWindows(title)}.${ext}`;
    }

    function startDownload(fileUrl, ext) {
        const name = defaultFilename(ext);
        if (typeof GM_download === 'function') {
            try {
                GM_download({
                    url: fileUrl,
                    name,
                    saveAs: true,
                    onerror: (e) => alert('Download failed: ' + (e && (e.error || e.details) || 'unknown')),
                });
            } catch (e) {
                anchorFallback(fileUrl, name);
            }
        } else {
            anchorFallback(fileUrl, name);
        }
    }

    function anchorFallback(fileUrl, suggestedName) {
        const a = document.createElement('a');
        a.href = fileUrl;
        a.download = suggestedName;
        document.body.appendChild(a);
        a.click();
        a.remove();
    }

    function updateUI(statusText, data = {}) {
        lastExtractedData = data || null;
        uiContainer.innerHTML = '';

        let contentHtml = '<h3>Media Links</h3>';

        const items = [
            { key: 'title', label: 'Title', value: data.title },
            { key: 'audio', label: 'Audio', value: data.audioLink },
            { key: 'video', label: 'Video', value: data.videoLink },
        ];

        items.forEach(item => {
            const hasValue = item.value && String(item.value).trim() !== '';
            const titleAttr = hasValue ? String(item.value) : 'N/A';
            const copyData = hasValue ? String(item.value) : '';
            const isAudio = item.key === 'audio';
            const isVideo = item.key === 'video';
            const downloadBtn = (isAudio || isVideo) && hasValue
                ? `<button class="download-btn" data-link="${copyData}" data-ext="${isAudio ? 'mp3' : 'mp4'}">Download ${isAudio ? 'MP3' : 'MP4'}</button>`
                : '';
            contentHtml += `
                <div class="info-item">
                    <label>${item.label}:</label>
                    <span class="content" title="${titleAttr}">${hasValue ? titleAttr : 'N/A'}</span>
                    <button class="copy-btn" data-copy="${copyData}" ${hasValue ? '' : 'disabled'}>Copy</button>
                    ${downloadBtn}
                </div>
            `;
        });

        const ogMetaTags = {};
        const twitterMetaTags = {};
        if (data.metaTags) {
            for (const [key, value] of Object.entries(data.metaTags)) {
                if (key.startsWith('og:')) ogMetaTags[key] = value;
                else if (key.startsWith('twitter:')) twitterMetaTags[key] = value;
            }
        }

        if (Object.keys(ogMetaTags).length > 0 || Object.keys(twitterMetaTags).length > 0) {
            contentHtml += `<div class="meta-tags-section">`;
            contentHtml += `<div class="meta-column"><h4>Open Graph Tags:</h4>`;
            const sortedOgKeys = Object.keys(ogMetaTags).sort();
            if (sortedOgKeys.length > 0) {
                sortedOgKeys.forEach(key => {
                    const value = ogMetaTags[key];
                    const hasValue = value && String(value).trim() !== '';
                    const val = hasValue ? String(value) : '';
                    contentHtml += `
                        <div class="meta-tag">
                            <strong>${key}:</strong>
                            <span class="meta-value" title="${hasValue ? val : 'N/A'}">${hasValue ? val : 'N/A'}</span>
                            <button class="copy-btn" data-copy="${hasValue ? val : ''}" ${hasValue ? '' : 'disabled'}>Copy</button>
                        </div>
                    `;
                });
            } else {
                contentHtml += `<div class="meta-tag"><span class="meta-value">No Open Graph tags found.</span></div>`;
            }
            contentHtml += `</div>`;
            contentHtml += `<div class="meta-column"><h4>Twitter Tags:</h4>`;
            const sortedTwitterKeys = Object.keys(twitterMetaTags).sort();
            if (sortedTwitterKeys.length > 0) {
                sortedTwitterKeys.forEach(key => {
                    const value = twitterMetaTags[key];
                    const hasValue = value && String(value).trim() !== '';
                    const val = hasValue ? String(value) : '';
                    contentHtml += `
                        <div class="meta-tag">
                            <strong>${key}:</strong>
                            <span class="meta-value" title="${hasValue ? val : 'N/A'}">${hasValue ? val : 'N/A'}</span>
                            <button class="copy-btn" data-copy="${hasValue ? val : ''}" ${hasValue ? '' : 'disabled'}>Copy</button>
                        </div>
                    `;
                });
            } else {
                contentHtml += `<div class="meta-tag"><span class="meta-value">No Twitter tags found.</span></div>`;
            }
            contentHtml += `</div>`;
            contentHtml += `</div>`;
        } else {
            contentHtml += `<div class="meta-tags-section"><p class="status">No Open Graph or Twitter meta tags found.</p></div>`;
        }

        contentHtml += `<p class="status">${statusText}</p>`;
        uiContainer.innerHTML = contentHtml;

        // Copy buttons
        uiContainer.querySelectorAll('.copy-btn').forEach(button => {
            button.addEventListener('click', (event) => {
                const textToCopy = event.currentTarget.dataset.copy || '';
                if (textToCopy) {
                    navigator.clipboard.writeText(textToCopy).then(() => {
                        const originalText = button.textContent;
                        button.textContent = 'Copied!';
                        setTimeout(() => { button.textContent = originalText; }, 1500);
                    }).catch(err => {
                        console.error('Failed to copy:', err);
                        alert('Failed to copy text. Please try again or copy manually.');
                    });
                }
            });
        });

        // Download buttons
        uiContainer.querySelectorAll('.download-btn').forEach(button => {
            button.addEventListener('click', (event) => {
                const btn = event.currentTarget;
                const url = btn.getAttribute('data-link');
                const ext = btn.getAttribute('data-ext') || 'bin';
                if (url) {
                    startDownload(url, ext);
                }
            });
        });
    }

    function findMediaLinks() {
        const url = window.location.href;
        const extractedData = {
            title: null,
            audioLink: null,
            videoLink: null,
            metaTags: {}
        };

        document.querySelectorAll('meta[property^="og:"], meta[name^="twitter:"]').forEach(meta => {
            const property = meta.getAttribute('property') || meta.getAttribute('name');
            const content = meta.getAttribute('content');
            if (property && content) {
                extractedData.metaTags[property] = content;
            }
        });

        extractedData.title = extractedData.metaTags['og:title'] ||
                              extractedData.metaTags['twitter:title'] ||
                              document.title;

        if (url.includes('udio.com')) {
            extractedData.audioLink = extractedData.metaTags['og:audio'] || extractedData.metaTags['twitter:player:stream'];
            extractedData.videoLink = extractedData.metaTags['og:video'];
        } else if (url.includes('suno.com')) {
            extractedData.audioLink = extractedData.metaTags['og:audio'] || extractedData.metaTags['twitter:player:stream'];
            extractedData.videoLink = extractedData.metaTags['og:video:secure_url'] || extractedData.metaTags['og:video'];
        } else if (url.includes('riffusion.com')) {
            extractedData.audioLink = extractedData.metaTags['og:audio'] || extractedData.metaTags['twitter:player:stream'];
            extractedData.videoLink = extractedData.metaTags['og:video:secure_url'] || extractedData.metaTags['og:video'];
        }

        const foundItems = [];
        const missingItems = [];
        extractedData.title ? foundItems.push('Title') : missingItems.push('Title');
        extractedData.audioLink ? foundItems.push('Audio') : missingItems.push('Audio');
        extractedData.videoLink ? foundItems.push('Video') : missingItems.push('Video');

        let statusMessage = '';
        if (foundItems.length === 3) statusMessage = 'All primary media links found!';
        else if (foundItems.length > 0) statusMessage = `Found ${foundItems.join(', ')}. Missing: ${missingItems.join(', ')}.`;
        else statusMessage = 'No primary media links found.';

        if (isOpen) updateUI(statusMessage, extractedData);
        else lastExtractedData = extractedData;
    }

    const observer = new MutationObserver((mutations, obs) => {
        const metaTitle = document.querySelector('meta[property="og:title"], meta[name="twitter:title"]');
        const metaAudio = document.querySelector('meta[property="og:audio"], meta[name="twitter:player:stream"]');
        const metaVideo = document.querySelector('meta[property="og:video"], meta[property="og:video:secure_url"]');
        if (metaTitle && (metaAudio || metaVideo)) {
            obs.disconnect();
            findMediaLinks();
        }
    });

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

    if (isOpen) {
        findMediaLinks();
    }

    const originalPushState = history.pushState;
    const originalReplaceState = history.replaceState;

    history.pushState = function() {
        originalPushState.apply(this, arguments);
        setTimeout(handleUrlChange, 500);
    };
    history.replaceState = function() {
        originalReplaceState.apply(this, arguments);
        setTimeout(handleUrlChange, 500);
    };
    window.addEventListener('popstate', handleUrlChange);

    let lastKnownUrl = window.location.href;
    function handleUrlChange() {
        if (window.location.href !== lastKnownUrl) {
            lastKnownUrl = window.location.href;
            if (
                window.location.href.startsWith('https://suno.com/song/') ||
                window.location.href.startsWith('https://www.udio.com/songs/') ||
                window.location.href.startsWith('https://www.riffusion.com/song/')
            ) {
                if (isOpen) {
                    updateUI('Page changed, searching...', {});
                    setTimeout(findMediaLinks, 500);
                } else {
                    setTimeout(findMediaLinks, 500);
                }
                observer.disconnect();
                observer.observe(document.body, { childList: true, subtree: true, attributes: true });
            }
        }
    }
})();